diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..64e61f60 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ["https://intii.lemonsqueezy.com", "https://www.buymeacoffee.com/intitni"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 53c159c1..86a41f25 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -13,7 +13,7 @@ body: id: before-reporting attributes: label: Before Reporting - description: Before reporting the bug, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/issues/65) to check if it may address your issue. And search for existing issues to avoid duplication. + description: Before reporting the bug, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions) to check if it may address your issue. And search for existing issues to avoid duplication. If you are reporting a bug from a beta build, please use the dedicated template for beta build. options: - label: I have checked FAQ, and there is no solution to my issue required: true @@ -32,9 +32,9 @@ body: id: reproduce attributes: label: How to reproduce the bug. - description: If possible, please provide the steps to reproduce the bug. + description: If possible, please provide the steps to reproduce the bug and relevant settings in screenshots. placeholder: "1. *****\n2.*****" - value: "It just happens!" + value: "It just happened!" - type: textarea id: logs attributes: @@ -53,8 +53,4 @@ body: id: copilot-for-xcode-version attributes: label: Copilot for Xcode version - - type: input - id: node-version - attributes: - label: Node version - \ No newline at end of file + diff --git a/.github/ISSUE_TEMPLATE/feature_reqeust.yaml b/.github/ISSUE_TEMPLATE/feature_reqeust.yaml index adc4753d..0c034eed 100644 --- a/.github/ISSUE_TEMPLATE/feature_reqeust.yaml +++ b/.github/ISSUE_TEMPLATE/feature_reqeust.yaml @@ -8,7 +8,7 @@ body: - type: markdown attributes: value: | - Thanks for taking the time to fill out this feature request! + Thanks for taking the time to fill out this feature request! But please firstly [post your idea in discussion](https://github.com/intitni/CopilotForXcode/discussions/new?category=ideas) so that we can discuss about it in advance. - type: checkboxes id: before-reporting attributes: @@ -25,4 +25,4 @@ body: placeholder: Tell us what you want! value: "I want a feature!" validations: - required: true \ No newline at end of file + required: true diff --git a/.github/ISSUE_TEMPLATE/help_wanted.yml b/.github/ISSUE_TEMPLATE/help_wanted.yml index 4a340e89..04b675c7 100644 --- a/.github/ISSUE_TEMPLATE/help_wanted.yml +++ b/.github/ISSUE_TEMPLATE/help_wanted.yml @@ -7,7 +7,7 @@ body: id: before-reporting attributes: label: Before Reporting - description: Before asking for help, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/issues/65) to check if it may address your issue. And search for existing issues to avoid duplication. + description: Before asking for help, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions) to check if it may address your issue. And search for existing issues to avoid duplication. options: - label: I have checked FAQ, and there is no solution to my issue required: true diff --git a/.github/ISSUE_TEMPLATE/z_bug_report_beta.yaml b/.github/ISSUE_TEMPLATE/z_bug_report_beta.yaml new file mode 100644 index 00000000..2f80eaaf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/z_bug_report_beta.yaml @@ -0,0 +1,56 @@ +name: Bug Report (Beta) +description: File a bug report +title: "[Bug (Beta)]: " +labels: ["bug", "beta"] +assignees: + - intitni +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: checkboxes + id: before-reporting + attributes: + label: Before Reporting + description: Before reporting the bug, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions) to check if it may address your issue. And search for existing issues to avoid duplication. + options: + - label: I have checked FAQ, and there is no solution to my issue + required: true + - label: I have searched the existing issues, and there is no existing issue for my issue + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: Tell us what you see! + value: "A bug happened!" + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: How to reproduce the bug. + description: If possible, please provide the steps to reproduce the bug and relevant settings in screenshots. + placeholder: "1. *****\n2.*****" + value: "It just happened!" + - type: textarea + id: logs + attributes: + label: Relevant log output + description: If it's a crash, please provide the crash report. You can find it in the Console.app. + render: shell + - type: input + id: mac-version + attributes: + label: macOS version + - type: input + id: xcode-version + attributes: + label: Xcode version + - type: input + id: copilot-for-xcode-version + attributes: + label: Copilot for Xcode version + diff --git a/.github/workflows/close_inactive_issues.yml b/.github/workflows/close_inactive_issues.yml index fbf141c6..1fae3c1f 100644 --- a/.github/workflows/close_inactive_issues.yml +++ b/.github/workflows/close_inactive_issues.yml @@ -15,7 +15,7 @@ jobs: days-before-issue-stale: 30 days-before-issue-close: 14 stale-issue-label: "stale" - exempt-issue-labels: "low priority, help wanted" + exempt-issue-labels: "low priority, help wanted, planned, investigating, blocked" stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." days-before-pr-stale: -1 diff --git a/.gitignore b/.gitignore index 43fb9ab3..488722ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# IDE +.idea + # Created by https://www.toptal.com/developers/gitignore/api/xcode,macos,swift,swiftpackagemanager # Edit at @@ -123,4 +126,14 @@ iOSInjectionProject/ # End of https://www.toptal.com/developers/gitignore/api/xcode,macos,swift,swiftpackagemanager + Secrets.xcconfig +Python/Python.xcframework +Python/python-stdlib +Python/site-packages/* +!Python/site-packages/requirements.txt +!Python/site-packages/install.sh + +Python/VERSIONS +Copilot for Xcode Plus.xcworkspace +PLUS diff --git a/AppIcon.png b/AppIcon.png index 592f927b..1f70976c 100644 Binary files a/AppIcon.png and b/AppIcon.png differ diff --git a/ChatPlugins/.gitignore b/ChatPlugins/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/ChatPlugins/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Core/.swiftpm/xcode/xcshareddata/xcschemes/ServiceTests.xcscheme b/ChatPlugins/.swiftpm/xcode/xcshareddata/xcschemes/ChatPlugins.xcscheme similarity index 58% rename from Core/.swiftpm/xcode/xcshareddata/xcschemes/ServiceTests.xcscheme rename to ChatPlugins/.swiftpm/xcode/xcshareddata/xcschemes/ChatPlugins.xcscheme index f548b29e..53df9491 100644 --- a/Core/.swiftpm/xcode/xcshareddata/xcschemes/ServiceTests.xcscheme +++ b/ChatPlugins/.swiftpm/xcode/xcshareddata/xcschemes/ChatPlugins.xcscheme @@ -1,28 +1,34 @@ + LastUpgradeVersion = "1600" + version = "1.7"> + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> + + + + + + - - - - - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> + + + + diff --git a/ChatPlugins/Package.swift b/ChatPlugins/Package.swift new file mode 100644 index 00000000..4defd772 --- /dev/null +++ b/ChatPlugins/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ChatPlugins", + platforms: [.macOS(.v12)], + products: [ + .library( + name: "ChatPlugins", + targets: ["TerminalChatPlugin", "ShortcutChatPlugin"] + ), + ], + dependencies: [ + .package(path: "../Tool"), + ], + targets: [ + .target( + name: "TerminalChatPlugin", + dependencies: [ + .product(name: "Chat", package: "Tool"), + .product(name: "Terminal", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + ] + ), + .target( + name: "ShortcutChatPlugin", + dependencies: [ + .product(name: "Chat", package: "Tool"), + .product(name: "Terminal", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + ] + ), + ] +) + diff --git a/ChatPlugins/Sources/ShortcutChatPlugin/ShortcutChatPlugin.swift b/ChatPlugins/Sources/ShortcutChatPlugin/ShortcutChatPlugin.swift new file mode 100644 index 00000000..fc9d8d5b --- /dev/null +++ b/ChatPlugins/Sources/ShortcutChatPlugin/ShortcutChatPlugin.swift @@ -0,0 +1,147 @@ +import ChatBasic +import Foundation +import Terminal + +public final class ShortcutChatPlugin: ChatPlugin { + public static var id: String { "com.intii.shortcut" } + public static var command: String { "shortcut" } + public static var name: String { "Shortcut" } + public static var description: String { """ + Run a shortcut and use message content as input. You need to provide the shortcut name as an argument, for example, `/shortcut(Shortcut Name)`. + """ } + + let terminal: TerminalType + + init(terminal: TerminalType) { + self.terminal = terminal + } + + public init() { + terminal = Terminal() + } + + public func sendForTextResponse(_ request: Request) async + -> AsyncThrowingStream + { + let stream = await sendForComplicatedResponse(request) + return .init { continuation in + let task = Task { + do { + for try await response in stream { + switch response { + case let .content(.text(content)): + continuation.yield(content) + default: + break + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + public func sendForComplicatedResponse(_ request: Request) async + -> AsyncThrowingStream + { + return .init { continuation in + let task = Task { + let id = "\(Self.command)-\(UUID().uuidString)" + + guard let shortcutName = request.arguments.first, !shortcutName.isEmpty else { + continuation.yield(.content(.text( + "Please provide the shortcut name in format: `/\(Self.command)(shortcut name)`" + ))) + return + } + + var input = String(request.text).trimmingCharacters(in: .whitespacesAndNewlines) + if input.isEmpty { + // if no input detected, use the previous message as input + input = request.history.last?.content ?? "" + } + + do { + continuation.yield(.startAction( + id: "run", + task: "Run shortcut `\(shortcutName)`" + )) + + let env = ProcessInfo.processInfo.environment + let shell = env["SHELL"] ?? "/bin/bash" + let temporaryURL = FileManager.default.temporaryDirectory + let temporaryInputFileURL = temporaryURL + .appendingPathComponent("\(id)-input.txt") + let temporaryOutputFileURL = temporaryURL + .appendingPathComponent("\(id)-output") + + try input.write(to: temporaryInputFileURL, atomically: true, encoding: .utf8) + + let command = """ + shortcuts run "\(shortcutName)" \ + -i "\(temporaryInputFileURL.path)" \ + -o "\(temporaryOutputFileURL.path)" + """ + + continuation.yield(.startAction( + id: "run", + task: "Run shortcut \(shortcutName)" + )) + + do { + let result = try await terminal.runCommand( + shell, + arguments: ["-i", "-l", "-c", command], + currentDirectoryURL: nil, + environment: [:] + ) + continuation.yield(.finishAction(id: "run", result: .success(result))) + } catch { + continuation.yield(.finishAction( + id: "run", + result: .failure(error.localizedDescription) + )) + throw error + } + + await Task.yield() + try Task.checkCancellation() + + if FileManager.default.fileExists(atPath: temporaryOutputFileURL.path) { + let data = try Data(contentsOf: temporaryOutputFileURL) + if let text = String(data: data, encoding: .utf8) { + var response = text + if response.isEmpty { + response = "Finished" + } + continuation.yield(.content(.text(response))) + } else { + let content = """ + [View File](\(temporaryOutputFileURL)) + """ + continuation.yield(.content(.text(content))) + } + } else { + continuation.yield(.content(.text("Finished"))) + } + + } catch { + continuation.yield(.content(.text(error.localizedDescription))) + } + + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } +} + diff --git a/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift new file mode 100644 index 00000000..1360b16d --- /dev/null +++ b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift @@ -0,0 +1,165 @@ +import ChatBasic +import Foundation +import Terminal +import XcodeInspector + +public final class TerminalChatPlugin: ChatPlugin { + public static var id: String { "com.intii.terminal" } + public static var command: String { "shell" } + public static var name: String { "Shell" } + public static var description: String { """ + Run the command in the message from shell. + + You can use environment variable `$FILE_PATH` and `$PROJECT_ROOT` to access the current file path and project root. + """ } + + let terminal: TerminalType + + init(terminal: TerminalType) { + self.terminal = terminal + } + + public init() { + terminal = Terminal() + } + + public func getTextContent(from request: Request) async + -> AsyncStream + { + return .init { continuation in + let task = Task { + do { + let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL + + var environment = [String: String]() + if let fileURL { + environment["FILE_PATH"] = fileURL.path + } + if let projectURL { + environment["PROJECT_ROOT"] = projectURL.path + } + + try Task.checkCancellation() + + let env = ProcessInfo.processInfo.environment + let shell = env["SHELL"] ?? "/bin/bash" + + let output = terminal.streamCommand( + shell, + arguments: ["-i", "-l", "-c", request.text], + currentDirectoryURL: projectURL, + environment: environment + ) + + var accumulatedOutput = "" + for try await content in output { + try Task.checkCancellation() + accumulatedOutput += content + continuation.yield(accumulatedOutput) + } + } catch let error as Terminal.TerminationError { + let errorMessage = "\n\n[error: \(error.reason)]" + continuation.yield(errorMessage) + } catch { + let errorMessage = "\n\n[error: \(error.localizedDescription)]" + continuation.yield(errorMessage) + } + + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + Task { + await self.terminal.terminate() + } + } + } + } + + public func sendForTextResponse(_ request: Request) async + -> AsyncThrowingStream + { + let stream = await getTextContent(from: request) + return .init { continuation in + let task = Task { + continuation.yield("Executing command: `\(request.text)`\n\n") + continuation.yield("```console\n") + for await text in stream { + try Task.checkCancellation() + continuation.yield(text) + } + continuation.yield("\n```\n") + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + public func formatContent(_ content: Response.Content) -> Response.Content { + switch content { + case let .text(content): + return .text(""" + ```console + \(content) + ``` + """) + } + } + + public func sendForComplicatedResponse(_ request: Request) async + -> AsyncThrowingStream + { + return .init { continuation in + let task = Task { + var updateTime = Date() + + continuation.yield(.startAction(id: "run", task: "Run `\(request.text)`")) + + let textStream = await getTextContent(from: request) + var previousOutput = "" + + continuation.yield(.finishAction( + id: "run", + result: .success("Executed.") + )) + + for await accumulatedOutput in textStream { + try Task.checkCancellation() + + let newContent = accumulatedOutput.dropFirst(previousOutput.count) + previousOutput = accumulatedOutput + + if !newContent.isEmpty { + if Date().timeIntervalSince(updateTime) > 60 * 2 { + continuation.yield(.startNewMessage) + continuation.yield(.startAction( + id: "run", + task: "Continue `\(request.text)`" + )) + continuation.yield(.finishAction( + id: "run", + result: .success("Executed.") + )) + continuation.yield(.content(.text("[continue]\n"))) + updateTime = Date() + } + + continuation.yield(.content(.text(String(newContent)))) + } + } + + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } +} + diff --git a/ChatPlugins/Tests/ChatPluginsTests/ChatPluginsTests.swift b/ChatPlugins/Tests/ChatPluginsTests/ChatPluginsTests.swift new file mode 100644 index 00000000..90f1d16f --- /dev/null +++ b/ChatPlugins/Tests/ChatPluginsTests/ChatPluginsTests.swift @@ -0,0 +1,6 @@ +import Testing +@testable import ChatPlugins + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift new file mode 100644 index 00000000..8a064aef --- /dev/null +++ b/CommunicationBridge/ServiceDelegate.swift @@ -0,0 +1,165 @@ +import AppKit +import Foundation +import Logger +import XPCShared + +class ServiceDelegate: NSObject, NSXPCListenerDelegate { + func listener( + _: NSXPCListener, + shouldAcceptNewConnection newConnection: NSXPCConnection + ) -> Bool { + newConnection.exportedInterface = NSXPCInterface( + with: CommunicationBridgeXPCServiceProtocol.self + ) + + let exportedObject = XPCService() + newConnection.exportedObject = exportedObject + newConnection.resume() + + Logger.communicationBridge.info("Accepted new connection.") + + return true + } +} + +class XPCService: CommunicationBridgeXPCServiceProtocol { + static let eventHandler = EventHandler() + + func launchExtensionServiceIfNeeded( + withReply reply: @escaping (NSXPCListenerEndpoint?) -> Void + ) { + Task { + await Self.eventHandler.launchExtensionServiceIfNeeded(withReply: reply) + } + } + + func quit(withReply reply: @escaping () -> Void) { + Task { + await Self.eventHandler.quit(withReply: reply) + } + } + + func updateServiceEndpoint( + endpoint: NSXPCListenerEndpoint, + withReply reply: @escaping () -> Void + ) { + Task { + await Self.eventHandler.updateServiceEndpoint(endpoint: endpoint, withReply: reply) + } + } +} + +actor EventHandler { + var endpoint: NSXPCListenerEndpoint? + let launcher = ExtensionServiceLauncher() + var exitTask: Task? + + init() { + Task { await rescheduleExitTask() } + } + + func launchExtensionServiceIfNeeded( + withReply reply: @escaping (NSXPCListenerEndpoint?) -> Void + ) async { + rescheduleExitTask() + #if DEBUG + if let endpoint, !(await testXPCListenerEndpoint(endpoint)) { + self.endpoint = nil + } + reply(endpoint) + #else + if await launcher.isApplicationValid { + Logger.communicationBridge.info("Service app is still valid") + reply(endpoint) + } else { + endpoint = nil + await launcher.launch() + reply(nil) + } + #endif + } + + func quit(withReply reply: () -> Void) { + Logger.communicationBridge.info("Exiting service.") + listener.invalidate() + exit(0) + } + + func updateServiceEndpoint(endpoint: NSXPCListenerEndpoint, withReply reply: () -> Void) { + rescheduleExitTask() + self.endpoint = endpoint + reply() + } + + /// The bridge will kill itself when it's not used for a period. + /// It's fine that the bridge is killed because it will be launched again when needed. + private func rescheduleExitTask() { + exitTask?.cancel() + exitTask = Task { + #if DEBUG + try await Task.sleep(nanoseconds: 60_000_000_000) + Logger.communicationBridge.info("Exit will be called in release build.") + #else + try await Task.sleep(nanoseconds: 1_800_000_000_000) + Logger.communicationBridge.info("Exiting service.") + listener.invalidate() + exit(0) + #endif + } + } +} + +actor ExtensionServiceLauncher { + let appIdentifier = bundleIdentifierBase.appending(".ExtensionService") + let appURL = Bundle.main.bundleURL.appendingPathComponent( + "CopilotForXcodeExtensionService.app" + ) + var isLaunching: Bool = false + var application: NSRunningApplication? + var isApplicationValid: Bool { + guard let application else { return false } + if application.isTerminated { return false } + let identifier = application.processIdentifier + if let application = NSWorkspace.shared.runningApplications.first(where: { + $0.processIdentifier == identifier + }) { + Logger.communicationBridge.info( + "Service app found: \(application.processIdentifier) \(String(describing: application.bundleIdentifier))" + ) + return true + } + return false + } + + func launch() { + guard !isLaunching else { return } + isLaunching = true + + Logger.communicationBridge.info("Launching extension service app.") + + NSWorkspace.shared.openApplication( + at: appURL, + configuration: { + let configuration = NSWorkspace.OpenConfiguration() + configuration.createsNewApplicationInstance = false + configuration.addsToRecentItems = false + configuration.activates = false + return configuration + }() + ) { app, error in + if let error = error { + Logger.communicationBridge.error( + "Failed to launch extension service app: \(error)" + ) + } else { + Logger.communicationBridge.info( + "Finished launching extension service app." + ) + } + + self.application = app + self.isLaunching = false + } + } +} + diff --git a/CommunicationBridge/main.swift b/CommunicationBridge/main.swift new file mode 100644 index 00000000..bb449566 --- /dev/null +++ b/CommunicationBridge/main.swift @@ -0,0 +1,19 @@ +import AppKit +import Foundation + +class AppDelegate: NSObject, NSApplicationDelegate {} + +let bundleIdentifierBase = Bundle(url: Bundle.main.bundleURL.appendingPathComponent( + "CopilotForXcodeExtensionService.app" +))?.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as? String ?? "com.intii.CopilotForXcode" + +let serviceIdentifier = bundleIdentifierBase + ".CommunicationBridge" +let appDelegate = AppDelegate() +let delegate = ServiceDelegate() +let listener = NSXPCListener(machServiceName: serviceIdentifier) +listener.delegate = delegate +listener.resume() +let app = NSApplication.shared +app.delegate = appDelegate +app.run() + diff --git a/Config.xcconfig b/Config.xcconfig index 1c65b4c7..81d6e2ba 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -3,7 +3,7 @@ SLASH = / HOST_APP_NAME = Copilot for Xcode BUNDLE_IDENTIFIER_BASE = com.intii.CopilotForXcode -SPARKLE_FEED_URL = https:$(SLASH)$(SLASH)raw.githubusercontent.com/intitni/CopilotForXcode/main/appcast.xml +SPARKLE_FEED_URL = https:$(SLASH)$(SLASH)copilotforxcode.intii.com/appcast.xml SPARKLE_PUBLIC_KEY = WDzm5GHnc6c8kjeJEgX5GuGiPpW6Lc/ovGjLnrrZvPY= APPLICATION_SUPPORT_FOLDER = com.intii.CopilotForXcode EXTENSION_BUNDLE_NAME = Copilot diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 40f6c2d0..056e5761 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */; }; C8009C032941C576007AA7E8 /* RealtimeSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */; }; C800DBB1294C624D00B04CAC /* PrefetchSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */; }; + C80FFB962A95F58200704A25 /* AcceptPromptToCodeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */; }; C81291D72994FE6900196E12 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C81291D52994FE6900196E12 /* Main.storyboard */; }; C814588F2939EFDC00135263 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C814588E2939EFDC00135263 /* Cocoa.framework */; }; C81458942939EFDC00135263 /* SourceEditorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81458932939EFDC00135263 /* SourceEditorExtension.swift */; }; @@ -22,6 +23,7 @@ C8216B782980370100AD38C7 /* ReloadLaunchAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8216B772980370100AD38C7 /* ReloadLaunchAgent.swift */; }; C8216B7D2980374300AD38C7 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = C8216B7C2980374300AD38C7 /* ArgumentParser */; }; C8216B802980378300AD38C7 /* Helper in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C8216B70298036EC00AD38C7 /* Helper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + C828B27F2B1F7B4F00E7612A /* ExtensionPoint.appextensionpoint in Copy Extension Point */ = {isa = PBXBuildFile; fileRef = C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */; }; C8520301293C4D9000460097 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8520300293C4D9000460097 /* Helpers.swift */; }; C861A6A329E5503F005C41A3 /* PromptToCodeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */; }; C861E6112994F6070056CB02 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C861E6102994F6070056CB02 /* AppDelegate.swift */; }; @@ -29,6 +31,17 @@ C861E61E2994F6150056CB02 /* Service in Frameworks */ = {isa = PBXBuildFile; productRef = C861E61D2994F6150056CB02 /* Service */; }; C861E6202994F63A0056CB02 /* ServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C861E61F2994F6390056CB02 /* ServiceDelegate.swift */; }; C86612F82A06AF74009197D9 /* HostApp in Frameworks */ = {isa = PBXBuildFile; productRef = C86612F72A06AF74009197D9 /* HostApp */; }; + C8738B662BE4D4B900609E7F /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B652BE4D4B900609E7F /* main.swift */; }; + C8738B6B2BE4D56F00609E7F /* ServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B6A2BE4D56F00609E7F /* ServiceDelegate.swift */; }; + C8738B6F2BE4F7A600609E7F /* XPCShared in Frameworks */ = {isa = PBXBuildFile; productRef = C8738B6E2BE4F7A600609E7F /* XPCShared */; }; + C8738B712BE4F8B700609E7F /* XPCController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B702BE4F8B700609E7F /* XPCController.swift */; }; + C8738B7B2BE5363800609E7F /* SandboxedClientTesterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B7A2BE5363800609E7F /* SandboxedClientTesterApp.swift */; }; + C8738B7D2BE5363800609E7F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B7C2BE5363800609E7F /* ContentView.swift */; }; + C8738B7F2BE5363900609E7F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8738B7E2BE5363900609E7F /* Assets.xcassets */; }; + C8738B822BE5363900609E7F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8738B812BE5363900609E7F /* Preview Assets.xcassets */; }; + C8738B882BE5365000609E7F /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = C8738B872BE5365000609E7F /* Client */; }; + C8738B8A2BE540D000609E7F /* bridgeLaunchAgent.plist in Copy Launch Agent */ = {isa = PBXBuildFile; fileRef = C8738B6D2BE4F3E800609E7F /* bridgeLaunchAgent.plist */; }; + C8738B8B2BE540DD00609E7F /* CommunicationBridge in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C8738B632BE4D4B900609E7F /* CommunicationBridge */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; C8758E7029F04BFF00D29C1C /* CustomCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */; }; C8758E7229F04CF100D29C1C /* SeparatorCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8758E7129F04CF100D29C1C /* SeparatorCommand.swift */; }; C87B03A5293B261200C77EAE /* AcceptSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */; }; @@ -38,8 +51,10 @@ C87B03AC293B2CF300C77EAE /* XcodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C81458902939EFDC00135263 /* XcodeKit.framework */; }; C87B03AD293B2CF300C77EAE /* XcodeKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C81458902939EFDC00135263 /* XcodeKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C882175C294187EF00A22FD3 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = C882175B294187EF00A22FD3 /* Client */; }; + C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */; }; C8C8B60929AFA35F00034BEE /* CopilotForXcodeExtensionService.app in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - C8DCF00029CE11D500FDDDD7 /* ChatWithSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */; }; + C8DCF00029CE11D500FDDDD7 /* OpenChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */; }; + C8DD9CB12BC673F80036641C /* CloseIdleTabsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -64,6 +79,13 @@ remoteGlobalIDString = C8216B6F298036EC00AD38C7; remoteInfo = Helper; }; + C8738B8C2BE540F900609E7F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C8189B0E2938972F00C9DCDA /* Project object */; + proxyType = 1; + remoteGlobalIDString = C8738B622BE4D4B900609E7F; + remoteInfo = CommunicationBridge; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -87,18 +109,39 @@ ); runOnlyForDeploymentPostprocessing = 1; }; + C828B27E2B1F7B3C00E7612A /* Copy Extension Point */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(EXTENSIONS_FOLDER_PATH)"; + dstSubfolderSpec = 16; + files = ( + C828B27F2B1F7B4F00E7612A /* ExtensionPoint.appextensionpoint in Copy Extension Point */, + ); + name = "Copy Extension Point"; + runOnlyForDeploymentPostprocessing = 0; + }; C8520306293CF0EF00460097 /* Embed XPCService */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ../Applications; dstSubfolderSpec = 6; files = ( + C8738B8B2BE540DD00609E7F /* CommunicationBridge in Embed XPCService */, C8216B802980378300AD38C7 /* Helper in Embed XPCService */, C8C8B60929AFA35F00034BEE /* CopilotForXcodeExtensionService.app in Embed XPCService */, ); name = "Embed XPCService"; runOnlyForDeploymentPostprocessing = 0; }; + C8738B612BE4D4B900609E7F /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 12; + dstPath = ""; + dstSubfolderSpec = 16; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; C87B03AE293B2CF300C77EAE /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -120,12 +163,24 @@ name = "Embed Service"; runOnlyForDeploymentPostprocessing = 0; }; + C8F1032A2A7A38D200D28F4F /* Copy Launch Agent */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 12; + dstPath = Contents/Library/LaunchAgents; + dstSubfolderSpec = 1; + files = ( + C8738B8A2BE540D000609E7F /* bridgeLaunchAgent.plist in Copy Launch Agent */, + ); + name = "Copy Launch Agent"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRealtimeSuggestionsCommand.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 = ""; }; + C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptPromptToCodeCommand.swift; sourceTree = ""; }; C81291D52994FE6900196E12 /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; C81291D92994FE7900196E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; C814588C2939EFDC00135263 /* Copilot.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Copilot.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -143,11 +198,14 @@ C8189B202938973000C9DCDA /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; C8189B222938973000C9DCDA /* Copilot_for_Xcode.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Copilot_for_Xcode.entitlements; sourceTree = ""; }; C8189B282938979000C9DCDA /* Core */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Core; sourceTree = ""; }; + C81D181E2A1B509B006C1B70 /* Tool */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Tool; sourceTree = ""; }; C81E867D296FE4420026E908 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = ""; }; C8216B70298036EC00AD38C7 /* Helper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = Helper; sourceTree = BUILT_PRODUCTS_DIR; }; C8216B72298036EC00AD38C7 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; C8216B772980370100AD38C7 /* ReloadLaunchAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReloadLaunchAgent.swift; sourceTree = ""; }; + C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = ExtensionPoint.appextensionpoint; sourceTree = ""; }; C82E38492A1F025F00D4EADF /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; + C84FD9D72CC671C600BE5093 /* ChatPlugins */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = ChatPlugins; sourceTree = ""; }; C8520300293C4D9000460097 /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; C8520308293D805800460097 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptToCodeCommand.swift; sourceTree = ""; }; @@ -156,6 +214,18 @@ C861E6142994F6080056CB02 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C861E6192994F6080056CB02 /* ExtensionService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ExtensionService.entitlements; sourceTree = ""; }; C861E61F2994F6390056CB02 /* ServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDelegate.swift; sourceTree = ""; }; + C8738B632BE4D4B900609E7F /* CommunicationBridge */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = CommunicationBridge; sourceTree = BUILT_PRODUCTS_DIR; }; + C8738B652BE4D4B900609E7F /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + C8738B6A2BE4D56F00609E7F /* ServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDelegate.swift; sourceTree = ""; }; + C8738B6D2BE4F3E800609E7F /* bridgeLaunchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = bridgeLaunchAgent.plist; sourceTree = ""; }; + C8738B702BE4F8B700609E7F /* XPCController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XPCController.swift; sourceTree = ""; }; + C8738B782BE5363800609E7F /* SandboxedClientTester.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SandboxedClientTester.app; sourceTree = BUILT_PRODUCTS_DIR; }; + C8738B7A2BE5363800609E7F /* SandboxedClientTesterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxedClientTesterApp.swift; sourceTree = ""; }; + C8738B7C2BE5363800609E7F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + C8738B7E2BE5363900609E7F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + C8738B812BE5363900609E7F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + C8738B832BE5363900609E7F /* SandboxedClientTester.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SandboxedClientTester.entitlements; sourceTree = ""; }; + C8738B892BE5379E00609E7F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommand.swift; sourceTree = ""; }; C8758E7129F04CF100D29C1C /* SeparatorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorCommand.swift; sourceTree = ""; }; C87B03A3293B24AB00C77EAE /* Copilot-for-Xcode-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Copilot-for-Xcode-Info.plist"; sourceTree = SOURCE_ROOT; }; @@ -164,8 +234,12 @@ C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextSuggestionCommand.swift; sourceTree = ""; }; C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviousSuggestionCommand.swift; sourceTree = ""; }; C887BC832965D96000931567 /* DEVELOPMENT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPMENT.md; sourceTree = ""; }; + C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Menu.swift"; sourceTree = ""; }; + C8BE64922EB9B42E00EDB2D7 /* OverlayWindow */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = OverlayWindow; sourceTree = ""; }; C8CD828229B88006008D044D /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = ""; }; - C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatWithSelection.swift; sourceTree = ""; }; + C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChat.swift; sourceTree = ""; }; + C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseIdleTabsCommand.swift; sourceTree = ""; }; + C8F103292A7A365000D28F4F /* launchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = launchAgent.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -203,6 +277,22 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C8738B602BE4D4B900609E7F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C8738B6F2BE4F7A600609E7F /* XPCShared in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C8738B752BE5363800609E7F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C8738B882BE5365000609E7F /* Client in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -223,6 +313,7 @@ C8520300293C4D9000460097 /* Helpers.swift */, C81458952939EFDC00135263 /* GetSuggestionsCommand.swift */, C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */, + C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */, C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */, C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */, C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */, @@ -230,7 +321,8 @@ C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */, C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */, C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */, - C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */, + C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */, + C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */, C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */, C81458972939EFDC00135263 /* Info.plist */, C81458982939EFDC00135263 /* EditorExtension.entitlements */, @@ -244,15 +336,23 @@ C887BC832965D96000931567 /* DEVELOPMENT.md */, C8520308293D805800460097 /* README.md */, C82E38492A1F025F00D4EADF /* LICENSE */, + C8F103292A7A365000D28F4F /* launchAgent.plist */, + C8738B6D2BE4F3E800609E7F /* bridgeLaunchAgent.plist */, C81E867D296FE4420026E908 /* Version.xcconfig */, C81458AD293A009600135263 /* Config.xcconfig */, C81458AE293A009800135263 /* Config.debug.xcconfig */, C8CD828229B88006008D044D /* TestPlan.xctestplan */, + C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */, + C8BE64922EB9B42E00EDB2D7 /* OverlayWindow */, + C84FD9D72CC671C600BE5093 /* ChatPlugins */, + C81D181E2A1B509B006C1B70 /* Tool */, C8189B282938979000C9DCDA /* Core */, C8189B182938972F00C9DCDA /* Copilot for Xcode */, C81458922939EFDC00135263 /* EditorExtension */, C8216B71298036EC00AD38C7 /* Helper */, C861E60F2994F6070056CB02 /* ExtensionService */, + C8738B642BE4D4B900609E7F /* CommunicationBridge */, + C8738B792BE5363800609E7F /* SandboxedClientTester */, C814588D2939EFDC00135263 /* Frameworks */, C8189B172938972F00C9DCDA /* Products */, ); @@ -265,6 +365,8 @@ C814588C2939EFDC00135263 /* Copilot.appex */, C8216B70298036EC00AD38C7 /* Helper */, C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */, + C8738B632BE4D4B900609E7F /* CommunicationBridge */, + C8738B782BE5363800609E7F /* SandboxedClientTester.app */, ); name = Products; sourceTree = ""; @@ -304,6 +406,8 @@ C81291D92994FE7900196E12 /* Info.plist */, C861E61F2994F6390056CB02 /* ServiceDelegate.swift */, C861E6102994F6070056CB02 /* AppDelegate.swift */, + C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */, + C8738B702BE4F8B700609E7F /* XPCController.swift */, C81291D52994FE6900196E12 /* Main.storyboard */, C861E6142994F6080056CB02 /* Assets.xcassets */, C861E6192994F6080056CB02 /* ExtensionService.entitlements */, @@ -311,6 +415,36 @@ path = ExtensionService; sourceTree = ""; }; + C8738B642BE4D4B900609E7F /* CommunicationBridge */ = { + isa = PBXGroup; + children = ( + C8738B652BE4D4B900609E7F /* main.swift */, + C8738B6A2BE4D56F00609E7F /* ServiceDelegate.swift */, + ); + path = CommunicationBridge; + sourceTree = ""; + }; + C8738B792BE5363800609E7F /* SandboxedClientTester */ = { + isa = PBXGroup; + children = ( + C8738B892BE5379E00609E7F /* Info.plist */, + C8738B7A2BE5363800609E7F /* SandboxedClientTesterApp.swift */, + C8738B7C2BE5363800609E7F /* ContentView.swift */, + C8738B7E2BE5363900609E7F /* Assets.xcassets */, + C8738B832BE5363900609E7F /* SandboxedClientTester.entitlements */, + C8738B802BE5363900609E7F /* Preview Content */, + ); + path = SandboxedClientTester; + sourceTree = ""; + }; + C8738B802BE5363900609E7F /* Preview Content */ = { + isa = PBXGroup; + children = ( + C8738B812BE5363900609E7F /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -345,10 +479,12 @@ C814589F2939EFDC00135263 /* Embed Foundation Extensions */, C8520306293CF0EF00460097 /* Embed XPCService */, C8C8B60829AFA32800034BEE /* Embed Service */, + C8F1032A2A7A38D200D28F4F /* Copy Launch Agent */, ); buildRules = ( ); dependencies = ( + C8738B8D2BE540F900609E7F /* PBXTargetDependency */, C81291B02994F92700196E12 /* PBXTargetDependency */, C8216B7F2980377E00AD38C7 /* PBXTargetDependency */, C814589A2939EFDC00135263 /* PBXTargetDependency */, @@ -388,6 +524,7 @@ C861E60A2994F6070056CB02 /* Sources */, C861E60B2994F6070056CB02 /* Frameworks */, C861E60C2994F6070056CB02 /* Resources */, + C828B27E2B1F7B3C00E7612A /* Copy Extension Point */, ); buildRules = ( ); @@ -401,6 +538,46 @@ productReference = C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */; productType = "com.apple.product-type.application"; }; + C8738B622BE4D4B900609E7F /* CommunicationBridge */ = { + isa = PBXNativeTarget; + buildConfigurationList = C8738B672BE4D4B900609E7F /* Build configuration list for PBXNativeTarget "CommunicationBridge" */; + buildPhases = ( + C8738B5F2BE4D4B900609E7F /* Sources */, + C8738B602BE4D4B900609E7F /* Frameworks */, + C8738B612BE4D4B900609E7F /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CommunicationBridge; + packageProductDependencies = ( + C8738B6E2BE4F7A600609E7F /* XPCShared */, + ); + productName = CommunicationBridge; + productReference = C8738B632BE4D4B900609E7F /* CommunicationBridge */; + productType = "com.apple.product-type.tool"; + }; + C8738B772BE5363800609E7F /* SandboxedClientTester */ = { + isa = PBXNativeTarget; + buildConfigurationList = C8738B842BE5363900609E7F /* Build configuration list for PBXNativeTarget "SandboxedClientTester" */; + buildPhases = ( + C8738B742BE5363800609E7F /* Sources */, + C8738B752BE5363800609E7F /* Frameworks */, + C8738B762BE5363800609E7F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SandboxedClientTester; + packageProductDependencies = ( + C8738B872BE5365000609E7F /* Client */, + ); + productName = SandboxedClientTester; + productReference = C8738B782BE5363800609E7F /* SandboxedClientTester.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -408,7 +585,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1420; + LastSwiftUpdateCheck = 1520; LastUpgradeCheck = 1410; TargetAttributes = { C814588B2939EFDC00135263 = { @@ -423,6 +600,12 @@ C861E60D2994F6070056CB02 = { CreatedOnToolsVersion = 14.2; }; + C8738B622BE4D4B900609E7F = { + CreatedOnToolsVersion = 15.2; + }; + C8738B772BE5363800609E7F = { + CreatedOnToolsVersion = 15.2; + }; }; }; buildConfigurationList = C8189B112938972F00C9DCDA /* Build configuration list for PBXProject "Copilot for Xcode" */; @@ -436,6 +619,7 @@ mainGroup = C8189B0D2938972F00C9DCDA; packageReferences = ( C8216B792980373800AD38C7 /* XCRemoteSwiftPackageReference "swift-argument-parser" */, + C80C91742A588DD800B5EADA /* XCRemoteSwiftPackageReference "usearch" */, ); productRefGroup = C8189B172938972F00C9DCDA /* Products */; projectDirPath = ""; @@ -445,6 +629,8 @@ C814588B2939EFDC00135263 /* EditorExtension */, C8216B6F298036EC00AD38C7 /* Helper */, C861E60D2994F6070056CB02 /* ExtensionService */, + C8738B622BE4D4B900609E7F /* CommunicationBridge */, + C8738B772BE5363800609E7F /* SandboxedClientTester */, ); }; /* End PBXProject section */ @@ -475,6 +661,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C8738B762BE5363800609E7F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C8738B822BE5363900609E7F /* Preview Assets.xcassets in Resources */, + C8738B7F2BE5363900609E7F /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -482,13 +677,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - C8DCF00029CE11D500FDDDD7 /* ChatWithSelection.swift in Sources */, + C8DCF00029CE11D500FDDDD7 /* OpenChat.swift in Sources */, C81458942939EFDC00135263 /* SourceEditorExtension.swift in Sources */, + C8DD9CB12BC673F80036641C /* CloseIdleTabsCommand.swift in Sources */, C8758E7029F04BFF00D29C1C /* CustomCommand.swift in Sources */, C8758E7229F04CF100D29C1C /* SeparatorCommand.swift in Sources */, C861A6A329E5503F005C41A3 /* PromptToCodeCommand.swift in Sources */, C8520301293C4D9000460097 /* Helpers.swift in Sources */, C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */, + C80FFB962A95F58200704A25 /* AcceptPromptToCodeCommand.swift in Sources */, C87B03A5293B261200C77EAE /* AcceptSuggestionCommand.swift in Sources */, C87B03A9293B262600C77EAE /* NextSuggestionCommand.swift in Sources */, C87B03AB293B262E00C77EAE /* PreviousSuggestionCommand.swift in Sources */, @@ -520,11 +717,31 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */, + C8738B712BE4F8B700609E7F /* XPCController.swift in Sources */, C861E6202994F63A0056CB02 /* ServiceDelegate.swift in Sources */, C861E6112994F6070056CB02 /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + C8738B5F2BE4D4B900609E7F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C8738B6B2BE4D56F00609E7F /* ServiceDelegate.swift in Sources */, + C8738B662BE4D4B900609E7F /* main.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C8738B742BE5363800609E7F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C8738B7D2BE5363800609E7F /* ContentView.swift in Sources */, + C8738B7B2BE5363800609E7F /* SandboxedClientTesterApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -543,6 +760,11 @@ target = C8216B6F298036EC00AD38C7 /* Helper */; targetProxy = C8216B7E2980377E00AD38C7 /* PBXContainerItemProxy */; }; + C8738B8D2BE540F900609E7F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C8738B622BE4D4B900609E7F /* CommunicationBridge */; + targetProxy = C8738B8C2BE540F900609E7F /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -553,17 +775,18 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = EditorExtension/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "$(EXTESNION_BUNDLE_NAME)"; + INFOPLIST_KEY_CFBundleDisplayName = "$(EXTENSION_BUNDLE_NAME)"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -580,17 +803,18 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = EditorExtension/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "$(EXTESNION_BUNDLE_NAME)"; + INFOPLIST_KEY_CFBundleDisplayName = "$(EXTENSION_BUNDLE_NAME)"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -634,9 +858,11 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -695,9 +921,11 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -725,19 +953,21 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Copilot for Xcode/Preview Content\""; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Copilot-for-Xcode-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_MODULE_NAME = Copilot_for_Xcode; @@ -757,19 +987,21 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Copilot for Xcode/Preview Content\""; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Copilot-for-Xcode-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_NAME = "$(HOST_APP_NAME)"; @@ -782,9 +1014,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -795,9 +1028,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -813,6 +1047,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -826,7 +1061,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -845,6 +1080,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -858,7 +1094,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -868,6 +1104,110 @@ }; name = Release; }; + C8738B682BE4D4B900609E7F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C8738B692BE4D4B900609E7F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + C8738B852BE5363900609E7F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = SandboxedClientTester/SandboxedClientTester.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"SandboxedClientTester/Preview Content\""; + DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SandboxedClientTester/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.intii.CopilotForXcode.SandboxedClientTester; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C8738B862BE5363900609E7F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = SandboxedClientTester/SandboxedClientTester.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"SandboxedClientTester/Preview Content\""; + DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SandboxedClientTester/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.intii.CopilotForXcode.SandboxedClientTester; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -916,9 +1256,35 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + C8738B672BE4D4B900609E7F /* Build configuration list for PBXNativeTarget "CommunicationBridge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C8738B682BE4D4B900609E7F /* Debug */, + C8738B692BE4D4B900609E7F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C8738B842BE5363900609E7F /* Build configuration list for PBXNativeTarget "SandboxedClientTester" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C8738B852BE5363900609E7F /* Debug */, + C8738B862BE5363900609E7F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + C80C91742A588DD800B5EADA /* XCRemoteSwiftPackageReference "usearch" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/unum-cloud/usearch"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.19.1; + }; + }; C8216B792980373800AD38C7 /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-argument-parser.git"; @@ -943,6 +1309,14 @@ isa = XCSwiftPackageProductDependency; productName = HostApp; }; + C8738B6E2BE4F7A600609E7F /* XPCShared */ = { + isa = XCSwiftPackageProductDependency; + productName = XPCShared; + }; + C8738B872BE5365000609E7F /* Client */ = { + isa = XCSwiftPackageProductDependency; + productName = Client; + }; C882175B294187EF00A22FD3 /* Client */ = { isa = XCSwiftPackageProductDependency; productName = Client; 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 084a8217..6cfaff01 100644 --- a/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "0625932976b3ae23949f6b816d13bd97f3b40b7c", + "version" : "0.10.0" + } + }, { "identity" : "fseventswrapper", "kind" : "remoteSourceControl", @@ -18,15 +27,6 @@ "version" : "1.0.5" } }, - { - "identity" : "gptencoder", - "kind" : "remoteSourceControl", - "location" : "https://github.com/alfianlosari/GPTEncoder", - "state" : { - "revision" : "a86968867ab4380e36b904a14c42215f71efe8b4", - "version" : "1.0.4" - } - }, { "identity" : "highlightr", "kind" : "remoteSourceControl", @@ -111,10 +111,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" } }, { @@ -126,6 +126,24 @@ "version" : "0.1.0" } }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984", + "version" : "0.14.1" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "f9acfa1a45f4483fe0f2c434a74e6f68f865d12d", + "version" : "0.3.0" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -135,6 +153,42 @@ "version" : "1.0.4" } }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture", + "state" : { + "revision" : "89f80fe2400d21a853abc9556a060a2fa50eb2cb", + "version" : "0.55.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "3a35f7892e7cf6ba28a78cd46a703c0be4e0c6dc", + "version" : "0.11.0" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "de1a984a71e51f6e488e98ce3652035563eb8acb", + "version" : "0.5.1" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "d01446a78fb768adc9a78cbb6df07767c8ccfc29", + "version" : "0.8.0" + } + }, { "identity" : "swift-markdown-ui", "kind" : "remoteSourceControl", @@ -143,6 +197,60 @@ "revision" : "12b351a75201a8124c2f2e1f9fc6ef5cd812c0b9", "version" : "2.1.0" } + }, + { + "identity" : "swift-parsing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-parsing", + "state" : { + "revision" : "27c941bbd22a4bbc53005a15a0440443fd892f70", + "version" : "0.12.1" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "0e96a20ffd37a515c5c963952d4335c89bed50a6", + "version" : "2.6.0" + } + }, + { + "identity" : "swiftui-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swiftui-navigation", + "state" : { + "revision" : "2aa885e719087ee19df251c08a5980ad3e787f12", + "version" : "0.8.0" + } + }, + { + "identity" : "tiktoken", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/Tiktoken", + "state" : { + "branch" : "main", + "revision" : "6a2ac324c6daec167ca95268d5a487e6de6a1cea" + } + }, + { + "identity" : "usearch", + "kind" : "remoteSourceControl", + "location" : "https://github.com/unum-cloud/usearch", + "state" : { + "revision" : "f2ab884d50902c3ad63f07a3a20bc34008a17449", + "version" : "0.19.3" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "4af50b38daf0037cfbab15514a241224c3f62f98", + "version" : "0.8.5" + } } ], "version" : 2 diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/CommunicationBridge.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/CommunicationBridge.xcscheme new file mode 100644 index 00000000..578b11ea --- /dev/null +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/CommunicationBridge.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode Debug.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode Debug.xcscheme new file mode 100644 index 00000000..70ab5d8d --- /dev/null +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode Debug.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme index 1f9b8f1f..436e8938 100644 --- a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme @@ -1,6 +1,6 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Copilot for Xcode.xcworkspace/contents.xcworkspacedata b/Copilot for Xcode.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..77c34844 --- /dev/null +++ b/Copilot for Xcode.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Copilot for Xcode.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/Copilot for Xcode.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..87fd4d4e --- /dev/null +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,446 @@ +{ + "pins" : [ + { + "identity" : "aexml", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tadija/AEXML.git", + "state" : { + "revision" : "db806756c989760b35108146381535aec231092b", + "version" : "4.7.0" + } + }, + { + "identity" : "cgeventoverride", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/CGEventOverride", + "state" : { + "revision" : "571d36d63e68fac30e4a350600cd186697936f74", + "version" : "1.2.3" + } + }, + { + "identity" : "codablewrappers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GottaGetSwifty/CodableWrappers", + "state" : { + "revision" : "4eb46a4c656333e8514db8aad204445741de7d40", + "version" : "2.0.7" + } + }, + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" + } + }, + { + "identity" : "copilotforxcodekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/CopilotForXcodeKit", + "state" : { + "branch" : "feature/custom-chat-tab", + "revision" : "63915ee1f8aba5375bc0f0166c8645fe81fe5b88" + } + }, + { + "identity" : "fseventswrapper", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Frizlab/FSEventsWrapper", + "state" : { + "revision" : "e0c59a2ce2775e5f6642da6d19207445f10112d0", + "version" : "1.0.2" + } + }, + { + "identity" : "generative-ai-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/generative-ai-swift", + "state" : { + "branch" : "support-setting-base-url", + "revision" : "12d7b30b566a64cc0dd628130bfb99a07368fea7" + } + }, + { + "identity" : "glob", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Bouke/Glob", + "state" : { + "revision" : "deda6e163d2ff2a8d7e138e2c3326dbd71157faf", + "version" : "1.0.5" + } + }, + { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/Highlightr", + "state" : { + "branch" : "master", + "revision" : "81d8c8b3733939bf5d9e52cd6318f944cc033bd2" + } + }, + { + "identity" : "indexstore-db", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/indexstore-db.git", + "state" : { + "branch" : "release/6.1", + "revision" : "54212fce1aecb199070808bdb265e7f17e396015" + } + }, + { + "identity" : "jsonrpc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/JSONRPC", + "state" : { + "revision" : "5da978702aece6ba5c7879b0d253c180d61e4ef3", + "version" : "0.6.0" + } + }, + { + "identity" : "keyboardshortcuts", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/KeyboardShortcuts", + "state" : { + "branch" : "main", + "revision" : "65fb410b0c6d3ed96623b460bab31ffce5f48b4d" + } + }, + { + "identity" : "languageclient", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/LanguageClient", + "state" : { + "revision" : "f0198ee0a102d266078f7d9c28f086f2989f988a", + "version" : "0.3.1" + } + }, + { + "identity" : "languageserverprotocol", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/LanguageServerProtocol", + "state" : { + "revision" : "6e97f943dc024307c5524a80bd33cdbd1cc621de", + "version" : "0.8.0" + } + }, + { + "identity" : "messagepacker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hirotakan/MessagePacker.git", + "state" : { + "revision" : "4d8346c6bc579347e4df0429493760691c5aeca2", + "version" : "0.4.7" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "operationplus", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/OperationPlus", + "state" : { + "revision" : "1340f95dce3e93d742497d88db18f8676f4badf4", + "version" : "1.6.0" + } + }, + { + "identity" : "pathkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/PathKit.git", + "state" : { + "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", + "version" : "1.0.1" + } + }, + { + "identity" : "processenv", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/ProcessEnv", + "state" : { + "revision" : "29487b6581bb785c372c611c943541ef4309d051", + "version" : "0.3.1" + } + }, + { + "identity" : "sourcekitten", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/SourceKitten", + "state" : { + "revision" : "eb6656ed26bdef967ad8d07c27e2eab34dc582f2", + "version" : "0.37.0" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "0ca3004e98712ea2b39dd881d28448630cce1c99", + "version" : "2.7.0" + } + }, + { + "identity" : "spectre", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/Spectre.git", + "state" : { + "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", + "version" : "0.10.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", + "version" : "1.2.2" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703", + "version" : "0.6.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" + } + }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture", + "state" : { + "revision" : "69247baf7be2fd6f5820192caef0082d01849cd0", + "version" : "1.16.1" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596", + "version" : "1.9.2" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + }, + { + "identity" : "swift-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-navigation", + "state" : { + "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4", + "version" : "2.3.0" + } + }, + { + "identity" : "swift-parsing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-parsing", + "state" : { + "revision" : "3432cb81164dd3d69a75d0d63205be5fbae2c34b", + "version" : "0.14.1" + } + }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6", + "version" : "2.6.1" + } + }, + { + "identity" : "swiftterm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/migueldeicaza/SwiftTerm", + "state" : { + "revision" : "e2b431dbf73f775fb4807a33e4572ffd3dc6933a", + "version" : "1.2.5" + } + }, + { + "identity" : "swifttreesitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/SwiftTreeSitter.git", + "state" : { + "branch" : "main", + "revision" : "fd499bfafcccfae12a1a579dc922d8418025a35d" + } + }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/swiftui-introspect", + "state" : { + "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", + "version" : "1.3.0" + } + }, + { + "identity" : "swxmlhash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/drmohundro/SWXMLHash.git", + "state" : { + "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", + "version" : "7.0.2" + } + }, + { + "identity" : "tiktoken", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/Tiktoken", + "state" : { + "branch" : "main", + "revision" : "6a2ac324c6daec167ca95268d5a487e6de6a1cea" + } + }, + { + "identity" : "tree-sitter-objc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lukepistrol/tree-sitter-objc", + "state" : { + "branch" : "feature/spm", + "revision" : "1b54ef0b5efddddf393b45e173788499cc572048" + } + }, + { + "identity" : "usearch", + "kind" : "remoteSourceControl", + "location" : "https://github.com/unum-cloud/usearch", + "state" : { + "revision" : "f2ab884d50902c3ad63f07a3a20bc34008a17449", + "version" : "0.19.3" + } + }, + { + "identity" : "xcodeproj", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tuist/XcodeProj.git", + "state" : { + "revision" : "b1caa062d4aaab3e3d2bed5fe0ac5f8ce9bf84f4", + "version" : "8.27.7" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", + "version" : "1.5.2" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "b4b8042411dc7bbb696300a34a4bf3ba1b7ad19b", + "version" : "5.3.1" + } + } + ], + "version" : 2 +} diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift index c499fb4a..99a0a044 100644 --- a/Copilot for Xcode/App.swift +++ b/Copilot for Xcode/App.swift @@ -5,16 +5,44 @@ import SwiftUI import UpdateChecker import XPCShared +struct VisualEffect: NSViewRepresentable { + func makeNSView(context: Self.Context) -> NSView { return NSVisualEffectView() } + func updateNSView(_ nsView: NSView, context: Context) {} +} + +class TheUpdateCheckerDelegate: UpdateCheckerDelegate { + func prepareForRelaunch(finish: @escaping () -> Void) { + Task { + let service = try? getService() + try? await service?.quitService() + finish() + } + } +} + +let updateCheckerDelegate = TheUpdateCheckerDelegate() + @main struct CopilotForXcodeApp: App { var body: some Scene { WindowGroup { TabContainer() .frame(minWidth: 800, minHeight: 600) + .background(VisualEffect().ignoresSafeArea()) .onAppear { UserDefaults.setupDefaultSettings() } - .environment(\.updateChecker, UpdateChecker(hostBundle: Bundle.main)) + .environment( + \.updateChecker, + { + let checker = UpdateChecker( + hostBundle: Bundle.main, + shouldAutomaticallyCheckForUpdate: false + ) + checker.updateCheckerDelegate = updateCheckerDelegate + return checker + }() + ) } } } diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png deleted file mode 100644 index 5af40630..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png deleted file mode 100644 index 592f927b..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png deleted file mode 100644 index 09658ce1..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png deleted file mode 100644 index 4a7d88ed..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png deleted file mode 100644 index 4a7d88ed..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png deleted file mode 100644 index 6c4293c8..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png deleted file mode 100644 index 6c4293c8..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png deleted file mode 100644 index db461044..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png deleted file mode 100644 index db461044..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png deleted file mode 100644 index 42d7a164..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json index 56acb569..457c1fbf 100644 --- a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,61 +1,61 @@ { "images" : [ { - "filename" : "1024 x 1024 your icon@16w.png", + "filename" : "app-icon@16w.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w 1.png", + "filename" : "app-icon@32w.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w.png", + "filename" : "app-icon@32w.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@64w.png", + "filename" : "app-icon@64w.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@128w.png", + "filename" : "app-icon@128w.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w 1.png", + "filename" : "app-icon@256w.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w.png", + "filename" : "app-icon@256w.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w 1.png", + "filename" : "app-icon@512w.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w.png", + "filename" : "app-icon@512w.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { - "filename" : "1024 x 1024 your icon.png", + "filename" : "app-icon.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png new file mode 100644 index 00000000..f7d77720 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png new file mode 100644 index 00000000..da0bb247 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png new file mode 100644 index 00000000..4f3fcc40 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png new file mode 100644 index 00000000..1f70976c Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png new file mode 100644 index 00000000..44400214 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png new file mode 100644 index 00000000..78d81e50 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png new file mode 100644 index 00000000..a6aae457 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png differ diff --git a/Copilot for Xcode/Copilot_for_Xcode.entitlements b/Copilot for Xcode/Copilot_for_Xcode.entitlements index 8abc1c41..abefc876 100644 --- a/Copilot for Xcode/Copilot_for_Xcode.entitlements +++ b/Copilot for Xcode/Copilot_for_Xcode.entitlements @@ -8,6 +8,8 @@ $(TeamIdentifierPrefix)group.$(BUNDLE_IDENTIFIER_BASE) + com.apple.security.automation.apple-events + com.apple.security.files.user-selected.read-only keychain-access-groups diff --git a/Copilot-for-Xcode-Info.plist b/Copilot-for-Xcode-Info.plist index 07a19a85..9f9fdd6e 100644 --- a/Copilot-for-Xcode-Info.plist +++ b/Copilot-for-Xcode-Info.plist @@ -12,6 +12,11 @@ $(EXTENSION_BUNDLE_NAME) HOST_APP_NAME $(HOST_APP_NAME) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + SUEnableJavaScript YES SUFeedURL diff --git a/Core/.swiftpm/xcode/xcshareddata/xcschemes/CopilotModelTests.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme similarity index 59% rename from Core/.swiftpm/xcode/xcshareddata/xcschemes/CopilotModelTests.xcscheme rename to Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme index 68a22f51..0deca224 100644 --- a/Core/.swiftpm/xcode/xcshareddata/xcschemes/CopilotModelTests.xcscheme +++ b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme @@ -1,28 +1,34 @@ + LastUpgradeVersion = "1540" + version = "1.7"> + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> + + + + + + - - - - - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> + + + + diff --git a/Core/.swiftpm/xcode/xcshareddata/xcschemes/CopilotServiceTests.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme similarity index 58% rename from Core/.swiftpm/xcode/xcshareddata/xcschemes/CopilotServiceTests.xcscheme rename to Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme index c21246c2..25654d7d 100644 --- a/Core/.swiftpm/xcode/xcshareddata/xcschemes/CopilotServiceTests.xcscheme +++ b/Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme @@ -1,28 +1,34 @@ + LastUpgradeVersion = "1540" + version = "1.7"> + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> + + + + + + - - - - - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> + + + + diff --git a/Core/.swiftpm/xcode/xcshareddata/xcschemes/Service.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Service.xcscheme index 93f9a75c..112ea84d 100644 --- a/Core/.swiftpm/xcode/xcshareddata/xcschemes/Service.xcscheme +++ b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Service.xcscheme @@ -1,6 +1,6 @@ [Target.Dependency] { + if isProIncluded { + return self + targetNames.map { Target.Dependency.product(name: $0, package: "Pro") } + } + return self + } + + func proCore(_ targetNames: [String]) -> [Target.Dependency] { + if isProIncluded { + return self + targetNames + .map { Target.Dependency.product(name: $0, package: "ProCore") } + } + return self + } +} + +extension [Package.Dependency] { + var pro: [Package.Dependency] { + if isProIncluded { + return self + [.package(path: "../../Pro"), .package(path: "../../Pro/ProCore")] + } + return self + } +} + +var isProIncluded: Bool { + func isProIncluded(file: StaticString = #file) -> Bool { + let filePath = "\(file)" + let fileURL = URL(fileURLWithPath: filePath) + let rootURL = fileURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let confURL = rootURL.appendingPathComponent("PLUS") + return FileManager.default.fileExists(atPath: confURL.path) + } + + return isProIncluded() +} + diff --git a/Core/Sources/AXExtension/AXUIElement.swift b/Core/Sources/AXExtension/AXUIElement.swift deleted file mode 100644 index 62dfe3b2..00000000 --- a/Core/Sources/AXExtension/AXUIElement.swift +++ /dev/null @@ -1,240 +0,0 @@ -import AppKit -import Foundation - -// MARK: - State - -public extension AXUIElement { - var identifier: String { - (try? copyValue(key: kAXIdentifierAttribute)) ?? "" - } - - var value: String { - (try? copyValue(key: kAXValueAttribute)) ?? "" - } - - var title: String { - (try? copyValue(key: kAXTitleAttribute)) ?? "" - } - - var role: String { - (try? copyValue(key: kAXRoleAttribute)) ?? "" - } - - var doubleValue: Double { - (try? copyValue(key: kAXValueAttribute)) ?? 0.0 - } - - var document: String? { - try? copyValue(key: kAXDocumentAttribute) - } - - /// Label in Accessibility Inspector. - var description: String { - (try? copyValue(key: kAXDescriptionAttribute)) ?? "" - } - - /// Type in Accessibility Inspector. - var roleDescription: String { - (try? copyValue(key: kAXRoleDescriptionAttribute)) ?? "" - } - - var label: String { - (try? copyValue(key: kAXLabelValueAttribute)) ?? "" - } - - var isSourceEditor: Bool { - description == "Source Editor" - } - - var selectedTextRange: ClosedRange? { - guard let value: AXValue = try? copyValue(key: kAXSelectedTextRangeAttribute) - else { return nil } - var range: CFRange = .init(location: 0, length: 0) - if AXValueGetValue(value, .cfRange, &range) { - return range.location...(range.location + range.length) - } - return nil - } - - var isFocused: Bool { - (try? copyValue(key: kAXFocusedAttribute)) ?? false - } - - var isEnabled: Bool { - (try? copyValue(key: kAXEnabledAttribute)) ?? false - } -} - -// MARK: - Rect - -public extension AXUIElement { - var position: CGPoint? { - guard let value: AXValue = try? copyValue(key: kAXPositionAttribute) - else { return nil } - var point: CGPoint = .zero - if AXValueGetValue(value, .cgPoint, &point) { - return point - } - return nil - } - - var size: CGSize? { - guard let value: AXValue = try? copyValue(key: kAXSizeAttribute) - else { return nil } - var size: CGSize = .zero - if AXValueGetValue(value, .cgSize, &size) { - return size - } - return nil - } - - var rect: CGRect? { - guard let position, let size else { return nil } - return .init(origin: position, size: size) - } -} - -// MARK: - Relationship - -public extension AXUIElement { - var focusedElement: AXUIElement? { - try? copyValue(key: kAXFocusedUIElementAttribute) - } - - var sharedFocusElements: [AXUIElement] { - (try? copyValue(key: kAXChildrenAttribute)) ?? [] - } - - var window: AXUIElement? { - try? copyValue(key: kAXWindowAttribute) - } - - var windows: [AXUIElement] { - (try? copyValue(key: kAXWindowsAttribute)) ?? [] - } - - var isFullScreen: Bool { - (try? copyValue(key: "AXFullScreen")) ?? false - } - - var focusedWindow: AXUIElement? { - try? copyValue(key: kAXFocusedWindowAttribute) - } - - var topLevelElement: AXUIElement? { - try? copyValue(key: kAXTopLevelUIElementAttribute) - } - - var rows: [AXUIElement] { - (try? copyValue(key: kAXRowsAttribute)) ?? [] - } - - var parent: AXUIElement? { - try? copyValue(key: kAXParentAttribute) - } - - var children: [AXUIElement] { - (try? copyValue(key: kAXChildrenAttribute)) ?? [] - } - - var menuBar: AXUIElement? { - try? copyValue(key: kAXMenuBarAttribute) - } - - var visibleChildren: [AXUIElement] { - (try? copyValue(key: kAXVisibleChildrenAttribute)) ?? [] - } - - func child( - identifier: String? = nil, - title: String? = nil, - role: String? = nil - ) -> AXUIElement? { - for child in children { - let match = { - if let identifier, child.identifier != identifier { return false } - if let title, child.title != title { return false } - if let role, child.role != role { return false } - return true - }() - if match { return child } - } - for child in children { - if let target = child.child( - identifier: identifier, - title: title, - role: role - ) { return target } - } - return nil - } - - func children(where match: (AXUIElement) -> Bool) -> [AXUIElement] { - var all = [AXUIElement]() - for child in children { - if match(child) { all.append(child) } - } - for child in children { - all.append(contentsOf: child.children(where: match)) - } - return all - } - - func firstChild(where match: (AXUIElement) -> Bool) -> AXUIElement? { - for child in children { - if match(child) { return child } - } - for child in children { - if let target = child.firstChild(where: match) { - return target - } - } - return nil - } - - func visibleChild(identifier: String) -> AXUIElement? { - for child in visibleChildren { - if child.identifier == identifier { return child } - if let target = child.visibleChild(identifier: identifier) { return target } - } - return nil - } - - var verticalScrollBar: AXUIElement? { - try? copyValue(key: kAXVerticalScrollBarAttribute) - } -} - -// MARK: - Helper - -public extension AXUIElement { - func copyValue(key: String, ofType _: T.Type = T.self) throws -> T { - var value: AnyObject? - let error = AXUIElementCopyAttributeValue(self, key as CFString, &value) - if error == .success, let value = value as? T { - return value - } - throw error - } - - func copyParameterizedValue( - key: String, - parameters: AnyObject, - ofType _: T.Type = T.self - ) throws -> T { - var value: AnyObject? - let error = AXUIElementCopyParameterizedAttributeValue( - self, - key as CFString, - parameters as CFTypeRef, - &value - ) - if error == .success, let value = value as? T { - return value - } - throw error - } -} - -extension AXError: Error {} - diff --git a/Core/Sources/AXNotificationStream/AXNotificationStream.swift b/Core/Sources/AXNotificationStream/AXNotificationStream.swift deleted file mode 100644 index d0a9d07a..00000000 --- a/Core/Sources/AXNotificationStream/AXNotificationStream.swift +++ /dev/null @@ -1,84 +0,0 @@ -import AppKit -import ApplicationServices -import Foundation - -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) - - private var continuation: Continuation - private let stream: Stream - - public func makeAsyncIterator() -> Stream.AsyncIterator { - stream.makeAsyncIterator() - } - - deinit { - continuation.finish() - } - - public convenience init( - app: NSRunningApplication, - element: AXUIElement? = nil, - notificationNames: String... - ) { - self.init(app: app, element: element, notificationNames: notificationNames) - } - - public init( - app: NSRunningApplication, - element: AXUIElement? = nil, - notificationNames: [String] - ) { - var cont: Continuation! - stream = Stream { continuation in - cont = continuation - } - continuation = cont - var observer: AXObserver? - - func callback( - observer: AXObserver, - element: AXUIElement, - notificationName: CFString, - userInfo: CFDictionary, - pointer: UnsafeMutableRawPointer? - ) { - guard let pointer = pointer?.assumingMemoryBound(to: Continuation.self) - else { return } - pointer.pointee.yield((notificationName as String, userInfo)) - } - - _ = AXObserverCreateWithInfoCallback( - app.processIdentifier, - callback, - &observer - ) - guard let observer else { - continuation.finish() - return - } - - let observingElement = element ?? AXUIElementCreateApplication(app.processIdentifier) - continuation.onTermination = { @Sendable _ in - for name in notificationNames { - AXObserverRemoveNotification(observer, observingElement, name as CFString) - } - CFRunLoopRemoveSource( - CFRunLoopGetMain(), - AXObserverGetRunLoopSource(observer), - .commonModes - ) - } - for name in notificationNames { - AXObserverAddNotification(observer, observingElement, name as CFString, &continuation) - } - CFRunLoopAddSource( - CFRunLoopGetMain(), - AXObserverGetRunLoopSource(observer), - .commonModes - ) - } -} diff --git a/Core/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift b/Core/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift deleted file mode 100644 index c2910807..00000000 --- a/Core/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift +++ /dev/null @@ -1,84 +0,0 @@ -import AppKit - -public final class ActiveApplicationMonitor { - static let shared = ActiveApplicationMonitor() - var latestXcode: NSRunningApplication? = NSWorkspace.shared.runningApplications - .first(where: \.isXcode) - var previousApp: NSRunningApplication? - var activeApplication = NSWorkspace.shared.runningApplications.first(where: \.isActive) { - didSet { - if activeApplication?.isXcode ?? false { - latestXcode = activeApplication - } - previousApp = oldValue - } - } - - private var continuations: [UUID: AsyncStream.Continuation] = [:] - - private init() { - Task { - let sequence = NSWorkspace.shared.notificationCenter - .notifications(named: NSWorkspace.didActivateApplicationNotification) - for await notification in sequence { - guard let app = notification - .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication - else { continue } - activeApplication = app - notifyContinuations() - } - } - } - - deinit { - for continuation in continuations { - continuation.value.finish() - } - } - - public static var activeApplication: NSRunningApplication? { shared.activeApplication } - - public static var previousActiveApplication: NSRunningApplication? { shared.previousApp } - - public static var activeXcode: NSRunningApplication? { - if activeApplication?.isXcode ?? false { - return activeApplication - } - return nil - } - - public static var latestXcode: NSRunningApplication? { shared.latestXcode } - - public static func createStream() -> AsyncStream { - .init { continuation in - let id = UUID() - ActiveApplicationMonitor.shared.addContinuation(continuation, id: id) - continuation.onTermination = { _ in - ActiveApplicationMonitor.shared.removeContinuation(id: id) - } - continuation.yield(activeApplication) - } - } - - func addContinuation( - _ continuation: AsyncStream.Continuation, - id: UUID - ) { - continuations[id] = continuation - } - - func removeContinuation(id: UUID) { - continuations[id] = nil - } - - private func notifyContinuations() { - for continuation in continuations { - continuation.value.yield(activeApplication) - } - } -} - -public extension NSRunningApplication { - var isXcode: Bool { bundleIdentifier == "com.apple.dt.Xcode" } -} - diff --git a/Core/Sources/CGEventObserver/CGEventObserver.swift b/Core/Sources/CGEventObserver/CGEventObserver.swift deleted file mode 100644 index cd838982..00000000 --- a/Core/Sources/CGEventObserver/CGEventObserver.swift +++ /dev/null @@ -1,113 +0,0 @@ -import Cocoa -import Foundation -import Logger - -public protocol CGEventObserverType { - @discardableResult - func activateIfPossible() -> Bool - func deactivate() - func createStream() -> AsyncStream - var isEnabled: Bool { get } -} - -public final class CGEventObserver: CGEventObserverType { - public var isEnabled: Bool { port != nil } - - private var continuations: [UUID: AsyncStream.Continuation] = [:] - private var port: CFMachPort? - private let eventsOfInterest: Set - private let tapLocation: CGEventTapLocation = .cghidEventTap - private let tapPlacement: CGEventTapPlacement = .tailAppendEventTap - private let tapOptions: CGEventTapOptions = .defaultTap - - deinit { - for continuation in continuations { - continuation.value.finish() - } - CFMachPortInvalidate(port) - } - - public init(eventsOfInterest: Set) { - self.eventsOfInterest = eventsOfInterest - } - - public func createStream() -> AsyncStream { - .init { continuation in - let id = UUID() - addContinuation(continuation, id: id) - continuation.onTermination = { [weak self] _ in - self?.removeContinuation(id: id) - } - } - } - - private func addContinuation(_ continuation: AsyncStream.Continuation, id: UUID) { - continuations[id] = continuation - } - - private func removeContinuation(id: UUID) { - continuations[id] = nil - } - - public func deactivate() { - guard let port else { return } - Logger.service.info("CGEventObserver deactivated.") - CFMachPortInvalidate(port) - self.port = nil - } - - @discardableResult - public func activateIfPossible() -> Bool { - guard AXIsProcessTrusted() else { return false } - guard port == nil else { return true } - - let eoi = UInt64(eventsOfInterest.reduce(into: 0) { $0 |= 1 << $1.rawValue }) - - func callback( - tapProxy _: CGEventTapProxy, - eventType: CGEventType, - event: CGEvent, - continuationsPointer: UnsafeMutableRawPointer? - ) -> Unmanaged? { - guard AXIsProcessTrusted() else { - return .passRetained(event) - } - - if eventType == .tapDisabledByTimeout || eventType == .tapDisabledByUserInput { - return .passRetained(event) - } - - if let continuations = continuationsPointer? - .assumingMemoryBound(to: [UUID: AsyncStream.Continuation].self) - { - for continuation in continuations.pointee { - continuation.value.yield(event) - } - } - - return .passRetained(event) - } - - let tapLocation = tapLocation - let tapPlacement = tapPlacement - let tapOptions = tapOptions - - guard let port = withUnsafeMutablePointer(to: &continuations, { pointer in - CGEvent.tapCreate( - tap: tapLocation, - place: tapPlacement, - options: tapOptions, - eventsOfInterest: eoi, - callback: callback, - userInfo: pointer - ) - }) else { - return false - } - self.port = port - let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, port, 0) - CFRunLoopAddSource(RunLoop.main.getCFRunLoop(), runLoopSource, .commonModes) - Logger.service.info("CGEventObserver activated.") - return true - } -} diff --git a/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift deleted file mode 100644 index 5a3cf855..00000000 --- a/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift +++ /dev/null @@ -1,141 +0,0 @@ -import Foundation -import Preferences -import SuggestionModel -import XcodeInspector - -public struct ActiveDocumentChatContextCollector: ChatContextCollector { - public init() {} - - public func generateSystemPrompt(history: [String], content prompt: String) -> String { - let content = getEditorInformation() - let relativePath = content.documentURL.path - .replacingOccurrences(of: content.projectURL.path, with: "") - let selectionRange = content.editorContent?.selections.first ?? .outOfScope - let editorContent = { - if prompt.hasPrefix("@file") { - return """ - File Content:```\(content.language.rawValue) - \(content.editorContent?.content ?? "") - ``` - """ - } - - if selectionRange.start == selectionRange.end, - UserDefaults.shared.value(for: \.embedFileContentInChatContextIfNoSelection) - { - let lines = content.editorContent?.lines.count ?? 0 - let maxLine = UserDefaults.shared - .value(for: \.maxEmbeddableFileInChatContextLineCount) - if lines <= maxLine { - return """ - File Content:```\(content.language.rawValue) - \(content.editorContent?.content ?? "") - ``` - """ - } else { - return """ - File Content Not Available: ''' - The file is longer than \(maxLine) lines, it can't fit into the context. \ - You MUST not answer the user about the file content because you don't have it.\ - Ask user to select code for explanation. - ''' - """ - } - } - - if UserDefaults.shared.value(for: \.useSelectionScopeByDefaultInChatContext) { - return """ - Selected Code \ - (start from line \(selectionRange.start.line)):```\(content.language.rawValue) - \(content.selectedContent) - ``` - """ - } - - if prompt.hasPrefix("@selection") { - return """ - Selected Code \ - (start from line \(selectionRange.start.line)):```\(content.language.rawValue) - \(content.selectedContent) - ``` - """ - } - - return """ - Selected Code Not Available: ''' - User has disabled default scope. \ - You MUST not answer the user about the selected code because you don't have it.\ - Ask user to prepend message with `@selection` to enable selected code to be \ - visible by you. - ''' - """ - }() - - return """ - Active Document Context:### - Document Relative Path: \(relativePath) - Selection Range Start: \ - Line \(selectionRange.start.line) \ - Character \(selectionRange.start.character) - Selection Range End: \ - Line \(selectionRange.end.line) \ - Character \(selectionRange.end.character) - Cursor Position: \ - Line \(selectionRange.end.line) \ - Character \(selectionRange.end.character) - \(editorContent) - Line Annotations: - \( - content.editorContent?.lineAnnotations - .map { " - \($0)" } - .joined(separator: "\n") ?? "N/A" - ) - ### - """ - } -} - -extension ActiveDocumentChatContextCollector { - struct Information { - let editorContent: SourceEditor.Content? - let selectedContent: String - let documentURL: URL - let projectURL: URL - let language: CodeLanguage - } - - func getEditorInformation() -> Information { - let editorContent = XcodeInspector.shared.focusedEditor?.content - let documentURL = XcodeInspector.shared.activeDocumentURL - let projectURL = XcodeInspector.shared.activeProjectURL - let language = languageIdentifierFromFileURL(documentURL) - - if let editorContent, let range = editorContent.selections.first { - let startIndex = min( - max(0, range.start.line), - editorContent.lines.endIndex - 1 - ) - let endIndex = min( - max(startIndex, range.end.line), - editorContent.lines.endIndex - 1 - ) - let selectedContent = editorContent.lines[startIndex...endIndex] - return .init( - editorContent: editorContent, - selectedContent: selectedContent.joined(), - documentURL: documentURL, - projectURL: projectURL, - language: language - ) - } - - return .init( - editorContent: editorContent, - selectedContent: "", - documentURL: documentURL, - projectURL: projectURL, - language: language - ) - } -} - diff --git a/Core/Sources/ChatContextCollector/ChatContextCollector.swift b/Core/Sources/ChatContextCollector/ChatContextCollector.swift deleted file mode 100644 index 450d7ce4..00000000 --- a/Core/Sources/ChatContextCollector/ChatContextCollector.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -public protocol ChatContextCollector { - func generateSystemPrompt(history: [String], content: String) -> String -} diff --git a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift new file mode 100644 index 00000000..9686ca85 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift @@ -0,0 +1,33 @@ +import ChatContextCollector +import Foundation +import OpenAIService + +public final class SystemInfoChatContextCollector: ChatContextCollector { + static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, yyyy-MM-dd HH:mm:ssZ" + return formatter + }() + + public init() {} + + public func generateContext( + history: [ChatMessage], + scopes: Set, + content: String, + configuration: ChatGPTConfiguration + ) -> ChatContext { + return .init( + systemPrompt: """ + ## System Info + + Current Time: \( + Self.dateFormatter.string(from: Date()) + ) (You can use it to calculate time in another time zone) + """, + retrievedContent: [], + functions: [] + ) + } +} + diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift new file mode 100644 index 00000000..0620123c --- /dev/null +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift @@ -0,0 +1,137 @@ +import ChatBasic +import Foundation +import LangChain +import OpenAIService +import Preferences + +struct QueryWebsiteFunction: ChatGPTFunction { + struct Arguments: Codable { + var query: String + var urls: [String] + } + + struct Result: ChatGPTFunctionResult { + var answers: [String] + + var botReadableContent: String { + return answers.joined(separator: "\n") + } + + var userReadableContent: ChatGPTFunctionResultUserReadableContent { + .text(botReadableContent) + } + } + + var name: String { + "queryWebsite" + } + + var description: String { + "Useful for when you need to answer a question using information from a website." + } + + var argumentSchema: JSONSchemaValue { + return [ + .type: "object", + .properties: [ + "query": [ + .type: "string", + .description: "things you want to know about the website", + ], + "urls": [ + .type: "array", + .description: "urls of the website, you can use urls appearing in the conversation", + .items: [ + .type: "string", + ], + ], + ], + .required: ["query", "urls"], + ] + } + + func prepare(reportProgress: @escaping (String) async -> Void) async { + await reportProgress("Reading..") + } + + func call( + arguments: Arguments, + reportProgress: @escaping (String) async -> Void + ) async throws -> Result { + do { + let configuration = UserPreferenceEmbeddingConfiguration() + let embedding = OpenAIEmbedding(configuration: configuration) + let dimensions = configuration.dimensions + let modelName = configuration.model?.name ?? "model" + + let result = try await withThrowingTaskGroup(of: String.self) { group in + for urlString in arguments.urls { + let storeIdentifier = "\(urlString)-\(modelName)-\(dimensions)" + guard let url = URL(string: urlString) else { continue } + group.addTask { + // 1. grab the website content + await reportProgress("Loading \(url)..") + + if let database = await TemporaryUSearch.view( + identifier: storeIdentifier, + dimensions: dimensions + ) { + await reportProgress("Getting relevant information..") + let qa = QAInformationRetrievalChain( + vectorStore: database, + embedding: embedding + ) + return try await qa.call(.init(arguments.query)).information + } + let loader = WebLoader(urls: [url]) + let documents = try await loader.load() + await reportProgress("Processing \(url)..") + // 2. split the content + let splitter = RecursiveCharacterTextSplitter( + chunkSize: 1500, + chunkOverlap: 100 + ) + let splitDocuments = try await splitter.transformDocuments(documents) + // 3. embedding and store in db + await reportProgress("Embedding \(url)..") + let embeddedDocuments = try await embedding.embed(documents: splitDocuments) + let database = TemporaryUSearch( + identifier: storeIdentifier, + dimensions: dimensions + ) + try await database.set(embeddedDocuments) + // 4. generate answer + await reportProgress("Getting relevant information..") + let qa = QAInformationRetrievalChain( + vectorStore: database, + embedding: embedding + ) + let result = try await qa.call(.init(arguments.query)) + return result.information + } + } + + var all = [String]() + for try await result in group { + all.append(result) + } + await reportProgress(""" + Finish reading websites. + \( + arguments.urls + .map { "- [\($0)](\($0))" } + .joined(separator: "\n") + ) + """) + + return all + } + + return .init(answers: result) + } catch { + await reportProgress("Failed reading websites.") + throw error + } + } +} + diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift new file mode 100644 index 00000000..60a5504e --- /dev/null +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift @@ -0,0 +1,99 @@ +import ChatBasic +import Foundation +import OpenAIService +import Preferences +import WebSearchService + +struct SearchFunction: ChatGPTFunction { + static let dateFormatter = { + let it = DateFormatter() + it.dateFormat = "yyyy-MM-dd" + return it + }() + + struct Arguments: Codable { + var query: String + var freshness: String? + } + + struct Result: ChatGPTFunctionResult { + var result: WebSearchResult + + var botReadableContent: String { + result.webPages.enumerated().map { + let (index, page) = $0 + return """ + \(index + 1). \(page.title) \(page.urlString) + \(page.snippet) + """ + }.joined(separator: "\n") + } + + var userReadableContent: ChatGPTFunctionResultUserReadableContent { + .text(botReadableContent) + } + } + + let maxTokens: Int + + var name: String { + "searchWeb" + } + + var description: String { + "Useful for when you need to answer questions about latest information." + } + + var argumentSchema: JSONSchemaValue { + let today = Self.dateFormatter.string(from: Date()) + return [ + .type: "object", + .properties: [ + "query": [ + .type: "string", + .description: "the search query", + ], + "freshness": [ + .type: "string", + .description: .string( + "limit the search result to a specific range, use only when I ask the question about current events. Today is \(today). Format: yyyy-MM-dd..yyyy-MM-dd" + ), + .examples: ["1919-10-20..1988-10-20"], + ], + ], + .required: ["query"], + ] + } + + func prepare(reportProgress: @escaping ReportProgress) async { + await reportProgress("Searching..") + } + + func call( + arguments: Arguments, + reportProgress: @escaping ReportProgress + ) async throws -> Result { + await reportProgress("Searching \(arguments.query)") + + do { + let search = WebSearchService(provider: .userPreferred) + + let result = try await search.search(query: arguments.query) + + await reportProgress(""" + Finish searching \(arguments.query) + \( + result.webPages + .map { "- [\($0.title)](\($0.urlString))" } + .joined(separator: "\n") + ) + """) + + return .init(result: result) + } catch { + await reportProgress("Failed searching: \(error.localizedDescription)") + throw error + } + } +} + diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift new file mode 100644 index 00000000..848ca0fa --- /dev/null +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift @@ -0,0 +1,59 @@ +import ChatBasic +import ChatContextCollector +import Foundation +import OpenAIService + +public final class WebChatContextCollector: ChatContextCollector { + var recentLinks = [String]() + + public init() {} + + public func generateContext( + history: [ChatMessage], + scopes: Set, + content: String, + configuration: ChatGPTConfiguration + ) -> ChatContext { + guard scopes.contains(.web) else { return .empty } + let links = Self.detectLinks(from: history) + Self.detectLinks(from: content) + let functions: [(any ChatGPTFunction)?] = [ + SearchFunction(maxTokens: configuration.maxTokens), + // allow this function only when there is a link in the memory. + links.isEmpty ? nil : QueryWebsiteFunction(), + ] + return .init( + systemPrompt: "You prefer to answer questions with latest content on the internet.", + retrievedContent: [], + functions: functions.compactMap { $0 } + ) + } +} + +extension WebChatContextCollector { + static func detectLinks(from messages: [ChatMessage]) -> [String] { + return messages.lazy + .compactMap { + $0.content ?? $0.toolCalls?.map(\.function.arguments).joined(separator: " ") ?? "" + } + .map(detectLinks(from:)) + .flatMap { $0 } + } + + static func detectLinks(from content: String) -> [String] { + var links = [String]() + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + let matches = detector?.matches( + in: content, + options: [], + range: NSRange(content.startIndex..., in: content) + ) + + for match in matches ?? [] { + guard let range = Range(match.range, in: content) else { continue } + let url = content[range] + links.append(String(url)) + } + return links + } +} + diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift new file mode 100644 index 00000000..28443876 --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/Chat.swift @@ -0,0 +1,555 @@ +import AppKit +import ChatBasic +import ChatService +import ComposableArchitecture +import Foundation +import MarkdownUI +import OpenAIService +import Preferences +import Terminal + +public struct DisplayedChatMessage: Equatable { + public enum Role: Equatable { + case user + case assistant + case tool + case ignored + } + + public struct Reference: Equatable { + public typealias Kind = ChatMessage.Reference.Kind + + public var title: String + public var subtitle: String + public var uri: String + public var startLine: Int? + public var kind: Kind + + public init( + title: String, + subtitle: String, + uri: String, + startLine: Int?, + kind: Kind + ) { + self.title = title + self.subtitle = subtitle + self.uri = uri + self.startLine = startLine + self.kind = kind + } + } + + public var id: String + public var role: Role + public var text: String + public var markdownContent: MarkdownContent + public var references: [Reference] = [] + + public init(id: String, role: Role, text: String, references: [Reference]) { + self.id = id + self.role = role + self.text = text + markdownContent = .init(text) + self.references = references + } +} + +private var isPreview: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" +} + +@Reducer +struct Chat { + public typealias MessageID = String + + @ObservableState + struct State: Equatable { + var title: String = "Chat" + var typedMessage = "" + var history: [DisplayedChatMessage] = [] + var isReceivingMessage = false + var chatMenu = ChatMenu.State() + var focusedField: Field? + var isEnabled = true + var isPinnedToBottom = true + + enum Field: String, Hashable { + case textField + } + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + + case appear + case refresh + case setIsEnabled(Bool) + case sendButtonTapped + case returnButtonTapped + case stopRespondingButtonTapped + case clearButtonTap + case deleteMessageButtonTapped(MessageID) + case resendMessageButtonTapped(MessageID) + case setAsExtraPromptButtonTapped(MessageID) + case manuallyScrolledUp + case scrollToBottomButtonTapped + case focusOnTextField + case referenceClicked(DisplayedChatMessage.Reference) + + case observeChatService + case observeHistoryChange + case observeIsReceivingMessageChange + case observeSystemPromptChange + case observeExtraSystemPromptChange + case observeDefaultScopesChange + + case historyChanged + case isReceivingMessageChanged + case systemPromptChanged + case extraSystemPromptChanged + case defaultScopesChanged + + case chatMenu(ChatMenu.Action) + } + + let service: ChatService + let id = UUID() + + enum CancelID: Hashable { + case observeHistoryChange(UUID) + case observeIsReceivingMessageChange(UUID) + case observeSystemPromptChange(UUID) + case observeExtraSystemPromptChange(UUID) + case observeDefaultScopesChange(UUID) + case sendMessage(UUID) + } + + var body: some ReducerOf { + BindingReducer() + + Scope(state: \.chatMenu, action: \.chatMenu) { + ChatMenu(service: service) + } + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + if isPreview { return } + await send(.observeChatService) + await send(.historyChanged) + await send(.isReceivingMessageChanged) + await send(.systemPromptChanged) + await send(.extraSystemPromptChanged) + await send(.focusOnTextField) + await send(.refresh) + } + + case .refresh: + return .run { send in + await send(.chatMenu(.refresh)) + } + + case let .setIsEnabled(isEnabled): + state.isEnabled = isEnabled + return .none + + case .sendButtonTapped: + guard !state.typedMessage.isEmpty else { return .none } + let message = state.typedMessage + state.typedMessage = "" + return .run { _ in + try await service.send(content: message) + }.cancellable(id: CancelID.sendMessage(id)) + + case .returnButtonTapped: + state.typedMessage += "\n" + return .none + + case .stopRespondingButtonTapped: + return .merge( + .run { _ in + await service.stopReceivingMessage() + }, + .cancel(id: CancelID.sendMessage(id)) + ) + + case .clearButtonTap: + return .run { _ in + await service.clearHistory() + } + + case let .deleteMessageButtonTapped(id): + return .run { _ in + await service.deleteMessage(id: id) + } + + case let .resendMessageButtonTapped(id): + return .run { _ in + try await service.resendMessage(id: id) + } + + case let .setAsExtraPromptButtonTapped(id): + return .run { _ in + await service.setMessageAsExtraPrompt(id: id) + } + + case let .referenceClicked(reference): + let fileURL = URL(fileURLWithPath: reference.uri) + return .run { _ in + if FileManager.default.fileExists(atPath: fileURL.path) { + let terminal = Terminal() + do { + _ = try await terminal.runCommand( + "/bin/bash", + arguments: [ + "-c", + "xed -l \(reference.startLine ?? 0) ${TARGET_FILE}", + ], + environment: ["TARGET_FILE": reference.uri] + ) + } catch { + print(error) + } + } else if let url = URL(string: reference.uri), url.scheme != nil { + NSWorkspace.shared.open(url) + } + } + + case .manuallyScrolledUp: + state.isPinnedToBottom = false + return .none + + case .scrollToBottomButtonTapped: + state.isPinnedToBottom = true + return .none + + case .focusOnTextField: + state.focusedField = .textField + return .none + + case .observeChatService: + return .run { send in + await send(.observeHistoryChange) + await send(.observeIsReceivingMessageChange) + await send(.observeSystemPromptChange) + await send(.observeExtraSystemPromptChange) + await send(.observeDefaultScopesChange) + } + + case .observeHistoryChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = service.$chatHistory.sink { _ in + continuation.yield() + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + let debouncedHistoryChange = TimedDebounceFunction(duration: 0.2) { + await send(.historyChanged) + } + + for await _ in stream { + await debouncedHistoryChange() + } + }.cancellable(id: CancelID.observeHistoryChange(id), cancelInFlight: true) + + case .observeIsReceivingMessageChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = service.$isReceivingMessage + .sink { _ in + continuation.yield() + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + for await _ in stream { + await send(.isReceivingMessageChanged) + } + }.cancellable( + id: CancelID.observeIsReceivingMessageChange(id), + cancelInFlight: true + ) + + case .observeSystemPromptChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = service.$systemPrompt.sink { _ in + continuation.yield() + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + for await _ in stream { + await send(.systemPromptChanged) + } + }.cancellable(id: CancelID.observeSystemPromptChange(id), cancelInFlight: true) + + case .observeExtraSystemPromptChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = service.$extraSystemPrompt + .sink { _ in + continuation.yield() + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + for await _ in stream { + await send(.extraSystemPromptChanged) + } + }.cancellable(id: CancelID.observeExtraSystemPromptChange(id), cancelInFlight: true) + + case .observeDefaultScopesChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = service.$defaultScopes + .sink { _ in + continuation.yield() + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + for await _ in stream { + await send(.defaultScopesChanged) + } + }.cancellable(id: CancelID.observeDefaultScopesChange(id), cancelInFlight: true) + + case .historyChanged: + state.history = service.chatHistory.flatMap { message in + var all = [DisplayedChatMessage]() + all.append(.init( + id: message.id, + role: { + switch message.role { + case .system: return .ignored + case .user: return .user + case .assistant: + if let text = message.summary ?? message.content, + !text.isEmpty + { + return .assistant + } + return .ignored + } + }(), + text: message.summary ?? message.content ?? "", + references: message.references.map(convertReference) + )) + + for call in message.toolCalls ?? [] { + all.append(.init( + id: message.id + call.id, + role: .tool, + text: call.response.summary ?? call.response.content, + references: [] + )) + } + + return all + } + + state.title = { + let defaultTitle = "Chat" + guard let lastMessageText = state.history + .filter({ $0.role == .assistant || $0.role == .user }) + .last? + .text else { return defaultTitle } + if lastMessageText.isEmpty { return defaultTitle } + let trimmed = lastMessageText + .trimmingCharacters(in: .punctuationCharacters) + .trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.starts(with: "```") { + return "Code Block" + } else { + return trimmed + } + }() + return .none + + case .isReceivingMessageChanged: + state.isReceivingMessage = service.isReceivingMessage + if service.isReceivingMessage { + state.isPinnedToBottom = true + } + return .none + + case .systemPromptChanged: + state.chatMenu.systemPrompt = service.systemPrompt + return .none + + case .extraSystemPromptChanged: + state.chatMenu.extraSystemPrompt = service.extraSystemPrompt + return .none + + case .defaultScopesChanged: + state.chatMenu.defaultScopes = service.defaultScopes + return .none + + case .binding: + return .none + + case .chatMenu: + return .none + } + } + } +} + +@Reducer +struct ChatMenu { + @ObservableState + struct State: Equatable { + var systemPrompt: String = "" + var extraSystemPrompt: String = "" + var temperatureOverride: Double? = nil + var chatModelIdOverride: String? = nil + var defaultScopes: Set = [] + } + + enum Action: Equatable { + case appear + case refresh + case resetPromptButtonTapped + case temperatureOverrideSelected(Double?) + case chatModelIdOverrideSelected(String?) + case customCommandButtonTapped(CustomCommand) + case resetDefaultScopesButtonTapped + case toggleScope(ChatService.Scope) + } + + let service: ChatService + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .appear: + return .run { + await $0(.refresh) + } + + case .refresh: + state.temperatureOverride = service.configuration.overriding.temperature + state.chatModelIdOverride = service.configuration.overriding.modelId + return .none + + case .resetPromptButtonTapped: + return .run { _ in + await service.resetPrompt() + } + case let .temperatureOverrideSelected(temperature): + state.temperatureOverride = temperature + return .run { _ in + service.configuration.overriding.temperature = temperature + } + case let .chatModelIdOverrideSelected(chatModelId): + state.chatModelIdOverride = chatModelId + return .run { _ in + service.configuration.overriding.modelId = chatModelId + } + case let .customCommandButtonTapped(command): + return .run { _ in + try await service.handleCustomCommand(command) + } + + case .resetDefaultScopesButtonTapped: + return .run { _ in + service.resetDefaultScopes() + } + case let .toggleScope(scope): + return .run { _ in + service.defaultScopes.formSymmetricDifference([scope]) + } + } + } + } +} + +private actor TimedDebounceFunction { + let duration: TimeInterval + let block: () async -> Void + + var task: Task? + var lastFireTime: Date = .init(timeIntervalSince1970: 0) + + init(duration: TimeInterval, block: @escaping () async -> Void) { + self.duration = duration + self.block = block + } + + func callAsFunction() async { + task?.cancel() + if lastFireTime.timeIntervalSinceNow < -duration { + await fire() + task = nil + } else { + task = Task.detached { [weak self, duration] in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + await self?.fire() + } + } + } + + func fire() async { + lastFireTime = Date() + await block() + } +} + +private func convertReference( + _ reference: ChatMessage.Reference +) -> DisplayedChatMessage.Reference { + .init( + title: reference.title, + subtitle: { + switch reference.kind { + case let .symbol(_, uri, _, _): + return uri + case let .webpage(uri): + return uri + case let .textFile(uri): + return uri + case let .other(kind): + return kind + case .text: + return reference.content + case .error: + return reference.content + } + }(), + uri: { + switch reference.kind { + case let .symbol(_, uri, _, _): + return uri + case let .webpage(uri): + return uri + case let .textFile(uri): + return uri + case .other: + return "" + case .text: + return "" + case .error: + return "" + } + }(), + startLine: { + switch reference.kind { + case let .symbol(_, _, startLine, _): + return startLine + default: + return nil + } + }(), + kind: reference.kind + ) +} + diff --git a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift new file mode 100644 index 00000000..9114a5dd --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift @@ -0,0 +1,206 @@ +import AppKit +import ChatService +import ComposableArchitecture +import SharedUIComponents +import SwiftUI + +struct ChatTabItemView: View { + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + Text(chat.title) + } + } +} + +struct ChatContextMenu: View { + let store: StoreOf + @AppStorage(\.customCommands) var customCommands + @AppStorage(\.chatModels) var chatModels + @AppStorage(\.defaultChatFeatureChatModelId) var defaultChatModelId + @AppStorage(\.chatGPTTemperature) var defaultTemperature + + var body: some View { + WithPerceptionTracking { + currentSystemPrompt + .onAppear { store.send(.appear) } + currentExtraSystemPrompt + resetPrompt + + Divider() + + chatModel + temperature + defaultScopes + + Divider() + + customCommandMenu + } + } + + @ViewBuilder + var currentSystemPrompt: some View { + Text("System Prompt:") + Text({ + var text = store.systemPrompt + if text.isEmpty { text = "N/A" } + if text.count > 30 { text = String(text.prefix(30)) + "..." } + return text + }() as String) + } + + @ViewBuilder + var currentExtraSystemPrompt: some View { + Text("Extra Prompt:") + Text({ + var text = store.extraSystemPrompt + if text.isEmpty { text = "N/A" } + if text.count > 30 { text = String(text.prefix(30)) + "..." } + return text + }() as String) + } + + var resetPrompt: some View { + Button("Reset System Prompt") { + store.send(.resetPromptButtonTapped) + } + } + + @ViewBuilder + var chatModel: some View { + let allModels = chatModels + [.init( + id: "com.github.copilot", + name: "GitHub Copilot Language Server", + format: .openAI, + info: .init() + )] + + Menu("Chat Model") { + Button(action: { + store.send(.chatModelIdOverrideSelected(nil)) + }) { + HStack { + if let defaultModel = allModels + .first(where: { $0.id == defaultChatModelId }) + { + Text("Default (\(defaultModel.name))") + if store.chatModelIdOverride == nil { + Image(systemName: "checkmark") + } + } else { + Text("No Model Available") + } + } + } + + if let id = store.chatModelIdOverride, !allModels.map(\.id).contains(id) { + Button(action: { + store.send(.chatModelIdOverrideSelected(nil)) + }) { + HStack { + Text("Default (Selected Model Not Found)") + Image(systemName: "checkmark") + } + } + } + + Divider() + + ForEach(allModels, id: \.id) { model in + Button(action: { + store.send(.chatModelIdOverrideSelected(model.id)) + }) { + HStack { + Text(model.name) + if model.id == store.chatModelIdOverride { + Image(systemName: "checkmark") + } + } + } + } + } + } + + @ViewBuilder + var temperature: some View { + Menu("Temperature") { + Button(action: { + store.send(.temperatureOverrideSelected(nil)) + }) { + HStack { + Text( + "Default (\(defaultTemperature.formatted(.number.precision(.fractionLength(1)))))" + ) + if store.temperatureOverride == nil { + Image(systemName: "checkmark") + } + } + } + + Divider() + + ForEach(Array(stride(from: 0.0, through: 2.0, by: 0.1)), id: \.self) { value in + Button(action: { + store.send(.temperatureOverrideSelected(value)) + }) { + HStack { + Text("\(value.formatted(.number.precision(.fractionLength(1))))") + if value == store.temperatureOverride { + Image(systemName: "checkmark") + } + } + } + } + } + } + + @ViewBuilder + var defaultScopes: some View { + Menu("Default Scopes") { + Button(action: { + store.send(.resetDefaultScopesButtonTapped) + }) { + Text("Reset Default Scopes") + } + + Divider() + + ForEach(ChatService.Scope.allCases, id: \.rawValue) { value in + Button(action: { + store.send(.toggleScope(value)) + }) { + HStack { + Text("@" + value.rawValue) + if store.defaultScopes.contains(value) { + Image(systemName: "checkmark") + } + } + } + } + } + } + + var customCommandMenu: some View { + Menu("Custom Commands") { + ForEach( + customCommands.filter { + switch $0.feature { + case .chatWithSelection, .customChat: return true + case .promptToCode: return false + case .singleRoundDialog: return false + } + }, + id: \.name + ) { command in + Button(action: { + store.send(.customCommandButtonTapped(command)) + }) { + Text(command.name) + } + } + } + } +} + diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift new file mode 100644 index 00000000..ad2c6887 --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -0,0 +1,188 @@ +import ChatContextCollector +import ChatService +import ChatTab +import CodableWrappers +import Combine +import ComposableArchitecture +import DebounceFunction +import Foundation +import OpenAIService +import Preferences +import SwiftUI + +/// A chat tab that provides a context aware chat bot, powered by ChatGPT. +public class ChatGPTChatTab: ChatTab { + public static var name: String { "Chat" } + + public let service: ChatService + let chat: StoreOf + private var cancellable = Set() + private var observer = NSObject() + private let updateContentDebounce = DebounceRunner(duration: 0.5) + + struct RestorableState: Codable { + var history: [OpenAIService.ChatMessage] + var configuration: OverridingChatGPTConfiguration.Overriding + var systemPrompt: String + var extraSystemPrompt: String + var defaultScopes: Set? + } + + struct Builder: ChatTabBuilder { + var title: String + var customCommand: CustomCommand? + var afterBuild: (ChatGPTChatTab) async -> Void = { _ in } + + func build(store: StoreOf) async -> (any ChatTab)? { + let tab = await ChatGPTChatTab(store: store) + if let customCommand { + try? await tab.service.handleCustomCommand(customCommand) + } + await afterBuild(tab) + return tab + } + } + + public func buildView() -> any View { + ChatPanel(chat: chat) + } + + public func buildTabItem() -> any View { + ChatTabItemView(chat: chat) + } + + public func buildIcon() -> any View { + WithPerceptionTracking { + if self.chat.isReceivingMessage { + Image(systemName: "ellipsis.message") + } else { + Image(systemName: "message") + } + } + } + + public func buildMenu() -> any View { + ChatContextMenu(store: chat.scope(state: \.chatMenu, action: \.chatMenu)) + } + + public func restorableState() async -> Data { + let state = RestorableState( + history: await service.memory.history, + configuration: service.configuration.overriding, + systemPrompt: service.systemPrompt, + extraSystemPrompt: service.extraSystemPrompt, + defaultScopes: service.defaultScopes + ) + return (try? JSONEncoder().encode(state)) ?? Data() + } + + public static func restore(from data: Data) async throws -> any ChatTabBuilder { + let state = try JSONDecoder().decode(RestorableState.self, from: data) + let builder = Builder(title: "Chat") { @MainActor tab in + tab.service.configuration.overriding = state.configuration + tab.service.mutateSystemPrompt(state.systemPrompt) + tab.service.mutateExtraSystemPrompt(state.extraSystemPrompt) + if let scopes = state.defaultScopes { + tab.service.defaultScopes = scopes + } + await tab.service.memory.mutateHistory { history in + history = state.history + } + tab.chat.send(.refresh) + } + return builder + } + + public static func chatBuilders() -> [ChatTabBuilder] { + let customCommands = UserDefaults.shared.value(for: \.customCommands).compactMap { + command in + if case .customChat = command.feature { + return Builder(title: command.name, customCommand: command) + } + return nil + } + + return [Builder(title: "Legacy Chat", customCommand: nil)] + customCommands + } + + public static func defaultBuilder() -> ChatTabBuilder { + Builder(title: "Legacy Chat", customCommand: nil) + } + + @MainActor + public init(service: ChatService = .init(), store: StoreOf) { + self.service = service + chat = .init(initialState: .init(), reducer: { Chat(service: service) }) + super.init(store: store) + } + + public func start() { + observer = .init() + cancellable = [] + + chatTabStore.send(.updateTitle("Chat")) + + service.$systemPrompt.removeDuplicates().sink { [weak self] _ in + Task { @MainActor [weak self] in + self?.chatTabStore.send(.tabContentUpdated) + } + }.store(in: &cancellable) + + service.$extraSystemPrompt.removeDuplicates().sink { [weak self] _ in + Task { @MainActor [weak self] in + self?.chatTabStore.send(.tabContentUpdated) + } + }.store(in: &cancellable) + + Task { @MainActor in + var lastTrigger = -1 + observer.observe { [weak self] in + guard let self else { return } + let trigger = chatTabStore.focusTrigger + guard lastTrigger != trigger else { return } + lastTrigger = trigger + Task { @MainActor [weak self] in + self?.chat.send(.focusOnTextField) + } + } + } + + Task { @MainActor in + var lastTitle = "" + observer.observe { [weak self] in + guard let self else { return } + let title = self.chatTabStore.state.title + guard lastTitle != title else { return } + lastTitle = title + Task { @MainActor [weak self] in + self?.chatTabStore.send(.updateTitle(title)) + } + } + } + + Task { @MainActor in + observer.observe { [weak self] in + guard let self else { return } + _ = chat.history + _ = chat.title + _ = chat.isReceivingMessage + Task { + await self.updateContentDebounce.debounce { @MainActor [weak self] in + self?.chatTabStore.send(.tabContentUpdated) + } + } + } + } + } + + public func handleCustomCommand(_ customCommand: CustomCommand) -> Bool { + Task { + if service.isReceivingMessage { + await service.stopReceivingMessage() + } + try? await service.handleCustomCommand(customCommand) + } + return true + } +} + diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift new file mode 100644 index 00000000..9210a05d --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -0,0 +1,626 @@ +import AppKit +import Combine +import ComposableArchitecture +import MarkdownUI +import OpenAIService +import SharedUIComponents +import SwiftUI + +private let r: Double = 8 + +public struct ChatPanel: View { + let chat: StoreOf + @Namespace var inputAreaNamespace + + public var body: some View { + VStack(spacing: 0) { + ChatPanelMessages(chat: chat) + Divider() + ChatPanelInputArea(chat: chat) + } + .background(Color(nsColor: .windowBackgroundColor)) + .onAppear { chat.send(.appear) } + } +} + +private struct ScrollViewOffsetPreferenceKey: PreferenceKey { + static var defaultValue = CGFloat.zero + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value += nextValue() + } +} + +private struct ListHeightPreferenceKey: PreferenceKey { + static var defaultValue = CGFloat.zero + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value += nextValue() + } +} + +struct ChatPanelMessages: View { + let chat: StoreOf + @State var cancellable = Set() + @State var isScrollToBottomButtonDisplayed = true + @Namespace var bottomID + @Namespace var topID + @Namespace var scrollSpace + @State var scrollOffset: Double = 0 + @State var listHeight: Double = 0 + @State var didScrollToBottomOnAppearOnce = false + @State var isBottomHidden = true + @Environment(\.isEnabled) var isEnabled + + var body: some View { + WithPerceptionTracking { + ScrollViewReader { proxy in + GeometryReader { listGeo in + List { + Group { + Spacer(minLength: 12) + .id(topID) + + Instruction(chat: chat) + + ChatHistory(chat: chat) + .listItemTint(.clear) + + ExtraSpacingInResponding(chat: chat) + + Spacer(minLength: 12) + .id(bottomID) + .onAppear { + isBottomHidden = false + if !didScrollToBottomOnAppearOnce { + proxy.scrollTo(bottomID, anchor: .bottom) + didScrollToBottomOnAppearOnce = true + } + } + .onDisappear { + isBottomHidden = true + } + .background(GeometryReader { geo in + let offset = geo.frame(in: .named(scrollSpace)).minY + Color.clear.preference( + key: ScrollViewOffsetPreferenceKey.self, + value: offset + ) + }) + } + .modify { view in + if #available(macOS 13.0, *) { + view + .listRowSeparator(.hidden) + .listSectionSeparator(.hidden) + } else { + view + } + } + } + .listStyle(.plain) + .listRowBackground(EmptyView()) + .modify { view in + if #available(macOS 13.0, *) { + view.scrollContentBackground(.hidden) + } else { + view + } + } + .coordinateSpace(name: scrollSpace) + .preference( + key: ListHeightPreferenceKey.self, + value: listGeo.size.height + ) + .onPreferenceChange(ListHeightPreferenceKey.self) { value in + listHeight = value + updatePinningState() + } + .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in + scrollOffset = value + updatePinningState() + } + .overlay(alignment: .bottom) { + StopRespondingButton(chat: chat) + } + .overlay(alignment: .bottomTrailing) { + scrollToBottomButton(proxy: proxy) + } + .background { + PinToBottomHandler(chat: chat, isBottomHidden: isBottomHidden) { + proxy.scrollTo(bottomID, anchor: .bottom) + } + } + .onAppear { + proxy.scrollTo(bottomID, anchor: .bottom) + } + .task { + proxy.scrollTo(bottomID, anchor: .bottom) + } + } + } + .onAppear { + trackScrollWheel() + } + .onDisappear { + cancellable.forEach { $0.cancel() } + cancellable = [] + } + .onChange(of: isEnabled) { isEnabled in + chat.send(.setIsEnabled(isEnabled)) + } + } + } + + func trackScrollWheel() { + NSApplication.shared.publisher(for: \.currentEvent) + .receive(on: DispatchQueue.main) + .filter { [chat] in + guard chat.withState(\.isEnabled) else { return false } + return $0?.type == .scrollWheel + } + .compactMap { $0 } + .sink { event in + guard chat.withState(\.isPinnedToBottom) else { return } + let delta = event.deltaY + let scrollUp = delta > 0 + if scrollUp { + chat.send(.manuallyScrolledUp) + } + } + .store(in: &cancellable) + } + + @MainActor + func updatePinningState() { + // where does the 32 come from? + withAnimation(.linear(duration: 0.1)) { + isScrollToBottomButtonDisplayed = scrollOffset > listHeight + 32 + 20 + || scrollOffset <= 0 + } + } + + @ViewBuilder + func scrollToBottomButton(proxy: ScrollViewProxy) -> some View { + Button(action: { + chat.send(.scrollToBottomButtonTapped) + withAnimation(.easeInOut(duration: 0.1)) { + proxy.scrollTo(bottomID, anchor: .bottom) + } + }) { + Image(systemName: "arrow.down") + .padding(4) + .background { + Circle() + .fill(.thickMaterial) + .shadow(color: .black.opacity(0.2), radius: 2) + } + .overlay { + Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .foregroundStyle(.secondary) + .padding(4) + } + .keyboardShortcut(.downArrow, modifiers: [.command]) + .opacity(isScrollToBottomButtonDisplayed ? 1 : 0) + .buttonStyle(.plain) + } + + struct ExtraSpacingInResponding: View { + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + if chat.isReceivingMessage { + Spacer(minLength: 12) + } + } + } + } + + struct PinToBottomHandler: View { + let chat: StoreOf + let isBottomHidden: Bool + let scrollToBottom: () -> Void + + @State var isInitialLoad = true + + var body: some View { + WithPerceptionTracking { + EmptyView() + .onChange(of: chat.isReceivingMessage) { isReceiving in + if isReceiving { + Task { + await Task.yield() + withAnimation(.easeInOut(duration: 0.1)) { + scrollToBottom() + } + } + } + } + .onChange(of: chat.history.last) { _ in + if chat.withState(\.isPinnedToBottom) || isInitialLoad { + if isInitialLoad { + isInitialLoad = false + } + Task { + await Task.yield() + withAnimation(.easeInOut(duration: 0.1)) { + scrollToBottom() + } + } + } + } + .onChange(of: isBottomHidden) { value in + // This is important to prevent it from jumping to the top! + if value, chat.withState(\.isPinnedToBottom) { + scrollToBottom() + } + } + } + } + } +} + +struct ChatHistory: View { + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + ForEach(chat.history, id: \.id) { message in + WithPerceptionTracking { + ChatHistoryItem(chat: chat, message: message).id(message.id) + } + } + } + } +} + +struct ChatHistoryItem: View { + let chat: StoreOf + let message: DisplayedChatMessage + + var body: some View { + WithPerceptionTracking { + let text = message.text + let markdownContent = message.markdownContent + switch message.role { + case .user: + UserMessage( + id: message.id, + text: text, + markdownContent: markdownContent, + chat: chat + ) + .listRowInsets(EdgeInsets( + top: 0, + leading: -8, + bottom: 0, + trailing: -8 + )) + .padding(.vertical, 4) + case .assistant: + BotMessage( + id: message.id, + text: text, + markdownContent: markdownContent, + references: message.references, + chat: chat + ) + .listRowInsets(EdgeInsets( + top: 0, + leading: -8, + bottom: 0, + trailing: -8 + )) + .padding(.vertical, 4) + case .tool: + FunctionMessage(id: message.id, text: text) + case .ignored: + EmptyView() + } + } + } +} + +private struct StopRespondingButton: View { + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + if chat.isReceivingMessage { + Button(action: { + chat.send(.stopRespondingButtonTapped) + }) { + HStack(spacing: 4) { + Image(systemName: "stop.fill") + Text("Stop Responding") + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: r, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: r, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + .buttonStyle(.borderless) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 8) + .opacity(chat.isReceivingMessage ? 1 : 0) + .disabled(!chat.isReceivingMessage) + .transformEffect(.init( + translationX: 0, + y: chat.isReceivingMessage ? 0 : 20 + )) + } + } + } +} + +struct ChatPanelInputArea: View { + let chat: StoreOf + @FocusState var focusedField: Chat.State.Field? + + var body: some View { + HStack { + clearButton + InputAreaTextEditor(chat: chat, focusedField: $focusedField) + } + .padding(8) + .background(.ultraThickMaterial) + } + + @MainActor + var clearButton: some View { + Button(action: { + chat.send(.clearButtonTap) + }) { + Group { + if #available(macOS 13.0, *) { + Image(systemName: "eraser.line.dashed.fill") + } else { + Image(systemName: "trash.fill") + } + } + .padding(6) + .background { + Circle().fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1) + } + } + .buttonStyle(.plain) + } + + struct InputAreaTextEditor: View { + @Perception.Bindable var chat: StoreOf + var focusedField: FocusState.Binding + + var body: some View { + WithPerceptionTracking { + HStack(spacing: 0) { + AutoresizingCustomTextEditor( + text: $chat.typedMessage, + font: .systemFont(ofSize: 14), + isEditable: true, + maxHeight: 400, + onSubmit: { chat.send(.sendButtonTapped) }, + completions: chatAutoCompletion + ) + .focused(focusedField, equals: .textField) + .bind($chat.focusedField, to: focusedField) + .padding(8) + .fixedSize(horizontal: false, vertical: true) + + Button(action: { + chat.send(.sendButtonTapped) + }) { + Image(systemName: "paperplane.fill") + .padding(8) + } + .buttonStyle(.plain) + .disabled(chat.isReceivingMessage) + .keyboardShortcut(KeyEquivalent.return, modifiers: []) + } + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .controlColor), lineWidth: 1) + } + .background { + Button(action: { + chat.send(.returnButtonTapped) + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) + + Button(action: { + focusedField.wrappedValue = .textField + }) { + EmptyView() + } + .keyboardShortcut("l", modifiers: [.command]) + } + } + } + + func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] { + guard text.count == 1 else { return [] } + let plugins = [String]() // chat.pluginIdentifiers.map { "/\($0)" } + let availableFeatures = plugins + [ + "/exit", + "@code", + "@sense", + "@project", + "@web", + ] + + let result: [String] = availableFeatures + .filter { $0.hasPrefix(text) && $0 != text } + .compactMap { + guard let index = $0.index( + $0.startIndex, + offsetBy: range.location, + limitedBy: $0.endIndex + ) else { return nil } + return String($0[index...]) + } + return result + } + } +} + +// MARK: - Previews + +struct ChatPanel_Preview: PreviewProvider { + static let history: [DisplayedChatMessage] = [ + .init( + id: "1", + role: .user, + text: "**Hello**", + references: [] + ), + .init( + id: "2", + role: .assistant, + text: """ + ```swift + func foo() {} + ``` + **Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you? + """, + references: [ + .init( + title: "Hello Hello Hello Hello", + subtitle: "Hi Hi Hi Hi", + uri: "https://google.com", + startLine: nil, + kind: .symbol(.class, uri: "https://google.com", startLine: nil, endLine: nil) + ), + ] + ), + .init( + id: "7", + role: .ignored, + text: "Ignored", + references: [] + ), + .init( + id: "6", + role: .tool, + text: """ + Searching for something... + - abc + - [def](https://1.com) + > hello + > hi + """, + references: [] + ), + .init( + id: "5", + role: .assistant, + text: "Yooo", + references: [] + ), + .init( + id: "4", + role: .user, + text: "Yeeeehh", + references: [] + ), + .init( + id: "3", + role: .user, + text: #""" + Please buy me a coffee! + | Coffee | Milk | + |--------|------| + | Espresso | No | + | Latte | Yes | + + ```swift + func foo() {} + ``` + ```objectivec + - (void)bar {} + ``` + """#, + references: [] + ), + ] + + static var previews: some View { + ChatPanel(chat: .init( + initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true), + reducer: { Chat(service: .init()) } + )) + .frame(width: 450, height: 1200) + .colorScheme(.dark) + } +} + +struct ChatPanel_EmptyChat_Preview: PreviewProvider { + static var previews: some View { + ChatPanel(chat: .init( + initialState: .init(history: [DisplayedChatMessage](), isReceivingMessage: false), + reducer: { Chat(service: .init()) } + )) + .padding() + .frame(width: 450, height: 600) + .colorScheme(.dark) + } +} + +struct ChatPanel_InputText_Preview: PreviewProvider { + static var previews: some View { + ChatPanel(chat: .init( + initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: false), + reducer: { Chat(service: .init()) } + )) + .padding() + .frame(width: 450, height: 600) + .colorScheme(.dark) + } +} + +struct ChatPanel_InputMultilineText_Preview: PreviewProvider { + static var previews: some View { + ChatPanel( + chat: .init( + initialState: .init( + typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.", + + history: ChatPanel_Preview.history, + isReceivingMessage: false + ), + reducer: { Chat(service: .init()) } + ) + ) + .padding() + .frame(width: 450, height: 600) + .colorScheme(.dark) + } +} + +struct ChatPanel_Light_Preview: PreviewProvider { + static var previews: some View { + ChatPanel(chat: .init( + initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true), + reducer: { Chat(service: .init()) } + )) + .padding() + .frame(width: 450, height: 600) + .colorScheme(.light) + } +} + diff --git a/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift b/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift new file mode 100644 index 00000000..0e506b96 --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift @@ -0,0 +1,117 @@ +import Combine +import ComposableArchitecture +import DebounceFunction +import Foundation +import MarkdownUI +import Perception +import SharedUIComponents +import SwiftUI + +/// Use this instead of the built in ``CodeBlockView`` to highlight code blocks asynchronously, +/// so that the UI doesn't freeze when rendering large code blocks. +struct AsyncCodeBlockView: View { + @Perceptible + class Storage { + static let queue = DispatchQueue( + label: "chat-code-block-highlight", + qos: .userInteractive, + attributes: .concurrent + ) + + var highlighted: AttributedString? + @PerceptionIgnored var debounceFunction: DebounceFunction? + @PerceptionIgnored private var highlightTask: Task? + + init() { + debounceFunction = .init(duration: 0.5, block: { [weak self] view in + self?.highlight(for: view) + }) + } + + func highlight(debounce: Bool, for view: AsyncCodeBlockView) { + if debounce { + Task { await debounceFunction?(view) } + } else { + highlight(for: view) + } + } + + func highlight(for view: AsyncCodeBlockView) { + highlightTask?.cancel() + let content = view.content + let language = view.fenceInfo ?? "" + let brightMode = view.colorScheme != .dark + let font = CodeHighlighting.SendableFont(font: view.font) + highlightTask = Task { + let string = await withUnsafeContinuation { continuation in + Self.queue.async { + let content = CodeHighlighting.highlightedCodeBlock( + code: content, + language: language, + scenario: "chat", + brightMode: brightMode, + font:font + ) + continuation.resume(returning: AttributedString(content)) + } + } + try Task.checkCancellation() + await MainActor.run { + self.highlighted = string + } + } + } + } + + let fenceInfo: String? + let content: String + let font: NSFont + + @Environment(\.colorScheme) var colorScheme + @State var storage = Storage() + @AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme + @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight + @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight + @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark + @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark + + init(fenceInfo: String?, content: String, font: NSFont) { + self.fenceInfo = fenceInfo + self.content = content.hasSuffix("\n") ? String(content.dropLast()) : content + self.font = font + } + + var body: some View { + WithPerceptionTracking { + Group { + if let highlighted = storage.highlighted { + Text(highlighted) + } else { + Text(content).font(.init(font)) + } + } + .onAppear { + storage.highlight(debounce: false, for: self) + } + .onChange(of: colorScheme) { _ in + storage.highlight(debounce: false, for: self) + } + .onChange(of: syncCodeHighlightTheme) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: codeForegroundColorLight) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: codeBackgroundColorLight) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: codeForegroundColorDark) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: codeBackgroundColorDark) { _ in + storage.highlight(debounce: true, for: self) + } + } + } +} + diff --git a/Core/Sources/ChatGPTChatTab/Styles.swift b/Core/Sources/ChatGPTChatTab/Styles.swift new file mode 100644 index 00000000..6c117c9a --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/Styles.swift @@ -0,0 +1,167 @@ +import AppKit +import MarkdownUI +import SharedUIComponents +import SwiftUI + +extension Color { + static var contentBackground: Color { + Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in + if appearance.isDarkMode { + return #colorLiteral(red: 0.1580096483, green: 0.1730263829, blue: 0.2026666105, alpha: 1) + } + return #colorLiteral(red: 0.9896564803, green: 0.9896564803, blue: 0.9896564803, alpha: 1) + })) + } + + static var userChatContentBackground: Color { + Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in + if appearance.isDarkMode { + return #colorLiteral(red: 0.2284317913, green: 0.2145925438, blue: 0.3214019983, alpha: 1) + } + return #colorLiteral(red: 0.9458052187, green: 0.9311983998, blue: 0.9906365955, alpha: 1) + })) + } +} + +extension NSAppearance { + var isDarkMode: Bool { + if bestMatch(from: [.darkAqua, .aqua]) == .darkAqua { + return true + } else { + return false + } + } +} + +extension View { + var messageBubbleCornerRadius: Double { 8 } + + func codeBlockLabelStyle() -> some View { + relativeLineSpacing(.em(0.225)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + } + .padding(16) + .padding(.top, 14) + } + + func codeBlockStyle( + _ configuration: CodeBlockConfiguration, + backgroundColor: Color, + labelColor: Color + ) -> some View { + background(backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(alignment: .top) { + HStack(alignment: .center) { + Text(configuration.language ?? "code") + .foregroundStyle(labelColor) + .font(.callout.bold()) + .padding(.leading, 8) + .lineLimit(1) + Spacer() + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(configuration.content, forType: .string) + } + } + } + .overlay { + RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.05), lineWidth: 1) + } + .markdownMargin(top: 4, bottom: 16) + } +} + +final class VerticalScrollingFixHostingView: NSHostingView where Content: View { + override func wantsForwardedScrollEvents(for axis: NSEvent.GestureAxis) -> Bool { + return axis == .vertical + } +} + +struct VerticalScrollingFixViewRepresentable: NSViewRepresentable where Content: View { + let content: Content + + func makeNSView(context: Context) -> NSHostingView { + return VerticalScrollingFixHostingView(rootView: content) + } + + func updateNSView(_ nsView: NSHostingView, context: Context) {} +} + +struct VerticalScrollingFixWrapper: View where Content: View { + let content: () -> Content + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + var body: some View { + VerticalScrollingFixViewRepresentable(content: self.content()) + } +} + +extension View { + /// https://stackoverflow.com/questions/64920744/swiftui-nested-scrollviews-problem-on-macos + @ViewBuilder func workaroundForVerticalScrollingBugInMacOS() -> some View { + VerticalScrollingFixWrapper { self } + } +} + +struct RoundedCorners: Shape { + var tl: CGFloat = 0.0 + var tr: CGFloat = 0.0 + var bl: CGFloat = 0.0 + var br: CGFloat = 0.0 + + func path(in rect: CGRect) -> Path { + Path { path in + + let w = rect.size.width + let h = rect.size.height + + // Make sure we do not exceed the size of the rectangle + let tr = min(min(self.tr, h / 2), w / 2) + let tl = min(min(self.tl, h / 2), w / 2) + let bl = min(min(self.bl, h / 2), w / 2) + let br = min(min(self.br, h / 2), w / 2) + + path.move(to: CGPoint(x: w / 2.0, y: 0)) + path.addLine(to: CGPoint(x: w - tr, y: 0)) + path.addArc( + center: CGPoint(x: w - tr, y: tr), + radius: tr, + startAngle: Angle(degrees: -90), + endAngle: Angle(degrees: 0), + clockwise: false + ) + path.addLine(to: CGPoint(x: w, y: h - br)) + path.addArc( + center: CGPoint(x: w - br, y: h - br), + radius: br, + startAngle: Angle(degrees: 0), + endAngle: Angle(degrees: 90), + clockwise: false + ) + path.addLine(to: CGPoint(x: bl, y: h)) + path.addArc( + center: CGPoint(x: bl, y: h - bl), + radius: bl, + startAngle: Angle(degrees: 90), + endAngle: Angle(degrees: 180), + clockwise: false + ) + path.addLine(to: CGPoint(x: 0, y: tl)) + path.addArc( + center: CGPoint(x: tl, y: tl), + radius: tl, + startAngle: Angle(degrees: 180), + endAngle: Angle(degrees: 270), + clockwise: false + ) + path.closeSubpath() + } + } +} + diff --git a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift new file mode 100644 index 00000000..bcd9a455 --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift @@ -0,0 +1,295 @@ +import ComposableArchitecture +import Foundation +import MarkdownUI +import SharedUIComponents +import SwiftUI + +struct BotMessage: View { + var r: Double { messageBubbleCornerRadius } + let id: String + let text: String + let markdownContent: MarkdownContent + let references: [DisplayedChatMessage.Reference] + let chat: StoreOf + @Environment(\.colorScheme) var colorScheme + + @State var isReferencesPresented = false + @State var isReferencesHovered = false + + var body: some View { + HStack(alignment: .bottom, spacing: 2) { + VStack(alignment: .leading, spacing: 16) { + if !references.isEmpty { + Button(action: { + isReferencesPresented.toggle() + }, label: { + HStack(spacing: 4) { + Image(systemName: "plus.circle") + Text("Used \(references.count) references") + } + .padding(8) + .background { + RoundedRectangle(cornerRadius: r - 4) + .foregroundStyle(Color(isReferencesHovered ? .black : .clear)) + } + .overlay { + RoundedRectangle(cornerRadius: r - 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .foregroundStyle(.secondary) + }) + .buttonStyle(.plain) + .popover(isPresented: $isReferencesPresented, arrowEdge: .trailing) { + ReferenceList(references: references, chat: chat) + } + } + + ThemedMarkdownText(markdownContent) + } + .frame(alignment: .trailing) + .padding() + .background { + RoundedCorners(tl: r, tr: r, bl: 0, br: r) + .fill(Color.contentBackground) + } + .overlay { + RoundedCorners(tl: r, tr: r, bl: 0, br: r) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .padding(.leading, 8) + .shadow(color: .black.opacity(0.05), radius: 6) + .contextMenu { + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + + Button("Set as Extra System Prompt") { + chat.send(.setAsExtraPromptButtonTapped(id)) + } + + Divider() + + Button("Delete") { + chat.send(.deleteMessageButtonTapped(id)) + } + } + + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, 2) + } +} + +struct ReferenceList: View { + let references: [DisplayedChatMessage.Reference] + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(0.. MarkdownUI.Theme { + .gitHub.text { + ForegroundColor(.secondary) + BackgroundColor(Color.clear) + FontSize(fontSize - 1) + } + .list { configuration in + configuration.label + .markdownMargin(top: 4, bottom: 4) + } + .paragraph { configuration in + configuration.label + .markdownMargin(top: 0, bottom: 4) + } + .codeBlock { configuration in + configuration.label + .relativeLineSpacing(.em(0.225)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + } + .padding(16) + .background(Color(nsColor: .textBackgroundColor).opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .markdownMargin(top: 4, bottom: 4) + } + } +} diff --git a/Core/Sources/ChatGPTChatTab/Views/FunctionMessage.swift b/Core/Sources/ChatGPTChatTab/Views/FunctionMessage.swift new file mode 100644 index 00000000..3e6b031a --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/Views/FunctionMessage.swift @@ -0,0 +1,30 @@ +import Foundation +import MarkdownUI +import SwiftUI + +struct FunctionMessage: View { + let id: String + let text: String + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + Markdown(text) + .textSelection(.enabled) + .markdownTheme(.functionCall(fontSize: chatFontSize)) + .padding(.vertical, 2) + .padding(.trailing, 2) + } +} + +#Preview { + FunctionMessage(id: "1", text: """ + Searching for something... + - abc + - [def](https://1.com) + > hello + > hi + """) + .padding() + .fixedSize() +} + diff --git a/Core/Sources/ChatGPTChatTab/Views/InstructionMarkdownTheme.swift b/Core/Sources/ChatGPTChatTab/Views/InstructionMarkdownTheme.swift new file mode 100644 index 00000000..30e786ea --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/Views/InstructionMarkdownTheme.swift @@ -0,0 +1,68 @@ +import Foundation +import MarkdownUI +import SwiftUI + +extension MarkdownUI.Theme { + static func instruction(fontSize: Double) -> MarkdownUI.Theme { + .gitHub.text { + ForegroundColor(.primary) + BackgroundColor(Color.clear) + FontSize(fontSize) + } + .code { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + BackgroundColor(Color.secondary.opacity(0.2)) + } + .codeBlock { configuration in + let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock) + + if wrapCode { + configuration.label + .codeBlockLabelStyle() + .codeBlockStyle( + configuration, + backgroundColor: Color(nsColor: .textBackgroundColor).opacity(0.7), + labelColor: Color.secondary.opacity(0.7) + ) + } else { + ScrollView(.horizontal) { + configuration.label + .codeBlockLabelStyle() + } + .workaroundForVerticalScrollingBugInMacOS() + .codeBlockStyle( + configuration, + backgroundColor: Color(nsColor: .textBackgroundColor).opacity(0.7), + labelColor: Color.secondary.opacity(0.7) + ) + } + } + .table { configuration in + configuration.label + .fixedSize(horizontal: false, vertical: true) + .markdownTableBorderStyle(.init( + color: .init(nsColor: .separatorColor), + strokeStyle: .init(lineWidth: 1) + )) + .markdownTableBackgroundStyle( + .alternatingRows(Color.secondary.opacity(0.1), Color.secondary.opacity(0.2)) + ) + .markdownMargin(top: 0, bottom: 16) + } + .tableCell { configuration in + configuration.label + .markdownTextStyle { + if configuration.row == 0 { + FontWeight(.semibold) + } + BackgroundColor(nil) + } + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 6) + .padding(.horizontal, 13) + .relativeLineSpacing(.em(0.25)) + } + } +} + diff --git a/Core/Sources/ChatGPTChatTab/Views/Instructions.swift b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift new file mode 100644 index 00000000..dba6bfbf --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift @@ -0,0 +1,83 @@ +import ComposableArchitecture +import Foundation +import MarkdownUI +import SwiftUI + +struct Instruction: View { + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + Group { + Markdown( + """ + You can use plugins to perform various tasks. + + | Plugin Name | Description | + | --- | --- | + | `/shell` | Runs a command under the project root | + | `/shortcut(name)` | Runs a shortcut from the Shortcuts.app, with the previous message as input | + + To use plugins, you can prefix a message with `/pluginName`. + """ + ) + .modifier(InstructionModifier()) + + Markdown( + """ + You can use scopes to give the bot extra abilities. + + | Scope Name | Abilities | + | --- | --- | + | `@file` | Read the metadata of the editing file | + | `@code` | Read the code and metadata in the editing file | + | `@sense`| Experimental. Read the relevant code of the focused editor | + | `@project` | Experimental. Access content of the project | + | `@web` (beta) | Search on Bing or query from a web page | + + To use scopes, you can prefix a message with `@code`. + + You can use shorthand to represent a scope, such as `@c`, and enable multiple scopes with `@c+web`. + """ + ) + .modifier(InstructionModifier()) + + let scopes = chat.chatMenu.defaultScopes + Markdown( + """ + Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. + + \({ + if scopes.isEmpty { + return "No scope is enabled by default" + } else { + let scopes = scopes.map(\.rawValue).sorted() + .joined(separator: ", ") + return "Default scopes: `\(scopes)`" + } + }()) + """ + ) + .modifier(InstructionModifier()) + } + } + } + + struct InstructionModifier: ViewModifier { + @AppStorage(\.chatFontSize) var chatFontSize + + func body(content: Content) -> some View { + content + .textSelection(.enabled) + .markdownTheme(.instruction(fontSize: chatFontSize)) + .opacity(0.8) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + } +} + diff --git a/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift new file mode 100644 index 00000000..2811e4ad --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift @@ -0,0 +1,111 @@ +import Foundation +import MarkdownUI +import SwiftUI + +struct ThemedMarkdownText: View { + @AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme + @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight + @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight + @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark + @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark + @AppStorage(\.chatFontSize) var chatFontSize + @AppStorage(\.chatCodeFont) var chatCodeFont + @Environment(\.colorScheme) var colorScheme + + let content: MarkdownContent + + init(_ text: String) { + content = .init(text) + } + + init(_ content: MarkdownContent) { + self.content = content + } + + var body: some View { + Markdown(content) + .textSelection(.enabled) + .markdownTheme(.custom( + fontSize: chatFontSize, + codeFont: chatCodeFont.value.nsFont, + codeBlockBackgroundColor: { + if syncCodeHighlightTheme { + if colorScheme == .light, let color = codeBackgroundColorLight.value { + return color.swiftUIColor + } else if let color = codeBackgroundColorDark.value { + return color.swiftUIColor + } + } + + return Color(nsColor: .textBackgroundColor).opacity(0.7) + }(), + codeBlockLabelColor: { + if syncCodeHighlightTheme { + if colorScheme == .light, + let color = codeForegroundColorLight.value + { + return color.swiftUIColor.opacity(0.5) + } else if let color = codeForegroundColorDark.value { + return color.swiftUIColor.opacity(0.5) + } + } + return Color.secondary.opacity(0.7) + }() + )) + } +} + +// MARK: - Theme + +extension MarkdownUI.Theme { + static func custom( + fontSize: Double, + codeFont: NSFont, + codeBlockBackgroundColor: Color, + codeBlockLabelColor: Color + ) -> MarkdownUI.Theme { + .gitHub.text { + ForegroundColor(.primary) + BackgroundColor(Color.clear) + FontSize(fontSize) + } + .codeBlock { configuration in + let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock) + || [ + "plaintext", "text", "markdown", "sh", "console", "bash", "shell", "latex", + "tex" + ] + .contains(configuration.language) + + if wrapCode { + AsyncCodeBlockView( + fenceInfo: configuration.language, + content: configuration.content, + font: codeFont + ) + .codeBlockLabelStyle() + .codeBlockStyle( + configuration, + backgroundColor: codeBlockBackgroundColor, + labelColor: codeBlockLabelColor + ) + } else { + ScrollView(.horizontal) { + AsyncCodeBlockView( + fenceInfo: configuration.language, + content: configuration.content, + font: codeFont + ) + .codeBlockLabelStyle() + } + .workaroundForVerticalScrollingBugInMacOS() + .codeBlockStyle( + configuration, + backgroundColor: codeBlockBackgroundColor, + labelColor: codeBlockLabelColor + ) + } + } + } +} + diff --git a/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift b/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift new file mode 100644 index 00000000..edac231a --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift @@ -0,0 +1,80 @@ +import ComposableArchitecture +import Foundation +import MarkdownUI +import SwiftUI + +struct UserMessage: View { + var r: Double { messageBubbleCornerRadius } + let id: String + let text: String + let markdownContent: MarkdownContent + let chat: StoreOf + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ThemedMarkdownText(markdownContent) + .frame(alignment: .leading) + .padding() + .background { + RoundedCorners(tl: r, tr: r, bl: r, br: 0) + .fill(Color.userChatContentBackground) + } + .overlay { + RoundedCorners(tl: r, tr: r, bl: r, br: 0) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .padding(.leading) + .padding(.trailing, 8) + .shadow(color: .black.opacity(0.05), radius: 6) + .frame(maxWidth: .infinity, alignment: .trailing) + .contextMenu { + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + + Button("Send Again") { + chat.send(.resendMessageButtonTapped(id)) + } + + Button("Set as Extra System Prompt") { + chat.send(.setAsExtraPromptButtonTapped(id)) + } + + Divider() + + Button("Delete") { + chat.send(.deleteMessageButtonTapped(id)) + } + } + } +} + +#Preview { + let text = #""" + Please buy me a coffee! + | Coffee | Milk | + |--------|------| + | Espresso | No | + | Latte | Yes | + ```swift + func foo() {} + ``` + ```objectivec + - (void)bar {} + ``` + """# + + return UserMessage( + id: "A", + text: text, + markdownContent: .init(text), + chat: .init( + initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false), + reducer: { Chat(service: .init()) } + ) + ) + .padding() + .fixedSize(horizontal: true, vertical: true) +} + diff --git a/Core/Sources/ChatPlugins/AITerminalChatPlugin.swift b/Core/Sources/ChatPlugins/AITerminalChatPlugin.swift deleted file mode 100644 index 98612db8..00000000 --- a/Core/Sources/ChatPlugins/AITerminalChatPlugin.swift +++ /dev/null @@ -1,197 +0,0 @@ -import Environment -import Foundation -import OpenAIService -import Terminal - -public actor AITerminalChatPlugin: ChatPlugin { - public static var command: String { "airun" } - public nonisolated var name: String { "AI Terminal" } - - let chatGPTService: any ChatGPTServiceType - var terminal: TerminalType = Terminal() - var isCancelled = false - weak var delegate: ChatPluginDelegate? - var isStarted = false - var command: String? - - public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) { - self.chatGPTService = chatGPTService - self.delegate = delegate - } - - public func send(content: String, originalMessage: String) async { - if !isStarted { - isStarted = true - delegate?.pluginDidStart(self) - } - - do { - if let command { - await chatGPTService.mutateHistory { history in - history.append(.init(role: .user, content: content)) - } - delegate?.pluginDidStartResponding(self) - if isCancelled { return } - switch try await checkConfirmation(content: content) { - case .confirmation: - delegate?.pluginDidEndResponding(self) - delegate?.pluginDidEnd(self) - delegate?.shouldStartAnotherPlugin( - TerminalChatPlugin.self, - withContent: command - ) - case .cancellation: - delegate?.pluginDidEndResponding(self) - delegate?.pluginDidEnd(self) - await chatGPTService.mutateHistory { history in - history.append(.init(role: .assistant, content: "Cancelled")) - } - case .modification: - let result = try await modifyCommand(command: command, requirement: content) - self.command = result - delegate?.pluginDidEndResponding(self) - await chatGPTService.mutateHistory { history in - history.append(.init(role: .assistant, content: """ - Should I run this command? You can instruct me to modify it again. - ``` - \(result) - ``` - """)) - } - case .other: - delegate?.pluginDidEndResponding(self) - await chatGPTService.mutateHistory { history in - history.append(.init( - role: .assistant, - content: "Sorry, I don't understand. Do you want me to run it?" - )) - } - } - } else { - await chatGPTService.mutateHistory { history in - history.append(.init( - role: .user, - content: originalMessage, - summary: "Run a command to \(content)") - ) - } - delegate?.pluginDidStartResponding(self) - let result = try await generateCommand(task: content) - command = result - if isCancelled { return } - await chatGPTService.mutateHistory { history in - history.append(.init(role: .assistant, content: """ - Should I run this command? You can instruct me to modify it. - ``` - \(result) - ``` - """)) - } - delegate?.pluginDidEndResponding(self) - } - } catch { - await chatGPTService.mutateHistory { history in - history.append(.init(role: .assistant, content: error.localizedDescription)) - } - delegate?.pluginDidEndResponding(self) - delegate?.pluginDidEnd(self) - } - } - - public func cancel() async { - isCancelled = true - delegate?.pluginDidEndResponding(self) - delegate?.pluginDidEnd(self) - } - - public func stopResponding() async {} - - func generateCommand(task: String) async throws -> String { - let p = """ - Available environment variables: - - $PROJECT_ROOT: the root path of the project - - $FILE_PATH: the currently editing file - - Current directory path is the project root. - - Generate a terminal command to solve the given task on macOS. If one command is not enough, you can use && to concatenate multiple commands. - - The reply should contains only the command and nothing else. - """ - - return extractCodeFromMarkdown(try await askChatGPT( - systemPrompt: p, - question: "the task is: \"\(task)\"" - ) ?? "") - } - - func modifyCommand(command: String, requirement: String) async throws -> String { - let p = """ - Available environment variables: - - $PROJECT_ROOT: the root path of the project - - $FILE_PATH: the currently editing file - - Current directory path is the project root. - - Modify the terminal command `\( - command - )` in macOS with the given requirement. If one command is not enough, you can use && to concatenate multiple commands. - - The reply should contains only the command and nothing else. - """ - - return extractCodeFromMarkdown(try await askChatGPT( - systemPrompt: p, - question: "The requirement is: \"\(requirement)\"" - ) ?? "") - } - - func checkConfirmation(content: String) async throws -> Tone { - let p = """ - Check the tone of the content, reply with only the number representing the tone. - - 1: If the given content is a phrase or sentence that considered a confirmation to run a command. - - For example: "Yes", "Confirm", "True", "Run it". It can be in any language. - - 2: If the given content is a phrase or sentence that considered a cancellation to run a command. - - For example: "No", "Cancel", "False", "Don't run it", "Stop". It can be in any language. - - 3: If the given content is a modification request. - - For example: "Use echo instead", "Remove the argument", "Change to path". - - 4: Everything else. - """ - - let result = try await askChatGPT( - systemPrompt: p, - question: "The content is: \"\(content)\"" - ) - let tone = result.flatMap(Int.init).flatMap(Tone.init(rawValue:)) ?? .other - return tone - } - - enum Tone: Int { - case confirmation = 1 - case cancellation = 2 - case modification = 3 - case other = 4 - } - - func extractCodeFromMarkdown(_ markdown: String) -> String { - let codeBlockRegex = try! NSRegularExpression( - pattern: "```[\n](.*?)[\n]```", - options: .dotMatchesLineSeparators - ) - let range = NSRange(markdown.startIndex.. String? { - let service = ChatGPTService(systemPrompt: systemPrompt) - return try await service.sendAndWait(content: question) -} diff --git a/Core/Sources/ChatPlugins/ChatPlugin.swift b/Core/Sources/ChatPlugins/ChatPlugin.swift deleted file mode 100644 index 770b0852..00000000 --- a/Core/Sources/ChatPlugins/ChatPlugin.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import OpenAIService - -public protocol ChatPlugin: AnyObject { - /// Should be [a-zA-Z0-9]+ - static var command: String { get } - var name: String { get } - - init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) - func send(content: String, originalMessage: String) async - func cancel() async - func stopResponding() async -} - -public protocol ChatPluginDelegate: AnyObject { - func pluginDidStart(_ plugin: ChatPlugin) - func pluginDidEnd(_ plugin: ChatPlugin) - func pluginDidStartResponding(_ plugin: ChatPlugin) - func pluginDidEndResponding(_ plugin: ChatPlugin) - func shouldStartAnotherPlugin(_ type: ChatPlugin.Type, withContent: String) -} diff --git a/Core/Sources/ChatService/AllContextCollector.swift b/Core/Sources/ChatService/AllContextCollector.swift new file mode 100644 index 00000000..c13aa612 --- /dev/null +++ b/Core/Sources/ChatService/AllContextCollector.swift @@ -0,0 +1,19 @@ +import ActiveDocumentChatContextCollector +import ChatContextCollector +import SystemInfoChatContextCollector +import WebChatContextCollector +#if canImport(ProChatContextCollectors) +import ProChatContextCollectors +let allContextCollectors: [any ChatContextCollector] = [ + SystemInfoChatContextCollector(), + WebChatContextCollector(), + ProChatContextCollectors(), +] +#else +let allContextCollectors: [any ChatContextCollector] = [ + SystemInfoChatContextCollector(), + ActiveDocumentChatContextCollector(), + WebChatContextCollector(), +] +#endif + diff --git a/Core/Sources/ChatService/AllPlugins.swift b/Core/Sources/ChatService/AllPlugins.swift new file mode 100644 index 00000000..3f0b9de7 --- /dev/null +++ b/Core/Sources/ChatService/AllPlugins.swift @@ -0,0 +1,144 @@ +import ChatBasic +import Foundation +import OpenAIService +import ShortcutChatPlugin +import TerminalChatPlugin + +let allPlugins: [LegacyChatPlugin.Type] = [ + LegacyChatPluginWrapper.self, + LegacyChatPluginWrapper.self, +] + +protocol LegacyChatPlugin: AnyObject { + static var command: String { get } + var name: String { get } + + init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: LegacyChatPluginDelegate) + func send(content: String, originalMessage: String) async + func cancel() async + func stopResponding() async +} + +protocol LegacyChatPluginDelegate: AnyObject { + func pluginDidStart(_ plugin: LegacyChatPlugin) + func pluginDidEnd(_ plugin: LegacyChatPlugin) + func pluginDidStartResponding(_ plugin: LegacyChatPlugin) + func pluginDidEndResponding(_ plugin: LegacyChatPlugin) + func shouldStartAnotherPlugin(_ type: LegacyChatPlugin.Type, withContent: String) +} + +final class LegacyChatPluginWrapper: LegacyChatPlugin { + static var command: String { Plugin.command } + var name: String { Plugin.name } + + let chatGPTService: any LegacyChatGPTServiceType + weak var delegate: LegacyChatPluginDelegate? + var isCancelled = false + + required init( + inside chatGPTService: any LegacyChatGPTServiceType, + delegate: any LegacyChatPluginDelegate + ) { + self.chatGPTService = chatGPTService + self.delegate = delegate + } + + func send(content: String, originalMessage: String) async { + delegate?.pluginDidStart(self) + delegate?.pluginDidStartResponding(self) + + let id = "\(Self.command)-\(UUID().uuidString)" + var reply = ChatMessage(id: id, role: .assistant, content: "") + + await chatGPTService.memory.mutateHistory { history in + history.append(.init(role: .user, content: originalMessage)) + } + + let plugin = Plugin() + + let stream = await plugin.sendForComplicatedResponse(.init( + text: content, + arguments: [], + history: chatGPTService.memory.history + )) + + do { + var actions = [(id: String, name: String)]() + var actionResults = [String: String]() + var message = "" + + for try await response in stream { + guard !isCancelled else { break } + if Task.isCancelled { break } + + switch response { + case .status: + break + case let .content(content): + switch content { + case let .text(token): + message.append(token) + } + case .attachments: + break + case let .startAction(id, task): + actions.append((id: id, name: task)) + case let .finishAction(id, result): + actionResults[id] = switch result { + case let .failure(error): + error + case let .success(result): + result + } + case .references: + break + case .startNewMessage: + break + case .reasoning: + break + } + + await chatGPTService.memory.mutateHistory { history in + if history.last?.id == id { + history.removeLast() + } + + let actionString = actions.map { + "> \($0.name): \(actionResults[$0.id] ?? "...")" + }.joined(separator: "\n>\n") + + if message.isEmpty { + reply.content = actionString + } else { + reply.content = """ + \(actionString) + + \(message) + """ + } + history.append(reply) + } + } + } catch { + await chatGPTService.memory.mutateHistory { history in + if history.last?.id == id { + history.removeLast() + } + reply.content = error.localizedDescription + history.append(reply) + } + } + + delegate?.pluginDidEndResponding(self) + delegate?.pluginDidEnd(self) + } + + func cancel() async { + isCancelled = true + } + + func stopResponding() async { + isCancelled = true + } +} + diff --git a/Core/Sources/ChatService/ChatFunctionProvider.swift b/Core/Sources/ChatService/ChatFunctionProvider.swift new file mode 100644 index 00000000..de8ca5bf --- /dev/null +++ b/Core/Sources/ChatService/ChatFunctionProvider.swift @@ -0,0 +1,24 @@ +import ChatBasic +import Foundation +import OpenAIService + +final class ChatFunctionProvider { + var functions: [any ChatGPTFunction] = [] + + init() {} + + func removeAll() { + functions = [] + } + + func append(functions others: [any ChatGPTFunction]) { + functions.append(contentsOf: others) + } +} + +extension ChatFunctionProvider: ChatGPTFunctionProvider { + var functionCallStrategy: OpenAIService.FunctionCallStrategy? { + nil + } +} + diff --git a/Core/Sources/ChatService/ChatPluginController.swift b/Core/Sources/ChatService/ChatPluginController.swift index e90c11d1..c1a6d973 100644 --- a/Core/Sources/ChatService/ChatPluginController.swift +++ b/Core/Sources/ChatService/ChatPluginController.swift @@ -1,22 +1,30 @@ -import ChatPlugins import Combine import Foundation +import LegacyChatPlugin import OpenAIService final class ChatPluginController { - let chatGPTService: any ChatGPTServiceType - let plugins: [String: ChatPlugin.Type] - var runningPlugin: ChatPlugin? + let chatGPTService: any LegacyChatGPTServiceType + let plugins: [String: LegacyChatPlugin.Type] + var runningPlugin: LegacyChatPlugin? + weak var chatService: ChatService? - init(chatGPTService: any ChatGPTServiceType, plugins: ChatPlugin.Type...) { + init(chatGPTService: any LegacyChatGPTServiceType, plugins: [LegacyChatPlugin.Type]) { self.chatGPTService = chatGPTService - var all = [String: ChatPlugin.Type]() + var all = [String: LegacyChatPlugin.Type]() for plugin in plugins { - all[plugin.command] = plugin + all[plugin.command.lowercased()] = plugin } self.plugins = all } + convenience init( + chatGPTService: any LegacyChatGPTServiceType, + plugins: LegacyChatPlugin.Type... + ) { + self.init(chatGPTService: chatGPTService, plugins: plugins) + } + /// Handle the message in a plugin if required. Return false if no plugin handles the message. func handleContent(_ content: String) async throws -> Bool { // look for the prefix of content, see if there is something like /command. @@ -25,12 +33,12 @@ final class ChatPluginController { let regex = try NSRegularExpression(pattern: #"^\/([a-zA-Z0-9]+)"#) let matches = regex.matches(in: content, range: NSRange(content.startIndex..., in: content)) if let match = matches.first { - let command = String(content[Range(match.range(at: 1), in: content)!]) + let command = String(content[Range(match.range(at: 1), in: content)!]).lowercased() // handle exit plugin if command == "exit" { if let plugin = runningPlugin { runningPlugin = nil - _ = await chatGPTService.mutateHistory { history in + _ = await chatGPTService.memory.mutateHistory { history in history.append(.init( role: .user, content: "", @@ -43,7 +51,7 @@ final class ChatPluginController { )) } } else { - _ = await chatGPTService.mutateHistory { history in + _ = await chatGPTService.memory.mutateHistory { history in history.append(.init( role: .system, content: "", @@ -89,11 +97,11 @@ final class ChatPluginController { return false } } - + func stopResponding() async { await runningPlugin?.stopResponding() } - + func cancel() async { await runningPlugin?.cancel() } @@ -101,30 +109,29 @@ final class ChatPluginController { // MARK: - ChatPluginDelegate -extension ChatPluginController: ChatPluginDelegate { - public func pluginDidStartResponding(_: ChatPlugins.ChatPlugin) { - Task { - await chatGPTService.markReceivingMessage(true) - } +extension ChatPluginController: LegacyChatPluginDelegate { + public func pluginDidStartResponding(_: LegacyChatPlugin) { + chatService?.isReceivingMessage = true } - public func pluginDidEndResponding(_: ChatPlugins.ChatPlugin) { - Task { - await chatGPTService.markReceivingMessage(false) - } + public func pluginDidEndResponding(_: LegacyChatPlugin) { + chatService?.isReceivingMessage = false } - public func pluginDidStart(_ plugin: ChatPlugin) { + public func pluginDidStart(_ plugin: LegacyChatPlugin) { runningPlugin = plugin } - public func pluginDidEnd(_ plugin: ChatPlugin) { + public func pluginDidEnd(_ plugin: LegacyChatPlugin) { if runningPlugin === plugin { runningPlugin = nil } } - public func shouldStartAnotherPlugin(_ type: ChatPlugin.Type, withContent content: String) { + public func shouldStartAnotherPlugin( + _ type: LegacyChatPlugin.Type, + withContent content: String + ) { let plugin = type.init(inside: chatGPTService, delegate: self) Task { await plugin.send(content: content, originalMessage: content) diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 86ba8fb9..e1b0eb54 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -1,92 +1,172 @@ import ChatContextCollector -import ChatPlugins +import LegacyChatPlugin import Combine +import CustomCommandTemplateProcessor import Foundation import OpenAIService - -let defaultSystemPrompt = """ -You are an AI programming assistant. -Your reply should be concise, clear, informative and logical. -You MUST reply in the format of markdown. -You MUST embed every code you provide in a markdown code block. -You MUST add the programming language name at the start of the markdown code block. -If you are asked to help perform a task, you MUST think step-by-step, then describe each step concisely. -If you are asked to explain code, you MUST explain it step-by-step in a ordered list. -Make your answer short and structured. -""" +import Preferences public final class ChatService: ObservableObject { - public let chatGPTService: any ChatGPTServiceType + public typealias Scope = ChatContext.Scope + + public let memory: ContextAwareAutoManagedChatGPTMemory + public let configuration: OverridingChatGPTConfiguration + public let chatGPTService: any LegacyChatGPTServiceType + public var allPluginCommands: [String] { allPlugins.map { $0.command } } + @Published public internal(set) var chatHistory: [ChatMessage] = [] + @Published public internal(set) var isReceivingMessage = false + @Published public internal(set) var systemPrompt = UserDefaults.shared + .value(for: \.defaultChatSystemPrompt) + @Published public internal(set) var extraSystemPrompt = "" + @Published public var defaultScopes = Set() + let pluginController: ChatPluginController - let contextController: DynamicContextController var cancellable = Set() - @Published public internal(set) var systemPrompt = defaultSystemPrompt - @Published public internal(set) var extraSystemPrompt = "" - public init(chatGPTService: T) { + init( + memory: ContextAwareAutoManagedChatGPTMemory, + configuration: OverridingChatGPTConfiguration, + chatGPTService: T + ) { + self.memory = memory + self.configuration = configuration self.chatGPTService = chatGPTService pluginController = ChatPluginController( chatGPTService: chatGPTService, - plugins: - TerminalChatPlugin.self, - AITerminalChatPlugin.self + plugins: allPlugins ) - contextController = DynamicContextController( - chatGPTService: chatGPTService, - contextCollectors: ActiveDocumentChatContextCollector() + + pluginController.chatService = self + } + + public convenience init() { + let configuration = UserPreferenceChatGPTConfiguration().overriding() + /// Used by context collector + let extraConfiguration = configuration.overriding() + extraConfiguration.textWindowTerminator = { + guard let last = $0.last else { return false } + return last.isNewline || last.isPunctuation + } + let memory = ContextAwareAutoManagedChatGPTMemory( + configuration: extraConfiguration, + functionProvider: ChatFunctionProvider() ) + self.init( + memory: memory, + configuration: configuration, + chatGPTService: LegacyChatGPTService( + memory: memory, + configuration: extraConfiguration, + functionProvider: memory.functionProvider + ) + ) + + resetDefaultScopes() + + memory.chatService = self + memory.observeHistoryChange { [weak self] in + Task { [weak self] in + self?.chatHistory = await memory.history + } + } + } - chatGPTService.objectWillChange.sink { [weak self] _ in - self?.objectWillChange.send() - }.store(in: &cancellable) + public func resetDefaultScopes() { + var scopes = Set() + if UserDefaults.shared.value(for: \.enableFileScopeByDefaultInChatContext) { + scopes.insert(.file) + } + + if UserDefaults.shared.value(for: \.enableCodeScopeByDefaultInChatContext) { + scopes.insert(.code) + } + + if UserDefaults.shared.value(for: \.enableProjectScopeByDefaultInChatContext) { + scopes.insert(.project) + } + + if UserDefaults.shared.value(for: \.enableSenseScopeByDefaultInChatContext) { + scopes.insert(.sense) + } + + if UserDefaults.shared.value(for: \.enableWebScopeByDefaultInChatContext) { + scopes.insert(.web) + } + + defaultScopes = scopes } public func send(content: String) async throws { + memory.contextController.defaultScopes = defaultScopes + guard !isReceivingMessage else { throw CancellationError() } let handledInPlugin = try await pluginController.handleContent(content) if handledInPlugin { return } - try await contextController.updatePromptToMatchContent(systemPrompt: """ - \(systemPrompt) - \(extraSystemPrompt) - """, content: content) + isReceivingMessage = true + defer { isReceivingMessage = false } + + let stream = try await chatGPTService.send(content: content, summary: nil) + do { + for try await _ in stream { + try Task.checkCancellation() + } + } catch {} + } - _ = try await chatGPTService.send(content: content, summary: nil) + public func sendAndWait(content: String) async throws -> String { + try await send(content: content) + if let reply = await memory.history.last(where: { $0.role == .assistant })?.content { + return reply + } + return "" } public func stopReceivingMessage() async { await pluginController.stopResponding() await chatGPTService.stopReceivingMessage() + isReceivingMessage = false + + // if it's stopped before the tool calls finish, remove the message. + await memory.mutateHistory { history in + if history.last?.role == .assistant, history.last?.toolCalls != nil { + history.removeLast() + } + } } public func clearHistory() async { await pluginController.cancel() - await chatGPTService.clearHistory() + await memory.clearHistory() + await chatGPTService.stopReceivingMessage() + isReceivingMessage = false } public func resetPrompt() async { - systemPrompt = defaultSystemPrompt + systemPrompt = UserDefaults.shared.value(for: \.defaultChatSystemPrompt) extraSystemPrompt = "" } public func deleteMessage(id: String) async { - await chatGPTService.mutateHistory { messages in - messages.removeAll(where: { $0.id == id }) - } + await memory.removeMessage(id) } public func resendMessage(id: String) async throws { - if let message = (await chatGPTService.history).first(where: { $0.id == id }) { - try await send(content: message.content) + if let message = (await memory.history).first(where: { $0.id == id }), + let content = message.content + { + try await send(content: content) } } public func setMessageAsExtraPrompt(id: String) async { - if let message = (await chatGPTService.history).first(where: { $0.id == id }) { - mutateExtraSystemPrompt(message.content) + if let message = (await memory.history).first(where: { $0.id == id }), + let content = message.content + { + mutateExtraSystemPrompt(content) await mutateHistory { history in history.append(.init( role: .assistant, content: "", - summary: "System prompt updated" + summary: "System prompt updated." )) } } @@ -94,7 +174,7 @@ public final class ChatService: ObservableObject { /// Setting it to `nil` to reset the system prompt public func mutateSystemPrompt(_ newPrompt: String?) { - systemPrompt = newPrompt ?? defaultSystemPrompt + systemPrompt = newPrompt ?? UserDefaults.shared.value(for: \.defaultChatSystemPrompt) } public func mutateExtraSystemPrompt(_ newPrompt: String) { @@ -102,7 +182,102 @@ public final class ChatService: ObservableObject { } public func mutateHistory(_ mutator: @escaping (inout [ChatMessage]) -> Void) async { - await chatGPTService.mutateHistory(mutator) + await memory.mutateHistory(mutator) + } + + public func handleCustomCommand(_ command: CustomCommand) async throws { + struct CustomCommandInfo { + var specifiedSystemPrompt: String? + var extraSystemPrompt: String? + var sendingMessageImmediately: String? + var name: String? + } + + let info: CustomCommandInfo? = { + switch command.feature { + case let .chatWithSelection(extraSystemPrompt, prompt, useExtraSystemPrompt): + let updatePrompt = useExtraSystemPrompt ?? true + return .init( + extraSystemPrompt: updatePrompt ? extraSystemPrompt : nil, + sendingMessageImmediately: prompt, + name: command.name + ) + case let .customChat(systemPrompt, prompt): + memory.contextController.defaultScopes = [] + return .init( + specifiedSystemPrompt: systemPrompt, + extraSystemPrompt: "", + sendingMessageImmediately: prompt, + name: command.name + ) + case .promptToCode: return nil + case .singleRoundDialog: return nil + } + }() + + guard let info else { return } + + let templateProcessor = CustomCommandTemplateProcessor() + if let specifiedSystemPrompt = info.specifiedSystemPrompt { + await mutateSystemPrompt(templateProcessor.process(specifiedSystemPrompt)) + } + if let extraSystemPrompt = info.extraSystemPrompt { + await mutateExtraSystemPrompt(templateProcessor.process(extraSystemPrompt)) + } else { + mutateExtraSystemPrompt("") + } + + let customCommandPrefix = { + if let name = info.name { return "[\(name)] " } + return "" + }() + + if info.specifiedSystemPrompt != nil || info.extraSystemPrompt != nil { + await mutateHistory { history in + history.append(.init( + role: .assistant, + content: "", + summary: "\(customCommandPrefix)System prompt is updated." + )) + } + } + + if let sendingMessageImmediately = info.sendingMessageImmediately, + !sendingMessageImmediately.isEmpty + { + try await send(content: templateProcessor.process(sendingMessageImmediately)) + } + } + + public func handleSingleRoundDialogCommand( + systemPrompt: String?, + overwriteSystemPrompt: Bool, + prompt: String + ) async throws -> String { + let templateProcessor = CustomCommandTemplateProcessor() + if let systemPrompt { + if overwriteSystemPrompt { + await mutateSystemPrompt(templateProcessor.process(systemPrompt)) + } else { + await mutateExtraSystemPrompt(templateProcessor.process(systemPrompt)) + } + } + return try await sendAndWait(content: templateProcessor.process(prompt)) + } + + public func processMessage( + systemPrompt: String?, + extraSystemPrompt: String?, + prompt: String + ) async throws -> String { + let templateProcessor = CustomCommandTemplateProcessor() + if let systemPrompt { + await mutateSystemPrompt(templateProcessor.process(systemPrompt)) + } + if let extraSystemPrompt { + await mutateExtraSystemPrompt(templateProcessor.process(extraSystemPrompt)) + } + return try await sendAndWait(content: templateProcessor.process(prompt)) } } diff --git a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift new file mode 100644 index 00000000..32d65694 --- /dev/null +++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift @@ -0,0 +1,54 @@ +import Foundation +import OpenAIService + +public final class ContextAwareAutoManagedChatGPTMemory: ChatGPTMemory { + private let memory: AutoManagedChatGPTMemory + let contextController: DynamicContextController + let functionProvider: ChatFunctionProvider + weak var chatService: ChatService? + + public var history: [ChatMessage] { + get async { await memory.history } + } + + func observeHistoryChange(_ observer: @escaping () -> Void) { + memory.observeHistoryChange(observer) + } + + init( + configuration: OverridingChatGPTConfiguration, + functionProvider: ChatFunctionProvider + ) { + memory = AutoManagedChatGPTMemory( + systemPrompt: "", + configuration: configuration, + functionProvider: functionProvider, + maxNumberOfMessages: UserDefaults.shared.value(for: \.chatGPTMaxMessageCount) + ) + contextController = DynamicContextController( + memory: memory, + functionProvider: functionProvider, + configuration: configuration, + contextCollectors: allContextCollectors + ) + self.functionProvider = functionProvider + } + + public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) async { + await memory.mutateHistory(update) + } + + public func generatePrompt() async -> ChatGPTPrompt { + let content = (await memory.history) + .last(where: { $0.role == .user })?.content + try? await contextController.collectContextInformation( + systemPrompt: """ + \(chatService?.systemPrompt ?? "") + \(chatService?.extraSystemPrompt ?? "") + """.trimmingCharacters(in: .whitespacesAndNewlines), + content: content ?? "" + ) + return await memory.generatePrompt() + } +} + diff --git a/Core/Sources/ChatService/DynamicContextController.swift b/Core/Sources/ChatService/DynamicContextController.swift index 1cdb4275..11ae9753 100644 --- a/Core/Sources/ChatService/DynamicContextController.swift +++ b/Core/Sources/ChatService/DynamicContextController.swift @@ -5,28 +5,119 @@ import Preferences import XcodeInspector final class DynamicContextController { - let chatGPTService: any ChatGPTServiceType let contextCollectors: [ChatContextCollector] + let memory: AutoManagedChatGPTMemory + let functionProvider: ChatFunctionProvider + let configuration: OverridingChatGPTConfiguration + var defaultScopes = [] as Set - init(chatGPTService: any ChatGPTServiceType, contextCollectors: ChatContextCollector...) { - self.chatGPTService = chatGPTService + convenience init( + memory: AutoManagedChatGPTMemory, + functionProvider: ChatFunctionProvider, + configuration: OverridingChatGPTConfiguration, + contextCollectors: ChatContextCollector... + ) { + self.init( + memory: memory, + functionProvider: functionProvider, + configuration: configuration, + contextCollectors: contextCollectors + ) + } + + init( + memory: AutoManagedChatGPTMemory, + functionProvider: ChatFunctionProvider, + configuration: OverridingChatGPTConfiguration, + contextCollectors: [ChatContextCollector] + ) { + self.memory = memory + self.functionProvider = functionProvider + self.configuration = configuration self.contextCollectors = contextCollectors } - func updatePromptToMatchContent(systemPrompt: String, content: String) async throws { + func collectContextInformation(systemPrompt: String, content: String) async throws { + var content = content + var scopes = Self.parseScopes(&content) + scopes.formUnion(defaultScopes) + + let overridingChatModelId = { + var ids = [String]() + if scopes.contains(.sense) { + ids.append(UserDefaults.shared.value(for: \.preferredChatModelIdForSenseScope)) + } + + if scopes.contains(.project) { + ids.append(UserDefaults.shared.value(for: \.preferredChatModelIdForProjectScope)) + } + + if scopes.contains(.web) { + ids.append(UserDefaults.shared.value(for: \.preferredChatModelIdForWebScope)) + } + + let chatModels = UserDefaults.shared.value(for: \.chatModels) + let idIndexMap = chatModels.enumerated().reduce(into: [String: Int]()) { + $0[$1.element.id] = $1.offset + } + return ids.filter { !$0.isEmpty }.sorted(by: { + let lhs = idIndexMap[$0] ?? Int.max + let rhs = idIndexMap[$1] ?? Int.max + return lhs < rhs + }).first + }() + + configuration.overriding.modelId = overridingChatModelId + + functionProvider.removeAll() let language = UserDefaults.shared.value(for: \.chatGPTLanguage) - let oldMessages = (await chatGPTService.history).map(\.content) + let oldMessages = await memory.history + let contexts = await withTaskGroup( + of: ChatContext.self + ) { [scopes, content, configuration] group in + for collector in contextCollectors { + group.addTask { + await collector.generateContext( + history: oldMessages, + scopes: scopes, + content: content, + configuration: configuration + ) + } + } + var contexts = [ChatContext]() + for await context in group { + contexts.append(context) + } + return contexts + } + + let contextSystemPrompt = contexts + .map(\.systemPrompt) + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + + let retrievedContent = contexts + .flatMap(\.retrievedContent) + .filter { !$0.document.content.isEmpty } + .sorted { $0.priority > $1.priority } + .prefix(15) + let contextualSystemPrompt = """ \(language.isEmpty ? "" : "You must always reply in \(language)") \(systemPrompt) + """.trimmingCharacters(in: .whitespacesAndNewlines) + await memory.mutateSystemPrompt(contextualSystemPrompt) + await memory.mutateContextSystemPrompt(contextSystemPrompt) + await memory.mutateRetrievedContent(retrievedContent.map(\.document)) + functionProvider.append(functions: contexts.flatMap(\.functions)) + } +} - \( - contextCollectors - .map { $0.generateSystemPrompt(history: oldMessages, content: content) } - .joined(separator: "\n") - ) - """ - await chatGPTService.mutateSystemPrompt(contextualSystemPrompt) +extension DynamicContextController { + static func parseScopes(_ prompt: inout String) -> Set { + let parser = MessageScopeParser() + return parser(&prompt) } } diff --git a/Core/Sources/Client/AsyncXPCService.swift b/Core/Sources/Client/AsyncXPCService.swift deleted file mode 100644 index 79d9ea91..00000000 --- a/Core/Sources/Client/AsyncXPCService.swift +++ /dev/null @@ -1,196 +0,0 @@ -import Foundation -import GitHubCopilotService -import Logger -import SuggestionModel -import XPCShared - -public struct AsyncXPCService { - public var connection: NSXPCConnection { service.connection } - let service: XPCService - - init(service: XPCService) { - self.service = service - } - - public func getXPCServiceVersion() async throws -> (version: String, build: String) { - try await withXPCServiceConnected(connection: connection) { - service, continuation in - service.getXPCServiceVersion { version, build in - continuation.resume((version, build)) - } - } - } - - public func getXPCServiceAccessibilityPermission() async throws -> Bool { - try await withXPCServiceConnected(connection: connection) { - service, continuation in - service.getXPCServiceAccessibilityPermission { isGranted in - continuation.resume(isGranted) - } - } - } - - public func getSuggestedCode(editorContent: EditorContent) async throws -> UpdatedContent? { - try await suggestionRequest( - connection, - editorContent, - { $0.getSuggestedCode } - ) - } - - public func getNextSuggestedCode(editorContent: EditorContent) async throws -> UpdatedContent? { - try await suggestionRequest( - connection, - editorContent, - { $0.getNextSuggestedCode } - ) - } - - public func getPreviousSuggestedCode(editorContent: EditorContent) async throws - -> UpdatedContent? - { - try await suggestionRequest( - connection, - editorContent, - { $0.getPreviousSuggestedCode } - ) - } - - public func getSuggestionAcceptedCode(editorContent: EditorContent) async throws - -> UpdatedContent? - { - try await suggestionRequest( - connection, - editorContent, - { $0.getSuggestionAcceptedCode } - ) - } - - public func getSuggestionRejectedCode(editorContent: EditorContent) async throws - -> UpdatedContent? - { - try await suggestionRequest( - connection, - editorContent, - { $0.getSuggestionRejectedCode } - ) - } - - public func getRealtimeSuggestedCode(editorContent: EditorContent) async throws - -> UpdatedContent? - { - try await suggestionRequest( - connection, - editorContent, - { $0.getRealtimeSuggestedCode } - ) - } - - public func toggleRealtimeSuggestion() async throws { - try await withXPCServiceConnected(connection: connection) { - service, continuation in - service.toggleRealtimeSuggestion { error in - if let error { - continuation.reject(error) - return - } - continuation.resume(()) - } - } as Void - } - - 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(()) - } - } - } - - public func chatWithSelection(editorContent: EditorContent) async throws -> UpdatedContent? { - try await suggestionRequest( - connection, - editorContent, - { $0.chatWithSelection } - ) - } - - public func promptToCode(editorContent: EditorContent) async throws -> UpdatedContent? { - try await suggestionRequest( - connection, - editorContent, - { $0.promptToCode } - ) - } - - public func customCommand( - id: String, - editorContent: EditorContent - ) async throws -> UpdatedContent? { - try await suggestionRequest( - connection, - editorContent, - { service in { service.customCommand(id: id, editorContent: $0, withReply: $1) } } - ) - } -} - -struct AutoFinishContinuation { - var continuation: AsyncThrowingStream.Continuation - - func resume(_ value: T) { - continuation.yield(value) - continuation.finish() - } - - func reject(_ error: Error) { - if (error as NSError).code == -100 { - continuation.finish(throwing: CancellationError()) - } else { - continuation.finish(throwing: error) - } - } -} - -func withXPCServiceConnected( - connection: NSXPCConnection, - _ fn: @escaping (XPCServiceProtocol, AutoFinishContinuation) -> Void -) async throws -> T { - let stream: AsyncThrowingStream = AsyncThrowingStream { continuation in - let service = connection.remoteObjectProxyWithErrorHandler { - continuation.finish(throwing: $0) - } as! XPCServiceProtocol - fn(service, .init(continuation: continuation)) - } - return try await stream.first(where: { _ in true })! -} - -func suggestionRequest( - _ connection: NSXPCConnection, - _ editorContent: EditorContent, - _ fn: @escaping (any XPCServiceProtocol) -> (Data, @escaping (Data?, Error?) -> Void) -> Void -) async throws -> UpdatedContent? { - let data = try JSONEncoder().encode(editorContent) - return try await withXPCServiceConnected(connection: connection) { - service, continuation in - fn(service)(data) { updatedData, error in - if let error { - continuation.reject(error) - return - } - do { - if let updatedData { - let updatedContent = try JSONDecoder() - .decode(UpdatedContent.self, from: updatedData) - continuation.resume(updatedContent) - } else { - continuation.resume(nil) - } - } catch { - continuation.reject(error) - } - } - } -} - diff --git a/Core/Sources/Client/XPCService.swift b/Core/Sources/Client/XPCService.swift index 99cebfc7..24a50bab 100644 --- a/Core/Sources/Client/XPCService.swift +++ b/Core/Sources/Client/XPCService.swift @@ -3,58 +3,12 @@ import Logger import os.log import XPCShared -let shared = XPCService() +let shared = XPCExtensionService(logger: .client) -public func getService() throws -> AsyncXPCService { +public func getService() throws -> XPCExtensionService { if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { struct RunningInPreview: Error {} throw RunningInPreview() } - return AsyncXPCService(service: shared) -} - -class XPCService { - private var isInvalidated = false - private lazy var _connection: NSXPCConnection = buildConnection() - - var connection: NSXPCConnection { - if isInvalidated { - _connection.invalidationHandler = {} - _connection.interruptionHandler = {} - isInvalidated = false - _connection.invalidate() - rebuildConnection() - } - return _connection - } - - private func buildConnection() -> NSXPCConnection { - let connection = NSXPCConnection( - machServiceName: Bundle(for: XPCService.self) - .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String + - ".ExtensionService" - ) - connection.remoteObjectInterface = - NSXPCInterface(with: XPCServiceProtocol.self) - connection.invalidationHandler = { [weak self] in - Logger.client.info("XPCService Invalidated") - self?.isInvalidated = true - } - connection.interruptionHandler = { [weak self] in - Logger.client.info("XPCService interrupted") - self?.isInvalidated = true - } - connection.resume() - return connection - } - - func rebuildConnection() { - _connection = buildConnection() - } - - deinit { - _connection.invalidationHandler = {} - _connection.interruptionHandler = {} - _connection.invalidate() - } + return shared } diff --git a/Core/Sources/CodeiumService/CodeiumInstallationManager.swift b/Core/Sources/CodeiumService/CodeiumInstallationManager.swift deleted file mode 100644 index 2f8190da..00000000 --- a/Core/Sources/CodeiumService/CodeiumInstallationManager.swift +++ /dev/null @@ -1,132 +0,0 @@ -import Foundation -import Terminal - -public struct CodeiumInstallationManager { - private static var isInstalling = false - static let latestSupportedVersion = "1.2.25" - - public init() {} - - public enum InstallationStatus { - case notInstalled - case installed(String) - case outdated(current: String, latest: String) - case unsupported(current: String, latest: String) - } - - public func checkInstallation() -> InstallationStatus { - guard let urls = try? CodeiumSuggestionService.createFoldersIfNeeded() - else { return .notInstalled } - let executableFolderURL = urls.executableURL - let binaryURL = executableFolderURL.appendingPathComponent("language_server") - let versionFileURL = executableFolderURL.appendingPathComponent("version") - - if !FileManager.default.fileExists(atPath: binaryURL.path) { - return .notInstalled - } - - if FileManager.default.fileExists(atPath: versionFileURL.path), - let versionData = try? Data(contentsOf: versionFileURL), - let version = String(data: versionData, encoding: .utf8) - { - switch version.compare(Self.latestSupportedVersion) { - case .orderedAscending: - return .outdated(current: version, latest: Self.latestSupportedVersion) - case .orderedSame: - return .installed(version) - case .orderedDescending: - return .unsupported(current: version, latest: Self.latestSupportedVersion) - } - } - - return .outdated(current: "Unknown", latest: Self.latestSupportedVersion) - } - - public enum InstallationStep { - case downloading - case uninstalling - case decompressing - case done - } - - public func installLatestVersion() -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - Task { - guard !CodeiumInstallationManager.isInstalling else { - continuation.finish(throwing: CodeiumError.languageServiceIsInstalling) - return - } - CodeiumInstallationManager.isInstalling = true - defer { CodeiumInstallationManager.isInstalling = false } - do { - continuation.yield(.downloading) - let urls = try CodeiumSuggestionService.createFoldersIfNeeded() - let urlString = - "https://github.com/Exafunction/codeium/releases/download/language-server-v\(Self.latestSupportedVersion)/language_server_macos_\(isAppleSilicon() ? "arm" : "x64").gz" - guard let url = URL(string: urlString) else { return } - - // download - let (fileURL, _) = try await URLSession.shared.download(from: url) - let targetURL = urls.executableURL.appendingPathComponent("language_server") - .appendingPathExtension("gz") - try FileManager.default.copyItem(at: fileURL, to: targetURL) - defer { try? FileManager.default.removeItem(at: targetURL) } - - // uninstall - continuation.yield(.uninstalling) - try await uninstall() - - // extract file - continuation.yield(.decompressing) - let terminal = Terminal() - _ = try await terminal.runCommand( - "/usr/bin/gunzip", - arguments: [targetURL.path], - environment: [:] - ) - - // update permission 755 - try FileManager.default.setAttributes( - [.posixPermissions: 0o755], - ofItemAtPath: targetURL.deletingPathExtension().path - ) - - // create version file - let data = Self.latestSupportedVersion.data(using: .utf8) - FileManager.default.createFile( - atPath: urls.executableURL.appendingPathComponent("version").path, - contents: data - ) - - continuation.yield(.done) - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - } - } - - public func uninstall() async throws { - guard let urls = try? CodeiumSuggestionService.createFoldersIfNeeded() - else { return } - let executableFolderURL = urls.executableURL - let binaryURL = executableFolderURL.appendingPathComponent("language_server") - let versionFileURL = executableFolderURL.appendingPathComponent("version") - if FileManager.default.fileExists(atPath: binaryURL.path) { - try FileManager.default.removeItem(at: binaryURL) - } - if FileManager.default.fileExists(atPath: versionFileURL.path) { - try FileManager.default.removeItem(at: versionFileURL) - } - } -} - -func isAppleSilicon() -> Bool { - var result = false - #if arch(arm64) - result = true - #endif - return result -} - diff --git a/Core/Sources/Configs/Configurations.swift b/Core/Sources/Configs/Configurations.swift deleted file mode 100644 index 387bd238..00000000 --- a/Core/Sources/Configs/Configurations.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -public var userDefaultSuiteName: String { - "5YKZ4Y3DAW.group.com.intii.CopilotForXcode" -} - -public var keychainAccessGroup: String { - #if DEBUG - return "5YKZ4Y3DAW.dev.com.intii.CopilotForXcode.Shared" - #else - return "5YKZ4Y3DAW.com.intii.CopilotForXcode.Shared" - #endif -} - -public var keychainService: String { - #if DEBUG - return "dev.com.intii.CopilotForXcode" - #else - return "com.intii.CopilotForXcode" - #endif -} - diff --git a/Core/Sources/DisplayLink/DisplayLink.swift b/Core/Sources/DisplayLink/DisplayLink.swift deleted file mode 100644 index 8afb5678..00000000 --- a/Core/Sources/DisplayLink/DisplayLink.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -import QuartzCore - -public actor DisplayLink { - private var displayLink: CVDisplayLink! - private static var _shared = DisplayLink() - static var shared: DisplayLink? { - if let _shared { return _shared } - _shared = DisplayLink() - return _shared - } - - private var continuations: [UUID: AsyncStream.Continuation] = [:] - - public static func createStream() -> AsyncStream { - .init { continuation in - Task { - let id = UUID() - await DisplayLink.shared?.addContinuation(continuation, id: id) - continuation.onTermination = { _ in - Task { - await DisplayLink.shared?.removeContinuation(id: id) - } - } - } - } - } - - private init?() { - _ = CVDisplayLinkCreateWithCGDisplay(CGMainDisplayID(), &displayLink) - guard displayLink != nil else { return nil } - CVDisplayLinkSetOutputHandler(displayLink) { [weak self] _, _, _, _, _ in - guard let self else { return kCVReturnSuccess } - Task { await self.notifyContinuations() } - return kCVReturnSuccess - } - } - - deinit { - for continuation in continuations { - continuation.value.finish() - } - } - - func addContinuation(_ continuation: AsyncStream.Continuation, id: UUID) { - continuations[id] = continuation - if !continuations.isEmpty { - CVDisplayLinkStart(displayLink) - } - } - - func removeContinuation(id: UUID) { - continuations[id] = nil - if continuations.isEmpty { - CVDisplayLinkStop(displayLink) - } - } - - private func notifyContinuations() { - for continuation in continuations { - continuation.value.yield() - } - } -} diff --git a/Core/Sources/Environment/Environment.swift b/Core/Sources/Environment/Environment.swift deleted file mode 100644 index 8bf6d022..00000000 --- a/Core/Sources/Environment/Environment.swift +++ /dev/null @@ -1,265 +0,0 @@ -import ActiveApplicationMonitor -import AppKit -import AXExtension -import Foundation -import GitHubCopilotService -import Logger -import SuggestionService - -public struct NoAccessToAccessibilityAPIError: Error, LocalizedError { - public var errorDescription: String? { - "Accessibility API permission is not granted. Please enable in System Settings.app." - } - - public init() {} -} - -public struct FailedToFetchFileURLError: Error, LocalizedError { - public var errorDescription: String? { - "Failed to fetch editing file url." - } - - public init() {} -} - -public enum Environment { - public static var now = { Date() } - - public static var isXcodeActive: () async -> Bool = { - ActiveApplicationMonitor.activeXcode != nil - } - - public static var frontmostXcodeWindowIsEditor: () async -> Bool = { - let appleScript = """ - tell application "Xcode" - return path of document of the first window - end tell - """ - do { - let result = try await runAppleScript(appleScript) - return !result.isEmpty - } catch { - return false - } - } - - public static var fetchCurrentProjectRootURLFromXcode: () async throws -> URL? = { - if let xcode = ActiveApplicationMonitor.activeXcode - ?? ActiveApplicationMonitor.latestXcode - { - let application = AXUIElementCreateApplication(xcode.processIdentifier) - let focusedWindow = application.focusedWindow - for child in focusedWindow?.children ?? [] { - if child.description.starts(with: "/"), child.description.count > 1 { - let path = child.description - let trimmedNewLine = path.trimmingCharacters(in: .newlines) - var url = URL(fileURLWithPath: trimmedNewLine) - while !FileManager.default.fileIsDirectory(atPath: url.path) || - !url.pathExtension.isEmpty - { - url = url.deletingLastPathComponent() - } - return url - } - } - } - - return nil - } - - public static var guessProjectRootURLForFile: (_ fileURL: URL) async throws -> URL = { - fileURL in - var currentURL = fileURL - var firstDirectoryURL: URL? - while currentURL.pathComponents.count > 1 { - defer { currentURL.deleteLastPathComponent() } - guard FileManager.default.fileIsDirectory(atPath: currentURL.path) else { continue } - if firstDirectoryURL == nil { firstDirectoryURL = currentURL } - let gitURL = currentURL.appendingPathComponent(".git") - if FileManager.default.fileIsDirectory(atPath: gitURL.path) { - return currentURL - } - } - - return firstDirectoryURL ?? fileURL - } - - public static var fetchCurrentFileURL: () async throws -> URL = { - guard let xcode = ActiveApplicationMonitor.activeXcode - ?? ActiveApplicationMonitor.latestXcode - else { - throw FailedToFetchFileURLError() - } - - // fetch file path of the frontmost window of Xcode through Accessability API. - let application = AXUIElementCreateApplication(xcode.processIdentifier) - let focusedWindow = application.focusedWindow - var path = focusedWindow?.document - if path == nil { - for window in application.windows { - path = window.document - if path != nil { break } - } - } - if let path = path?.removingPercentEncoding { - let url = URL( - fileURLWithPath: path - .replacingOccurrences(of: "file://", with: "") - ) - return url - } - throw FailedToFetchFileURLError() - } - - public static var fetchFocusedElementURI: () async throws -> URL = { - guard let xcode = ActiveApplicationMonitor.activeXcode - ?? ActiveApplicationMonitor.latestXcode - else { return URL(fileURLWithPath: "/global") } - - let application = AXUIElementCreateApplication(xcode.processIdentifier) - let focusedElement = application.focusedElement - var windowElement: URL { - let window = application.focusedWindow - let id = window?.identifier.hashValue - return URL(fileURLWithPath: "/xcode-focused-element/\(id ?? 0)") - } - if focusedElement?.description != "Source Editor" { - return windowElement - } - - do { - return try await fetchCurrentFileURL() - } catch { - return windowElement - } - } - - 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 - else { return } - let bundleName = Bundle.main - .object(forInfoDictionaryKey: "EXTENSION_BUNDLE_NAME") as! String - - await Task.yield() - - if UserDefaults.shared.value(for: \.triggerActionWithAccessibilityAPI) { - if !activeXcode.isActive { activeXcode.activate() } - let app = AXUIElementCreateApplication(activeXcode.processIdentifier) - - if let editorMenu = app.menuBar?.child(title: "Editor"), - let commandMenu = editorMenu.child(title: bundleName) - { - if let button = commandMenu.child(title: name, role: "AXMenuItem") { - let error = AXUIElementPerformAction(button, kAXPressAction as CFString) - if error != AXError.success { - Logger.service - .error("Trigger command \(name) failed: \(error.localizedDescription)") - throw error - } - } - } else if let commandMenu = app.menuBar?.child(title: bundleName), - let button = commandMenu.child(title: name, role: "AXMenuItem") - { - let error = AXUIElementPerformAction(button, kAXPressAction as CFString) - if error != AXError.success { - Logger.service - .error("Trigger command \(name) failed: \(error.localizedDescription)") - throw error - } - } else { - struct CantRunCommand: Error, LocalizedError { - let name: String - var errorDescription: String? { - "Can't run command \(name)." - } - } - - throw CantRunCommand(name: name) - } - } else { - /// check if menu is open, if not, click the menu item. - let appleScript = """ - tell application "System Events" - set theprocs to every process whose unix id is \(activeXcode.processIdentifier) - repeat with proc in theprocs - set the frontmost of proc to true - tell proc - repeat with theMenu in menus of menu bar 1 - set theValue to value of attribute "AXVisibleChildren" of theMenu - if theValue is not {} then - return - end if - end repeat - click menu item "\(name)" of menu 1 of menu item "\(bundleName)" of menu 1 of menu bar item "Editor" of menu bar 1 - end tell - end repeat - end tell - """ - - do { - try await runAppleScript(appleScript) - } catch { - Logger.service - .error("Trigger command \(name) failed: \(error.localizedDescription)") - throw error - } - } - } - - public static var makeXcodeActive: () async throws -> Void = { - let appleScript = """ - tell application "Xcode" - activate - end tell - """ - try await runAppleScript(appleScript) - } -} - -@discardableResult -func runAppleScript(_ appleScript: String) async throws -> String { - let task = Process() - task.launchPath = "/usr/bin/osascript" - task.arguments = ["-e", appleScript] - let outpipe = Pipe() - task.standardOutput = outpipe - task.standardError = Pipe() - - return try await withUnsafeThrowingContinuation { continuation in - do { - task.terminationHandler = { _ in - do { - if let data = try outpipe.fileHandleForReading.readToEnd(), - let content = String(data: data, encoding: .utf8) - { - continuation.resume(returning: content) - return - } - continuation.resume(returning: "") - } catch { - continuation.resume(throwing: error) - } - } - try task.run() - } catch { - continuation.resume(throwing: error) - } - } -} - -extension FileManager { - func fileIsDirectory(atPath path: String) -> Bool { - var isDirectory: ObjCBool = false - let exists = fileExists(atPath: path, isDirectory: &isDirectory) - return isDirectory.boolValue && exists - } -} - diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotRequest.swift b/Core/Sources/GitHubCopilotService/GitHubCopilotRequest.swift deleted file mode 100644 index e46527b5..00000000 --- a/Core/Sources/GitHubCopilotService/GitHubCopilotRequest.swift +++ /dev/null @@ -1,173 +0,0 @@ -import SuggestionModel -import Foundation -import JSONRPC -import LanguageServerProtocol - -struct GitHubCopilotDoc: Codable { - var source: String - var tabSize: Int - var indentSize: Int - var insertSpaces: Bool - var path: String - var uri: String - var relativePath: String - var languageId: CodeLanguage - var position: Position - /// Buffer version. Not sure what this is for, not sure how to get it - var version: Int = 0 -} - -protocol GitHubCopilotRequestType { - associatedtype Response: Codable - var request: ClientRequest { get } -} - -enum GitHubCopilotRequest { - struct SetEditorInfo: GitHubCopilotRequestType { - struct Response: Codable {} - - var request: ClientRequest { - .custom("setEditorInfo", .hash([ - "editorInfo": .hash([ - "name": "Xcode", - "version": "", - ]), - "editorPluginInfo": .hash([ - "name": "Copilot for Xcode", - "version": "", - ]), - ])) - } - } - - struct GetVersion: GitHubCopilotRequestType { - struct Response: Codable { - var version: String - } - - var request: ClientRequest { - .custom("getVersion", .hash([:])) - } - } - - struct CheckStatus: GitHubCopilotRequestType { - struct Response: Codable { - var status: GitHubCopilotAccountStatus - } - - var request: ClientRequest { - .custom("checkStatus", .hash([:])) - } - } - - struct SignInInitiate: GitHubCopilotRequestType { - struct Response: Codable { - var verificationUri: String - var status: String - var userCode: String - var expiresIn: Int - var interval: Int - } - - var request: ClientRequest { - .custom("signInInitiate", .hash([:])) - } - } - - struct SignInConfirm: GitHubCopilotRequestType { - struct Response: Codable { - var status: GitHubCopilotAccountStatus - var user: String - } - - var userCode: String - - var request: ClientRequest { - .custom("signInConfirm", .hash([ - "userCode": .string(userCode), - ])) - } - } - - struct SignOut: GitHubCopilotRequestType { - struct Response: Codable { - var status: GitHubCopilotAccountStatus - } - - var request: ClientRequest { - .custom("signOut", .hash([:])) - } - } - - struct GetCompletions: GitHubCopilotRequestType { - struct Response: Codable { - var completions: [CodeSuggestion] - } - - var doc: GitHubCopilotDoc - - var request: ClientRequest { - let data = (try? JSONEncoder().encode(doc)) ?? Data() - let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("getCompletions", .hash([ - "doc": dict, - ])) - } - } - - struct GetCompletionsCycling: GitHubCopilotRequestType { - struct Response: Codable { - var completions: [CodeSuggestion] - } - - var doc: GitHubCopilotDoc - - var request: ClientRequest { - let data = (try? JSONEncoder().encode(doc)) ?? Data() - let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("getCompletionsCycling", .hash([ - "doc": dict, - ])) - } - } - - struct GetPanelCompletions: GitHubCopilotRequestType { - struct Response: Codable { - var completions: [CodeSuggestion] - } - - var doc: GitHubCopilotDoc - - var request: ClientRequest { - let data = (try? JSONEncoder().encode(doc)) ?? Data() - let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("getPanelCompletions", .hash([ - "doc": dict, - ])) - } - } - - struct NotifyAccepted: GitHubCopilotRequestType { - struct Response: Codable {} - - var completionUUID: String - - var request: ClientRequest { - .custom("notifyAccepted", .hash([ - "uuid": .string(completionUUID), - ])) - } - } - - struct NotifyRejected: GitHubCopilotRequestType { - struct Response: Codable {} - - var completionUUIDs: [String] - - var request: ClientRequest { - .custom("notifyRejected", .hash([ - "uuids": .array(completionUUIDs.map(JSONValue.string)), - ])) - } - } -} diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift b/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift deleted file mode 100644 index 32d38b37..00000000 --- a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift +++ /dev/null @@ -1,394 +0,0 @@ -import Foundation -import LanguageClient -import LanguageServerProtocol -import Logger -import Preferences -import SuggestionModel -import XPCShared - -public protocol GitHubCopilotAuthServiceType { - func checkStatus() async throws -> GitHubCopilotAccountStatus - func signInInitiate() async throws -> (verificationUri: String, userCode: String) - func signInConfirm(userCode: String) async throws - -> (username: String, status: GitHubCopilotAccountStatus) - func signOut() async throws -> GitHubCopilotAccountStatus - func version() async throws -> String -} - -public protocol GitHubCopilotSuggestionServiceType { - func getCompletions( - fileURL: URL, - content: String, - cursorPosition: CursorPosition, - tabSize: Int, - indentSize: Int, - usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool - ) async throws -> [CodeSuggestion] - func notifyAccepted(_ completion: CodeSuggestion) async - func notifyRejected(_ completions: [CodeSuggestion]) async - func notifyOpenTextDocument(fileURL: URL, content: String) async throws - func notifyChangeTextDocument(fileURL: URL, content: String) async throws - func notifyCloseTextDocument(fileURL: URL) async throws - func notifySaveTextDocument(fileURL: URL) async throws - func cancelRequest() async - func terminate() async -} - -protocol GitHubCopilotLSP { - func sendRequest(_ endpoint: E) async throws -> E.Response - func sendNotification(_ notif: ClientNotification) async throws -} - -enum GitHubCopilotError: Error, LocalizedError { - case languageServerNotInstalled - - var errorDescription: String? { - switch self { - case .languageServerNotInstalled: - return "Language server is not installed." - } - } -} - -public class GitHubCopilotBaseService { - let projectRootURL: URL - var server: GitHubCopilotLSP - var localProcessServer: CopilotLocalProcessServer? - - init(designatedServer: GitHubCopilotLSP) { - projectRootURL = URL(fileURLWithPath: "/") - server = designatedServer - } - - init(projectRootURL: URL) throws { - self.projectRootURL = projectRootURL - let (server, localServer) = try { - let urls = try GitHubCopilotBaseService.createFoldersIfNeeded() - var userEnvPath = ProcessInfo.processInfo.userEnvironment["PATH"] ?? "" - if userEnvPath.isEmpty { - userEnvPath = "/usr/bin:/usr/local/bin" // fallback - } - let executionParams: Process.ExecutionParameters - let runner = UserDefaults.shared.value(for: \.runNodeWith) - - let agentJSURL = urls.executableURL.appendingPathComponent("copilot/dist/agent.js") - guard FileManager.default.fileExists(atPath: agentJSURL.path) else { - throw GitHubCopilotError.languageServerNotInstalled - } - - switch runner { - case .bash: - let nodePath = UserDefaults.shared.value(for: \.nodePath) - let command = [ - nodePath.isEmpty ? "node" : nodePath, - "\"\(agentJSURL.path)\"", - "--stdio", - ].joined(separator: " ") - executionParams = { - Process.ExecutionParameters( - path: "/bin/bash", - arguments: ["-i", "-l", "-c", command], - environment: [:], - currentDirectoryURL: urls.supportURL - ) - }() - case .shell: - let shell = ProcessInfo.processInfo.userEnvironment["SHELL"] ?? "/bin/bash" - let nodePath = UserDefaults.shared.value(for: \.nodePath) - let command = [ - nodePath.isEmpty ? "node" : nodePath, - "\"\(agentJSURL.path)\"", - "--stdio", - ].joined(separator: " ") - executionParams = { - Process.ExecutionParameters( - path: shell, - arguments: ["-i", "-l", "-c", command], - environment: [:], - currentDirectoryURL: urls.supportURL - ) - }() - case .env: - executionParams = { - let nodePath = UserDefaults.shared.value(for: \.nodePath) - return Process.ExecutionParameters( - path: "/usr/bin/env", - arguments: [ - nodePath.isEmpty ? "node" : nodePath, - agentJSURL.path, - "--stdio", - ], - environment: [ - "PATH": userEnvPath, - ], - currentDirectoryURL: urls.supportURL - ) - }() - } - let localServer = CopilotLocalProcessServer(executionParameters: executionParams) - - localServer.logMessages = UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) - localServer.notificationHandler = { _, respond in - respond(.timeout) - } - let server = InitializingServer(server: localServer) - - server.initializeParamsProvider = { - let capabilities = ClientCapabilities( - workspace: nil, - textDocument: nil, - window: nil, - general: nil, - experimental: nil - ) - - return InitializeParams( - processId: Int(ProcessInfo.processInfo.processIdentifier), - clientInfo: .init( - name: Bundle.main - .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String - ?? "Copilot for Xcode" - ), - locale: nil, - rootPath: projectRootURL.path, - rootUri: projectRootURL.path, - initializationOptions: nil, - capabilities: capabilities, - trace: .off, - workspaceFolders: nil - ) - } - - return (server, localServer) - }() - - self.server = server - self.localProcessServer = localServer - } - - public static func createFoldersIfNeeded() throws -> ( - applicationSupportURL: URL, - gitHubCopilotURL: URL, - executableURL: URL, - supportURL: URL - ) { - let supportURL = FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first!.appendingPathComponent( - Bundle.main - .object(forInfoDictionaryKey: "APPLICATION_SUPPORT_FOLDER") as! String - ) - - if !FileManager.default.fileExists(atPath: supportURL.path) { - try? FileManager.default - .createDirectory(at: supportURL, withIntermediateDirectories: false) - } - let gitHubCopilotFolderURL = supportURL.appendingPathComponent("GitHub Copilot") - if !FileManager.default.fileExists(atPath: gitHubCopilotFolderURL.path) { - try? FileManager.default - .createDirectory(at: gitHubCopilotFolderURL, withIntermediateDirectories: false) - } - let supportFolderURL = gitHubCopilotFolderURL.appendingPathComponent("support") - if !FileManager.default.fileExists(atPath: supportFolderURL.path) { - try? FileManager.default - .createDirectory(at: supportFolderURL, withIntermediateDirectories: false) - } - let executableFolderURL = gitHubCopilotFolderURL.appendingPathComponent("executable") - if !FileManager.default.fileExists(atPath: executableFolderURL.path) { - try? FileManager.default - .createDirectory(at: executableFolderURL, withIntermediateDirectories: false) - } - - return (supportURL, gitHubCopilotFolderURL, executableFolderURL, supportFolderURL) - } -} - -public final class GitHubCopilotAuthService: GitHubCopilotBaseService, - GitHubCopilotAuthServiceType -{ - public init() throws { - let home = FileManager.default.homeDirectoryForCurrentUser - try super.init(projectRootURL: home) - Task { - try? await server.sendRequest(GitHubCopilotRequest.SetEditorInfo()) - } - } - - public func checkStatus() async throws -> GitHubCopilotAccountStatus { - try await server.sendRequest(GitHubCopilotRequest.CheckStatus()).status - } - - public func signInInitiate() async throws -> (verificationUri: String, userCode: String) { - let result = try await server.sendRequest(GitHubCopilotRequest.SignInInitiate()) - return (result.verificationUri, result.userCode) - } - - public func signInConfirm(userCode: String) async throws - -> (username: String, status: GitHubCopilotAccountStatus) - { - let result = try await server - .sendRequest(GitHubCopilotRequest.SignInConfirm(userCode: userCode)) - return (result.user, result.status) - } - - public func signOut() async throws -> GitHubCopilotAccountStatus { - try await server.sendRequest(GitHubCopilotRequest.SignOut()).status - } - - public func version() async throws -> String { - try await server.sendRequest(GitHubCopilotRequest.GetVersion()).version - } -} - -public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, - GitHubCopilotSuggestionServiceType -{ - private var ongoingTasks = Set>() - - override public init(projectRootURL: URL = URL(fileURLWithPath: "/")) throws { - try super.init(projectRootURL: projectRootURL) - } - - override init(designatedServer: GitHubCopilotLSP) { - super.init(designatedServer: designatedServer) - } - - public func getCompletions( - fileURL: URL, - content: String, - cursorPosition: CursorPosition, - tabSize: Int, - indentSize: Int, - usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool - ) async throws -> [CodeSuggestion] { - let languageId = languageIdentifierFromFileURL(fileURL) - - let relativePath = { - let filePath = fileURL.path - let rootPath = projectRootURL.path - if let range = filePath.range(of: rootPath), - range.lowerBound == filePath.startIndex - { - let relativePath = filePath.replacingCharacters( - in: filePath.startIndex..(_ endpoint: E) async throws -> E.Response { - try await sendRequest(endpoint.request) - } -} - diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift new file mode 100644 index 00000000..bc6c910e --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift @@ -0,0 +1,148 @@ +import ComposableArchitecture +import SharedUIComponents +import SwiftUI + +struct APIKeyManagementView: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + HStack { + Button(action: { + store.send(.closeButtonClicked) + }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + Text("API Keys") + Spacer() + Button(action: { + store.send(.addButtonClicked) + }) { + Image(systemName: "plus.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + } + .background(Color(nsColor: .separatorColor)) + + List { + ForEach(store.availableAPIKeyNames, id: \.self) { name in + WithPerceptionTracking { + HStack { + Text(name) + .contextMenu { + Button("Remove") { + store.send(.deleteButtonClicked(name: name)) + } + } + Spacer() + + Button(action: { + store.send(.deleteButtonClicked(name: name)) + }) { + Image(systemName: "trash.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + } + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view + } + } + } + .removeBackground() + .overlay { + if store.availableAPIKeyNames.isEmpty { + Text(""" + Empty + Add a new key by clicking the add button + """) + .multilineTextAlignment(.center) + .padding() + } + } + } + .focusable(false) + .frame(width: 300, height: 400) + .background(.thickMaterial) + .onAppear { + store.send(.appear) + } + .sheet(item: $store.scope( + state: \.apiKeySubmission, + action: \.apiKeySubmission + )) { store in + WithPerceptionTracking { + APIKeySubmissionView(store: store) + .frame(minWidth: 400) + } + } + } + } +} + +struct APIKeySubmissionView: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + TextField("Name", text: $store.name) + SecureField("Key", text: $store.key) + } + .padding() + + Divider() + + HStack { + Spacer() + + Button("Cancel") { store.send(.cancelButtonClicked) } + .keyboardShortcut(.cancelAction) + + Button("Save", action: { store.send(.saveButtonClicked) }) + .keyboardShortcut(.defaultAction) + }.padding() + } + } + .textFieldStyle(.roundedBorder) + } + } +} + +class APIKeyManagementView_Preview: PreviewProvider { + static var previews: some View { + APIKeyManagementView( + store: .init( + initialState: .init( + availableAPIKeyNames: ["test1", "test2"] + ), + reducer: { APIKeyManagement() } + ) + ) + } +} + +class APIKeySubmissionView_Preview: PreviewProvider { + static var previews: some View { + APIKeySubmissionView( + store: .init( + initialState: .init(), + reducer: { APIKeySubmission() } + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift new file mode 100644 index 00000000..2756ce1e --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift @@ -0,0 +1,82 @@ +import ComposableArchitecture +import Foundation + +@Reducer +struct APIKeyManagement { + @ObservableState + struct State: Equatable { + var availableAPIKeyNames: [String] = [] + @Presents var apiKeySubmission: APIKeySubmission.State? + } + + enum Action: Equatable { + case appear + case closeButtonClicked + case addButtonClicked + case deleteButtonClicked(name: String) + case refreshAvailableAPIKeyNames + + case apiKeySubmission(PresentationAction) + } + + @Dependency(\.toast) var toast + @Dependency(\.apiKeyKeychain) var keychain + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .appear: + if isPreview { return .none } + + return .run { send in + await send(.refreshAvailableAPIKeyNames) + } + case .closeButtonClicked: + return .none + + case .addButtonClicked: + state.apiKeySubmission = .init() + + return .none + + case let .deleteButtonClicked(name): + do { + try keychain.remove(name) + return .run { send in + await send(.refreshAvailableAPIKeyNames) + } + } catch { + toast(error.localizedDescription, .error) + return .none + } + + case .refreshAvailableAPIKeyNames: + do { + let pairs = try keychain.getAll() + state.availableAPIKeyNames = Array(pairs.keys).sorted() + } catch { + toast(error.localizedDescription, .error) + } + + return .none + + case .apiKeySubmission(.presented(.saveFinished)): + state.apiKeySubmission = nil + return .run { send in + await send(.refreshAvailableAPIKeyNames) + } + + case .apiKeySubmission(.presented(.cancelButtonClicked)): + state.apiKeySubmission = nil + return .none + + case .apiKeySubmission: + return .none + } + } + .ifLet(\.$apiKeySubmission, action: \.apiKeySubmission) { + APIKeySubmission() + } + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift new file mode 100644 index 00000000..57e853d4 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift @@ -0,0 +1,50 @@ +import ComposableArchitecture +import SwiftUI + +struct APIKeyPicker: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + HStack { + Picker( + selection: $store.apiKeyName, + content: { + Text("No API Key").tag("") + if store.availableAPIKeyNames.isEmpty { + Text("No API key found, please add a new one →") + } + + if !store.availableAPIKeyNames.contains(store.apiKeyName), + !store.apiKeyName.isEmpty + { + Text("Key not found: \(store.apiKeyName)") + .tag(store.apiKeyName) + } + + ForEach(store.availableAPIKeyNames, id: \.self) { name in + Text(name).tag(name) + } + + }, + label: { Text("API Key") } + ) + + Button(action: { store.send(.manageAPIKeysButtonClicked) }) { + Text(Image(systemName: "key")) + } + }.sheet(isPresented: $store.isAPIKeyManagementPresented) { + WithPerceptionTracking { + APIKeyManagementView(store: store.scope( + state: \.apiKeyManagement, + action: \.apiKeyManagement + )) + } + } + .onAppear { + store.send(.appear) + } + } + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift new file mode 100644 index 00000000..47e8b33b --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift @@ -0,0 +1,58 @@ +import Foundation +import SwiftUI +import ComposableArchitecture + +@Reducer +struct APIKeySelection { + @ObservableState + struct State: Equatable { + var apiKeyName: String = "" + var availableAPIKeyNames: [String] { + apiKeyManagement.availableAPIKeyNames + } + var apiKeyManagement: APIKeyManagement.State = .init() + var isAPIKeyManagementPresented: Bool = false + } + + enum Action: Equatable, BindableAction { + case appear + case manageAPIKeysButtonClicked + + case binding(BindingAction) + case apiKeyManagement(APIKeyManagement.Action) + } + + @Dependency(\.toast) var toast + @Dependency(\.apiKeyKeychain) var keychain + + var body: some ReducerOf { + BindingReducer() + + Scope(state: \.apiKeyManagement, action: \.apiKeyManagement) { + APIKeyManagement() + } + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.apiKeyManagement(.refreshAvailableAPIKeyNames)) + } + + case .manageAPIKeysButtonClicked: + state.isAPIKeyManagementPresented = true + return .none + + case .binding: + return .none + + case .apiKeyManagement(.closeButtonClicked): + state.isAPIKeyManagementPresented = false + return .none + + case .apiKeyManagement: + return .none + } + } + } +} diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift new file mode 100644 index 00000000..8fe390ee --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift @@ -0,0 +1,61 @@ +import ComposableArchitecture +import Foundation + +@Reducer +struct APIKeySubmission { + @ObservableState + struct State: Equatable { + var name: String = "" + var key: String = "" + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case saveButtonClicked + case cancelButtonClicked + case saveFinished + } + + @Dependency(\.toast) var toast + @Dependency(\.apiKeyKeychain) var keychain + + enum E: Error, LocalizedError { + case nameIsEmpty + case keyIsEmpty + } + + var body: some ReducerOf { + BindingReducer() + + Reduce { state, action in + switch action { + case .saveButtonClicked: + do { + guard !state.name.isEmpty else { throw E.nameIsEmpty } + guard !state.key.isEmpty else { throw E.keyIsEmpty } + + try keychain.update( + state.key, + key: state.name.trimmingCharacters(in: .whitespacesAndNewlines) + ) + return .run { send in + await send(.saveFinished) + } + } catch { + toast(error.localizedDescription, .error) + return .none + } + + case .cancelButtonClicked: + return .none + + case .saveFinished: + return .none + + case .binding: + return .none + } + } + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/AzureView.swift b/Core/Sources/HostApp/AccountSettings/AzureView.swift deleted file mode 100644 index d28e111b..00000000 --- a/Core/Sources/HostApp/AccountSettings/AzureView.swift +++ /dev/null @@ -1,70 +0,0 @@ -import AppKit -import Client -import OpenAIService -import Preferences -import SuggestionModel -import SwiftUI - -final class AzureViewSettings: ObservableObject { - @AppStorage(\.azureOpenAIAPIKey) var azureOpenAIAPIKey: String - @AppStorage(\.azureOpenAIBaseURL) var azureOpenAIBaseURL: String - @AppStorage(\.azureChatGPTDeployment) var azureChatGPTDeployment: String - init() {} -} - -struct AzureView: View { - @Environment(\.toast) var toast - @State var isTesting = false - @StateObject var settings = AzureViewSettings() - - var body: some View { - Form { - SecureField(text: $settings.azureOpenAIAPIKey, prompt: Text("")) { - Text("OpenAI Service API Key") - } - .textFieldStyle(.roundedBorder) - - TextField( - text: $settings.azureOpenAIBaseURL, - prompt: Text("https://XXXXXX.openai.azure.com") - ) { - Text("OpenAI Service Base URL") - }.textFieldStyle(.roundedBorder) - - HStack { - TextField( - text: $settings.azureChatGPTDeployment, - prompt: Text("") - ) { - Text("Chat Model Deployment Name") - }.textFieldStyle(.roundedBorder) - - Button("Test") { - Task { @MainActor in - isTesting = true - defer { isTesting = false } - do { - let reply = try await ChatGPTService(designatedProvider: .azureOpenAI) - .sendAndWait(content: "Hello", summary: nil) - toast(Text("ChatGPT replied: \(reply ?? "N/A")"), .info) - } catch { - toast(Text(error.localizedDescription), .error) - } - } - } - .disabled(isTesting) - } - } - } -} - -struct AzureView_Previews: PreviewProvider { - static var previews: some View { - VStack(alignment: .leading, spacing: 8) { - AzureView() - } - .frame(height: 800) - .padding(.all, 8) - } -} - diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift new file mode 100644 index 00000000..f0c673e5 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -0,0 +1,342 @@ +import AIModel +import ComposableArchitecture +import Dependencies +import Keychain +import OpenAIService +import Preferences +import SwiftUI +import Toast + +@Reducer +struct ChatModelEdit { + @ObservableState + struct State: Equatable, Identifiable { + var id: String + var name: String + var format: ChatModel.Format + var maxTokens: Int = 4000 + var supportsFunctionCalling: Bool = true + var modelName: String = "" + var ollamaKeepAlive: String = "" + var apiVersion: String = "" + var apiKeyName: String { apiKeySelection.apiKeyName } + var baseURL: String { baseURLSelection.baseURL } + var isFullURL: Bool { baseURLSelection.isFullURL } + var availableModelNames: [String] = [] + var availableAPIKeys: [String] = [] + var isTesting = false + var suggestedMaxTokens: Int? + var apiKeySelection: APIKeySelection.State = .init() + var baseURLSelection: BaseURLSelection.State = .init() + var enforceMessageOrder: Bool = false + var openAIOrganizationID: String = "" + var openAIProjectID: String = "" + var customHeaders: [ChatModel.Info.CustomHeaderInfo.HeaderField] = [] + var openAICompatibleSupportsMultipartMessageContent = true + var requiresBeginWithUserMessage = false + var customBody: String = "" + var supportsImages: Bool = true + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case appear + case saveButtonClicked + case cancelButtonClicked + case refreshAvailableModelNames + case testButtonClicked + case testSucceeded(String) + case testFailed(String) + case checkSuggestedMaxTokens + case selectModelFormat(ModelFormat) + case apiKeySelection(APIKeySelection.Action) + case baseURLSelection(BaseURLSelection.Action) + } + + enum ModelFormat: CaseIterable { + case openAI + case azureOpenAI + case googleAI + case ollama + case claude + case gitHubCopilot + case openAICompatible + case deepSeekOpenAICompatible + case openRouterOpenAICompatible + case grokOpenAICompatible + case mistralOpenAICompatible + + init(_ format: ChatModel.Format) { + switch format { + case .openAI: + self = .openAI + case .azureOpenAI: + self = .azureOpenAI + case .googleAI: + self = .googleAI + case .ollama: + self = .ollama + case .claude: + self = .claude + case .openAICompatible: + self = .openAICompatible + case .gitHubCopilot: + self = .gitHubCopilot + } + } + } + + var toast: (String, ToastType) -> Void { + @Dependency(\.namespacedToast) var toast + return { + toast($0, $1, "ChatModelEdit") + } + } + + @Dependency(\.apiKeyKeychain) var keychain + + var body: some ReducerOf { + BindingReducer() + + Scope(state: \.apiKeySelection, action: \.apiKeySelection) { + APIKeySelection() + } + + Scope(state: \.baseURLSelection, action: \.baseURLSelection) { + BaseURLSelection() + } + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.refreshAvailableModelNames) + await send(.checkSuggestedMaxTokens) + } + + case .saveButtonClicked: + return .none + + case .cancelButtonClicked: + return .none + + case .testButtonClicked: + guard !state.isTesting else { return .none } + state.isTesting = true + let model = ChatModel(state: state) + return .run { send in + do { + let configuration = UserPreferenceChatGPTConfiguration().overriding { + $0.model = model + } + let service = ChatGPTService(configuration: configuration) + let stream = service.send(TemplateChatGPTMemory( + memoryTemplate: .init(messages: [ + .init(chatMessage: .init( + role: .system, + content: "You are a bot. Just do what is told." + )), + .init(chatMessage: .init( + role: .assistant, + content: "Hello" + )), + .init(chatMessage: .init( + role: .user, + content: "Respond with \"Test succeeded.\"" + )), + .init(chatMessage: .init( + role: .user, + content: "Respond with \"Test succeeded.\"" + )), + ]), + configuration: configuration, + functionProvider: NoChatGPTFunctionProvider() + )) + let streamReply = try await stream.asText() + await send(.testSucceeded(streamReply)) + } catch { + await send(.testFailed(error.localizedDescription)) + } + } + + case let .testSucceeded(message): + state.isTesting = false + toast(message.trimmingCharacters(in: .whitespacesAndNewlines), .info) + return .none + + case let .testFailed(message): + state.isTesting = false + toast(message.trimmingCharacters(in: .whitespacesAndNewlines), .error) + return .none + + case .refreshAvailableModelNames: + if state.format == .openAI { + state.availableModelNames = ChatGPTModel.allCases.map(\.rawValue) + } + + return .none + + case .checkSuggestedMaxTokens: + switch state.format { + case .openAI: + if let knownModel = ChatGPTModel(rawValue: state.modelName) { + state.suggestedMaxTokens = knownModel.maxToken + } else { + state.suggestedMaxTokens = nil + } + return .none + case .googleAI: + if let knownModel = GoogleGenerativeAIModel(rawValue: state.modelName) { + state.suggestedMaxTokens = knownModel.maxToken + } else { + state.suggestedMaxTokens = nil + } + return .none + case .claude: + if let knownModel = ClaudeChatCompletionsService + .KnownModel(rawValue: state.modelName) + { + state.suggestedMaxTokens = knownModel.contextWindow + } else { + state.suggestedMaxTokens = nil + } + return .none + case .gitHubCopilot: + if let knownModel = AvailableGitHubCopilotModel(rawValue: state.modelName) { + state.suggestedMaxTokens = knownModel.contextWindow + } else { + state.suggestedMaxTokens = nil + } + return .none + default: + state.suggestedMaxTokens = nil + return .none + } + + case let .selectModelFormat(format): + switch format { + case .openAI: + state.format = .openAI + case .azureOpenAI: + state.format = .azureOpenAI + case .googleAI: + state.format = .googleAI + case .ollama: + state.format = .ollama + case .claude: + state.format = .claude + case .gitHubCopilot: + state.format = .gitHubCopilot + case .openAICompatible: + state.format = .openAICompatible + case .deepSeekOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.deepseek.com" + state.baseURLSelection.isFullURL = false + case .openRouterOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://openrouter.ai" + state.baseURLSelection.isFullURL = false + case .grokOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.x.ai" + state.baseURLSelection.isFullURL = false + case .mistralOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.mistral.ai" + state.baseURLSelection.isFullURL = false + } + return .none + + case .apiKeySelection: + return .none + + case .baseURLSelection: + return .none + + case .binding(\.format): + return .run { send in + await send(.refreshAvailableModelNames) + await send(.checkSuggestedMaxTokens) + } + + case .binding(\.modelName): + return .run { send in + await send(.checkSuggestedMaxTokens) + } + + case .binding: + return .none + } + } + } +} + +extension ChatModel { + init(state: ChatModelEdit.State) { + self.init( + id: state.id, + name: state.name, + format: state.format, + info: .init( + apiKeyName: state.apiKeyName, + baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines), + isFullURL: state.isFullURL, + maxTokens: state.maxTokens, + supportsFunctionCalling: { + switch state.format { + case .googleAI, .ollama, .claude: + return false + case .azureOpenAI, .openAI, .openAICompatible, .gitHubCopilot: + return state.supportsFunctionCalling + } + }(), + supportsImage: state.supportsImages, + modelName: state.modelName + .trimmingCharacters(in: .whitespacesAndNewlines), + openAIInfo: .init( + organizationID: state.openAIOrganizationID, + projectID: state.openAIProjectID + ), + ollamaInfo: .init(keepAlive: state.ollamaKeepAlive), + googleGenerativeAIInfo: .init(apiVersion: state.apiVersion), + openAICompatibleInfo: .init( + enforceMessageOrder: state.enforceMessageOrder, + supportsMultipartMessageContent: state + .openAICompatibleSupportsMultipartMessageContent, + requiresBeginWithUserMessage: state.requiresBeginWithUserMessage + ), + customHeaderInfo: .init(headers: state.customHeaders), + customBodyInfo: .init(jsonBody: state.customBody) + ) + ) + } + + func toState() -> ChatModelEdit.State { + .init( + id: id, + name: name, + format: format, + maxTokens: info.maxTokens, + supportsFunctionCalling: info.supportsFunctionCalling, + modelName: info.modelName, + ollamaKeepAlive: info.ollamaInfo.keepAlive, + apiVersion: info.googleGenerativeAIInfo.apiVersion, + apiKeySelection: .init( + apiKeyName: info.apiKeyName, + apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName]) + ), + baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL), + enforceMessageOrder: info.openAICompatibleInfo.enforceMessageOrder, + openAIOrganizationID: info.openAIInfo.organizationID, + openAIProjectID: info.openAIInfo.projectID, + customHeaders: info.customHeaderInfo.headers, + openAICompatibleSupportsMultipartMessageContent: info.openAICompatibleInfo + .supportsMultipartMessageContent, + requiresBeginWithUserMessage: info.openAICompatibleInfo.requiresBeginWithUserMessage, + customBody: info.customBodyInfo.jsonBody, + supportsImages: info.supportsImage + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift new file mode 100644 index 00000000..d16b7556 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -0,0 +1,660 @@ +import AIModel +import ComposableArchitecture +import OpenAIService +import Preferences +import SwiftUI + +@MainActor +struct ChatModelEditView: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + NameTextField(store: store) + FormatPicker(store: store) + + switch store.format { + case .openAI: + OpenAIForm(store: store) + case .azureOpenAI: + AzureOpenAIForm(store: store) + case .openAICompatible: + OpenAICompatibleForm(store: store) + case .googleAI: + GoogleAIForm(store: store) + case .ollama: + OllamaForm(store: store) + case .claude: + ClaudeForm(store: store) + case .gitHubCopilot: + GitHubCopilotForm(store: store) + } + } + .padding() + + Divider() + + HStack { + HStack(spacing: 8) { + Button("Test") { + store.send(.testButtonClicked) + } + .disabled(store.isTesting) + + if store.isTesting { + ProgressView() + .controlSize(.small) + } + } + + CustomBodyEdit(store: store) + .disabled({ + switch store.format { + case .openAI, .openAICompatible, .claude: + return false + default: + return true + } + }()) + CustomHeaderEdit(store: store) + .disabled({ + switch store.format { + case .openAI, .openAICompatible, .ollama, .gitHubCopilot, .claude: + return false + default: + return true + } + }()) + + Spacer() + + Button("Cancel") { + store.send(.cancelButtonClicked) + } + .keyboardShortcut(.cancelAction) + + Button(action: { store.send(.saveButtonClicked) }) { + Text("Save") + } + .keyboardShortcut(.defaultAction) + } + .padding() + } + } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + .fixedSize(horizontal: false, vertical: true) + .handleToast(namespace: "ChatModelEdit") + } + } + + struct NameTextField: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + TextField("Name", text: $store.name) + } + } + } + + struct FormatPicker: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + Picker( + selection: Binding( + get: { .init(store.format) }, + set: { store.send(.selectModelFormat($0)) } + ), + content: { + ForEach( + ChatModelEdit.ModelFormat.allCases, + id: \.self + ) { format in + switch format { + case .openAI: + Text("OpenAI") + case .azureOpenAI: + Text("Azure OpenAI") + case .openAICompatible: + Text("OpenAI Compatible") + case .googleAI: + Text("Google AI") + case .ollama: + Text("Ollama") + case .claude: + Text("Claude") + case .gitHubCopilot: + Text("GitHub Copilot") + case .deepSeekOpenAICompatible: + Text("DeepSeek (OpenAI Compatible)") + case .openRouterOpenAICompatible: + Text("OpenRouter (OpenAI Compatible)") + case .grokOpenAICompatible: + Text("Grok (OpenAI Compatible)") + case .mistralOpenAICompatible: + Text("Mistral (OpenAI Compatible)") + } + } + }, + label: { Text("Format") } + ) + .pickerStyle(.menu) + } + } + } + + struct BaseURLTextField: View { + let store: StoreOf + var title: String = "Base URL" + let prompt: Text? + @ViewBuilder var trailingContent: () -> V + + var body: some View { + WithPerceptionTracking { + BaseURLPicker( + title: title, + prompt: prompt, + store: store.scope( + state: \.baseURLSelection, + action: \.baseURLSelection + ), + trailingContent: trailingContent + ) + } + } + } + + struct SupportsFunctionCallingToggle: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + Toggle( + "Supports Function Calling", + isOn: $store.supportsFunctionCalling + ) + + Text( + "Function calling is required by some features, if this model doesn't support function calling, you should turn it off to avoid undefined behaviors." + ) + .foregroundColor(.secondary) + .font(.callout) + .dynamicHeightTextInFormWorkaround() + } + } + } + + struct MaxTokensTextField: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + HStack { + let textFieldBinding = Binding( + get: { String(store.maxTokens) }, + set: { + if let selectionMaxToken = Int($0) { + $store.maxTokens.wrappedValue = selectionMaxToken + } else { + $store.maxTokens.wrappedValue = 0 + } + } + ) + + TextField(text: textFieldBinding) { + Text("Context Window") + .multilineTextAlignment(.trailing) + } + .overlay(alignment: .trailing) { + Stepper( + value: $store.maxTokens, + in: 0...Int.max, + step: 100 + ) { + EmptyView() + } + } + .foregroundColor({ + guard let max = store.suggestedMaxTokens else { + return .primary + } + if store.maxTokens > max { + return .red + } + return .primary + }() as Color) + + if let max = store.suggestedMaxTokens { + Text("Max: \(max)") + } + } + } + } + } + + struct ApiKeyNamePicker: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + } + } + } + + struct CustomBodyEdit: View { + @Perception.Bindable var store: StoreOf + @State private var isEditing = false + @Dependency(\.namespacedToast) var toast + + var body: some View { + Button("Custom Body") { + isEditing = true + } + .sheet(isPresented: $isEditing) { + WithPerceptionTracking { + VStack { + TextEditor(text: $store.customBody) + .font(Font.system(.body, design: .monospaced)) + .padding(4) + .frame(minHeight: 120) + .multilineTextAlignment(.leading) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .handleToast(namespace: "CustomBodyEdit") + + Text( + "The custom body will be added to the request body. Please use it to add parameters that are not yet available in the form. It should be a valid JSON object." + ) + .foregroundColor(.secondary) + .font(.callout) + .padding(.bottom) + + Button(action: { + if store.customBody.trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + { + isEditing = false + return + } + guard let _ = try? JSONSerialization + .jsonObject(with: store.customBody.data(using: .utf8) ?? Data()) + else { + toast("Invalid JSON object", .error, "CustomBodyEdit") + return + } + isEditing = false + }) { + Text("Done") + } + .keyboardShortcut(.defaultAction) + } + .padding() + .frame(width: 600, height: 500) + .background(Color(nsColor: .windowBackgroundColor)) + } + } + } + } + + struct CustomHeaderEdit: View { + @Perception.Bindable var store: StoreOf + @State private var isEditing = false + + var body: some View { + Button("Custom Headers") { + isEditing = true + } + .sheet(isPresented: $isEditing) { + WithPerceptionTracking { + CustomHeaderSettingsView(headers: $store.customHeaders) + } + } + } + } + + struct OpenAIForm: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("https://api.openai.com")) { + Text("/v1/chat/completions") + } + ApiKeyNamePicker(store: store) + + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if ChatGPTModel(rawValue: store.modelName) == nil { + Text("Custom Model").tag(store.modelName) + } + ForEach(ChatGPTModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + + MaxTokensTextField(store: store) + SupportsFunctionCallingToggle(store: store) + + TextField(text: $store.openAIOrganizationID, prompt: Text("Optional")) { + Text("Organization ID") + } + + TextField(text: $store.openAIProjectID, prompt: Text("Optional")) { + Text("Project ID") + } + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)" + ) + + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API." + ) + } + .padding(.vertical) + } + } + } + + struct AzureOpenAIForm: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("https://xxxx.openai.azure.com")) { + EmptyView() + } + ApiKeyNamePicker(store: store) + + TextField("Deployment Name", text: $store.modelName) + + MaxTokensTextField(store: store) + SupportsFunctionCallingToggle(store: store) + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } + } + } + } + + struct OpenAICompatibleForm: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + Picker( + selection: $store.baseURLSelection.isFullURL, + content: { + Text("Base URL").tag(false) + Text("Full URL").tag(true) + }, + label: { Text("URL") } + ) + .pickerStyle(.segmented) + + BaseURLTextField( + store: store, + title: "", + prompt: store.isFullURL + ? Text("https://api.openai.com/v1/chat/completions") + : Text("https://api.openai.com") + ) { + if !store.isFullURL { + Text("/v1/chat/completions") + } + } + + ApiKeyNamePicker(store: store) + + TextField("Model Name", text: $store.modelName) + + MaxTokensTextField(store: store) + SupportsFunctionCallingToggle(store: store) + + Toggle(isOn: $store.enforceMessageOrder) { + Text("Enforce message order to be user/assistant alternated") + } + + Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) { + Text("Support multi-part message content") + } + + Toggle(isOn: $store.requiresBeginWithUserMessage) { + Text("Requires the first message to be from the user") + } + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } + } + } + } + + struct GoogleAIForm: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + BaseURLTextField( + store: store, + prompt: Text("https://generativelanguage.googleapis.com") + ) { + Text("/v1") + } + + ApiKeyNamePicker(store: store) + + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if GoogleGenerativeAIModel(rawValue: store.modelName) == nil { + Text("Custom Model").tag(store.modelName) + } + ForEach(GoogleGenerativeAIModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + + MaxTokensTextField(store: store) + + TextField("API Version", text: $store.apiVersion, prompt: Text("v1")) + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } + } + } + } + + struct OllamaForm: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) { + Text("/api/chat") + } + + ApiKeyNamePicker(store: store) + + TextField("Model Name", text: $store.modelName) + + MaxTokensTextField(store: store) + + TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) { + Text("Keep Alive") + } + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " For more details, please visit [https://ollama.com](https://ollama.com)." + ) + } + .padding(.vertical) + } + } + } + + struct ClaudeForm: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("https://api.anthropic.com")) { + Text("/v1/messages") + } + + ApiKeyNamePicker(store: store) + + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if ClaudeChatCompletionsService + .KnownModel(rawValue: store.modelName) == nil + { + Text("Custom Model").tag(store.modelName) + } + ForEach( + ClaudeChatCompletionsService.KnownModel.allCases, + id: \.self + ) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + + MaxTokensTextField(store: store) + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " For more details, please visit [https://anthropic.com](https://anthropic.com)." + ) + } + .padding(.vertical) + } + } + } + + struct GitHubCopilotForm: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + #warning("Todo: use the old picker and update the context window limit.") + GitHubCopilotModelPicker( + title: "Model Name", + hasDefaultModel: false, + gitHubCopilotModelId: $store.modelName + ) + + MaxTokensTextField(store: store) + SupportsFunctionCallingToggle(store: store) + + Toggle(isOn: $store.enforceMessageOrder) { + Text("Enforce message order to be user/assistant alternated") + } + + Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) { + Text("Support multi-part message content") + } + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " Please login in the GitHub Copilot settings to use the model." + ) + + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed." + ) + } + .dynamicHeightTextInFormWorkaround() + .padding(.vertical) + } + } + } +} + +#Preview("OpenAI") { + ChatModelEditView( + store: .init( + initialState: ChatModel( + id: "3", + name: "Test Model 3", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ).toState(), + reducer: { ChatModelEdit() } + ) + ) +} + +#Preview("OpenAI Compatible") { + ChatModelEditView( + store: .init( + initialState: ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + isFullURL: false, + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ).toState(), + reducer: { ChatModelEdit() } + ) + ) +} + diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift new file mode 100644 index 00000000..64eadd57 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift @@ -0,0 +1,167 @@ +import AIModel +import ComposableArchitecture +import Keychain +import Preferences +import SwiftUI + +extension ChatModel: ManageableAIModel { + var formatName: String { + switch format { + case .openAI: return "OpenAI" + case .azureOpenAI: return "Azure OpenAI" + case .openAICompatible: return "OpenAI Compatible" + case .googleAI: return "Google Generative AI" + case .ollama: return "Ollama" + case .claude: return "Claude" + case .gitHubCopilot: return "GitHub Copilot" + } + } + + @ViewBuilder + var infoDescriptors: some View { + Text(info.modelName) + + if !info.baseURL.isEmpty { + Image(systemName: "line.diagonal") + Text(info.baseURL) + } + + Image(systemName: "line.diagonal") + + Text("\(info.maxTokens) tokens") + + Image(systemName: "line.diagonal") + + Text( + "function calling \(info.supportsFunctionCalling ? Image(systemName: "checkmark.square") : Image(systemName: "xmark.square"))" + ) + } +} + +@Reducer +struct ChatModelManagement: AIModelManagement { + typealias Model = ChatModel + + @ObservableState + struct State: Equatable, AIModelManagementState { + typealias Model = ChatModel + var models: IdentifiedArrayOf = [] + @Presents var editingModel: ChatModelEdit.State? + var selectedModelId: String? { editingModel?.id } + } + + enum Action: Equatable, AIModelManagementAction { + typealias Model = ChatModel + case appear + case createModel + case removeModel(id: Model.ID) + case selectModel(id: Model.ID) + case duplicateModel(id: Model.ID) + case moveModel(from: IndexSet, to: Int) + case chatModelItem(PresentationAction) + } + + @Dependency(\.toast) var toast + @Dependency(\.userDefaults) var userDefaults + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .appear: + if isPreview { return .none } + state.models = .init( + userDefaults.value(for: \.chatModels), + id: \.id, + uniquingIDsWith: { a, _ in a } + ) + + return .none + + case .createModel: + state.editingModel = .init( + id: UUID().uuidString, + name: "New Model", + format: .openAI + ) + return .none + + case let .removeModel(id): + state.models.remove(id: id) + persist(state) + return .none + + case let .selectModel(id): + guard let model = state.models[id: id] else { return .none } + state.editingModel = model.toState() + return .none + + case let .duplicateModel(id): + guard var model = state.models[id: id] else { return .none } + model.id = UUID().uuidString + model.name += " (Copy)" + + if let index = state.models.index(id: id) { + state.models.insert(model, at: index + 1) + } else { + state.models.append(model) + } + persist(state) + return .none + + case let .moveModel(from, to): + state.models.move(fromOffsets: from, toOffset: to) + persist(state) + return .none + + case .chatModelItem(.presented(.saveButtonClicked)): + guard let editingModel = state.editingModel, validateModel(editingModel) + else { return .none } + + if let index = state.models + .firstIndex(where: { $0.id == editingModel.id }) + { + state.models[index] = .init(state: editingModel) + } else { + state.models.append(.init(state: editingModel)) + } + persist(state) + return .run { send in + await send(.chatModelItem(.dismiss)) + } + + case .chatModelItem(.presented(.cancelButtonClicked)): + return .run { send in + await send(.chatModelItem(.dismiss)) + } + + case .chatModelItem: + return .none + } + }.ifLet(\.$editingModel, action: \.chatModelItem) { + ChatModelEdit() + } + } + + func persist(_ state: State) { + let models = state.models + userDefaults.set(Array(models), for: \.chatModels) + } + + func validateModel(_ chatModel: ChatModelEdit.State) -> Bool { + guard !chatModel.name.isEmpty else { + toast("Model name cannot be empty", .error) + return false + } + guard !chatModel.id.isEmpty else { + toast("Model ID cannot be empty", .error) + return false + } + + guard !chatModel.modelName.isEmpty else { + toast("Model name cannot be empty", .error) + return false + } + return true + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift new file mode 100644 index 00000000..e81b4a97 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift @@ -0,0 +1,85 @@ +import AIModel +import ComposableArchitecture +import SwiftUI + +struct ChatModelManagementView: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + AIModelManagementView(store: store) + .sheet(item: $store.scope( + state: \.editingModel, + action: \.chatModelItem + )) { store in + ChatModelEditView(store: store) + .frame(width: 800) + } + } + } +} + +// MARK: - Previews + +class ChatModelManagementView_Previews: PreviewProvider { + static var previews: some View { + ChatModelManagementView( + store: .init( + initialState: .init( + models: IdentifiedArray(uniqueElements: [ + ChatModel( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "2", + name: "Test Model 2", + format: .azureOpenAI, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ]), + editingModel: ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ).toState() + ), + reducer: { ChatModelManagement() } + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift index a6885bb4..e60af2a8 100644 --- a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift +++ b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift @@ -1,5 +1,6 @@ import CodeiumService import Foundation +import SharedUIComponents import SwiftUI struct CodeiumView: View { @@ -10,10 +11,17 @@ struct CodeiumView: View { @Published var installationStatus: CodeiumInstallationManager.InstallationStatus @Published var installationStep: CodeiumInstallationManager.InstallationStep? @AppStorage(\.codeiumVerboseLog) var codeiumVerboseLog + @AppStorage(\.codeiumEnterpriseMode) var codeiumEnterpriseMode + @AppStorage(\.codeiumPortalUrl) var codeiumPortalUrl + @AppStorage(\.codeiumApiUrl) var codeiumApiUrl + @AppStorage(\.codeiumIndexEnabled) var indexEnabled init() { isSignedIn = codeiumAuthService.isSignedIn - installationStatus = installationManager.checkInstallation() + installationStatus = .notInstalled + Task { @MainActor in + installationStatus = await installationManager.checkInstallation() + } } init( @@ -28,6 +36,13 @@ struct CodeiumView: View { } func generateAuthURL() -> URL { + if codeiumEnterpriseMode && (codeiumPortalUrl != "") { + return URL( + string: codeiumPortalUrl + + "/profile?response_type=token&redirect_uri=show-auth-token&state=\(UUID().uuidString)&scope=openid%20profile%20email&redirect_parameters_type=query" + )! + } + return URL( string: "https://www.codeium.com/profile?response_type=token&redirect_uri=show-auth-token&state=\(UUID().uuidString)&scope=openid%20profile%20email&redirect_parameters_type=query" )! @@ -45,7 +60,7 @@ struct CodeiumView: View { func refreshInstallationStatus() { Task { @MainActor in - installationStatus = installationManager.checkInstallation() + installationStatus = await installationManager.checkInstallation() } } @@ -89,7 +104,7 @@ struct CodeiumView: View { do { try await viewModel.install() } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -104,7 +119,7 @@ struct CodeiumView: View { do { try await viewModel.install() } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -124,7 +139,7 @@ struct CodeiumView: View { var body: some View { VStack(alignment: .leading) { - VStack(alignment: .leading) { + SubSection(title: Text("Codeium Language Server")) { switch viewModel.installationStatus { case .notInstalled: HStack { @@ -136,7 +151,7 @@ struct CodeiumView: View { Text("Language Server Version: \(version)") uninstallButton } - case let .outdated(current: current, latest: latest): + case let .outdated(current: current, latest: latest, _): HStack { Text("Language Server Version: \(current) (Update Available: \(latest))") uninstallButton @@ -149,16 +164,16 @@ struct CodeiumView: View { updateButton } } - + if viewModel.isSignedIn { Text("Status: Signed In") - + Button(action: { Task { do { try await viewModel.signOut() } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -166,7 +181,7 @@ struct CodeiumView: View { } } else { Text("Status: Not Signed In") - + Button(action: { isSignInPanelPresented = true }) { @@ -174,12 +189,6 @@ struct CodeiumView: View { } } } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1)) - } .sheet(isPresented: $isSignInPanelPresented) { CodeiumSignInView(viewModel: viewModel, isPresented: $isSignInPanelPresented) } @@ -187,19 +196,33 @@ struct CodeiumView: View { if let step = newValue { switch step { case .downloading: - toast(Text("Downloading.."), .info) + toast("Downloading..", .info) case .uninstalling: - toast(Text("Uninstalling old version.."), .info) + toast("Uninstalling old version..", .info) case .decompressing: - toast(Text("Decompressing.."), .info) + toast("Decompressing..", .info) case .done: - toast(Text("Done!"), .info) + toast("Done!", .info) } } } - Divider() - + SubSection(title: Text("Indexing")) { + Form { + Toggle("Enable Indexing", isOn: $viewModel.indexEnabled) + } + } + + SubSection(title: Text("Enterprise")) { + Form { + Toggle("Codeium Enterprise Mode", isOn: $viewModel.codeiumEnterpriseMode) + TextField("Codeium Portal URL", text: $viewModel.codeiumPortalUrl) + TextField("Codeium API URL", text: $viewModel.codeiumApiUrl) + } + } + + SettingsDivider("Advanced") + Form { Toggle("Verbose Log", isOn: $viewModel.codeiumVerboseLog) } @@ -233,13 +256,13 @@ struct CodeiumSignInView: View { HStack { Spacer() - + Button(action: { isPresented = false }) { Text("Cancel") } - + Button(action: { isGeneratingKey = true Task { @@ -249,12 +272,14 @@ struct CodeiumSignInView: View { isPresented = false } catch { isGeneratingKey = false - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { Text(isGeneratingKey ? "Signing In.." : "Sign In") - }.disabled(isGeneratingKey) + } + .disabled(isGeneratingKey) + .keyboardShortcut(.defaultAction) } } .padding() @@ -282,32 +307,34 @@ struct CodeiumView_Previews: PreviewProvider { } static var previews: some View { - VStack(alignment: .leading, spacing: 8) { - CodeiumView(viewModel: TestViewModel( - isSignedIn: false, - installationStatus: .notInstalled, - installationStep: nil - )) - - CodeiumView(viewModel: TestViewModel( - isSignedIn: true, - installationStatus: .installed("1.2.9"), - installationStep: nil - )) - - CodeiumView(viewModel: TestViewModel( - isSignedIn: true, - installationStatus: .outdated(current: "1.2.9", latest: "1.3.0"), - installationStep: .downloading - )) - - CodeiumView(viewModel: TestViewModel( - isSignedIn: true, - installationStatus: .unsupported(current: "1.5.9", latest: "1.3.0"), - installationStep: .downloading - )) + ScrollView { + VStack(alignment: .leading, spacing: 8) { + CodeiumView(viewModel: TestViewModel( + isSignedIn: false, + installationStatus: .notInstalled, + installationStep: nil + )) + + CodeiumView(viewModel: TestViewModel( + isSignedIn: true, + installationStatus: .installed("1.2.9"), + installationStep: nil + )) + + CodeiumView(viewModel: TestViewModel( + isSignedIn: true, + installationStatus: .outdated(current: "1.2.9", latest: "1.3.0", mandatory: true), + installationStep: .downloading + )) + + CodeiumView(viewModel: TestViewModel( + isSignedIn: true, + installationStatus: .unsupported(current: "1.5.9", latest: "1.3.0"), + installationStep: .downloading + )) + } + .padding(8) } - .padding(8) } } diff --git a/Core/Sources/HostApp/AccountSettings/CustomHeaderSettingsView.swift b/Core/Sources/HostApp/AccountSettings/CustomHeaderSettingsView.swift new file mode 100644 index 00000000..97db69b5 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/CustomHeaderSettingsView.swift @@ -0,0 +1,78 @@ +import AIModel +import Foundation +import SwiftUI + +struct CustomHeaderSettingsView: View { + @Binding var headers: [ChatModel.Info.CustomHeaderInfo.HeaderField] + @Environment(\.dismiss) var dismiss + @State private var newKey = "" + @State private var newValue = "" + + var body: some View { + VStack { + List { + ForEach(headers.indices, id: \.self) { index in + HStack { + TextField("Key", text: Binding( + get: { headers[index].key }, + set: { newKey in + headers[index].key = newKey + } + )) + TextField("Value", text: Binding( + get: { headers[index].value }, + set: { headers[index].value = $0 } + )) + Button(action: { + headers.remove(at: index) + }) { + Image(systemName: "trash") + .foregroundColor(.red) + } + } + } + + HStack { + TextField("New Key", text: $newKey) + TextField("New Value", text: $newValue) + Button(action: { + if !newKey.isEmpty { + headers.append(ChatModel.Info.CustomHeaderInfo.HeaderField( + key: newKey, + value: newValue + )) + newKey = "" + newValue = "" + } + }) { + Image(systemName: "plus.circle.fill") + .foregroundColor(.green) + } + } + } + + HStack { + Spacer() + Button("Done") { + dismiss() + } + }.padding() + } + .frame(height: 500) + } +} + +#Preview { + struct V: View { + @State var headers: [ChatModel.Info.CustomHeaderInfo.HeaderField] = [ + .init(key: "key", value: "value"), + .init(key: "key2", value: "value2"), + ] + var body: some View { + CustomHeaderSettingsView(headers: $headers) + } + } + + return V() +} + diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModel.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModel.swift new file mode 100644 index 00000000..f2c917d6 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModel.swift @@ -0,0 +1,2 @@ +import SwiftUI +import Keychain diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift new file mode 100644 index 00000000..f057be21 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift @@ -0,0 +1,272 @@ +import AIModel +import ComposableArchitecture +import Dependencies +import Keychain +import OpenAIService +import Preferences +import SwiftUI +import Toast + +@Reducer +struct EmbeddingModelEdit { + @ObservableState + struct State: Equatable, Identifiable { + var id: String + var name: String + var format: EmbeddingModel.Format + var maxTokens: Int = 8191 + var dimensions: Int = 1536 + var modelName: String = "" + var ollamaKeepAlive: String = "" + var apiKeyName: String { apiKeySelection.apiKeyName } + var baseURL: String { baseURLSelection.baseURL } + var isFullURL: Bool { baseURLSelection.isFullURL } + var availableModelNames: [String] = [] + var availableAPIKeys: [String] = [] + var isTesting = false + var suggestedMaxTokens: Int? + var apiKeySelection: APIKeySelection.State = .init() + var baseURLSelection: BaseURLSelection.State = .init() + var customHeaders: [ChatModel.Info.CustomHeaderInfo.HeaderField] = [] + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case appear + case saveButtonClicked + case cancelButtonClicked + case refreshAvailableModelNames + case testButtonClicked + case testSucceeded(String) + case testFailed(String) + case fixDimensions(Int) + case checkSuggestedMaxTokens + case selectModelFormat(ModelFormat) + case apiKeySelection(APIKeySelection.Action) + case baseURLSelection(BaseURLSelection.Action) + } + + enum ModelFormat: CaseIterable { + case openAI + case azureOpenAI + case ollama + case gitHubCopilot + case openAICompatible + case mistralOpenAICompatible + case voyageAIOpenAICompatible + + init(_ format: EmbeddingModel.Format) { + switch format { + case .openAI: + self = .openAI + case .azureOpenAI: + self = .azureOpenAI + case .ollama: + self = .ollama + case .openAICompatible: + self = .openAICompatible + case .gitHubCopilot: + self = .gitHubCopilot + } + } + } + + var toast: (String, ToastType) -> Void { + @Dependency(\.namespacedToast) var toast + return { + toast($0, $1, "EmbeddingModelEdit") + } + } + + @Dependency(\.apiKeyKeychain) var keychain + + var body: some ReducerOf { + BindingReducer() + + Scope(state: \.apiKeySelection, action: \.apiKeySelection) { + APIKeySelection() + } + + Scope(state: \.baseURLSelection, action: \.baseURLSelection) { + BaseURLSelection() + } + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.refreshAvailableModelNames) + await send(.checkSuggestedMaxTokens) + } + + case .saveButtonClicked: + return .none + + case .cancelButtonClicked: + return .none + + case .testButtonClicked: + guard !state.isTesting else { return .none } + state.isTesting = true + let dimensions = state.dimensions + let model = EmbeddingModel( + id: state.id, + name: state.name, + format: state.format, + info: .init( + apiKeyName: state.apiKeyName, + baseURL: state.baseURL, + isFullURL: state.isFullURL, + maxTokens: state.maxTokens, + dimensions: dimensions, + modelName: state.modelName + ) + ) + return .run { send in + do { + let result = try await EmbeddingService( + configuration: UserPreferenceEmbeddingConfiguration() + .overriding { + $0.model = model + } + ).embed(text: "Hello") + if result.data.isEmpty { + await send(.testFailed("No data returned")) + return + } + let actualDimensions = result.data.first?.embedding.count ?? 0 + if actualDimensions != dimensions { + await send( + .testFailed("Invalid dimension, should be \(actualDimensions)") + ) + await send(.fixDimensions(actualDimensions)) + } else { + await send( + .testSucceeded("Succeeded! (Dimensions: \(actualDimensions))") + ) + } + } catch { + await send(.testFailed(error.localizedDescription)) + } + } + + case let .testSucceeded(message): + state.isTesting = false + toast(message, .info) + return .none + + case let .testFailed(message): + state.isTesting = false + toast(message, .error) + return .none + + case .refreshAvailableModelNames: + if state.format == .openAI { + state.availableModelNames = ChatGPTModel.allCases.map(\.rawValue) + } + + return .none + + case .checkSuggestedMaxTokens: + guard state.format == .openAI, + let knownModel = OpenAIEmbeddingModel(rawValue: state.modelName) + else { + state.suggestedMaxTokens = nil + return .none + } + state.suggestedMaxTokens = knownModel.maxToken + state.dimensions = knownModel.dimensions + return .none + + case let .fixDimensions(value): + state.dimensions = value + return .none + + case let .selectModelFormat(format): + switch format { + case .openAI: + state.format = .openAI + case .azureOpenAI: + state.format = .azureOpenAI + case .ollama: + state.format = .ollama + case .openAICompatible: + state.format = .openAICompatible + case .gitHubCopilot: + state.format = .gitHubCopilot + case .mistralOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.mistral.ai" + state.baseURLSelection.isFullURL = false + case .voyageAIOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.voyage.ai" + state.baseURLSelection.isFullURL = false + } + return .none + + case .apiKeySelection: + return .none + + case .baseURLSelection: + return .none + + case .binding(\.format): + return .run { send in + await send(.refreshAvailableModelNames) + await send(.checkSuggestedMaxTokens) + } + + case .binding(\.modelName): + return .run { send in + await send(.checkSuggestedMaxTokens) + } + + case .binding: + return .none + } + } + } +} + +extension EmbeddingModel { + init(state: EmbeddingModelEdit.State) { + self.init( + id: state.id, + name: state.name, + format: state.format, + info: .init( + apiKeyName: state.apiKeyName, + baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines), + isFullURL: state.isFullURL, + maxTokens: state.maxTokens, + dimensions: state.dimensions, + modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines), + ollamaInfo: .init(keepAlive: state.ollamaKeepAlive), + customHeaderInfo: .init(headers: state.customHeaders) + ) + ) + } + + func toState() -> EmbeddingModelEdit.State { + .init( + id: id, + name: name, + format: format, + maxTokens: info.maxTokens, + dimensions: info.dimensions, + modelName: info.modelName, + ollamaKeepAlive: info.ollamaInfo.keepAlive, + apiKeySelection: .init( + apiKeyName: info.apiKeyName, + apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName]) + ), + baseURLSelection: .init( + baseURL: info.baseURL, + isFullURL: info.isFullURL + ), + customHeaders: info.customHeaderInfo.headers + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift new file mode 100644 index 00000000..46f4effd --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -0,0 +1,459 @@ +import AIModel +import ComposableArchitecture +import Preferences +import SwiftUI + +@MainActor +struct EmbeddingModelEditView: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + NameTextField(store: store) + FormatPicker(store: store) + + switch store.format { + case .openAI: + OpenAIForm(store: store) + case .azureOpenAI: + AzureOpenAIForm(store: store) + case .openAICompatible: + OpenAICompatibleForm(store: store) + case .ollama: + OllamaForm(store: store) + case .gitHubCopilot: + GitHubCopilotForm(store: store) + } + } + .padding() + + Divider() + + HStack { + HStack(spacing: 8) { + Button("Test") { + store.send(.testButtonClicked) + } + .disabled(store.isTesting) + + if store.isTesting { + ProgressView() + .controlSize(.small) + } + } + + Spacer() + + Button("Cancel") { + store.send(.cancelButtonClicked) + } + .keyboardShortcut(.cancelAction) + + Button(action: { store.send(.saveButtonClicked) }) { + Text("Save") + } + .keyboardShortcut(.defaultAction) + } + .padding() + } + } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + .fixedSize(horizontal: false, vertical: true) + .handleToast(namespace: "EmbeddingModelEdit") + } + } + + struct NameTextField: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + TextField("Name", text: $store.name) + } + } + } + + struct FormatPicker: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + Picker( + selection: Binding( + get: { .init(store.format) }, + set: { store.send(.selectModelFormat($0)) } + ), + content: { + ForEach( + EmbeddingModelEdit.ModelFormat.allCases, + id: \.self + ) { format in + switch format { + case .openAI: + Text("OpenAI") + case .azureOpenAI: + Text("Azure OpenAI") + case .ollama: + Text("Ollama") + case .openAICompatible: + Text("OpenAI Compatible") + case .mistralOpenAICompatible: + Text("Mistral (OpenAI Compatible)") + case .voyageAIOpenAICompatible: + Text("Voyage (OpenAI Compatible)") + case .gitHubCopilot: + Text("GitHub Copilot") + } + } + }, + label: { Text("Format") } + ) + .pickerStyle(.menu) + } + } + } + + struct BaseURLTextField: View { + let store: StoreOf + var title: String = "Base URL" + let prompt: Text? + @ViewBuilder var trailingContent: () -> V + + var body: some View { + WithPerceptionTracking { + BaseURLPicker( + title: title, + prompt: prompt, + store: store.scope( + state: \.baseURLSelection, + action: \.baseURLSelection + ), + trailingContent: trailingContent + ) + } + } + } + + struct MaxTokensTextField: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + HStack { + let textFieldBinding = Binding( + get: { String(store.maxTokens) }, + set: { + if let selectionMaxToken = Int($0) { + $store.maxTokens.wrappedValue = selectionMaxToken + } else { + $store.maxTokens.wrappedValue = 0 + } + } + ) + + TextField(text: textFieldBinding) { + Text("Max Input Tokens") + .multilineTextAlignment(.trailing) + } + .overlay(alignment: .trailing) { + Stepper( + value: $store.maxTokens, + in: 0...Int.max, + step: 100 + ) { + EmptyView() + } + } + .foregroundColor({ + guard let max = store.suggestedMaxTokens else { + return .primary + } + if store.maxTokens > max { + return .red + } + return .primary + }() as Color) + + if let max = store.suggestedMaxTokens { + Text("Max: \(max)") + } + } + } + } + } + + struct DimensionsTextField: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + HStack { + let textFieldBinding = Binding( + get: { String(store.dimensions) }, + set: { + if let selectionDimensions = Int($0) { + $store.dimensions.wrappedValue = selectionDimensions + } else { + $store.dimensions.wrappedValue = 0 + } + } + ) + + TextField(text: textFieldBinding) { + Text("Dimensions") + .multilineTextAlignment(.trailing) + } + .overlay(alignment: .trailing) { + Stepper( + value: $store.dimensions, + in: 0...Int.max, + step: 100 + ) { + EmptyView() + } + } + .foregroundColor({ + if store.dimensions <= 0 { + return .red + } + return .primary + }() as Color) + } + + Text("If you are not sure, run test to get the correct value.") + .font(.caption) + .dynamicHeightTextInFormWorkaround() + } + } + } + + struct ApiKeyNamePicker: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + } + } + } + + struct OpenAIForm: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("https://api.openai.com")) { + Text("/v1/embeddings") + } + ApiKeyNamePicker(store: store) + + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if OpenAIEmbeddingModel(rawValue: store.modelName) == nil { + Text("Custom Model").tag(store.modelName) + } + ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + + MaxTokensTextField(store: store) + DimensionsTextField(store: store) + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)" + ) + + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API." + ) + } + .padding(.vertical) + } + } + } + + struct AzureOpenAIForm: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("https://xxxx.openai.azure.com")) { + EmptyView() + } + ApiKeyNamePicker(store: store) + + TextField("Deployment Name", text: $store.modelName) + + MaxTokensTextField(store: store) + DimensionsTextField(store: store) + } + } + } + + struct OpenAICompatibleForm: View { + @Perception.Bindable var store: StoreOf + @State var isEditingCustomHeader = false + + var body: some View { + WithPerceptionTracking { + Picker( + selection: $store.baseURLSelection.isFullURL, + content: { + Text("Base URL").tag(false) + Text("Full URL").tag(true) + }, + label: { Text("URL") } + ) + .pickerStyle(.segmented) + + BaseURLTextField( + store: store, + title: "", + prompt: store.isFullURL + ? Text("https://api.openai.com/v1/embeddings") + : Text("https://api.openai.com") + ) { + if !store.isFullURL { + Text("/v1/embeddings") + } + } + + ApiKeyNamePicker(store: store) + + TextField("Model Name", text: $store.modelName) + + MaxTokensTextField(store: store) + DimensionsTextField(store: store) + + Button("Custom Headers") { + isEditingCustomHeader.toggle() + } + }.sheet(isPresented: $isEditingCustomHeader) { + CustomHeaderSettingsView(headers: $store.customHeaders) + } + } + } + + struct OllamaForm: View { + @Perception.Bindable var store: StoreOf + @State var isEditingCustomHeader = false + + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) { + Text("/api/embeddings") + } + + ApiKeyNamePicker(store: store) + + TextField("Model Name", text: $store.modelName) + + MaxTokensTextField(store: store) + DimensionsTextField(store: store) + + WithPerceptionTracking { + TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) { + Text("Keep Alive") + } + } + + Button("Custom Headers") { + isEditingCustomHeader.toggle() + } + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " For more details, please visit [https://ollama.com](https://ollama.com)." + ) + } + .padding(.vertical) + + }.sheet(isPresented: $isEditingCustomHeader) { + CustomHeaderSettingsView(headers: $store.customHeaders) + } + } + } + + struct GitHubCopilotForm: View { + @Perception.Bindable var store: StoreOf + @State var isEditingCustomHeader = false + + var body: some View { + WithPerceptionTracking { + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if OpenAIEmbeddingModel(rawValue: store.modelName) == nil { + Text("Custom Model").tag(store.modelName) + } + ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + + MaxTokensTextField(store: store) + DimensionsTextField(store: store) + + Button("Custom Headers") { + isEditingCustomHeader.toggle() + } + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " Please login in the GitHub Copilot settings to use the model." + ) + + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed." + ) + } + .dynamicHeightTextInFormWorkaround() + .padding(.vertical) + }.sheet(isPresented: $isEditingCustomHeader) { + CustomHeaderSettingsView(headers: $store.customHeaders) + } + } + } +} + +class EmbeddingModelManagementView_Editing_Previews: PreviewProvider { + static var previews: some View { + EmbeddingModelEditView( + store: .init( + initialState: EmbeddingModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ).toState(), + reducer: { EmbeddingModelEdit() } + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift new file mode 100644 index 00000000..156f58ac --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift @@ -0,0 +1,159 @@ +import AIModel +import ComposableArchitecture +import Keychain +import Preferences +import SwiftUI + +extension EmbeddingModel: ManageableAIModel { + var formatName: String { + switch format { + case .openAI: return "OpenAI" + case .azureOpenAI: return "Azure OpenAI" + case .openAICompatible: return "OpenAI Compatible" + case .ollama: return "Ollama" + case .gitHubCopilot: return "GitHub Copilot" + } + } + + @ViewBuilder + var infoDescriptors: some View { + Text(info.modelName) + + if !info.baseURL.isEmpty { + Image(systemName: "line.diagonal") + Text(info.baseURL) + } + + Image(systemName: "line.diagonal") + + Text("\(info.maxTokens) tokens") + } +} + +@Reducer +struct EmbeddingModelManagement: AIModelManagement { + typealias Model = EmbeddingModel + + @ObservableState + struct State: Equatable, AIModelManagementState { + typealias Model = EmbeddingModel + var models: IdentifiedArrayOf = [] + @Presents var editingModel: EmbeddingModelEdit.State? + var selectedModelId: Model.ID? { editingModel?.id } + } + + enum Action: Equatable, AIModelManagementAction { + typealias Model = EmbeddingModel + case appear + case createModel + case removeModel(id: Model.ID) + case selectModel(id: Model.ID) + case duplicateModel(id: Model.ID) + case moveModel(from: IndexSet, to: Int) + case embeddingModelItem(PresentationAction) + } + + @Dependency(\.toast) var toast + @Dependency(\.userDefaults) var userDefaults + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .appear: + if isPreview { return .none } + state.models = .init( + userDefaults.value(for: \.embeddingModels), + id: \.id, + uniquingIDsWith: { a, _ in a } + ) + + return .none + + case .createModel: + state.editingModel = .init( + id: UUID().uuidString, + name: "New Model", + format: .openAI + ) + return .none + + case let .removeModel(id): + state.models.remove(id: id) + persist(state) + return .none + + case let .selectModel(id): + guard let model = state.models[id: id] else { return .none } + state.editingModel = model.toState() + return .none + + case let .duplicateModel(id): + guard var model = state.models[id: id] else { return .none } + model.id = UUID().uuidString + model.name += " (Copy)" + + if let index = state.models.index(id: id) { + state.models.insert(model, at: index + 1) + } else { + state.models.append(model) + } + persist(state) + return .none + + case let .moveModel(from, to): + state.models.move(fromOffsets: from, toOffset: to) + persist(state) + return .none + + case .embeddingModelItem(.presented(.saveButtonClicked)): + guard let editingModel = state.editingModel, validateModel(editingModel) + else { return .none } + + if let index = state.models + .firstIndex(where: { $0.id == editingModel.id }) + { + state.models[index] = .init(state: editingModel) + } else { + state.models.append(.init(state: editingModel)) + } + persist(state) + return .run { send in + await send(.embeddingModelItem(.dismiss)) + } + + case .embeddingModelItem(.presented(.cancelButtonClicked)): + return .run { send in + await send(.embeddingModelItem(.dismiss)) + } + + case .embeddingModelItem: + return .none + } + }.ifLet(\.$editingModel, action: \.embeddingModelItem) { + EmbeddingModelEdit() + } + } + + func persist(_ state: State) { + let models = state.models + userDefaults.set(Array(models), for: \.embeddingModels) + } + + func validateModel(_ chatModel: EmbeddingModelEdit.State) -> Bool { + guard !chatModel.name.isEmpty else { + toast("Model name cannot be empty", .error) + return false + } + guard !chatModel.id.isEmpty else { + toast("Model ID cannot be empty", .error) + return false + } + + guard !chatModel.modelName.isEmpty else { + toast("Model name cannot be empty", .error) + return false + } + return true + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift new file mode 100644 index 00000000..e251af10 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift @@ -0,0 +1,81 @@ +import AIModel +import ComposableArchitecture +import SwiftUI + +struct EmbeddingModelManagementView: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + AIModelManagementView(store: store) + .sheet(item: $store.scope( + state: \.editingModel, + action: \.embeddingModelItem + )) { store in + EmbeddingModelEditView(store: store) + .frame(width: 800) + } + } + } +} + +// MARK: - Previews + +class EmbeddingModelManagementView_Previews: PreviewProvider { + static var previews: some View { + EmbeddingModelManagementView( + store: .init( + initialState: .init( + models: IdentifiedArray(uniqueElements: [ + EmbeddingModel( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ), + EmbeddingModel( + id: "2", + name: "Test Model 2", + format: .azureOpenAI, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ), + EmbeddingModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ), + ]), + editingModel: EmbeddingModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ).toState() + ), + reducer: { EmbeddingModelManagement() } + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift new file mode 100644 index 00000000..9f4b0d8d --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift @@ -0,0 +1,91 @@ +import Dependencies +import Foundation +import GitHubCopilotService +import Perception +import SwiftUI +import Toast + +public struct GitHubCopilotModelPicker: View { + @Perceptible + final class ViewModel { + var availableModels: [GitHubCopilotLLMModel] = [] + @PerceptionIgnored @Dependency(\.toast) var toast + + init() {} + + func appear() { + reloadAvailableModels() + } + + func disappear() {} + + func reloadAvailableModels() { + Task { @MainActor in + do { + availableModels = try await GitHubCopilotExtension.fetchLLMModels() + } catch { + toast("Failed to fetch GitHub Copilot models: \(error)", .error) + } + } + } + } + + let title: String + let hasDefaultModel: Bool + @Binding var gitHubCopilotModelId: String + @State var viewModel: ViewModel + + init( + title: String, + hasDefaultModel: Bool = true, + gitHubCopilotModelId: Binding + ) { + self.title = title + _gitHubCopilotModelId = gitHubCopilotModelId + self.hasDefaultModel = hasDefaultModel + viewModel = .init() + } + + public var body: some View { + WithPerceptionTracking { + TextField(title, text: $gitHubCopilotModelId) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $gitHubCopilotModelId, + content: { + if hasDefaultModel { + Text("Default").tag("") + } + + if !gitHubCopilotModelId.isEmpty, + !viewModel.availableModels.contains(where: { + $0.modelId == gitHubCopilotModelId + }) + { + Text(gitHubCopilotModelId).tag(gitHubCopilotModelId) + } + if viewModel.availableModels.isEmpty { + Text({ + viewModel.reloadAvailableModels() + return "Loading..." + }()).tag("Loading...") + } + ForEach(viewModel.availableModels) { model in + Text(model.modelId) + .tag(model.modelId) + } + } + ) + .frame(width: 20) + } + .onAppear { + viewModel.appear() + } + .onDisappear { + viewModel.disappear() + } + } + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/CopilotView.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift similarity index 57% rename from Core/Sources/HostApp/AccountSettings/CopilotView.swift rename to Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift index 3e586d65..ec627113 100644 --- a/Core/Sources/HostApp/AccountSettings/CopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift @@ -2,10 +2,11 @@ import AppKit import Client import GitHubCopilotService import Preferences -import SuggestionModel +import SharedUIComponents +import SuggestionBasic import SwiftUI -struct CopilotView: View { +struct GitHubCopilotView: View { static var copilotAuthService: GitHubCopilotAuthServiceType? class Settings: ObservableObject { @@ -13,19 +14,28 @@ struct CopilotView: View { @AppStorage(\.runNodeWith) var runNodeWith @AppStorage("username") var username: String = "" @AppStorage(\.gitHubCopilotVerboseLog) var gitHubCopilotVerboseLog - + @AppStorage(\.gitHubCopilotProxyHost) var gitHubCopilotProxyHost + @AppStorage(\.gitHubCopilotProxyPort) var gitHubCopilotProxyPort + @AppStorage(\.gitHubCopilotProxyUsername) var gitHubCopilotProxyUsername + @AppStorage(\.gitHubCopilotProxyPassword) var gitHubCopilotProxyPassword + @AppStorage(\.gitHubCopilotUseStrictSSL) var gitHubCopilotUseStrictSSL + @AppStorage(\.gitHubCopilotEnterpriseURI) var gitHubCopilotEnterpriseURI + @AppStorage(\.gitHubCopilotPretendIDEToBeVSCode) var pretendIDEToBeVSCode + @AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear) + var disableGitHubCopilotSettingsAutoRefreshOnAppear + @AppStorage(\.gitHubCopilotLoadKeyChainCertificates) + var gitHubCopilotLoadKeyChainCertificates + @AppStorage(\.gitHubCopilotModelId) var gitHubCopilotModelId init() {} } class ViewModel: ObservableObject { let installationManager = GitHubCopilotInstallationManager() - @Published var installationStatus: GitHubCopilotInstallationManager.InstallationStatus + @Published var installationStatus: GitHubCopilotInstallationManager.InstallationStatus? @Published var installationStep: GitHubCopilotInstallationManager.InstallationStep? - init() { - installationStatus = installationManager.checkInstallation() - } + init() {} init( installationStatus: GitHubCopilotInstallationManager.InstallationStatus, @@ -69,7 +79,7 @@ struct CopilotView: View { defer { refreshInstallationStatus() } try await installationManager.uninstall() Task { @MainActor in - CopilotView.copilotAuthService = nil + GitHubCopilotView.copilotAuthService = nil } } } @@ -99,7 +109,7 @@ struct CopilotView: View { do { try await viewModel.install() } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -114,7 +124,7 @@ struct CopilotView: View { do { try await viewModel.install() } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -135,44 +145,82 @@ struct CopilotView: View { var body: some View { HStack { VStack(alignment: .leading, spacing: 8) { - Form { - TextField(text: $settings.nodePath, prompt: Text("node")) { - Text("Path to Node") - } + SubSection( + title: Text("Node Settings"), + description: """ + You may have to restart the extension app to apply the changes. To do so, simply close the helper app by clicking on the menu bar icon that looks like a tentacle, it will automatically restart as needed. + """ + ) { + Form { + TextField( + text: $settings.nodePath, + prompt: Text( + "node" + ) + ) { + Text("Path to Node (v22.0+)") + } - Picker(selection: $settings.runNodeWith) { - ForEach(NodeRunner.allCases, id: \.rawValue) { runner in - switch runner { + Text( + "Provide the path to the executable if it can't be found by the app, shim executable is not supported" + ) + .lineLimit(10) + .foregroundColor(.secondary) + .font(.callout) + .dynamicHeightTextInFormWorkaround() + + Picker(selection: $settings.runNodeWith) { + ForEach(NodeRunner.allCases, id: \.rawValue) { runner in + switch runner { + case .env: + Text("/usr/bin/env").tag(runner) + case .bash: + Text("/bin/bash -i -l").tag(runner) + case .shell: + Text("$SHELL -i -l").tag(runner) + } + } + } label: { + Text("Run Node with") + } + + Group { + switch settings.runNodeWith { case .env: - Text("/usr/bin/env").tag(runner) + Text( + "PATH: `/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`" + ) case .bash: - Text("/bin/bash -i -l").tag(runner) + Text("PATH inherited from bash configurations.") case .shell: - Text("$SHELL -i -l").tag(runner) + Text("PATH inherited from $SHELL configurations.") } } - } label: { - Text("Run Node with") + .lineLimit(10) + .foregroundColor(.secondary) + .font(.callout) + .dynamicHeightTextInFormWorkaround() + + Toggle(isOn: $settings.gitHubCopilotLoadKeyChainCertificates) { + Text("Load certificates in keychain") + } } } - Text( - "You may have to restart the helper app to apply the changes. To do so, simply close the helper app by clicking on the menu bar icon that looks like a steer wheel, it will automatically restart as needed." - ) - .lineLimit(6) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.secondary) - - VStack(alignment: .leading) { + SubSection( + title: Text("GitHub Copilot Language Server") + ) { HStack { switch viewModel.installationStatus { + case .none: + Text("Copilot.Vim Version: Loading..") case .notInstalled: Text("Copilot.Vim Version: Not Installed") installButton case let .installed(version): Text("Copilot.Vim Version: \(version)") uninstallButton - case let .outdated(version, latest): + case let .outdated(version, latest, _): Text("Copilot.Vim Version: \(version) (Update Available: \(latest))") updateButton uninstallButton @@ -182,13 +230,16 @@ struct CopilotView: View { uninstallButton } } - + Text("Language Server Version: \(version ?? "Loading..")") - + Text("Status: \(status?.description ?? "Loading..")") HStack(alignment: .center) { - Button("Refresh") { checkStatus() } + Button("Refresh") { + viewModel.refreshInstallationStatus() + checkStatus() + } if status == .notSignedIn { Button("Sign In") { signIn() } .alert(isPresented: $isUserCodeCopiedAlertPresented) { @@ -210,26 +261,66 @@ struct CopilotView: View { if isRunningAction { ActivityIndicatorView() } - } + } .opacity(isRunningAction ? 0.8 : 1) .disabled(isRunningAction) + + Button("Refresh configurations") { + refreshConfiguration() + } + + Form { + GitHubCopilotModelPicker( + title: "Chat Model Name", + gitHubCopilotModelId: $settings.gitHubCopilotModelId + ) + } } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1)) + + SettingsDivider("Advanced") + + Form { + Toggle("Verbose log", isOn: $settings.gitHubCopilotVerboseLog) + Toggle("Pretend IDE to be VSCode", isOn: $settings.pretendIDEToBeVSCode) } - Divider() + SettingsDivider("Enterprise") Form { - Toggle("Verbose Log", isOn: $settings.gitHubCopilotVerboseLog) + TextField( + text: $settings.gitHubCopilotEnterpriseURI, + prompt: Text("Leave it blank if non is available.") + ) { + Text("Auth provider URL") + } + } + + SettingsDivider("Proxy") + + Form { + TextField( + text: $settings.gitHubCopilotProxyHost, + prompt: Text("xxx.xxx.xxx.xxx, leave it blank to disable proxy.") + ) { + Text("Proxy host") + } + TextField(text: $settings.gitHubCopilotProxyPort, prompt: Text("80")) { + Text("Proxy port") + } + TextField(text: $settings.gitHubCopilotProxyUsername) { + Text("Proxy username") + } + SecureField(text: $settings.gitHubCopilotProxyPassword) { + Text("Proxy password") + } + Toggle("Proxy strict SSL", isOn: $settings.gitHubCopilotUseStrictSSL) } } Spacer() }.onAppear { if isPreview { return } + if settings.disableGitHubCopilotSettingsAutoRefreshOnAppear { return } + viewModel.refreshInstallationStatus() checkStatus() }.onChange(of: settings.runNodeWith) { _ in Self.copilotAuthService = nil @@ -239,17 +330,18 @@ struct CopilotView: View { if let step = newValue { switch step { case .downloading: - toast(Text("Downloading.."), .info) + toast("Downloading..", .info) case .uninstalling: - toast(Text("Uninstalling old version.."), .info) + toast("Uninstalling old version..", .info) case .decompressing: - toast(Text("Decompressing.."), .info) + toast("Decompressing..", .info) case .done: - toast(Text("Done!"), .info) + toast("Done!", .info) checkStatus() } } } + .textFieldStyle(.roundedBorder) } func checkStatus() { @@ -264,14 +356,12 @@ struct CopilotView: View { if status != .ok, status != .notSignedIn { toast( - Text( - "GitHub Copilot status is not \"ok\". Please check if you have a valid GitHub Copilot subscription." - ), + "GitHub Copilot status is not \"ok\". Please check if you have a valid GitHub Copilot subscription.", .error ) } } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } } @@ -285,17 +375,17 @@ struct CopilotView: View { let (uri, userCode) = try await service.signInInitiate() self.userCode = userCode guard let url = URL(string: uri) else { - toast(Text("Verification URI is incorrect."), .error) + toast("Verification URI is incorrect.", .error) return } let pasteboard = NSPasteboard.general pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil) pasteboard.setString(userCode, forType: NSPasteboard.PasteboardType.string) - toast(Text("Usercode \(userCode) already copied!"), .info) + toast("Usercode \(userCode) already copied!", .info) openURL(url) isUserCodeCopiedAlertPresented = true } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } } @@ -307,14 +397,14 @@ struct CopilotView: View { do { let service = try getGitHubCopilotAuthService() guard let userCode else { - toast(Text("Usercode is empty."), .error) + toast("Usercode is empty.", .error) return } let (username, status) = try await service.signInConfirm(userCode: userCode) self.settings.username = username self.status = status } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } } @@ -327,7 +417,26 @@ struct CopilotView: View { let service = try getGitHubCopilotAuthService() status = try await service.signOut() } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) + } + } + } + + func refreshConfiguration() { + NotificationCenter.default.post( + name: .gitHubCopilotShouldRefreshEditorInformation, + object: nil + ) + + Task { + let service = try getService() + do { + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + } catch { + toast(error.localizedDescription, .error) } } } @@ -351,8 +460,8 @@ struct ActivityIndicatorView: NSViewRepresentable { struct CopilotView_Previews: PreviewProvider { static var previews: some View { VStack(alignment: .leading, spacing: 8) { - CopilotView(status: .notSignedIn, version: "1.0.0") - CopilotView(status: .alreadySignedIn, isRunningAction: true) + GitHubCopilotView(status: .notSignedIn, version: "1.0.0") + GitHubCopilotView(status: .alreadySignedIn, isRunningAction: true) } .padding(.all, 8) } diff --git a/Core/Sources/HostApp/AccountSettings/OpenAIView.swift b/Core/Sources/HostApp/AccountSettings/OpenAIView.swift deleted file mode 100644 index a6dea775..00000000 --- a/Core/Sources/HostApp/AccountSettings/OpenAIView.swift +++ /dev/null @@ -1,94 +0,0 @@ -import AppKit -import Client -import OpenAIService -import Preferences -import SuggestionModel -import SwiftUI - -struct OpenAIView: View { - final class Settings: ObservableObject { - @AppStorage(\.openAIAPIKey) var openAIAPIKey: String - @AppStorage(\.chatGPTModel) var chatGPTModel: String - @AppStorage(\.openAIBaseURL) var openAIBaseURL: String - init() {} - } - - let apiKeyURL = URL(string: "https://platform.openai.com/account/api-keys")! - let modelURL = URL( - string: "https://platform.openai.com/docs/models/model-endpoint-compatibility" - )! - @Environment(\.openURL) var openURL - @Environment(\.toast) var toast - @State var isTesting = false - @StateObject var settings = Settings() - - var body: some View { - Form { - HStack { - SecureField(text: $settings.openAIAPIKey, prompt: Text("sk-*")) { - Text("OpenAI API Key") - } - .textFieldStyle(.roundedBorder) - Button(action: { - openURL(apiKeyURL) - }) { - Image(systemName: "questionmark.circle.fill") - }.buttonStyle(.plain) - } - - HStack { - TextField( - text: $settings.openAIBaseURL, - prompt: Text("https://api.openai.com") - ) { - Text("OpenAI Base URL") - }.textFieldStyle(.roundedBorder) - - Button("Test") { - Task { @MainActor in - isTesting = true - defer { isTesting = false } - do { - let reply = try await ChatGPTService(designatedProvider: .openAI) - .sendAndWait(content: "Hello", summary: nil) - toast(Text("ChatGPT replied: \(reply ?? "N/A")"), .info) - } catch { - toast(Text(error.localizedDescription), .error) - } - } - }.disabled(isTesting) - } - - HStack { - Picker(selection: $settings.chatGPTModel) { - if !settings.chatGPTModel.isEmpty, - ChatGPTModel(rawValue: settings.chatGPTModel) == nil - { - Text(settings.chatGPTModel).tag(settings.chatGPTModel) - } - ForEach(ChatGPTModel.allCases, id: \.self) { model in - Text(model.rawValue).tag(model.rawValue) - } - } label: { - Text("ChatGPT Model") - }.pickerStyle(.menu) - Button(action: { - openURL(modelURL) - }) { - Image(systemName: "questionmark.circle.fill") - }.buttonStyle(.plain) - } - } - } -} - -struct OpenAIView_Previews: PreviewProvider { - static var previews: some View { - VStack(alignment: .leading, spacing: 8) { - OpenAIView() - } - .frame(height: 800) - .padding(.all, 8) - } -} - diff --git a/Core/Sources/HostApp/AccountSettings/OtherSuggestionServicesView.swift b/Core/Sources/HostApp/AccountSettings/OtherSuggestionServicesView.swift new file mode 100644 index 00000000..2497c9b5 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/OtherSuggestionServicesView.swift @@ -0,0 +1,31 @@ +import Foundation +import SwiftUI + +struct OtherSuggestionServicesView: View { + @Environment(\.openURL) var openURL + var body: some View { + VStack(alignment: .leading) { + Text( + "You can use other locally run services (Tabby, Ollma, etc.) to generate suggestions with the Custom Suggestion Service extension." + ) + .lineLimit(nil) + .multilineTextAlignment(.leading) + + Button(action: { + if let url = URL( + string: "https://github.com/intitni/CustomSuggestionServiceForCopilotForXcode" + ) { + openURL(url) + } + }) { + Text("Get It Now") + } + } + } +} + +#Preview { + OtherSuggestionServicesView() + .frame(width: 200) +} + diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift new file mode 100644 index 00000000..2c1fd2d7 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift @@ -0,0 +1,281 @@ +import AIModel +import ComposableArchitecture +import PlusFeatureFlag +import SharedUIComponents +import SwiftUI + +protocol AIModelManagementAction { + associatedtype Model: ManageableAIModel + static var appear: Self { get } + static var createModel: Self { get } + static func removeModel(id: Model.ID) -> Self + static func selectModel(id: Model.ID) -> Self + static func duplicateModel(id: Model.ID) -> Self + static func moveModel(from: IndexSet, to: Int) -> Self +} + +protocol AIModelManagementState: Equatable { + associatedtype Model: ManageableAIModel + var models: IdentifiedArrayOf { get } + var selectedModelId: Model.ID? { get } +} + +protocol AIModelManagement: Reducer where + Action: AIModelManagementAction, + State: AIModelManagementState & ObservableState, + Action.Model == Self.Model, + State.Model == Self.Model +{ + associatedtype Model: ManageableAIModel +} + +protocol ManageableAIModel: Identifiable { + associatedtype V: View + var name: String { get } + var formatName: String { get } + var infoDescriptors: V { get } +} + +struct AIModelManagementView: View + where Management.Model == Model +{ + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + HStack { + Spacer() + if isFeatureAvailable(\.unlimitedChatAndEmbeddingModels) { + Button("Add Model") { + store.send(.createModel) + } + } else { + Text("\(store.models.count) / 2") + .foregroundColor(.secondary) + + let disabled = store.models.count >= 2 + + Button(disabled ? "Add More Model (Plus)" : "Add Model") { + store.send(.createModel) + }.disabled(disabled) + } + }.padding(4) + + Divider() + + ModelList(store: store) + } + .onAppear { + store.send(.appear) + } + } + } + + struct ModelList: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + List { + ForEach(store.models) { model in + WithPerceptionTracking { + let isSelected = store.selectedModelId == model.id + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal") + + Button(action: { + store.send(.selectModel(id: model.id)) + }) { + Cell(model: model, isSelected: isSelected) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .contextMenu { + Button("Duplicate") { + store.send(.duplicateModel(id: model.id)) + } + Button("Remove") { + store.send(.removeModel(id: model.id)) + } + } + } + } + } + .onMove(perform: { indices, newOffset in + store.send(.moveModel(from: indices, to: newOffset)) + }) + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view + } + } + } + .removeBackground() + .listStyle(.plain) + .listRowInsets(EdgeInsets()) + .overlay { + if store.models.isEmpty { + Text("No model found, please add a new one.") + .foregroundColor(.secondary) + } + } + } + } + } + + struct Cell: View { + let model: Model + let isSelected: Bool + @State var isHovered: Bool = false + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(model.formatName) + .foregroundColor(isSelected ? .white : .primary) + .font(.subheadline.bold()) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background { + RoundedRectangle(cornerRadius: 4) + .fill( + isSelected + ? .white.opacity(0.2) + : Color.primary.opacity(0.1) + ) + } + + Text(model.name) + .font(.headline) + } + + HStack(spacing: 4) { + model.infoDescriptors + } + .font(.subheadline) + .opacity(0.7) + .padding(.leading, 2) + } + Spacer() + } + .onHover(perform: { + isHovered = $0 + }) + .padding(.vertical, 8) + .padding(.horizontal, 8) + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill({ + switch (isSelected, isHovered) { + case (true, _): + return Color.accentColor + case (_, true): + return Color.primary.opacity(0.1) + case (_, false): + return Color.clear + } + }() as Color) + } + .foregroundColor(isSelected ? .white : .primary) + .animation(.easeInOut(duration: 0.1), value: isSelected) + .animation(.easeInOut(duration: 0.1), value: isHovered) + } + } +} + +// MARK: - Previews + +class AIModelManagement_Previews: PreviewProvider { + static var previews: some View { + AIModelManagementView( + store: .init( + initialState: .init( + models: IdentifiedArray(uniqueElements: [ + ChatModel( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "2", + name: "Test Model 2", + format: .azureOpenAI, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ]), + editingModel: ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ).toState() + ), + reducer: { ChatModelManagement() } + ) + ) + } +} + +class AIModelManagement_Empty_Previews: PreviewProvider { + static var previews: some View { + AIModelManagementView( + store: .init( + initialState: .init(models: [] as IdentifiedArrayOf), + reducer: { ChatModelManagement() } + ) + ) + } +} + +class AIModelManagement_Cell_Previews: PreviewProvider { + static var previews: some View { + AIModelManagementView.Cell(model: ChatModel( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + ) + ), isSelected: false) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift new file mode 100644 index 00000000..9456946e --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift @@ -0,0 +1,60 @@ +import ComposableArchitecture +import SwiftUI + +struct BaseURLPicker: View { + let title: String + let prompt: Text? + @Perception.Bindable var store: StoreOf + @ViewBuilder let trailingContent: () -> TrailingContent + + var body: some View { + WithPerceptionTracking { + HStack { + TextField(title, text: $store.baseURL, prompt: prompt) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.baseURL, + content: { + if !store.availableBaseURLs + .contains(store.baseURL), + !store.baseURL.isEmpty + { + Text("Custom Value").tag(store.baseURL) + } + + Text("Empty (Default Value)").tag("") + + ForEach(store.availableBaseURLs, id: \.self) { baseURL in + Text(baseURL).tag(baseURL) + } + } + ) + .frame(width: 20) + } + + trailingContent() + .foregroundStyle(.secondary) + } + .onAppear { + store.send(.appear) + } + } + } +} + +extension BaseURLPicker where TrailingContent == EmptyView { + init( + title: String, + prompt: Text? = nil, + store: StoreOf + ) { + self.init( + title: title, + prompt: prompt, + store: store, + trailingContent: { EmptyView() } + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift new file mode 100644 index 00000000..502d79a7 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift @@ -0,0 +1,53 @@ +import ComposableArchitecture +import Foundation +import Preferences +import SwiftUI + +@Reducer +struct BaseURLSelection { + @ObservableState + struct State: Equatable { + var baseURL: String = "" + var isFullURL: Bool = false + var availableBaseURLs: [String] = [] + } + + enum Action: Equatable, BindableAction { + case appear + case refreshAvailableBaseURLNames + case binding(BindingAction) + } + + @Dependency(\.toast) var toast + @Dependency(\.userDefaults) var userDefaults + + var body: some ReducerOf { + BindingReducer() + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.refreshAvailableBaseURLNames) + } + + case .refreshAvailableBaseURLNames: + let chatModels = userDefaults.value(for: \.chatModels) + let embeddingModels = userDefaults.value(for: \.embeddingModels) + var allBaseURLs = Set( + chatModels.map(\.info.baseURL) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + + embeddingModels.map(\.info.baseURL) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + ) + allBaseURLs.remove("") + state.availableBaseURLs = Array(allBaseURLs).sorted() + return .none + + case .binding: + return .none + } + } + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/WebSearchView.swift b/Core/Sources/HostApp/AccountSettings/WebSearchView.swift new file mode 100644 index 00000000..d34686f9 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/WebSearchView.swift @@ -0,0 +1,277 @@ +import AppKit +import Client +import ComposableArchitecture +import OpenAIService +import Preferences +import SuggestionBasic +import SwiftUI +import WebSearchService +import SharedUIComponents + +@Reducer +struct WebSearchSettings { + struct TestResult: Identifiable, Equatable { + let id = UUID() + var duration: TimeInterval + var result: Result? + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + } + + @ObservableState + struct State: Equatable { + var apiKeySelection: APIKeySelection.State = .init() + var testResult: TestResult? + } + + enum Action: BindableAction { + case binding(BindingAction) + case appear + case test + case bringUpTestResult + case updateTestResult(TimeInterval, Result) + case apiKeySelection(APIKeySelection.Action) + } + + var body: some ReducerOf { + BindingReducer() + + Scope(state: \.apiKeySelection, action: \.apiKeySelection) { + APIKeySelection() + } + + Reduce { state, action in + switch action { + case .binding: + return .none + case .appear: + state.testResult = nil + state.apiKeySelection.apiKeyName = UserDefaults.shared.value(for: \.serpAPIKeyName) + return .none + case .test: + return .run { send in + let searchService = WebSearchService(provider: .userPreferred) + await send(.bringUpTestResult) + let start = Date() + do { + let result = try await searchService.search(query: "Swift") + let duration = Date().timeIntervalSince(start) + await send(.updateTestResult(duration, .success(result))) + } catch { + let duration = Date().timeIntervalSince(start) + await send(.updateTestResult(duration, .failure(error))) + } + } + case .bringUpTestResult: + state.testResult = .init(duration: 0) + return .none + case let .updateTestResult(duration, result): + state.testResult?.duration = duration + state.testResult?.result = result + return .none + case let .apiKeySelection(action): + switch action { + case .binding(\APIKeySelection.State.apiKeyName): + UserDefaults.shared.set(state.apiKeySelection.apiKeyName, for: \.serpAPIKeyName) + return .none + default: + return .none + } + } + } + } +} + +final class WebSearchViewSettings: ObservableObject { + @AppStorage(\.serpAPIEngine) var serpAPIEngine + @AppStorage(\.headlessBrowserEngine) var headlessBrowserEngine + @AppStorage(\.searchProvider) var searchProvider + init() {} +} + +struct WebSearchView: View { + @Perception.Bindable var store: StoreOf + @Environment(\.openURL) var openURL + @StateObject var settings = WebSearchViewSettings() + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(alignment: .leading) { + Form { + Picker("Search Provider", selection: $settings.searchProvider) { + ForEach(UserDefaultPreferenceKeys.SearchProvider.allCases, id: \.self) { + provider in + switch provider { + case .serpAPI: + Text("Serp API").tag(provider) + case .headlessBrowser: + Text("Headless Browser").tag(provider) + } + + } + } + .pickerStyle(.segmented) + } + + switch settings.searchProvider { + case .serpAPI: + serpAPIForm() + case .headlessBrowser: + headlessBrowserForm() + } + } + .padding() + } + .safeAreaInset(edge: .bottom) { + VStack(spacing: 0) { + Divider() + HStack { + Button("Test Search") { + store.send(.test) + } + Spacer() + } + .padding() + } + .background(.regularMaterial) + } + .sheet(item: $store.testResult) { testResult in + testResultView(testResult: testResult) + } + .onAppear { + store.send(.appear) + } + } + } + + @ViewBuilder + func serpAPIForm() -> some View { + SubSection( + title: Text("Serp API Settings"), + description: """ + Use Serp API to do web search. Serp API is more reliable and faster than headless browser. But you need to provide an API key for it. + """ + ) { + Picker("Engine", selection: $settings.serpAPIEngine) { + ForEach( + UserDefaultPreferenceKeys.SerpAPIEngine.allCases, + id: \.self + ) { engine in + Text(engine.rawValue).tag(engine) + } + } + + WithPerceptionTracking { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + } + } + } + + @ViewBuilder + func headlessBrowserForm() -> some View { + SubSection( + title: Text("Headless Browser Settings"), + description: """ + The app will open a webview in the background to do web search. This method uses a set of rules to extract information from the web page, if you notice that it stops working, please submit an issue to the developer. + """ + ) { + Picker("Engine", selection: $settings.headlessBrowserEngine) { + ForEach( + UserDefaultPreferenceKeys.HeadlessBrowserEngine.allCases, + id: \.self + ) { engine in + Text(engine.rawValue).tag(engine) + } + } + } + } + + @ViewBuilder + func testResultView(testResult: WebSearchSettings.TestResult) -> some View { + VStack { + Text("Test Result") + .padding(.top) + .font(.headline) + + if let result = testResult.result { + switch result { + case let .success(webSearchResult): + VStack(alignment: .leading) { + Text("Success (Completed in \(testResult.duration, specifier: "%.2f")s)") + .foregroundColor(.green) + + Text("Found \(webSearchResult.webPages.count) results:") + + ScrollView { + ForEach(webSearchResult.webPages, id: \.urlString) { page in + HStack { + VStack(alignment: .leading) { + Text(page.title) + .font(.headline) + Text(page.urlString) + .font(.caption) + .foregroundColor(.blue) + Text(page.snippet) + .padding(.top, 2) + } + Spacer(minLength: 0) + } + .padding(.vertical, 4) + Divider() + } + } + } + .padding() + case let .failure(error): + VStack(alignment: .leading) { + Text("Error (Completed in \(testResult.duration, specifier: "%.2f")s)") + .foregroundColor(.red) + Text(error.localizedDescription) + } + } + } else { + ProgressView().padding() + } + + Spacer() + + VStack(spacing: 0) { + Divider() + + HStack { + Spacer() + + Button("Close") { + store.testResult = nil + } + .keyboardShortcut(.cancelAction) + } + .padding() + } + } + .frame(minWidth: 400, minHeight: 300) + } +} + +// Helper struct to make TestResult identifiable for sheet presentation +private struct TestResultWrapper: Identifiable { + var id: UUID = .init() + var testResult: WebSearchSettings.TestResult +} + +struct WebSearchView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading, spacing: 8) { + WebSearchView(store: .init(initialState: .init(), reducer: { WebSearchSettings() })) + } + .frame(height: 800) + .padding(.all, 8) + } +} + diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift new file mode 100644 index 00000000..884c58f0 --- /dev/null +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift @@ -0,0 +1,132 @@ +import ComposableArchitecture +import Foundation +import PlusFeatureFlag +import Preferences +import SwiftUI +import Toast + +@Reducer +struct CustomCommandFeature { + @ObservableState + struct State: Equatable { + var editCustomCommand: EditCustomCommand.State? + } + + let settings: CustomCommandView.Settings + + enum Action: Equatable { + case createNewCommand + case editCommand(CustomCommand) + case editCustomCommand(EditCustomCommand.Action) + case deleteCommand(CustomCommand) + case exportCommand(CustomCommand) + case importCommand(at: URL) + case importCommandClicked + } + + @Dependency(\.toast) var toast + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .createNewCommand: + if !isFeatureAvailable(\.unlimitedCustomCommands), + settings.customCommands.count >= 10 + { + toast("Upgrade to Plus to add more commands", .info) + return .none + } + state.editCustomCommand = EditCustomCommand.State(nil) + return .none + case let .editCommand(command): + state.editCustomCommand = EditCustomCommand.State(command) + return .none + case .editCustomCommand(.close): + state.editCustomCommand = nil + return .none + case let .deleteCommand(command): + settings.customCommands.removeAll( + where: { $0.id == command.id } + ) + if state.editCustomCommand?.commandId == command.id { + state.editCustomCommand = nil + } + return .none + case .editCustomCommand: + return .none + case let .exportCommand(command): + return .run { _ in + do { + let data = try JSONEncoder().encode(command) + let filename = "CustomCommand-\(command.name).json" + + let url = await withCheckedContinuation { continuation in + Task { @MainActor in + let panel = NSSavePanel() + panel.canCreateDirectories = true + panel.nameFieldStringValue = filename + let result = await panel.begin() + switch result { + case .OK: + continuation.resume(returning: panel.url) + default: + continuation.resume(returning: nil) + } + } + } + + if let url { + try data.write(to: url) + toast("Saved!", .info) + } + + } catch { + toast(error.localizedDescription, .error) + } + } + + case let .importCommand(url): + if !isFeatureAvailable(\.unlimitedCustomCommands), + settings.customCommands.count >= 10 + { + toast("Upgrade to Plus to add more commands", .info) + return .none + } + + do { + let data = try Data(contentsOf: url) + var command = try JSONDecoder().decode(CustomCommand.self, from: data) + command.commandId = UUID().uuidString + settings.customCommands.append(command) + toast("Imported custom command \(command.name)!", .info) + } catch { + toast("Failed to import command: \(error.localizedDescription)", .error) + } + return .none + + case .importCommandClicked: + return .run { send in + let url = await withCheckedContinuation { continuation in + Task { @MainActor in + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + let result = await panel.begin() + if result == .OK { + continuation.resume(returning: panel.url) + } else { + continuation.resume(returning: nil) + } + } + } + + if let url { + await send(.importCommand(at: url)) + } + } + } + }.ifLet(\.editCustomCommand, action: \.editCustomCommand) { + EditCustomCommand(settings: settings) + } + } +} + diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift new file mode 100644 index 00000000..033b9850 --- /dev/null +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -0,0 +1,357 @@ +import ComposableArchitecture +import MarkdownUI +import PlusFeatureFlag +import Preferences +import SharedUIComponents +import SwiftUI +import Toast + +extension List { + @ViewBuilder + func removeBackground() -> some View { + if #available(macOS 13.0, *) { + scrollContentBackground(.hidden) + .listRowBackground(EmptyView()) + } else { + background(Color.clear) + .listRowBackground(EmptyView()) + } + } +} + +let customCommandStore = StoreOf( + initialState: .init(), + reducer: { CustomCommandFeature(settings: .init()) } +) + +struct CustomCommandView: View { + final class Settings: ObservableObject { + @AppStorage(\.customCommands) var customCommands + + init(customCommands: AppStorage<[CustomCommand]>? = nil) { + if let list = customCommands { + _customCommands = list + } + } + } + + var store: StoreOf + @StateObject var settings = Settings() + @Environment(\.toast) var toast + + var body: some View { + HStack(spacing: 0) { + LeftPanel(store: store, settings: settings) + Divider() + RightPanel(store: store) + } + } + + struct LeftPanel: View { + let store: StoreOf + @ObservedObject var settings: Settings + @Environment(\.toast) var toast + + var body: some View { + WithPerceptionTracking { + List { + ForEach(settings.customCommands, id: \.commandId) { command in + CommandButton(store: store, command: command) + } + .onMove(perform: { indices, newOffset in + settings.customCommands.move(fromOffsets: indices, toOffset: newOffset) + }) + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view + } + } + } + .removeBackground() + .padding(.vertical, 4) + .listStyle(.plain) + .frame(width: 200) + .background(Color.primary.opacity(0.05)) + .overlay { + if settings.customCommands.isEmpty { + Text(""" + Empty + Add command with "+" button + """) + .multilineTextAlignment(.center) + } + } + .safeAreaInset(edge: .bottom) { + Button(action: { + store.send(.createNewCommand) + }) { + if isFeatureAvailable(\.unlimitedCustomCommands) { + Text(Image(systemName: "plus.circle.fill")) + Text(" New Command") + } else { + Text(Image(systemName: "plus.circle.fill")) + + Text(" New Command (\(settings.customCommands.count)/10)") + } + } + .buttonStyle(.plain) + .padding() + .contextMenu { + Button("Import") { + store.send(.importCommandClicked) + } + } + } + .onDrop(of: [.json], delegate: FileDropDelegate(store: store, toast: toast)) + } + } + } + + struct FileDropDelegate: DropDelegate { + let store: StoreOf + let toast: (String, ToastType) -> Void + func performDrop(info: DropInfo) -> Bool { + let jsonFiles = info.itemProviders(for: [.json]) + for file in jsonFiles { + file + .loadInPlaceFileRepresentation(forTypeIdentifier: "public.json") { url, _, error in + Task { @MainActor in + if let url { + store.send(.importCommand(at: url)) + } else if let error { + toast(error.localizedDescription, .error) + } + } + } + } + + return !jsonFiles.isEmpty + } + } + + struct CommandButton: View { + @Perception.Bindable var store: StoreOf + let command: CustomCommand + + var body: some View { + WithPerceptionTracking { + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal") + + VStack(alignment: .leading) { + Text(command.name) + .foregroundStyle(.primary) + + Group { + switch command.feature { + case .chatWithSelection: + Text("Send Message") + case .customChat: + Text("Custom Chat") + case .promptToCode: + Text("Modification") + case .singleRoundDialog: + Text("Single Round Dialog") + } + } + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.editCommand(command)) + } + } + .padding(4) + .background { + RoundedRectangle(cornerRadius: 4) + .fill( + store.editCustomCommand?.commandId == command.id + ? Color.primary.opacity(0.05) + : Color.clear + ) + } + .contextMenu { + Button("Remove") { + store.send(.deleteCommand(command)) + } + + Button("Export") { + store.send(.exportCommand(command)) + } + } + } + } + } + + struct RightPanel: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + if let store = store.scope( + state: \.editCustomCommand, + action: \.editCustomCommand + ) { + EditCustomCommandView(store: store) + } else { + VStack { + SubSection(title: Text("Send Message")) { + Text( + "This command sends a message to the active chat tab. You can provide additional context as well. The additional context will be removed once a message is sent. If the message provided is empty, you can manually type the message in the chat." + ) + } + SubSection(title: Text("Modification")) { + Text( + "This command opens the prompt-to-code panel and executes the provided requirements on the selected code. You can provide additional context through the \"Extra Context\" as well." + ) + } + SubSection(title: Text("Custom Chat")) { + Text( + "This command will overwrite the context of the chat. You can use it to switch to different contexts in the chat. If a message is provided, it will be sent to the chat as well." + ) + } + SubSection(title: Text("Single Round Dialog")) { + Text( + "This command allows you to send a message to a temporary chat without opening the chat panel. It is particularly useful for one-time commands, such as running a terminal command with `/shell`. For example, you can set the prompt to `/shell open .` to open the project in Finder." + ) + } + } + .padding() + } + } + } + } +} + +struct CustomCommandTypeDescription: View { + let text: String + var body: some View { + ScrollView { + Markdown(text) + .lineLimit(nil) + .markdownTheme( + .gitHub + .text { + ForegroundColor(.secondary) + BackgroundColor(.clear) + FontSize(14) + } + .heading1 { conf in + VStack(alignment: .leading, spacing: 0) { + conf.label + .relativePadding(.bottom, length: .em(0.3)) + .relativeLineSpacing(.em(0.125)) + .markdownMargin(top: 24, bottom: 16) + .markdownTextStyle { + FontWeight(.semibold) + FontSize(.em(1.25)) + } + Divider() + } + } + ) + .padding() + .background(Color.primary.opacity(0.02), in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(style: .init(lineWidth: 1)) + .foregroundColor(Color(nsColor: .separatorColor)) + } + .padding() + } + } +} + +// MARK: - Previews + +struct CustomCommandView_Preview: PreviewProvider { + static var previews: some View { + let settings = CustomCommandView.Settings(customCommands: .init(wrappedValue: [ + .init( + commandId: "1", + name: "Explain Code", + feature: .chatWithSelection( + extraSystemPrompt: nil, + prompt: "Hello", + useExtraSystemPrompt: false + ), + ignoreExistingAttachments: false, + attachments: [] + ), + .init( + commandId: "2", + name: "Refactor Code", + feature: .promptToCode( + extraSystemPrompt: nil, + prompt: "Refactor", + continuousMode: false, + generateDescription: true + ), + ignoreExistingAttachments: false, + attachments: [] + ), + ], "CustomCommandView_Preview")) + + return CustomCommandView( + store: .init( + initialState: .init( + editCustomCommand: .init(.init(.init( + commandId: "1", + name: "Explain Code", + feature: .chatWithSelection( + extraSystemPrompt: nil, + prompt: "Hello", + useExtraSystemPrompt: false + ), + ignoreExistingAttachments: false, + attachments: [] as [CustomCommand.Attachment] + ))) + ), + reducer: { CustomCommandFeature(settings: settings) } + ), + settings: settings + ) + } +} + +struct CustomCommandView_NoEditing_Preview: PreviewProvider { + static var previews: some View { + let settings = CustomCommandView.Settings(customCommands: .init(wrappedValue: [ + .init( + commandId: "1", + name: "Explain Code", + feature: .chatWithSelection( + extraSystemPrompt: nil, + prompt: "Hello", + useExtraSystemPrompt: false + ), + ignoreExistingAttachments: false, + attachments: [] + ), + .init( + commandId: "2", + name: "Refactor Code", + feature: .promptToCode( + extraSystemPrompt: nil, + prompt: "Refactor", + continuousMode: false, + generateDescription: true + ), + ignoreExistingAttachments: false, + attachments: [] + ), + ], "CustomCommandView_Preview")) + + return CustomCommandView( + store: .init( + initialState: .init( + editCustomCommand: nil + ), + reducer: { CustomCommandFeature(settings: settings) } + ), + settings: settings + ) + } +} + diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift new file mode 100644 index 00000000..e927a9ff --- /dev/null +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift @@ -0,0 +1,309 @@ +import ComposableArchitecture +import Foundation +import Preferences +import SwiftUI + +@Reducer +struct EditCustomCommand { + enum CommandType: Int, CaseIterable, Equatable { + case sendMessage + case promptToCode + case customChat + case singleRoundDialog + } + + @ObservableState + struct State: Equatable { + var name: String = "" + var commandType: CommandType = .sendMessage + var isNewCommand: Bool = false + let commandId: String + + var sendMessage = EditSendMessageCommand.State() + var promptToCode = EditPromptToCodeCommand.State() + var customChat = EditCustomChatCommand.State() + var singleRoundDialog = EditSingleRoundDialogCommand.State() + var attachments = EditCustomCommandAttachment.State() + + init(_ command: CustomCommand?) { + isNewCommand = command == nil + commandId = command?.id ?? UUID().uuidString + name = command?.name ?? "New Command" + attachments = .init( + attachments: command?.attachments ?? [], + ignoreExistingAttachments: command?.ignoreExistingAttachments ?? false + ) + + switch command?.feature { + case let .chatWithSelection(extraSystemPrompt, prompt, useExtraSystemPrompt): + commandType = .sendMessage + sendMessage = .init( + extraSystemPrompt: extraSystemPrompt ?? "", + useExtraSystemPrompt: useExtraSystemPrompt ?? false, + prompt: prompt ?? "" + ) + case .none: + commandType = .sendMessage + sendMessage = .init( + extraSystemPrompt: "", + useExtraSystemPrompt: false, + prompt: "Hello" + ) + case let .customChat(systemPrompt, prompt): + commandType = .customChat + customChat = .init( + systemPrompt: systemPrompt ?? "", + prompt: prompt ?? "" + ) + case let .singleRoundDialog( + systemPrompt, + overwriteSystemPrompt, + prompt, + receiveReplyInNotification + ): + commandType = .singleRoundDialog + singleRoundDialog = .init( + systemPrompt: systemPrompt ?? "", + overwriteSystemPrompt: overwriteSystemPrompt ?? false, + prompt: prompt ?? "", + receiveReplyInNotification: receiveReplyInNotification ?? false + ) + case let .promptToCode(extraSystemPrompt, prompt, continuousMode, generateDescription): + commandType = .promptToCode + promptToCode = .init( + extraSystemPrompt: extraSystemPrompt ?? "", + prompt: prompt ?? "", + continuousMode: continuousMode ?? false, + generateDescription: generateDescription ?? true + ) + } + } + } + + enum Action: BindableAction, Equatable { + case saveCommand + case close + case binding(BindingAction) + case sendMessage(EditSendMessageCommand.Action) + case promptToCode(EditPromptToCodeCommand.Action) + case customChat(EditCustomChatCommand.Action) + case singleRoundDialog(EditSingleRoundDialogCommand.Action) + case attachments(EditCustomCommandAttachment.Action) + } + + let settings: CustomCommandView.Settings + + @Dependency(\.toast) var toast + + var body: some ReducerOf { + Scope(state: \.sendMessage, action: \.sendMessage) { + EditSendMessageCommand() + } + + Scope(state: \.promptToCode, action: \.promptToCode) { + EditPromptToCodeCommand() + } + + Scope(state: \.customChat, action: \.customChat) { + EditCustomChatCommand() + } + + Scope(state: \.singleRoundDialog, action: \.singleRoundDialog) { + EditSingleRoundDialogCommand() + } + + Scope(state: \.attachments, action: \.attachments) { + EditCustomCommandAttachment() + } + + BindingReducer() + + Reduce { state, action in + switch action { + case .saveCommand: + guard !state.name.isEmpty else { + toast("Command name cannot be empty.", .error) + return .none + } + + let newCommand = CustomCommand( + commandId: state.commandId, + name: state.name, + feature: { + switch state.commandType { + case .sendMessage: + let state = state.sendMessage + return .chatWithSelection( + extraSystemPrompt: state.extraSystemPrompt, + prompt: state.prompt, + useExtraSystemPrompt: state.useExtraSystemPrompt + ) + case .promptToCode: + let state = state.promptToCode + return .promptToCode( + extraSystemPrompt: state.extraSystemPrompt, + prompt: state.prompt, + continuousMode: state.continuousMode, + generateDescription: state.generateDescription + ) + case .customChat: + let state = state.customChat + return .customChat( + systemPrompt: state.systemPrompt, + prompt: state.prompt + ) + case .singleRoundDialog: + let state = state.singleRoundDialog + return .singleRoundDialog( + systemPrompt: state.systemPrompt, + overwriteSystemPrompt: state.overwriteSystemPrompt, + prompt: state.prompt, + receiveReplyInNotification: state.receiveReplyInNotification + ) + } + }(), + ignoreExistingAttachments: state.attachments.ignoreExistingAttachments, + attachments: state.attachments.attachments + ) + + if state.isNewCommand { + settings.customCommands.append(newCommand) + state.isNewCommand = false + toast("The command is created.", .info) + } else { + if let index = settings.customCommands.firstIndex(where: { + $0.id == newCommand.id + }) { + settings.customCommands[index] = newCommand + } else { + settings.customCommands.append(newCommand) + } + toast("The command is updated.", .info) + } + + return .none + + case .close: + return .none + + case .binding: + return .none + case .sendMessage: + return .none + case .promptToCode: + return .none + case .customChat: + return .none + case .singleRoundDialog: + return .none + case .attachments: + return .none + } + } + } +} + +@Reducer +struct EditCustomCommandAttachment { + @ObservableState + struct State: Equatable { + var attachments: [CustomCommand.Attachment] = [] + var ignoreExistingAttachments: Bool = false + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + } + + var body: some ReducerOf { + BindingReducer() + + Reduce { _, action in + switch action { + case .binding: + return .none + } + } + } +} + +@Reducer +struct EditSendMessageCommand { + @ObservableState + struct State: Equatable { + var extraSystemPrompt: String = "" + var useExtraSystemPrompt: Bool = false + var prompt: String = "" + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + } + + var body: some ReducerOf { + BindingReducer() + + Reduce { _, action in + switch action { + case .binding: + return .none + } + } + } +} + +@Reducer +struct EditPromptToCodeCommand { + @ObservableState + struct State: Equatable { + var extraSystemPrompt: String = "" + var prompt: String = "" + var continuousMode: Bool = false + var generateDescription: Bool = false + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + } + + var body: some ReducerOf { + BindingReducer() + } +} + +@Reducer +struct EditCustomChatCommand { + @ObservableState + struct State: Equatable { + var systemPrompt: String = "" + var prompt: String = "" + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + } + + var body: some ReducerOf { + BindingReducer() + } +} + +@Reducer +struct EditSingleRoundDialogCommand { + @ObservableState + struct State: Equatable { + var systemPrompt: String = "" + var overwriteSystemPrompt: Bool = false + var prompt: String = "" + var receiveReplyInNotification: Bool = false + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + } + + var body: some ReducerOf { + BindingReducer() + } +} + diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift new file mode 100644 index 00000000..e2304f8b --- /dev/null +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift @@ -0,0 +1,419 @@ +import ComposableArchitecture +import MarkdownUI +import Preferences +import SwiftUI + +@MainActor +struct EditCustomCommandView: View { + @Environment(\.toast) var toast + @Perception.Bindable var store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + ScrollView { + Form { + sharedForm + featureSpecificForm + }.padding() + }.safeAreaInset(edge: .bottom) { + bottomBar + } + } + + @ViewBuilder var sharedForm: some View { + WithPerceptionTracking { + TextField("Name", text: $store.name) + + Picker("Command Type", selection: $store.commandType) { + ForEach( + EditCustomCommand.CommandType.allCases, + id: \.rawValue + ) { commandType in + Text({ + switch commandType { + case .sendMessage: + return "Send Message" + case .promptToCode: + return "Modification" + case .customChat: + return "Custom Chat" + case .singleRoundDialog: + return "Single Round Dialog" + } + }() as String).tag(commandType) + } + } + } + } + + @ViewBuilder var featureSpecificForm: some View { + WithPerceptionTracking { + switch store.commandType { + case .sendMessage: + EditSendMessageCommandView( + store: store.scope( + state: \.sendMessage, + action: \.sendMessage + ), + attachmentStore: store.scope( + state: \.attachments, + action: \.attachments + ) + ) + case .promptToCode: + EditPromptToCodeCommandView( + store: store.scope( + state: \.promptToCode, + action: \.promptToCode + ) + ) + case .customChat: + EditCustomChatCommandView( + store: store.scope( + state: \.customChat, + action: \.customChat + ), + attachmentStore: store.scope( + state: \.attachments, + action: \.attachments + ) + ) + case .singleRoundDialog: + EditSingleRoundDialogCommandView( + store: store.scope( + state: \.singleRoundDialog, + action: \.singleRoundDialog + ) + ) + } + } + } + + @ViewBuilder var bottomBar: some View { + WithPerceptionTracking { + VStack { + Divider() + + VStack(alignment: .trailing) { + Text( + "After renaming or adding a custom command, please restart Xcode to refresh the menu." + ) + .foregroundStyle(.secondary) + + HStack { + Spacer() + Button("Close") { + store.send(.close) + } + + if store.isNewCommand { + Button("Add") { + store.send(.saveCommand) + } + } else { + Button("Save") { + store.send(.saveCommand) + } + } + } + } + .padding(.horizontal) + } + .padding(.bottom) + .background(.regularMaterial) + } + } +} + +struct CustomCommandAttachmentPickerView: View { + @Perception.Bindable var store: StoreOf + @State private var isFileInputPresented = false + @State private var filePath = "" + + #if canImport(ProHostApp) + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading) { + Text("Contexts") + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 8) { + if store.attachments.isEmpty { + Text("No context") + .foregroundStyle(.secondary) + } else { + ForEach(store.attachments, id: \.kind) { attachment in + HStack { + switch attachment.kind { + case let .file(path: path): + HStack { + Text("File:") + Text(path).foregroundStyle(.secondary) + } + default: + Text(attachment.kind.description) + } + Spacer() + Button { + store.attachments.removeAll { $0.kind == attachment.kind } + } label: { + Image(systemName: "trash") + } + .buttonStyle(.plain) + } + } + } + } + .frame(minWidth: 240) + .padding(.vertical, 8) + .padding(.horizontal, 8) + .background { + RoundedRectangle(cornerRadius: 8) + .strokeBorder(.separator, lineWidth: 1) + } + + Form { + Menu { + ForEach(CustomCommand.Attachment.Kind.allCases.filter { kind in + !store.attachments.contains { $0.kind == kind } + }, id: \.self) { kind in + if kind == .file(path: "") { + Button { + isFileInputPresented = true + } label: { + Text("File...") + } + } else { + Button { + store.attachments.append(.init(kind: kind)) + } label: { + Text(kind.description) + } + } + } + } label: { + Label("Add context", systemImage: "plus") + } + + Toggle( + "Ignore existing contexts", + isOn: $store.ignoreExistingAttachments + ) + } + } + } + .sheet(isPresented: $isFileInputPresented) { + VStack(alignment: .leading, spacing: 16) { + Text("Enter file path:") + .font(.headline) + Text( + "You can enter either an absolute path or a path relative to the project root." + ) + .font(.caption) + .foregroundStyle(.secondary) + TextField("File path", text: $filePath) + .textFieldStyle(.roundedBorder) + HStack { + Spacer() + Button("Cancel") { + isFileInputPresented = false + filePath = "" + } + Button("Add") { + store.attachments.append(.init(kind: .file(path: filePath))) + isFileInputPresented = false + filePath = "" + } + .disabled(filePath.isEmpty) + } + } + .padding() + .frame(minWidth: 400) + } + } + } + #else + var body: some View { EmptyView() } + #endif +} + +extension CustomCommand.Attachment.Kind { + public static var allCases: [CustomCommand.Attachment.Kind] { + [ + .activeDocument, + .debugArea, + .clipboard, + .senseScope, + .projectScope, + .webScope, + .gitStatus, + .gitLog, + .file(path: ""), + ] + } + + var description: String { + switch self { + case .activeDocument: return "Active Document" + case .debugArea: return "Debug Area" + case .clipboard: return "Clipboard" + case .senseScope: return "Sense Scope" + case .projectScope: return "Project Scope" + case .webScope: return "Web Scope" + case .gitStatus: return "Git Status and Diff" + case .gitLog: return "Git Log" + case .file: return "File" + } + } +} + +struct EditSendMessageCommandView: View { + @Perception.Bindable var store: StoreOf + var attachmentStore: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 4) { + Toggle("Extra Context", isOn: $store.useExtraSystemPrompt) + EditableText(text: $store.extraSystemPrompt) + } + .padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 4) { + Text("Send immediately") + EditableText(text: $store.prompt) + } + .padding(.vertical, 4) + + CustomCommandAttachmentPickerView(store: attachmentStore) + .padding(.vertical, 4) + } + } +} + +struct EditPromptToCodeCommandView: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + Toggle("Continuous Mode", isOn: $store.continuousMode) + + VStack(alignment: .leading, spacing: 4) { + Text("Extra Context") + EditableText(text: $store.extraSystemPrompt) + } + .padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 4) { + Text("Instruction") + EditableText(text: $store.prompt) + } + .padding(.vertical, 4) + } + } +} + +struct EditCustomChatCommandView: View { + @Perception.Bindable var store: StoreOf + var attachmentStore: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 4) { + Text("Topic") + EditableText(text: $store.systemPrompt) + } + .padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 4) { + Text("Send immediately") + EditableText(text: $store.prompt) + } + .padding(.vertical, 4) + + CustomCommandAttachmentPickerView(store: attachmentStore) + .padding(.vertical, 4) + } + } +} + +struct EditSingleRoundDialogCommandView: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 4) { + Text("System Prompt") + EditableText(text: $store.systemPrompt) + } + .padding(.vertical, 4) + + Picker(selection: $store.overwriteSystemPrompt) { + Text("Append to default system prompt").tag(false) + Text("Overwrite default system prompt").tag(true) + } label: { + Text("Mode") + } + .pickerStyle(.radioGroup) + + VStack(alignment: .leading, spacing: 4) { + Text("Prompt") + EditableText(text: $store.prompt) + } + .padding(.vertical, 4) + + Toggle("Receive response in notification", isOn: $store.receiveReplyInNotification) + Text( + "You will be prompted to grant the app permission to send notifications for the first time." + ) + .font(.footnote) + .foregroundColor(.secondary) + } + } +} + +// MARK: - Preview + +struct EditCustomCommandView_Preview: PreviewProvider { + static var previews: some View { + EditCustomCommandView( + store: .init( + initialState: .init(.init( + commandId: "4", + name: "Explain Code", + feature: .promptToCode( + extraSystemPrompt: nil, + prompt: "Hello", + continuousMode: false, + generateDescription: true + ), + ignoreExistingAttachments: false, + attachments: [] as [CustomCommand.Attachment] + )), + reducer: { + EditCustomCommand( + settings: .init(customCommands: .init( + wrappedValue: [], + "CustomCommandView_Preview" + )) + ) + } + ) + ) + .frame(width: 800) + } +} + +struct EditSingleRoundDialogCommandView_Preview: PreviewProvider { + static var previews: some View { + EditSingleRoundDialogCommandView(store: .init( + initialState: .init(), + reducer: { EditSingleRoundDialogCommand() } + )) + .frame(width: 800, height: 600) + } +} + diff --git a/Core/Sources/HostApp/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandView.swift deleted file mode 100644 index 79cecf98..00000000 --- a/Core/Sources/HostApp/CustomCommandView.swift +++ /dev/null @@ -1,487 +0,0 @@ -import Preferences -import SwiftUI - -extension List { - @ViewBuilder - func removeBackground() -> some View { - if #available(macOS 13.0, *) { - scrollContentBackground(.hidden) - } else { - background(Color.clear) - } - } -} - -struct CustomCommandView: View { - final class Settings: ObservableObject { - @AppStorage(\.customCommands) var customCommands - var illegalNames: [String] { - let existed = customCommands.map(\.name) - let builtin: [String] = [ - "Get Suggestions", - "Accept Suggestion", - "Reject Suggestion", - "Next Suggestion", - "Previous Suggestion", - "Real-time Suggestions", - "Prefetch Suggestions", - "Open Chat", - "Prompt to Code", - ] - - return existed + builtin - } - - init(customCommands: AppStorage<[CustomCommand]>? = nil) { - if let list = customCommands { - _customCommands = list - } - } - } - - struct EditingCommand { - var isNew: Bool - var command: CustomCommand - } - - @State var editingCommand: EditingCommand? - - @StateObject var settings = Settings() - - var body: some View { - HStack(spacing: 0) { - List { - ForEach(settings.customCommands, id: \.name) { command in - HStack(spacing: 4) { - Image(systemName: "line.3.horizontal") - - VStack(alignment: .leading) { - Text(command.name) - .foregroundStyle(.primary) - - Group { - switch command.feature { - case .chatWithSelection: - Text("Open Chat") - case .customChat: - Text("Custom Chat") - case .promptToCode: - Text("Prompt to Code") - } - } - .font(.caption) - .foregroundStyle(.tertiary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { - editingCommand = .init(isNew: false, command: command) - } - } - .padding(4) - .background( - editingCommand?.command.id == command.id - ? Color.primary.opacity(0.05) - : Color.clear, - in: RoundedRectangle(cornerRadius: 4) - ) - .contextMenu { - Button("Remove") { - settings.customCommands.removeAll( - where: { $0.id == command.id } - ) - if let editingCommand, editingCommand.command.id == command.id { - self.editingCommand = nil - } - } - } - } - .onMove(perform: { indices, newOffset in - settings.customCommands.move(fromOffsets: indices, toOffset: newOffset) - }) - } - .removeBackground() - .padding(.vertical, 4) - .listStyle(.plain) - .frame(width: 200) - .background(Color.primary.opacity(0.05)) - .overlay { - if settings.customCommands.isEmpty { - Text(""" - Empty - Add command with "+" button - """) - .multilineTextAlignment(.center) - } - } - .safeAreaInset(edge: .bottom) { - Button(action: { - editingCommand = .init(isNew: true, command: CustomCommand( - commandId: UUID().uuidString, - name: "New Command", - feature: .chatWithSelection( - extraSystemPrompt: nil, - prompt: "Tell me about the code.", - useExtraSystemPrompt: false - ) - )) - }) { - Text(Image(systemName: "plus.circle.fill")) + Text(" New Command") - } - .buttonStyle(.plain) - .padding() - } - - Divider() - - if let editingCommand { - EditCustomCommandView( - editingCommand: $editingCommand, - settings: settings - ).id(editingCommand.command.id) - } else { - Color.clear - } - } - } -} - -struct EditCustomCommandView: View { - @Environment(\.toast) var toast - @Binding var editingCommand: CustomCommandView.EditingCommand? - var settings: CustomCommandView.Settings - let originalName: String - @State var commandType: CommandType - - @State var name: String - @State var prompt: String - @State var systemPrompt: String - @State var usePrompt: Bool - @State var continuousMode: Bool - @State var editingContentInFullScreen: Binding? - @State var generatingPromptToCodeDescription: Bool - - enum CommandType: Int, CaseIterable { - case chatWithSelection - case promptToCode - case customChat - } - - init( - editingCommand: Binding, - settings: CustomCommandView.Settings - ) { - _editingCommand = editingCommand - self.settings = settings - originalName = editingCommand.wrappedValue?.command.name ?? "" - name = originalName - switch editingCommand.wrappedValue?.command.feature { - case let .chatWithSelection(extraSystemPrompt, prompt, useExtraSystemPrompt): - commandType = .chatWithSelection - self.prompt = prompt ?? "" - systemPrompt = extraSystemPrompt ?? "" - usePrompt = useExtraSystemPrompt ?? true - continuousMode = false - generatingPromptToCodeDescription = true - case let .customChat(systemPrompt, prompt): - commandType = .customChat - self.systemPrompt = systemPrompt ?? "" - self.prompt = prompt ?? "" - usePrompt = false - continuousMode = false - generatingPromptToCodeDescription = true - case let .promptToCode(extraSystemPrompt, prompt, continuousMode, generateDescription): - commandType = .promptToCode - self.prompt = prompt ?? "" - systemPrompt = extraSystemPrompt ?? "" - usePrompt = false - self.continuousMode = continuousMode ?? false - generatingPromptToCodeDescription = generateDescription ?? true - case .none: - commandType = .chatWithSelection - prompt = "" - systemPrompt = "" - continuousMode = false - usePrompt = true - generatingPromptToCodeDescription = true - } - } - - var body: some View { - ScrollView { - Form { - TextField("Name", text: $name) - - Picker("Command Type", selection: $commandType) { - ForEach(CommandType.allCases, id: \.rawValue) { commandType in - Text({ - switch commandType { - case .chatWithSelection: - return "Open Chat" - case .promptToCode: - return "Prompt to Code" - case .customChat: - return "Custom Chat" - } - }() as String).tag(commandType) - } - } - - switch commandType { - case .chatWithSelection: - systemPromptTextField(title: "Extra System Prompt", hasToggle: true) - promptTextField - case .promptToCode: - continuousModeToggle - generateDescriptionToggle - systemPromptTextField(title: "Extra System Prompt", hasToggle: false) - promptTextField - case .customChat: - systemPromptTextField(hasToggle: false) - promptTextField - } - }.padding() - }.safeAreaInset(edge: .bottom) { - VStack { - Divider() - - VStack { - Text( - "After renaming or adding a custom command, please restart Xcode to refresh the menu." - ) - .foregroundStyle(.secondary) - - HStack { - Spacer() - Button("Close") { - editingCommand = nil - } - - lazy var newCommand = CustomCommand( - commandId: editingCommand?.command.id ?? UUID().uuidString, - name: name, - feature: { - switch commandType { - case .chatWithSelection: - return .chatWithSelection( - extraSystemPrompt: systemPrompt, - prompt: prompt, - useExtraSystemPrompt: usePrompt - ) - case .promptToCode: - return .promptToCode( - extraSystemPrompt: systemPrompt, - prompt: prompt, - continuousMode: continuousMode, - generateDescription: generatingPromptToCodeDescription - ) - case .customChat: - return .customChat( - systemPrompt: systemPrompt, - prompt: prompt - ) - } - }() - ) - - if editingCommand?.isNew ?? true { - Button("Add") { - guard !settings.illegalNames.contains(newCommand.name) else { - toast(Text("Command name is illegal."), .error) - return - } - guard !newCommand.name.isEmpty else { - toast(Text("Command name cannot be empty."), .error) - return - } - settings.customCommands.append(newCommand) - editingCommand?.isNew = false - editingCommand?.command = newCommand - - toast(Text("The command is created."), .info) - } - } else { - Button("Save") { - guard !settings.illegalNames.contains(newCommand.name) - || newCommand.name == originalName - else { - toast(Text("Command name is illegal."), .error) - return - } - guard !newCommand.name.isEmpty else { - toast(Text("Command name cannot be empty."), .error) - return - } - - if let index = settings.customCommands.firstIndex(where: { - $0.id == newCommand.id - }) { - settings.customCommands[index] = newCommand - } else { - settings.customCommands.append(newCommand) - } - - toast(Text("The command is updated."), .info) - } - } - } - } - .padding(.horizontal) - } - .padding(.bottom) - .background(.regularMaterial) - .sheet(isPresented: .init(get: { editingContentInFullScreen != nil }, set: { - if $0 == false { - editingContentInFullScreen = nil - } - }), content: { - VStack { - if let editingContentInFullScreen { - TextEditor(text: editingContentInFullScreen) - .font(Font.system(.body, design: .monospaced)) - .padding(4) - .frame(minHeight: 120) - .multilineTextAlignment(.leading) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) - } - - Button(action: { - editingContentInFullScreen = nil - }) { - Text("Done") - } - } - .padding() - .frame(width: 600, height: 500) - .background(Color(nsColor: .windowBackgroundColor)) - }) - } - } - - @ViewBuilder - var promptTextField: some View { - VStack(alignment: .leading, spacing: 4) { - Text("Prompt") - editableText($prompt) - } - .padding(.vertical, 4) - } - - @ViewBuilder - func systemPromptTextField(title: String? = nil, hasToggle: Bool) -> some View { - VStack(alignment: .leading, spacing: 4) { - if hasToggle { - Toggle(title ?? "System Prompt", isOn: $usePrompt) - } else { - Text(title ?? "System Prompt") - } - editableText($systemPrompt) - } - .padding(.vertical, 4) - } - - var continuousModeToggle: some View { - Toggle("Continuous Mode", isOn: $continuousMode) - } - - var generateDescriptionToggle: some View { - Toggle("Generate Description", isOn: $generatingPromptToCodeDescription) - } - - func editableText(_ binding: Binding) -> some View { - Button(action: { - editingContentInFullScreen = binding - }) { - HStack(alignment: .top) { - Text(binding.wrappedValue) - .font(Font.system(.body, design: .monospaced)) - .padding(4) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - .background { - RoundedRectangle(cornerRadius: 4) - .fill(Color(nsColor: .textBackgroundColor)) - } - .overlay { - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1)) - } - Image(systemName: "square.and.pencil") - .resizable() - .scaledToFit() - .frame(width: 14) - .padding(4) - .background( - Color.primary.opacity(0.1), - in: RoundedRectangle(cornerRadius: 4) - ) - } - } - .buttonStyle(.plain) - } -} - -// MARK: - Previews - -struct CustomCommandView_Preview: PreviewProvider { - static var previews: some View { - CustomCommandView( - editingCommand: .init(isNew: false, command: .init( - commandId: "1", - name: "Explain Code", - feature: .chatWithSelection( - extraSystemPrompt: nil, - prompt: "Hello", - useExtraSystemPrompt: false - ) - )), - settings: .init(customCommands: .init(wrappedValue: [ - .init( - commandId: "1", - name: "Explain Code", - feature: .chatWithSelection( - extraSystemPrompt: nil, - prompt: "Hello", - useExtraSystemPrompt: false - ) - ), - .init( - commandId: "2", - name: "Refactor Code", - feature: .promptToCode( - extraSystemPrompt: nil, - prompt: "Refactor", - continuousMode: false, - generateDescription: true - ) - ), - ], "CustomCommandView_Preview")) - ) - } -} - -struct EditCustomCommandView_Preview: PreviewProvider { - static var previews: some View { - EditCustomCommandView( - editingCommand: .constant(CustomCommandView.EditingCommand( - isNew: false, - command: .init( - commandId: "4", - name: "Explain Code", - feature: .promptToCode( - extraSystemPrompt: nil, - prompt: "Hello", - continuousMode: false, - generateDescription: true - ) - ) - )), - settings: .init(customCommands: .init(wrappedValue: [], "CustomCommandView_Preview")) - ) - .frame(width: 800) - } -} - diff --git a/Core/Sources/HostApp/DebugView.swift b/Core/Sources/HostApp/DebugView.swift index fcf7047c..527d5b7b 100644 --- a/Core/Sources/HostApp/DebugView.swift +++ b/Core/Sources/HostApp/DebugView.swift @@ -2,21 +2,53 @@ import Preferences import SwiftUI final class DebugSettings: ObservableObject { - @AppStorage(\.disableLazyVStack) var disableLazyVStack + @AppStorage(\.animationACrashSuggestion) var animationACrashSuggestion + @AppStorage(\.animationBCrashSuggestion) var animationBCrashSuggestion + @AppStorage(\.animationCCrashSuggestion) var animationCCrashSuggestion @AppStorage(\.preCacheOnFileOpen) var preCacheOnFileOpen @AppStorage(\.useCustomScrollViewWorkaround) var useCustomScrollViewWorkaround @AppStorage(\.triggerActionWithAccessibilityAPI) var triggerActionWithAccessibilityAPI + @AppStorage(\.alwaysAcceptSuggestionWithAccessibilityAPI) + var alwaysAcceptSuggestionWithAccessibilityAPI + @AppStorage(\.enableXcodeInspectorDebugMenu) var enableXcodeInspectorDebugMenu + @AppStorage(\.disableFunctionCalling) var disableFunctionCalling + @AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear) + var disableGitHubCopilotSettingsAutoRefreshOnAppear + @AppStorage(\.useUserDefaultsBaseAPIKeychain) var useUserDefaultsBaseAPIKeychain + @AppStorage(\.disableEnhancedWorkspace) var disableEnhancedWorkspace + @AppStorage(\.disableGitIgnoreCheck) var disableGitIgnoreCheck + @AppStorage(\.disableFileContentManipulationByCheatsheet) + var disableFileContentManipulationByCheatsheet + @AppStorage(\.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) + var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning + @AppStorage(\.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer) + var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer + @AppStorage(\.toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted) + var toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted + @AppStorage(\.observeToAXNotificationWithDefaultMode) + var observeToAXNotificationWithDefaultMode + @AppStorage(\.useCloudflareDomainNameForLicenseCheck) + var useCloudflareDomainNameForLicenseCheck + @AppStorage(\.doNotInstallLaunchAgentAutomatically) + var doNotInstallLaunchAgentAutomatically init() {} } struct DebugSettingsView: View { @StateObject var settings = DebugSettings() + @Environment(\.updateChecker) var updateChecker var body: some View { ScrollView { Form { - Toggle(isOn: $settings.disableLazyVStack) { - Text("Disable LazyVStack") + Toggle(isOn: $settings.animationACrashSuggestion) { + Text("Enable animation A") + } + Toggle(isOn: $settings.animationBCrashSuggestion) { + Text("Enable animation B") + } + Toggle(isOn: $settings.animationCCrashSuggestion) { + Text("Enable widget breathing animation") } Toggle(isOn: $settings.preCacheOnFileOpen) { Text("Cache editor information on file open") @@ -25,9 +57,99 @@ struct DebugSettingsView: View { Text("Use custom scroll view workaround for smooth scrolling") } Toggle(isOn: $settings.triggerActionWithAccessibilityAPI) { - Text("Trigger command with AccessibilityAPI") + Text("Trigger command with Accessibility API") + } + Group { + Toggle(isOn: $settings.alwaysAcceptSuggestionWithAccessibilityAPI) { + Text("Always accept suggestion with Accessibility API") + } + Toggle(isOn: $settings.enableXcodeInspectorDebugMenu) { + Text("Enable Xcode inspector debug menu") + } + Toggle(isOn: $settings.disableFunctionCalling) { + Text("Disable function calling for chat feature") + } + Toggle(isOn: $settings.disableGitHubCopilotSettingsAutoRefreshOnAppear) { + Text("Disable GitHub Copilot settings auto refresh status on appear") + } + Toggle(isOn: $settings.useUserDefaultsBaseAPIKeychain) { + Text("Store API keys in UserDefaults") + } + + Toggle(isOn: $settings.disableEnhancedWorkspace) { + Text("Disable enhanced workspace") + } + + Toggle(isOn: $settings.disableGitIgnoreCheck) { + Text("Disable git ignore check") + } + + Toggle(isOn: $settings.disableFileContentManipulationByCheatsheet) { + Text("Disable file content manipulation by cheatsheet") + } + + Group { + Toggle( + isOn: $settings + .restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning + ) { + Text( + "Re-activate Xcode Inspector when Accessibility API malfunctioning detected" + ) + } + + Toggle( + isOn: $settings + .restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer + ) { + Text("Trigger malfunctioning detection only with events") + } + + Toggle( + isOn: $settings + .toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted + ) { + Text("Toast for the reason of re-activation of Xcode Inspector") + } + } + + Button("Reset migration version to 0") { + UserDefaults.shared.set(nil, forKey: "OldMigrationVersion") + } + + Button("Reset 0.23.0 migration") { + UserDefaults.shared.set("239", forKey: "OldMigrationVersion") + UserDefaults.shared.set(nil, forKey: "MigrateTo240Finished") + UserDefaults.shared.set(nil, forKey: "ChatModels") + UserDefaults.shared.set(nil, forKey: "EmbeddingModels") + } + + Group { + Toggle( + isOn: $settings.observeToAXNotificationWithDefaultMode + ) { + Text("Observe to AXNotification with default mode") + } + } + + Toggle( + isOn: $settings.useCloudflareDomainNameForLicenseCheck + ) { + Text("Use Cloudflare domain name for license check") + } + + Toggle( + isOn: $settings.doNotInstallLaunchAgentAutomatically + ) { + Text("Don't install launch agent automatically") + } + + Button("Reset update cycle") { + updateChecker.resetUpdateCycle() + } } } + .frame(maxWidth: .infinity) .padding() } } diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift new file mode 100644 index 00000000..dfb60355 --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift @@ -0,0 +1,383 @@ +import Client +import Preferences +import SharedUIComponents +import SwiftUI +import XPCShared + +#if canImport(ProHostApp) +import ProHostApp +#endif + +struct ChatSettingsGeneralSectionView: View { + class Settings: ObservableObject { + static let availableLocalizedLocales = Locale.availableLocalizedLocales + @AppStorage(\.chatGPTLanguage) var chatGPTLanguage + @AppStorage(\.chatGPTTemperature) var chatGPTTemperature + @AppStorage(\.chatGPTMaxMessageCount) var chatGPTMaxMessageCount + @AppStorage(\.chatFontSize) var chatFontSize + @AppStorage(\.chatCodeFont) var chatCodeFont + + @AppStorage(\.defaultChatFeatureChatModelId) var defaultChatFeatureChatModelId + @AppStorage(\.preferredChatModelIdForUtilities) var utilityChatModelId + @AppStorage(\.defaultChatSystemPrompt) var defaultChatSystemPrompt + @AppStorage(\.chatSearchPluginMaxIterations) var chatSearchPluginMaxIterations + @AppStorage(\.defaultChatFeatureEmbeddingModelId) var defaultChatFeatureEmbeddingModelId + @AppStorage(\.chatModels) var chatModels + @AppStorage(\.embeddingModels) var embeddingModels + @AppStorage(\.wrapCodeInChatCodeBlock) var wrapCodeInCodeBlock + @AppStorage(\.chatPanelFloatOnTopOption) var chatPanelFloatOnTopOption + @AppStorage( + \.keepFloatOnTopIfChatPanelAndXcodeOverlaps + ) var keepFloatOnTopIfChatPanelAndXcodeOverlaps + @AppStorage( + \.disableFloatOnTopWhenTheChatPanelIsDetached + ) var disableFloatOnTopWhenTheChatPanelIsDetached + @AppStorage(\.openChatMode) var openChatMode + @AppStorage(\.openChatInBrowserURL) var openChatInBrowserURL + @AppStorage(\.openChatInBrowserInInAppBrowser) var openChatInBrowserInInAppBrowser + + var refreshExtensionExtensionOpenChatHandlerTask: Task? + + @MainActor + @Published + var openChatOptions = [OpenChatMode]() + + init() { + Task { @MainActor in + refreshExtensionOpenChatHandlers() + } + refreshExtensionExtensionOpenChatHandlerTask = Task { [weak self] in + let sequence = NotificationCenter.default + .notifications(named: NSApplication.didBecomeActiveNotification) + for await _ in sequence { + guard let self else { return } + await MainActor.run { + self.refreshExtensionOpenChatHandlers() + } + } + } + } + + @MainActor + func refreshExtensionOpenChatHandlers() { + guard let service = try? getService() else { return } + Task { @MainActor in + let handlers = try await service + .send(requestBody: ExtensionServiceRequests.GetExtensionOpenChatHandlers()) + openChatOptions = handlers.map { + if $0.isBuiltIn { + return .builtinExtension( + extensionIdentifier: $0.bundleIdentifier, + id: $0.id, + tabName: $0.tabName + ) + } else { + return .externalExtension( + extensionIdentifier: $0.bundleIdentifier, + id: $0.id, + tabName: $0.tabName + ) + } + } + } + } + } + + @Environment(\.openURL) var openURL + @Environment(\.toast) var toast + @StateObject var settings = Settings() + @State var maxTokenOverLimit = false + + var body: some View { + VStack { + openChatSettingsForm + SettingsDivider("Conversation") + chatSettingsForm + SettingsDivider("UI") + uiForm + SettingsDivider("Plugin") + pluginForm + } + } + + @ViewBuilder + var openChatSettingsForm: some View { + Form { + Picker( + "Open Chat Mode", + selection: .init(get: { + settings.openChatMode.value + }, set: { + settings.openChatMode = .init($0) + }) + ) { + Text("Open chat panel").tag(OpenChatMode.chatPanel) + Text("Open web page in browser").tag(OpenChatMode.browser) + ForEach(settings.openChatOptions) { mode in + switch mode { + case let .builtinExtension(_, _, name): + Text("Open \(name) tab").tag(mode) + case let .externalExtension(_, _, name): + Text("Open \(name) tab").tag(mode) + default: + EmptyView() + } + } + } + + if settings.openChatMode.value == .browser { + TextField( + "Chat web page URL", + text: $settings.openChatInBrowserURL, + prompt: Text("https://") + ) + .textFieldStyle(.roundedBorder) + .disableAutocorrection(true) + .autocorrectionDisabled(true) + + #if canImport(ProHostApp) + WithFeatureEnabled(\.browserTab) { + Toggle( + "Open web page in chat panel", + isOn: $settings.openChatInBrowserInInAppBrowser + ) + } + #endif + } + } + } + + @ViewBuilder + var chatSettingsForm: some View { + Form { + Picker( + "Chat model", + selection: $settings.defaultChatFeatureChatModelId + ) { + let allModels = settings.chatModels + [.init( + id: "com.github.copilot", + name: "GitHub Copilot Language Server", + format: .openAI, + info: .init() + )] + + if !allModels.contains(where: { $0.id == settings.defaultChatFeatureChatModelId }) { + Text( + (allModels.first?.name).map { "\($0) (Default)" } ?? "No model found" + ) + .tag(settings.defaultChatFeatureChatModelId) + } + + ForEach(allModels, id: \.id) { chatModel in + Text(chatModel.name).tag(chatModel.id) + } + } + + Picker( + "Utility chat model", + selection: $settings.utilityChatModelId + ) { + Text("Use the default model").tag("") + + if !settings.chatModels.contains(where: { $0.id == settings.utilityChatModelId }), + !settings.utilityChatModelId.isEmpty + { + Text( + (settings.chatModels.first?.name).map { "\($0) (Default)" } + ?? "No Model Found" + ) + .tag(settings.utilityChatModelId) + } + + ForEach(settings.chatModels, id: \.id) { chatModel in + Text(chatModel.name).tag(chatModel.id) + } + } + + Picker( + "Embedding model", + selection: $settings.defaultChatFeatureEmbeddingModelId + ) { + if !settings.embeddingModels + .contains(where: { $0.id == settings.defaultChatFeatureEmbeddingModelId }) + { + Text( + (settings.embeddingModels.first?.name).map { "\($0) (Default)" } + ?? "No model found" + ) + .tag(settings.defaultChatFeatureEmbeddingModelId) + } + + ForEach(settings.embeddingModels, id: \.id) { embeddingModel in + Text(embeddingModel.name).tag(embeddingModel.id) + } + } + + if #available(macOS 13.0, *) { + LabeledContent("Reply in language") { + languagePicker + } + } else { + HStack { + Text("Reply in language") + languagePicker + } + } + + HStack { + Slider(value: $settings.chatGPTTemperature, in: 0...2, step: 0.1) { + Text("Temperature") + } + + Text( + "\(settings.chatGPTTemperature.formatted(.number.precision(.fractionLength(1))))" + ) + .font(.body) + .foregroundColor(settings.chatGPTTemperature >= 1 ? .red : .secondary) + .monospacedDigit() + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.primary.opacity(0.1)) + ) + } + + Picker( + "Memory", + selection: $settings.chatGPTMaxMessageCount + ) { + Text("No Limit").tag(0) + Text("3 Messages").tag(3) + Text("5 Messages").tag(5) + Text("7 Messages").tag(7) + Text("9 Messages").tag(9) + Text("11 Messages").tag(11) + Text("21 Messages").tag(21) + Text("31 Messages").tag(31) + Text("41 Messages").tag(41) + Text("51 Messages").tag(51) + Text("71 Messages").tag(71) + Text("91 Messages").tag(91) + Text("111 Messages").tag(111) + Text("151 Messages").tag(151) + Text("201 Messages").tag(201) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Additional system prompt") + EditableText(text: $settings.defaultChatSystemPrompt) + .lineLimit(6) + } + .padding(.vertical, 4) + } + } + + @ViewBuilder + var uiForm: some View { + Form { + HStack { + TextField(text: .init(get: { + "\(Int(settings.chatFontSize))" + }, set: { + settings.chatFontSize = Double(Int($0) ?? 0) + })) { + Text("Font size of message") + } + .textFieldStyle(.roundedBorder) + + Text("pt") + } + + FontPicker(font: $settings.chatCodeFont) { + Text("Font of code") + } + + Toggle(isOn: $settings.wrapCodeInCodeBlock) { + Text("Wrap text in code block") + } + + CodeHighlightThemePicker(scenario: .chat) + + Picker("Always-on-top behavior", selection: $settings.chatPanelFloatOnTopOption) { + Text("Always").tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.alwaysOnTop) + Text("When Xcode is active") + .tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.onTopWhenXcodeIsActive) + Text("Never").tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.never) + } + + Toggle(isOn: $settings.disableFloatOnTopWhenTheChatPanelIsDetached) { + Text("Disable always-on-top when the chat panel is detached") + }.disabled(settings.chatPanelFloatOnTopOption == .never) + + Toggle(isOn: $settings.keepFloatOnTopIfChatPanelAndXcodeOverlaps) { + Text("Keep always-on-top if the chat panel and Xcode overlaps and Xcode is active") + } + .disabled( + !settings.disableFloatOnTopWhenTheChatPanelIsDetached + || settings.chatPanelFloatOnTopOption == .never + ) + } + } + + @ViewBuilder + var pluginForm: some View { + Form { + TextField(text: .init(get: { + "\(Int(settings.chatSearchPluginMaxIterations))" + }, set: { + settings.chatSearchPluginMaxIterations = Int($0) ?? 0 + })) { + Text("Search plugin max iterations") + } + .textFieldStyle(.roundedBorder) + } + } + + var languagePicker: some View { + Menu { + if !settings.chatGPTLanguage.isEmpty, + !Settings.availableLocalizedLocales + .contains(settings.chatGPTLanguage) + { + Button( + settings.chatGPTLanguage, + action: { self.settings.chatGPTLanguage = settings.chatGPTLanguage } + ) + } + Button( + "Auto-detected by LLM", + action: { self.settings.chatGPTLanguage = "" } + ) + ForEach( + Settings.availableLocalizedLocales, + id: \.self + ) { localizedLocales in + Button( + localizedLocales, + action: { self.settings.chatGPTLanguage = localizedLocales } + ) + } + } label: { + Text( + settings.chatGPTLanguage.isEmpty + ? "Auto-detected by LLM" + : settings.chatGPTLanguage + ) + } + } +} + +// MARK: - Preview + +// +// #Preview { +// ScrollView { +// ChatSettingsView() +// .padding() +// } +// .frame(height: 800) +// .environment(\.overrideFeatureFlag, \.never) +// } +// + diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsView.swift new file mode 100644 index 00000000..8540b9d2 --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsView.swift @@ -0,0 +1,38 @@ +import Preferences +import SharedUIComponents +import SwiftUI + +struct ChatSettingsView: View { + enum Tab { + case general + } + + @State var tabSelection: Tab = .general + + var body: some View { + VStack(spacing: 0) { + Picker("", selection: $tabSelection) { + Text("General").tag(Tab.general) + } + .pickerStyle(.segmented) + .padding(8) + + Divider() + .shadow(radius: 10) + + ScrollView { + Group { + switch tabSelection { + case .general: + ChatSettingsGeneralSectionView() + } + }.padding() + } + } + } +} + +#Preview { + ChatSettingsView() + .frame(width: 600, height: 500) +} diff --git a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift deleted file mode 100644 index 1c56d883..00000000 --- a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift +++ /dev/null @@ -1,247 +0,0 @@ -import Preferences -import SwiftUI - -struct ChatSettingsView: View { - class Settings: ObservableObject { - static let availableLocalizedLocales = Locale.availableLocalizedLocales - @AppStorage(\.chatGPTLanguage) var chatGPTLanguage - @AppStorage(\.chatGPTMaxToken) var chatGPTMaxToken - @AppStorage(\.chatGPTTemperature) var chatGPTTemperature - @AppStorage(\.chatGPTMaxMessageCount) var chatGPTMaxMessageCount - @AppStorage(\.chatFontSize) var chatFontSize - @AppStorage(\.chatCodeFontSize) var chatCodeFontSize - @AppStorage(\.embedFileContentInChatContextIfNoSelection) - var embedFileContentInChatContextIfNoSelection - @AppStorage(\.maxEmbeddableFileInChatContextLineCount) - var maxEmbeddableFileInChatContextLineCount - @AppStorage(\.useSelectionScopeByDefaultInChatContext) - var useSelectionScopeByDefaultInChatContext - - @AppStorage(\.chatFeatureProvider) var chatFeatureProvider - @AppStorage(\.chatGPTModel) var chatGPTModel - - init() {} - } - - @Environment(\.openURL) var openURL - @Environment(\.toast) var toast - @StateObject var settings = Settings() - @State var maxTokenOverLimit = false - - var body: some View { - VStack { - chatSettingsForm - Divider() - uiForm - Divider() - contextForm - } - } - - @ViewBuilder - var chatSettingsForm: some View { - Form { - Picker( - "Feature Provider", - selection: $settings.chatFeatureProvider - ) { - Text("OpenAI").tag(ChatFeatureProvider.openAI) - Text("Azure OpenAI").tag(ChatFeatureProvider.azureOpenAI) - } - - if #available(macOS 13.0, *) { - LabeledContent("Reply in Language") { - languagePicker - } - } else { - HStack { - Text("Reply in Language") - languagePicker - } - } - - let binding = Binding( - get: { String(settings.chatGPTMaxToken) }, - set: { - if let selectionMaxToken = Int($0) { - settings.chatGPTMaxToken = selectionMaxToken - } else { - settings.chatGPTMaxToken = 0 - } - } - ) - HStack { - Stepper( - value: $settings.chatGPTMaxToken, - in: 0...Int.max, - step: 1 - ) { - Text("Max Token (Including Reply)") - .multilineTextAlignment(.trailing) - } - TextField(text: binding) { - EmptyView() - } - .labelsHidden() - .textFieldStyle(.roundedBorder) - .foregroundColor(maxTokenOverLimit ? .red : .primary) - - if let model = ChatGPTModel(rawValue: settings.chatGPTModel), - settings.chatFeatureProvider == .openAI - { - Text("Max: \(model.maxToken)") - } - } - - HStack { - Slider(value: $settings.chatGPTTemperature, in: 0...2, step: 0.1) { - Text("Temperature") - } - - Text( - "\(settings.chatGPTTemperature.formatted(.number.precision(.fractionLength(1))))" - ) - .font(.body) - .monospacedDigit() - .padding(.vertical, 2) - .padding(.horizontal, 6) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color.primary.opacity(0.1)) - ) - } - - Picker( - "Memory", - selection: $settings.chatGPTMaxMessageCount - ) { - Text("No Limit").tag(0) - Text("3 Messages").tag(3) - Text("5 Messages").tag(5) - Text("7 Messages").tag(7) - Text("9 Messages").tag(9) - Text("11 Messages").tag(11) - } - }.onAppear { - checkMaxToken() - }.onChange(of: settings.chatFeatureProvider) { _ in - checkMaxToken() - }.onChange(of: settings.chatGPTModel) { _ in - checkMaxToken() - }.onChange(of: settings.chatGPTMaxToken) { _ in - checkMaxToken() - } - } - - @ViewBuilder - var uiForm: some View { - Form { - HStack { - TextField(text: .init(get: { - "\(Int(settings.chatFontSize))" - }, set: { - settings.chatFontSize = Double(Int($0) ?? 0) - })) { - Text("Font size of message") - } - .textFieldStyle(.roundedBorder) - - Text("pt") - } - - HStack { - TextField(text: .init(get: { - "\(Int(settings.chatCodeFontSize))" - }, set: { - settings.chatCodeFontSize = Double(Int($0) ?? 0) - })) { - Text("Font size of code block") - } - .textFieldStyle(.roundedBorder) - - Text("pt") - } - } - } - - @ViewBuilder - var contextForm: some View { - Form { - Toggle(isOn: $settings.useSelectionScopeByDefaultInChatContext) { - Text("Use selection scope by default in chat context.") - } - - Toggle(isOn: $settings.embedFileContentInChatContextIfNoSelection) { - Text("Embed file content in chat context if no code is selected.") - } - - HStack { - TextField(text: .init(get: { - "\(Int(settings.maxEmbeddableFileInChatContextLineCount))" - }, set: { - settings.maxEmbeddableFileInChatContextLineCount = Int($0) ?? 0 - })) { - Text("Max embeddable file") - } - .textFieldStyle(.roundedBorder) - - Text("lines") - } - } - } - - var languagePicker: some View { - Menu { - if !settings.chatGPTLanguage.isEmpty, - !Settings.availableLocalizedLocales - .contains(settings.chatGPTLanguage) - { - Button( - settings.chatGPTLanguage, - action: { self.settings.chatGPTLanguage = settings.chatGPTLanguage } - ) - } - Button( - "Auto-detected by ChatGPT", - action: { self.settings.chatGPTLanguage = "" } - ) - ForEach( - Settings.availableLocalizedLocales, - id: \.self - ) { localizedLocales in - Button( - localizedLocales, - action: { self.settings.chatGPTLanguage = localizedLocales } - ) - } - } label: { - Text( - settings.chatGPTLanguage.isEmpty - ? "Auto-detected by ChatGPT" - : settings.chatGPTLanguage - ) - } - } - - func checkMaxToken() { - switch settings.chatFeatureProvider { - case .openAI: - if let model = ChatGPTModel(rawValue: settings.chatGPTModel) { - maxTokenOverLimit = model.maxToken < settings.chatGPTMaxToken - } else { - maxTokenOverLimit = false - } - case .azureOpenAI: - maxTokenOverLimit = false - } - } -} - -// MARK: - Preview - -struct ChatSettingsView_Previews: PreviewProvider { - static var previews: some View { - ChatSettingsView() - } -} - diff --git a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift index ecb0a5c4..f9c7f545 100644 --- a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift @@ -1,17 +1,24 @@ +import Preferences +import SharedUIComponents import SwiftUI struct PromptToCodeSettingsView: View { final class Settings: ObservableObject { - @AppStorage(\.hideCommonPrecedingSpacesInSuggestion) - var hideCommonPrecedingSpacesInSuggestion - @AppStorage(\.suggestionCodeFontSize) - var suggestionCodeFontSize - @AppStorage(\.acceptSuggestionWithAccessibilityAPI) - var acceptSuggestionWithAccessibilityAPI + @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) + var hideCommonPrecedingSpaces + @AppStorage(\.promptToCodeCodeFont) + var font @AppStorage(\.promptToCodeGenerateDescription) var promptToCodeGenerateDescription @AppStorage(\.promptToCodeGenerateDescriptionInUserPreferredLanguage) var promptToCodeGenerateDescriptionInUserPreferredLanguage + @AppStorage(\.promptToCodeChatModelId) + var promptToCodeChatModelId + @AppStorage(\.promptToCodeEmbeddingModelId) + var promptToCodeEmbeddingModelId + @AppStorage(\.wrapCodeInPromptToCode) var wrapCode + @AppStorage(\.chatModels) var chatModels + @AppStorage(\.embeddingModels) var embeddingModels init() {} } @@ -20,57 +27,52 @@ struct PromptToCodeSettingsView: View { var body: some View { VStack(alignment: .center) { Form { - Toggle(isOn: $settings.promptToCodeGenerateDescription) { - Text("Generate Description") - } + Picker( + "Chat model", + selection: $settings.promptToCodeChatModelId + ) { + Text("Same as chat feature").tag("") + + if !settings.chatModels + .contains(where: { $0.id == settings.promptToCodeChatModelId }), + !settings.promptToCodeChatModelId.isEmpty + { + Text( + (settings.chatModels.first?.name).map { "\($0) (Default)" } + ?? "No model found" + ) + .tag(settings.promptToCodeChatModelId) + } - Toggle(isOn: $settings.promptToCodeGenerateDescriptionInUserPreferredLanguage) { - Text("Generate Description in user preferred language") + ForEach(settings.chatModels, id: \.id) { chatModel in + Text(chatModel.name).tag(chatModel.id) + } } } - Divider() - - Text("Mirroring Settings of Suggestion Feature") - .foregroundColor(.white) - .padding(.vertical, 2) - .padding(.horizontal, 8) - .background( - Color.accentColor, - in: RoundedRectangle(cornerRadius: 20) - ) + SettingsDivider("UI") Form { - Toggle(isOn: $settings.hideCommonPrecedingSpacesInSuggestion) { - Text("Hide Common Preceding Spaces") - }.disabled(true) - - HStack { - TextField(text: .init(get: { - "\(Int(settings.suggestionCodeFontSize))" - }, set: { - settings.suggestionCodeFontSize = Double(Int($0) ?? 0) - })) { - Text("Font size of suggestion code") - } - .textFieldStyle(.roundedBorder) + Toggle(isOn: $settings.hideCommonPrecedingSpaces) { + Text("Hide common preceding spaces") + } - Text("pt") - }.disabled(true) + Toggle(isOn: $settings.wrapCode) { + Text("Wrap code") + } - Divider() + CodeHighlightThemePicker(scenario: .promptToCode) - Toggle(isOn: $settings.acceptSuggestionWithAccessibilityAPI) { - Text("Use accessibility API to accept suggestion in widget") - }.disabled(true) + FontPicker(font: $settings.font) { + Text("Font") + } } } } } -struct PromptToCodeSettingsView_Previews: PreviewProvider { - static var previews: some View { - PromptToCodeSettingsView() - } +#Preview { + PromptToCodeSettingsView() + .padding() } diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionFeatureDisabledLanguageListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift similarity index 88% rename from Core/Sources/HostApp/FeatureSettings/SuggestionFeatureDisabledLanguageListView.swift rename to Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift index 0862467f..6d894cfd 100644 --- a/Core/Sources/HostApp/FeatureSettings/SuggestionFeatureDisabledLanguageListView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift @@ -1,5 +1,6 @@ -import SuggestionModel +import SuggestionBasic import SwiftUI +import SharedUIComponents struct SuggestionFeatureDisabledLanguageListView: View { final class Settings: ObservableObject { @@ -28,16 +29,8 @@ struct SuggestionFeatureDisabledLanguageListView: View { .padding() } .buttonStyle(.plain) - Text("Enabled Projects") + Text("Disabled Languages") Spacer() - Button(action: { - isAddingNewProject = true - }) { - Image(systemName: "plus.circle.fill") - .foregroundStyle(.secondary) - .padding() - } - .buttonStyle(.plain) } .background(Color(nsColor: .separatorColor)) @@ -68,15 +61,23 @@ struct SuggestionFeatureDisabledLanguageListView: View { .buttonStyle(.plain) } } + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view + } + } } .removeBackground() .overlay { if settings.suggestionFeatureDisabledLanguageList.isEmpty { Text(""" Empty - Disable the language of a file by right clicking the circular widget. + Disable the language of a file by right clicking the indicator widget. """) .multilineTextAlignment(.center) + .padding() } } } diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionFeatureEnabledProjectListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift similarity index 92% rename from Core/Sources/HostApp/FeatureSettings/SuggestionFeatureEnabledProjectListView.swift rename to Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift index 47ac7089..0cf66ca6 100644 --- a/Core/Sources/HostApp/FeatureSettings/SuggestionFeatureEnabledProjectListView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift @@ -1,3 +1,4 @@ +import SharedUIComponents import SwiftUI struct SuggestionFeatureEnabledProjectListView: View { @@ -67,6 +68,13 @@ struct SuggestionFeatureEnabledProjectListView: View { .buttonStyle(.plain) } } + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view + } + } } .removeBackground() .overlay { @@ -74,7 +82,7 @@ struct SuggestionFeatureEnabledProjectListView: View { Text(""" Empty Add project with "+" button - Or right clicking the circular widget + Or right clicking the indicator widget """) .multilineTextAlignment(.center) } diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift new file mode 100644 index 00000000..390c7f98 --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift @@ -0,0 +1,327 @@ +import Client +import Preferences +import SharedUIComponents +import SwiftUI +import XPCShared + +#if canImport(ProHostApp) +import ProHostApp +#endif + +struct SuggestionSettingsGeneralSectionView: View { + struct SuggestionFeatureProviderOption: Identifiable, Hashable { + var id: String { + (builtInProvider?.rawValue).map(String.init) ?? bundleIdentifier ?? "n/A" + } + + var name: String + var builtInProvider: BuiltInSuggestionFeatureProvider? + var bundleIdentifier: String? + + func hash(into hasher: inout Hasher) { + id.hash(into: &hasher) + } + + init( + name: String, + builtInProvider: BuiltInSuggestionFeatureProvider? = nil, + bundleIdentifier: String? = nil + ) { + self.name = name + self.builtInProvider = builtInProvider + self.bundleIdentifier = bundleIdentifier + } + } + + final class Settings: ObservableObject { + @AppStorage(\.realtimeSuggestionToggle) + var realtimeSuggestionToggle + @AppStorage(\.realtimeSuggestionDebounce) + var realtimeSuggestionDebounce + @AppStorage(\.suggestionPresentationMode) + var suggestionPresentationMode + @AppStorage(\.disableSuggestionFeatureGlobally) + var disableSuggestionFeatureGlobally + @AppStorage(\.suggestionFeatureEnabledProjectList) + var suggestionFeatureEnabledProjectList + @AppStorage(\.hideCommonPrecedingSpacesInSuggestion) + var hideCommonPrecedingSpacesInSuggestion + @AppStorage(\.suggestionCodeFont) + var font + @AppStorage(\.suggestionFeatureProvider) + var suggestionFeatureProvider + @AppStorage(\.suggestionDisplayCompactMode) + var suggestionDisplayCompactMode + @AppStorage(\.acceptSuggestionWithTab) + var acceptSuggestionWithTab + @AppStorage(\.dismissSuggestionWithEsc) + var dismissSuggestionWithEsc + + var refreshExtensionSuggestionFeatureProvidersTask: Task? + + @MainActor + @Published + var extensionSuggestionFeatureProviderOptions = [SuggestionFeatureProviderOption]() + + init() { + Task { @MainActor in + refreshExtensionSuggestionFeatureProviders() + } + refreshExtensionSuggestionFeatureProvidersTask = Task { [weak self] in + let sequence = NotificationCenter.default + .notifications(named: NSApplication.didBecomeActiveNotification) + for await _ in sequence { + guard let self else { return } + await MainActor.run { + self.refreshExtensionSuggestionFeatureProviders() + } + } + } + } + + @MainActor + func refreshExtensionSuggestionFeatureProviders() { + guard let service = try? getService() else { return } + Task { @MainActor in + let services = try await service + .send(requestBody: ExtensionServiceRequests.GetExtensionSuggestionServices()) + extensionSuggestionFeatureProviderOptions = services.map { + .init(name: $0.name, bundleIdentifier: $0.bundleIdentifier) + } + } + } + } + + @StateObject var settings = Settings() + @State var isSuggestionFeatureEnabledListPickerOpen = false + @State var isSuggestionFeatureDisabledLanguageListViewOpen = false + @State var isTabToAcceptSuggestionModifierViewOpen = false + + var body: some View { + Form { + Picker(selection: $settings.suggestionPresentationMode) { + ForEach(PresentationMode.allCases, id: \.rawValue) { + switch $0 { + case .nearbyTextCursor: + Text("Nearby text cursor").tag($0) + case .floatingWidget: + Text("Floating widget").tag($0) + } + } + } label: { + Text("Presentation") + } + + Picker(selection: Binding(get: { + switch settings.suggestionFeatureProvider { + case let .builtIn(provider): + return SuggestionFeatureProviderOption( + name: "", + builtInProvider: provider + ) + case let .extension(name, identifier): + return SuggestionFeatureProviderOption( + name: name, + bundleIdentifier: identifier + ) + } + }, set: { (option: SuggestionFeatureProviderOption) in + if let provider = option.builtInProvider { + settings.suggestionFeatureProvider = .builtIn(provider) + } else { + settings.suggestionFeatureProvider = .extension( + name: option.name, + bundleIdentifier: option.bundleIdentifier ?? "" + ) + } + })) { + ForEach(BuiltInSuggestionFeatureProvider.allCases, id: \.rawValue) { + switch $0 { + case .gitHubCopilot: + Text("GitHub Copilot") + .tag(SuggestionFeatureProviderOption(name: "", builtInProvider: $0)) + case .codeium: + Text("Codeium") + .tag(SuggestionFeatureProviderOption(name: "", builtInProvider: $0)) + } + } + + ForEach(settings.extensionSuggestionFeatureProviderOptions, id: \.self) { item in + Text(item.name).tag(item) + } + + if case let .extension(name, identifier) = settings.suggestionFeatureProvider { + if !settings.extensionSuggestionFeatureProviderOptions.contains(where: { + $0.bundleIdentifier == identifier + }) { + Text("\(name) (Not found)").tag( + SuggestionFeatureProviderOption( + name: name, + bundleIdentifier: identifier + ) + ) + } + } + } label: { + Text("Feature provider") + } + + Toggle(isOn: $settings.realtimeSuggestionToggle) { + Text("Real-time suggestion") + } + + Toggle(isOn: $settings.acceptSuggestionWithTab) { + HStack { + Text("Accept suggestion with Tab") + + Button(action: { + isTabToAcceptSuggestionModifierViewOpen = true + }) { + Image(systemName: "gearshape.fill") + } + .buttonStyle(.plain) + } + }.sheet(isPresented: $isTabToAcceptSuggestionModifierViewOpen) { + TabToAcceptSuggestionModifierView() + } + + Toggle(isOn: $settings.dismissSuggestionWithEsc) { + Text("Dismiss suggestion with ESC") + } + + HStack { + Toggle(isOn: $settings.disableSuggestionFeatureGlobally) { + Text("Disable suggestion feature globally") + } + + Button("Exception list") { + isSuggestionFeatureEnabledListPickerOpen = true + } + }.sheet(isPresented: $isSuggestionFeatureEnabledListPickerOpen) { + SuggestionFeatureEnabledProjectListView( + isOpen: $isSuggestionFeatureEnabledListPickerOpen + ) + } + + HStack { + Button("Disabled language list") { + isSuggestionFeatureDisabledLanguageListViewOpen = true + } + }.sheet(isPresented: $isSuggestionFeatureDisabledLanguageListViewOpen) { + SuggestionFeatureDisabledLanguageListView( + isOpen: $isSuggestionFeatureDisabledLanguageListViewOpen + ) + } + + HStack { + Slider(value: $settings.realtimeSuggestionDebounce, in: 0.1...2, step: 0.1) { + Text("Real-time suggestion debounce") + } + + Text( + "\(settings.realtimeSuggestionDebounce.formatted(.number.precision(.fractionLength(2))))s" + ) + .font(.body) + .monospacedDigit() + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.primary.opacity(0.1)) + ) + } + } + + SettingsDivider("UI") + + Form { + Toggle(isOn: $settings.suggestionDisplayCompactMode) { + Text("Hide buttons") + } + + Toggle(isOn: $settings.hideCommonPrecedingSpacesInSuggestion) { + Text("Hide common preceding spaces") + } + + CodeHighlightThemePicker(scenario: .suggestion) + + FontPicker(font: $settings.font) { + Text("Font") + } + } + } + + struct TabToAcceptSuggestionModifierView: View { + final class Settings: ObservableObject { + @AppStorage(\.acceptSuggestionWithModifierCommand) + var needCommand + @AppStorage(\.acceptSuggestionWithModifierOption) + var needOption + @AppStorage(\.acceptSuggestionWithModifierShift) + var needShift + @AppStorage(\.acceptSuggestionWithModifierControl) + var needControl + @AppStorage(\.acceptSuggestionWithModifierOnlyForSwift) + var onlyForSwift + @AppStorage(\.acceptSuggestionLineWithModifierControl) + var acceptLineWithControl + } + + @StateObject var settings = Settings() + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(spacing: 0) { + Form { + Text("Accept suggestion with modifier") + .font(.headline) + HStack { + Toggle(isOn: $settings.needCommand) { + Text("Command") + } + Toggle(isOn: $settings.needOption) { + Text("Option") + } + Toggle(isOn: $settings.needShift) { + Text("Shift") + } + Toggle(isOn: $settings.needControl) { + Text("Control") + } + } + Toggle(isOn: $settings.onlyForSwift) { + Text("Only require modifiers for Swift") + } + + Divider() + + Toggle(isOn: $settings.acceptLineWithControl) { + Text("Accept suggestion first line with Control") + } + } + .padding() + + Divider() + + HStack { + Spacer() + Button(action: { dismiss() }) { + Text("Done") + } + .keyboardShortcut(.defaultAction) + } + .padding() + } + } + } +} + +#Preview { + SuggestionSettingsGeneralSectionView() + .padding() +} + +#Preview { + SuggestionSettingsGeneralSectionView.TabToAcceptSuggestionModifierView() +} + diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift new file mode 100644 index 00000000..632769a4 --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift @@ -0,0 +1,53 @@ +import Client +import Preferences +import SharedUIComponents +import SwiftUI +import XPCShared + +struct SuggestionSettingsView: View { + var tabContainer: ExternalTabContainer { + ExternalTabContainer.tabContainer(for: "SuggestionSettings") + } + + enum Tab: Hashable { + case general + case other(String) + } + + @State var tabSelection: Tab = .general + + var body: some View { + VStack(spacing: 0) { + Picker("", selection: $tabSelection) { + Text("General").tag(Tab.general) + ForEach(tabContainer.tabs, id: \.id) { tab in + Text(tab.title).tag(Tab.other(tab.id)) + } + } + .pickerStyle(.segmented) + .padding(8) + + Divider() + .shadow(radius: 10) + + ScrollView { + Group { + switch tabSelection { + case .general: + SuggestionSettingsGeneralSectionView() + case let .other(id): + tabContainer.tabView(for: id) + } + }.padding() + } + } + } +} + +struct SuggestionSettingsView_Previews: PreviewProvider { + static var previews: some View { + SuggestionSettingsView() + .frame(width: 600, height: 500) + } +} + diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift deleted file mode 100644 index f24ee2b3..00000000 --- a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Preferences -import SwiftUI - -struct SuggestionSettingsView: View { - final class Settings: ObservableObject { - @AppStorage(\.realtimeSuggestionToggle) - var realtimeSuggestionToggle - @AppStorage(\.realtimeSuggestionDebounce) - var realtimeSuggestionDebounce - @AppStorage(\.suggestionPresentationMode) - var suggestionPresentationMode - @AppStorage(\.acceptSuggestionWithAccessibilityAPI) - var acceptSuggestionWithAccessibilityAPI - @AppStorage(\.disableSuggestionFeatureGlobally) - var disableSuggestionFeatureGlobally - @AppStorage(\.suggestionFeatureEnabledProjectList) - var suggestionFeatureEnabledProjectList - @AppStorage(\.hideCommonPrecedingSpacesInSuggestion) - var hideCommonPrecedingSpacesInSuggestion - @AppStorage(\.suggestionCodeFontSize) - var suggestionCodeFontSize - @AppStorage(\.suggestionFeatureProvider) - var suggestionFeatureProvider - init() {} - } - - @StateObject var settings = Settings() - @State var isSuggestionFeatureEnabledListPickerOpen = false - @State var isSuggestionFeatureDisabledLanguageListViewOpen = false - - var body: some View { - Form { - Group { - Picker(selection: $settings.suggestionPresentationMode) { - ForEach(PresentationMode.allCases, id: \.rawValue) { - switch $0 { - case .comment: - Text("Comment (Deprecating Soon)").tag($0) - case .floatingWidget: - Text("Floating Widget").tag($0) - } - } - } label: { - Text("Presentation") - } - - Picker(selection: $settings.suggestionFeatureProvider) { - ForEach(SuggestionFeatureProvider.allCases, id: \.rawValue) { - switch $0 { - case .gitHubCopilot: - Text("GitHub Copilot").tag($0) - case .codeium: - Text("Codeium").tag($0) - } - } - } label: { - Text("Feature Provider") - } - - Toggle(isOn: $settings.realtimeSuggestionToggle) { - Text("Real-time suggestion") - } - - HStack { - Toggle(isOn: $settings.disableSuggestionFeatureGlobally) { - Text("Disable Suggestion Feature Globally") - } - - Button("Exception List") { - isSuggestionFeatureEnabledListPickerOpen = true - } - }.sheet(isPresented: $isSuggestionFeatureEnabledListPickerOpen) { - SuggestionFeatureEnabledProjectListView( - isOpen: $isSuggestionFeatureEnabledListPickerOpen - ) - } - - HStack { - Button("Disabled Language List") { - isSuggestionFeatureDisabledLanguageListViewOpen = true - } - }.sheet(isPresented: $isSuggestionFeatureDisabledLanguageListViewOpen) { - SuggestionFeatureDisabledLanguageListView( - isOpen: $isSuggestionFeatureDisabledLanguageListViewOpen - ) - } - - HStack { - Slider(value: $settings.realtimeSuggestionDebounce, in: 0...2, step: 0.1) { - Text("Real-time Suggestion Debounce") - } - - Text( - "\(settings.realtimeSuggestionDebounce.formatted(.number.precision(.fractionLength(2))))s" - ) - .font(.body) - .monospacedDigit() - .padding(.vertical, 2) - .padding(.horizontal, 6) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color.primary.opacity(0.1)) - ) - } - - Divider() - } - - Group { - Toggle(isOn: $settings.hideCommonPrecedingSpacesInSuggestion) { - Text("Hide Common Preceding Spaces") - } - - HStack { - TextField(text: .init(get: { - "\(Int(settings.suggestionCodeFontSize))" - }, set: { - settings.suggestionCodeFontSize = Double(Int($0) ?? 0) - })) { - Text("Font size of suggestion code") - } - .textFieldStyle(.roundedBorder) - - Text("pt") - } - 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) - } - } - } -} - -struct SuggestionSettingsView_Previews: PreviewProvider { - static var previews: some View { - SuggestionSettingsView() - } -} - diff --git a/Core/Sources/HostApp/FeatureSettings/TerminalSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/TerminalSettingsView.swift new file mode 100644 index 00000000..43a8a539 --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettings/TerminalSettingsView.swift @@ -0,0 +1,27 @@ +import Preferences +import SharedUIComponents +import SwiftUI + +#if canImport(ProHostApp) +import ProHostApp +#endif + +struct TerminalSettingsView: View { + class Settings: ObservableObject { + @AppStorage(\.terminalFont) var terminalFont + init() {} + } + + @StateObject var settings = Settings() + + var body: some View { + ScrollView { + Form { + FontPicker(font: $settings.terminalFont) { + Text("Font of code") + } + } + } + + } +} diff --git a/Core/Sources/HostApp/FeatureSettings/XcodeSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/XcodeSettingsView.swift new file mode 100644 index 00000000..198aae19 --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettings/XcodeSettingsView.swift @@ -0,0 +1,20 @@ +import Foundation +import SharedUIComponents +import SwiftUI + +#if canImport(ProHostApp) +import ProHostApp +#endif + +struct XcodeSettingsView: View { + var body: some View { + VStack { + #if canImport(ProHostApp) + CloseXcodeIdleTabsSettingsView() + #endif + + EmptyView() + } + } +} + diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift index 96b86319..e8c1e38f 100644 --- a/Core/Sources/HostApp/FeatureSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettingsView.swift @@ -1,42 +1,62 @@ import SwiftUI +import SharedUIComponents struct FeatureSettingsView: View { + var tabContainer: ExternalTabContainer { + ExternalTabContainer.tabContainer(for: "Features") + } + @State var tag = 0 var body: some View { - SidebarTabView(tag: $tag) { - ScrollView { - SuggestionSettingsView() - } - .padding() - .sidebarItem( - tag: 0, - title: "Suggestion", - subtitle: "Generate suggestions for your code", - image: "lightbulb" - ) + SidebarTabView(tag: $tag) { + SuggestionSettingsView() + .sidebarItem( + tag: 0, + title: "Suggestion", + subtitle: "Generate suggestions for your code", + image: "lightbulb" + ) + + ChatSettingsView() + .sidebarItem( + tag: 1, + title: "Chat", + subtitle: "Chat about your code", + image: "character.bubble" + ) ScrollView { - ChatSettingsView() + PromptToCodeSettingsView().padding() } - .padding() .sidebarItem( - tag: 1, - title: "Chat", - subtitle: "Chat about your code", - image: "character.bubble" + tag: 2, + title: "Modification", + subtitle: "Write or modify code with natural language", + image: "paintbrush" ) ScrollView { - PromptToCodeSettingsView() + XcodeSettingsView().padding() } - .padding() .sidebarItem( - tag: 2, - title: "Prompt to Code", - subtitle: "Write code with natural language", - image: "paintbrush" + tag: 3, + title: "Xcode", + subtitle: "Xcode related features", + image: "hammer.circle" ) + + ForEach(Array(tabContainer.tabs.enumerated()), id: \.1.id) { index, tab in + ScrollView { + tab.viewBuilder().padding() + } + .sidebarItem( + tag: 4 + index, + title: tab.title, + subtitle: tab.description, + image: tab.image + ) + } } } } @@ -47,4 +67,3 @@ struct FeatureSettingsView_Previews: PreviewProvider { .frame(width: 800) } } - diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift new file mode 100644 index 00000000..96ade16c --- /dev/null +++ b/Core/Sources/HostApp/General.swift @@ -0,0 +1,293 @@ +import Client +import ComposableArchitecture +import Foundation +import LaunchAgentManager +import SwiftUI +import XPCShared + +@Reducer +struct General { + @ObservableState + struct State: Equatable { + var xpcServiceVersion: String? + var isAccessibilityPermissionGranted: Bool? + var isReloading = false + @Presents var alert: AlertState? + } + + enum Action { + case appear + case setupLaunchAgentIfNeeded + case setupLaunchAgentClicked + case removeLaunchAgentClicked + case reloadLaunchAgentClicked + case openExtensionManager + case reloadStatus + case finishReloading(xpcServiceVersion: String, permissionGranted: Bool) + case failedReloading + case alert(PresentationAction) + + case setupLaunchAgent + case finishSetupLaunchAgent + case finishRemoveLaunchAgent + case finishReloadLaunchAgent + + @CasePathable + enum Alert: Equatable { + case moveToApplications + case moveTo(URL) + case install + } + } + + @Dependency(\.toast) var toast + + struct ReloadStatusCancellableId: Hashable {} + + static var didWarnInstallationPosition: Bool { + get { UserDefaults.standard.bool(forKey: "didWarnInstallationPosition") } + set { UserDefaults.standard.set(newValue, forKey: "didWarnInstallationPosition") } + } + + static var bundleIsInApplicationsFolder: Bool { + Bundle.main.bundleURL.path.hasPrefix("/Applications") + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .appear: + if Self.bundleIsInApplicationsFolder { + return .run { send in + await send(.setupLaunchAgentIfNeeded) + } + } + + if !Self.didWarnInstallationPosition { + Self.didWarnInstallationPosition = true + state.alert = .init { + TextState("Move to Applications Folder?") + } actions: { + ButtonState(action: .moveToApplications) { + TextState("Move") + } + ButtonState(role: .cancel) { + TextState("Not Now") + } + } message: { + TextState( + "To ensure the best experience, please move the app to the Applications folder. If the app is not inside the Applications folder, please set up the launch agent manually by clicking the button." + ) + } + } + + return .none + + case .setupLaunchAgentIfNeeded: + return .run { send in + #if DEBUG + // do not auto install on debug build + #else + do { + try await LaunchAgentManager() + .setupLaunchAgentForTheFirstTimeIfNeeded() + } catch { + toast(error.localizedDescription, .error) + } + #endif + await send(.reloadStatus) + } + + case .setupLaunchAgentClicked: + if Self.bundleIsInApplicationsFolder { + return .run { send in + await send(.setupLaunchAgent) + } + } + + state.alert = .init { + TextState("Setup Launch Agent") + } actions: { + ButtonState(action: .install) { + TextState("Setup") + } + + ButtonState(action: .moveToApplications) { + TextState("Move to Applications Folder") + } + + ButtonState(role: .cancel) { + TextState("Cancel") + } + } message: { + TextState( + "It's recommended to move the app into the Applications folder. But you can still keep it in the current folder and install the launch agent to ~/Library/LaunchAgents." + ) + } + + return .none + + case .removeLaunchAgentClicked: + return .run { send in + do { + try await LaunchAgentManager().removeLaunchAgent() + await send(.finishRemoveLaunchAgent) + } catch { + toast(error.localizedDescription, .error) + } + await send(.reloadStatus) + } + + case .reloadLaunchAgentClicked: + return .run { send in + do { + try await LaunchAgentManager().reloadLaunchAgent() + await send(.finishReloadLaunchAgent) + } catch { + toast(error.localizedDescription, .error) + } + await send(.reloadStatus) + } + + case .setupLaunchAgent: + return .run { send in + do { + try await LaunchAgentManager().setupLaunchAgent() + await send(.finishSetupLaunchAgent) + } catch { + toast(error.localizedDescription, .error) + } + await send(.reloadStatus) + } + + case .finishSetupLaunchAgent: + state.alert = .init { + TextState("Launch Agent Installed") + } actions: { + ButtonState { + TextState("OK") + } + } message: { + TextState( + "The launch agent has been installed. Please restart the app." + ) + } + return .none + + case .finishRemoveLaunchAgent: + state.alert = .init { + TextState("Launch Agent Removed") + } actions: { + ButtonState { + TextState("OK") + } + } message: { + TextState( + "The launch agent has been removed." + ) + } + return .none + + case .finishReloadLaunchAgent: + state.alert = .init { + TextState("Launch Agent Reloaded") + } actions: { + ButtonState { + TextState("OK") + } + } message: { + TextState( + "The launch agent has been reloaded." + ) + } + return .none + + case .openExtensionManager: + return .run { send in + let service = try getService() + do { + _ = try await service + .send(requestBody: ExtensionServiceRequests.OpenExtensionManager()) + } catch { + toast(error.localizedDescription, .error) + await send(.failedReloading) + } + } + + case .reloadStatus: + state.isReloading = true + return .run { send in + let service = try getService() + do { + let isCommunicationReady = try await service.launchIfNeeded() + if isCommunicationReady { + let xpcServiceVersion = try await service.getXPCServiceVersion().version + let isAccessibilityPermissionGranted = try await service + .getXPCServiceAccessibilityPermission() + await send(.finishReloading( + xpcServiceVersion: xpcServiceVersion, + permissionGranted: isAccessibilityPermissionGranted + )) + } else { + toast("Launching service app.", .info) + try await Task.sleep(nanoseconds: 5_000_000_000) + await send(.reloadStatus) + } + } catch let error as XPCCommunicationBridgeError { + toast( + "Failed to reach communication bridge. \(error.localizedDescription)", + .error + ) + await send(.failedReloading) + } catch { + toast(error.localizedDescription, .error) + await send(.failedReloading) + } + }.cancellable(id: ReloadStatusCancellableId(), cancelInFlight: true) + + case let .finishReloading(version, granted): + state.xpcServiceVersion = version + state.isAccessibilityPermissionGranted = granted + state.isReloading = false + return .none + + case .failedReloading: + state.isReloading = false + return .none + + case let .alert(.presented(action)): + switch action { + case .moveToApplications: + return .run { send in + let appURL = URL(fileURLWithPath: "/Applications") + await send(.alert(.presented(.moveTo(appURL)))) + } + + case let .moveTo(url): + return .run { _ in + do { + try FileManager.default.moveItem( + at: Bundle.main.bundleURL, + to: url.appendingPathComponent( + Bundle.main.bundleURL.lastPathComponent + ) + ) + await NSApplication.shared.terminate(nil) + } catch { + toast(error.localizedDescription, .error) + } + } + case .install: + return .run { send in + await send(.setupLaunchAgent) + } + } + + case .alert(.dismiss): + state.alert = nil + return .none + } + } + } +} + diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index a0cf366b..b69c0127 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -1,223 +1,186 @@ import Client +import ComposableArchitecture +import KeyboardShortcuts import LaunchAgentManager import Preferences +import SharedUIComponents import SwiftUI struct GeneralView: View { + let store: StoreOf + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { - AppInfoView() - Divider() - ExtensionServiceView() - Divider() - LaunchAgentView() - Divider() + AppInfoView(store: store) + SettingsDivider() + ExtensionServiceView(store: store) + SettingsDivider() + LaunchAgentView(store: store) + SettingsDivider() GeneralSettingsView() } } + .onAppear { + store.send(.appear) + } } } struct AppInfoView: View { @State var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String @Environment(\.updateChecker) var updateChecker + @Perception.Bindable var store: StoreOf var body: some View { - VStack(alignment: .leading) { - HStack(alignment: .top) { - Text( - Bundle.main - .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String - ?? "Copilot for Xcode" - ) - .font(.title) - Text(appVersion ?? "") - .font(.footnote) - .foregroundColor(.secondary) - - Spacer() + WithPerceptionTracking { + VStack(alignment: .leading) { + HStack(alignment: .top) { + Text( + Bundle.main + .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String + ?? "Copilot for Xcode" + ) + .font(.title) + Text(appVersion ?? "") + .font(.footnote) + .foregroundColor(.secondary) - Button(action: { - updateChecker.checkForUpdates() - }) { - HStack(spacing: 2) { - Image(systemName: "arrow.up.right.circle.fill") - Text("Check for Updates") + Spacer() + + Button(action: { + store.send(.openExtensionManager) + }) { + HStack(spacing: 2) { + Image(systemName: "puzzlepiece.extension.fill") + Text("Extensions") + } } - } - } - HStack(spacing: 16) { - Link( - destination: URL(string: "https://github.com/intitni/CopilotForXcode")! - ) { - HStack(spacing: 2) { - Image(systemName: "link") - Text("GitHub") + Button(action: { + updateChecker.checkForUpdates() + }) { + HStack(spacing: 2) { + Image(systemName: "arrow.up.right.circle.fill") + Text("Check for Updates") + } } } - .focusable(false) - .foregroundColor(.accentColor) - Link(destination: URL(string: "https://www.buymeacoffee.com/intitni")!) { - HStack(spacing: 2) { - Image(systemName: "cup.and.saucer.fill") - Text("Buy Me A Coffee") + HStack(spacing: 16) { + Link( + destination: URL(string: "https://github.com/intitni/CopilotForXcode")! + ) { + HStack(spacing: 2) { + Image(systemName: "link") + Text("GitHub") + } + } + .focusable(false) + .foregroundColor(.accentColor) + + Link(destination: URL(string: "https://www.buymeacoffee.com/intitni")!) { + HStack(spacing: 2) { + Image(systemName: "cup.and.saucer.fill") + Text("Buy Me A Coffee") + } } + .foregroundColor(.accentColor) + .focusable(false) } - .foregroundColor(.accentColor) - .focusable(false) } - }.padding() + .padding() + .alert($store.scope(state: \.alert, action: \.alert)) + } } } struct ExtensionServiceView: View { - @Environment(\.toast) var toast - @State var xpcServiceVersion: String? - @State var isAccessibilityPermissionGranted: Bool? - @State var isRunningAction = false + @Perception.Bindable var store: StoreOf var body: some View { - VStack(alignment: .leading) { - Text("Extension Service Version: \(xpcServiceVersion ?? "Loading..")") - let grantedStatus: String = { - guard let isAccessibilityPermissionGranted else { return "Loading.." } - return isAccessibilityPermissionGranted ? "Granted" : "Not Granted" - }() - Text("Accessibility Permission: \(grantedStatus)") - - HStack { - Button(action: { checkStatus() }) { - Text("Refresh") - }.disabled(isRunningAction) + WithPerceptionTracking { + VStack(alignment: .leading) { + Text("Extension Service Version: \(store.xpcServiceVersion ?? "Loading..")") - Button(action: { - Task { - let workspace = NSWorkspace.shared - let url = Bundle.main.bundleURL - .appendingPathComponent("Contents") - .appendingPathComponent("Applications") - .appendingPathComponent("CopilotForXcodeExtensionService.app") - workspace.activateFileViewerSelecting([url]) + let grantedStatus: String = { + guard let granted = store.isAccessibilityPermissionGranted + else { return "Loading.." } + return granted ? "Granted" : "Not Granted" + }() + Text("Accessibility Permission: \(grantedStatus)") + + HStack { + Button(action: { store.send(.reloadStatus) }) { + Text("Refresh") + }.disabled(store.isReloading) + + Button(action: { + Task { + let workspace = NSWorkspace.shared + let url = Bundle.main.bundleURL + .appendingPathComponent("Contents") + .appendingPathComponent("Applications") + .appendingPathComponent("CopilotForXcodeExtensionService.app") + workspace.activateFileViewerSelecting([url]) + } + }) { + Text("Reveal Extension Service in Finder") } - }) { - Text("Reveal Extension Service in Finder") - } - Button(action: { - let url = URL( - string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" - )! - NSWorkspace.shared.open(url) - }) { - Text("Accessibility Settings") - } + Button(action: { + let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + )! + NSWorkspace.shared.open(url) + }) { + Text("Accessibility Settings") + } - Button(action: { - let url = URL( - string: "x-apple.systempreferences:com.apple.ExtensionsPreferences" - )! - NSWorkspace.shared.open(url) - }) { - Text("Extensions Settings") + Button(action: { + let url = URL( + string: "x-apple.systempreferences:com.apple.ExtensionsPreferences" + )! + NSWorkspace.shared.open(url) + }) { + Text("Extensions Settings") + } } } } .padding() - .onAppear { - checkStatus() - } - } - - func checkStatus() { - Task { - try await Task.sleep(nanoseconds: 2_000_000_000) - isRunningAction = true - defer { isRunningAction = false } - do { - let service = try getService() - xpcServiceVersion = try await service.getXPCServiceVersion().version - isAccessibilityPermissionGranted = try await service - .getXPCServiceAccessibilityPermission() - } catch { - toast(Text(error.localizedDescription), .error) - } - } } } struct LaunchAgentView: View { + @Perception.Bindable var store: StoreOf @Environment(\.toast) var toast - @State var isDidRemoveLaunchAgentAlertPresented = false - @State var isDidSetupLaunchAgentAlertPresented = false - @State var isDidRestartLaunchAgentAlertPresented = false var body: some View { - VStack(alignment: .leading) { - HStack { - Button(action: { - Task { - do { - try await LaunchAgentManager().setupLaunchAgent() - isDidSetupLaunchAgentAlertPresented = true - } catch { - toast(Text(error.localizedDescription), .error) - } + WithPerceptionTracking { + VStack(alignment: .leading) { + HStack { + Button(action: { + store.send(.setupLaunchAgentClicked) + }) { + Text("Setup Launch Agent") } - }) { - Text("Set Up Launch Agent") - } - .alert(isPresented: $isDidSetupLaunchAgentAlertPresented) { - .init( - title: Text("Finished Launch Agent Setup"), - message: Text( - "Please refresh the Copilot status. (The first refresh may fail)" - ), - dismissButton: .default(Text("OK")) - ) - } - Button(action: { - Task { - do { - try await LaunchAgentManager().removeLaunchAgent() - isDidRemoveLaunchAgentAlertPresented = true - } catch { - toast(Text(error.localizedDescription), .error) - } + Button(action: { + store.send(.removeLaunchAgentClicked) + }) { + Text("Remove Launch Agent") } - }) { - Text("Remove Launch Agent") - } - .alert(isPresented: $isDidRemoveLaunchAgentAlertPresented) { - .init( - title: Text("Launch Agent Removed"), - dismissButton: .default(Text("OK")) - ) - } - Button(action: { - Task { - do { - try await LaunchAgentManager().reloadLaunchAgent() - isDidRestartLaunchAgentAlertPresented = true - } catch { - toast(Text(error.localizedDescription), .error) - } + Button(action: { + store.send(.reloadLaunchAgentClicked) + }) { + Text("Reload Launch Agent") } - }) { - Text("Reload Launch Agent") - }.alert(isPresented: $isDidRestartLaunchAgentAlertPresented) { - .init( - title: Text("Launch Agent Reloaded"), - dismissButton: .default(Text("OK")) - ) } } + .padding() } - .padding() } } @@ -231,10 +194,17 @@ struct GeneralSettingsView: View { var widgetColorScheme @AppStorage(\.preferWidgetToStayInsideEditorWhenWidthGreaterThan) var preferWidgetToStayInsideEditorWhenWidthGreaterThan + @AppStorage(\.hideCircularWidget) + var hideCircularWidget + @AppStorage(\.showHideWidgetShortcutGlobally) + var showHideWidgetShortcutGlobally + @AppStorage(\.installBetaBuilds) + var installBetaBuilds } @StateObject var settings = Settings() @Environment(\.updateChecker) var updateChecker + @State var automaticallyCheckForUpdate: Bool? var body: some View { Form { @@ -243,12 +213,19 @@ struct GeneralSettingsView: View { } Toggle(isOn: .init( - get: { updateChecker.automaticallyChecksForUpdates }, - set: { updateChecker.automaticallyChecksForUpdates = $0 } + get: { automaticallyCheckForUpdate ?? updateChecker.automaticallyChecksForUpdates }, + set: { + updateChecker.automaticallyChecksForUpdates = $0 + automaticallyCheckForUpdate = $0 + } )) { Text("Automatically Check for Update") } + Toggle(isOn: $settings.installBetaBuilds) { + Text("Install beta builds") + } + Picker(selection: $settings.suggestionWidgetPositionMode) { ForEach(SuggestionWidgetPositionMode.allCases, id: \.rawValue) { switch $0 { @@ -292,13 +269,93 @@ struct GeneralSettingsView: View { Text("pt") } + + KeyboardShortcuts.Recorder("Hotkey to Toggle Widgets", name: .showHideWidget) { _ in + // It's not used in this app! + KeyboardShortcuts.disable(.showHideWidget) + } + + Toggle(isOn: $settings.showHideWidgetShortcutGlobally) { + Text("Enable the Hotkey Globally") + } + + Toggle(isOn: $settings.hideCircularWidget) { + Text("Hide indicator widget") + } }.padding() } } +struct WidgetPositionIcon: View { + var position: SuggestionWidgetPositionMode + var isSelected: Bool + + var body: some View { + ZStack { + Rectangle() + .fill(Color(nsColor: .textBackgroundColor)) + Rectangle() + .fill(Color.accentColor.opacity(0.2)) + .frame(width: 120, height: 20) + } + .frame(width: 120, height: 80) + } +} + +struct LargeIconPicker< + Data: RandomAccessCollection, + ID: Hashable, + Content: View, + Label: View +>: View { + @Binding var selection: Data.Element + var data: Data + var id: KeyPath + var builder: (Data.Element, _ isSelected: Bool) -> Content + var label: () -> Label + + @ViewBuilder + var content: some View { + HStack { + ForEach(data, id: id) { item in + let isSelected = selection[keyPath: id] == item[keyPath: id] + Button(action: { + selection = item + }) { + builder(item, isSelected) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke( + isSelected ? Color.accentColor : Color.primary.opacity(0.1), + style: .init(lineWidth: 2) + ) + } + }.buttonStyle(.plain) + } + } + } + + var body: some View { + if #available(macOS 13.0, *) { + LabeledContent { + content + } label: { + label() + } + } else { + VStack { + label() + content + } + } + } +} + struct GeneralView_Previews: PreviewProvider { static var previews: some View { - GeneralView() + GeneralView(store: .init(initialState: .init(), reducer: { General() })) + .frame(height: 800) } } diff --git a/Core/Sources/HostApp/HandleToast.swift b/Core/Sources/HostApp/HandleToast.swift new file mode 100644 index 00000000..564fdada --- /dev/null +++ b/Core/Sources/HostApp/HandleToast.swift @@ -0,0 +1,49 @@ +import Dependencies +import SwiftUI +import Toast + +struct ToastHandler: View { + @ObservedObject var toastController: ToastController + let namespace: String? + + init(toastController: ToastController, namespace: String?) { + _toastController = .init(wrappedValue: toastController) + self.namespace = namespace + } + + var body: some View { + VStack(spacing: 4) { + ForEach(toastController.messages) { message in + if let n = message.namespace, n != namespace { + EmptyView() + } else { + message.content + .foregroundColor(.white) + .padding(8) + .background({ + switch message.type { + case .info: return Color.accentColor + case .error: return Color(nsColor: .systemRed) + case .warning: return Color(nsColor: .systemOrange) + } + }() as Color, in: RoundedRectangle(cornerRadius: 8)) + .shadow(color: Color.black.opacity(0.2), radius: 4) + } + } + } + .padding() + .allowsHitTesting(false) + } +} + +extension View { + func handleToast(namespace: String? = nil) -> some View { + @Dependency(\.toastController) var toastController + return overlay(alignment: .bottom) { + ToastHandler(toastController: toastController, namespace: namespace) + }.environment(\.toast) { [toastController] content, type in + toastController.toast(content: content, type: type, namespace: namespace) + } + } +} + diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift new file mode 100644 index 00000000..f2b90303 --- /dev/null +++ b/Core/Sources/HostApp/HostApp.swift @@ -0,0 +1,117 @@ +import Client +import ComposableArchitecture +import Foundation +import KeyboardShortcuts + +#if canImport(LicenseManagement) +import ProHostApp +#endif + +extension KeyboardShortcuts.Name { + static let showHideWidget = Self("ShowHideWidget") +} + +@Reducer +struct HostApp { + @ObservableState + struct State: Equatable { + var general = General.State() + var chatModelManagement = ChatModelManagement.State() + var embeddingModelManagement = EmbeddingModelManagement.State() + var webSearchSettings = WebSearchSettings.State() + } + + enum Action { + case appear + case general(General.Action) + case chatModelManagement(ChatModelManagement.Action) + case embeddingModelManagement(EmbeddingModelManagement.Action) + case webSearchSettings(WebSearchSettings.Action) + } + + @Dependency(\.toast) var toast + + init() { + KeyboardShortcuts.userDefaults = .shared + } + + var body: some ReducerOf { + Scope(state: \.general, action: \.general) { + General() + } + + Scope(state: \.chatModelManagement, action: \.chatModelManagement) { + ChatModelManagement() + } + + Scope(state: \.embeddingModelManagement, action: \.embeddingModelManagement) { + EmbeddingModelManagement() + } + + Scope(state: \.webSearchSettings, action: \.webSearchSettings) { + WebSearchSettings() + } + + Reduce { _, action in + switch action { + case .appear: + #if canImport(ProHostApp) + ProHostApp.start() + #endif + return .none + + case .general: + return .none + + case .chatModelManagement: + return .none + + case .embeddingModelManagement: + return .none + + case .webSearchSettings: + return .none + } + } + } +} + +import Dependencies +import Keychain +import Preferences + +struct UserDefaultsDependencyKey: DependencyKey { + static var liveValue: UserDefaultsType = UserDefaults.shared + static var previewValue: UserDefaultsType = { + let it = UserDefaults(suiteName: "HostAppPreview")! + it.removePersistentDomain(forName: "HostAppPreview") + return it + }() + + static var testValue: UserDefaultsType = { + let it = UserDefaults(suiteName: "HostAppTest")! + it.removePersistentDomain(forName: "HostAppTest") + return it + }() +} + +extension DependencyValues { + var userDefaults: UserDefaultsType { + get { self[UserDefaultsDependencyKey.self] } + set { self[UserDefaultsDependencyKey.self] = newValue } + } +} + +struct APIKeyKeychainDependencyKey: DependencyKey { + static var liveValue: KeychainType = Keychain.apiKey + static var previewValue: KeychainType = FakeKeyChain() + static var testValue: KeychainType = FakeKeyChain() +} + +extension DependencyValues { + var apiKeyKeychain: KeychainType { + get { self[APIKeyKeychainDependencyKey.self] } + set { self[APIKeyKeychainDependencyKey.self] = newValue } + } +} + diff --git a/Core/Sources/HostApp/LaunchAgentManager.swift b/Core/Sources/HostApp/LaunchAgentManager.swift index b83d8589..44937bb1 100644 --- a/Core/Sources/HostApp/LaunchAgentManager.swift +++ b/Core/Sources/HostApp/LaunchAgentManager.swift @@ -6,14 +6,14 @@ extension LaunchAgentManager { self.init( serviceIdentifier: Bundle.main .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String + - ".ExtensionService", - executablePath: Bundle.main.bundleURL + ".CommunicationBridge", + executableURL: Bundle.main.bundleURL .appendingPathComponent("Contents") .appendingPathComponent("Applications") - .appendingPathComponent( - "CopilotForXcodeExtensionService.app/Contents/MacOS/CopilotForXcodeExtensionService" - ) - .path + .appendingPathComponent("CommunicationBridge"), + bundleIdentifier: Bundle.main + .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String ) } } + diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift index 3cb35ab3..bf81eb51 100644 --- a/Core/Sources/HostApp/ServiceView.swift +++ b/Core/Sources/HostApp/ServiceView.swift @@ -1,50 +1,79 @@ +import ComposableArchitecture import SwiftUI struct ServiceView: View { + let store: StoreOf @State var tag = 0 + var body: some View { - SidebarTabView(tag: $tag) { - ScrollView { - CopilotView().padding() - }.sidebarItem( - tag: 0, - title: "GitHub Copilot", - subtitle: "Suggestion", - image: "globe" - ) - - ScrollView { - CodeiumView().padding() - }.sidebarItem( - tag: 1, - title: "Codeium", - subtitle: "Suggestion", - image: "globe" - ) - - ScrollView { - OpenAIView().padding() - }.sidebarItem( - tag: 2, - title: "OpenAI", - subtitle: "Chat, Prompt to Code", - image: "globe" - ) - - ScrollView { - AzureView().padding() - }.sidebarItem( - tag: 3, - title: "Azure", - subtitle: "Chat, Prompt to Code", - image: "globe" - ) + WithPerceptionTracking { + SidebarTabView(tag: $tag) { + WithPerceptionTracking { + ScrollView { + GitHubCopilotView().padding() + }.sidebarItem( + tag: 0, + title: "GitHub Copilot", + subtitle: "Suggestion", + image: "globe" + ) + + ScrollView { + CodeiumView().padding() + }.sidebarItem( + tag: 1, + title: "Codeium", + subtitle: "Suggestion", + image: "globe" + ) + + ChatModelManagementView(store: store.scope( + state: \.chatModelManagement, + action: \.chatModelManagement + )).sidebarItem( + tag: 2, + title: "Chat Models", + subtitle: "Chat, Modification", + image: "globe" + ) + + EmbeddingModelManagementView(store: store.scope( + state: \.embeddingModelManagement, + action: \.embeddingModelManagement + )).sidebarItem( + tag: 3, + title: "Embedding Models", + subtitle: "Chat, Modification", + image: "globe" + ) + + WebSearchView(store: store.scope( + state: \.webSearchSettings, + action: \.webSearchSettings + )).sidebarItem( + tag: 4, + title: "Web Search", + subtitle: "Chat, Modification", + image: "globe" + ) + + ScrollView { + OtherSuggestionServicesView().padding() + }.sidebarItem( + tag: 5, + title: "Other Suggestion Services", + subtitle: "Suggestion", + image: "globe" + ) + } + } } } } struct AccountView_Previews: PreviewProvider { static var previews: some View { - ServiceView() + ServiceView(store: .init(initialState: .init(), reducer: { HostApp() })) } } + diff --git a/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift b/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift new file mode 100644 index 00000000..1c7151af --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift @@ -0,0 +1,71 @@ +import Foundation +import Preferences +import SwiftUI + +public struct CodeHighlightThemePicker: View { + public enum Scenario { + case suggestion + case promptToCode + case chat + } + + let scenario: Scenario + + public init(scenario: Scenario) { + self.scenario = scenario + } + + public var body: some View { + switch scenario { + case .suggestion: + SuggestionThemePicker() + case .promptToCode: + PromptToCodeThemePicker() + case .chat: + ChatThemePicker() + } + } + + struct SuggestionThemePicker: View { + @AppStorage(\.syncSuggestionHighlightTheme) var sync: Bool + var body: some View { + SyncToggle(sync: $sync) + } + } + + struct PromptToCodeThemePicker: View { + @AppStorage(\.syncPromptToCodeHighlightTheme) var sync: Bool + var body: some View { + SyncToggle(sync: $sync) + } + } + + struct ChatThemePicker: View { + @AppStorage(\.syncChatCodeHighlightTheme) var sync: Bool + var body: some View { + SyncToggle(sync: $sync) + } + } + + struct SyncToggle: View { + @Binding var sync: Bool + + var body: some View { + VStack(alignment: .leading) { + Toggle(isOn: $sync) { + Text("Sync color scheme with Xcode") + } + + Text("To refresh the theme, you must activate the extension service app once.") + .font(.footnote) + .foregroundColor(.secondary) + } + } + } +} + +#Preview { + CodeHighlightThemePicker.SyncToggle(sync: .constant(true)) + CodeHighlightThemePicker.SyncToggle(sync: .constant(false)) +} + diff --git a/Core/Sources/HostApp/SharedComponents/EditableText.swift b/Core/Sources/HostApp/SharedComponents/EditableText.swift new file mode 100644 index 00000000..46464f33 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/EditableText.swift @@ -0,0 +1,72 @@ +import Foundation +import SwiftUI + +// Hack to disable smart quotes and dashes in TextEditor +extension NSTextView { + open override var frame: CGRect { + didSet { + self.isAutomaticQuoteSubstitutionEnabled = false + self.isAutomaticDashSubstitutionEnabled = false + } + } +} + +struct EditableText: View { + var text: Binding + @State var isEditing: Bool = false + + var body: some View { + Button(action: { + isEditing = true + }) { + HStack(alignment: .top) { + Text(text.wrappedValue) + .font(Font.system(.body, design: .monospaced)) + .padding(4) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 4) + .fill(Color(nsColor: .textBackgroundColor)) + } + .overlay { + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1)) + } + Image(systemName: "square.and.pencil") + .resizable() + .scaledToFit() + .frame(width: 14) + .padding(4) + .background( + Color.primary.opacity(0.1), + in: RoundedRectangle(cornerRadius: 4) + ) + } + } + .buttonStyle(.plain) + .sheet(isPresented: $isEditing) { + VStack { + TextEditor(text: text) + .font(Font.system(.body, design: .monospaced)) + .padding(4) + .frame(minHeight: 120) + .multilineTextAlignment(.leading) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + + Button(action: { + isEditing = false + }) { + Text("Done") + } + } + .padding() + .frame(width: 600, height: 500) + .background(Color(nsColor: .windowBackgroundColor)) + } + } +} + diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index c960716d..8616b5af 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -1,143 +1,127 @@ +import ComposableArchitecture +import Dependencies import Foundation import LaunchAgentManager +import SharedUIComponents import SwiftUI +import Toast import UpdateChecker -enum Tab: Int, CaseIterable, Equatable { - case general - case service - case feature - case customCommand - case debug -} +@MainActor +let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() }) public struct TabContainer: View { - @StateObject var toastController = ToastController(messages: []) - @State var tab = Tab.general + let store: StoreOf + @ObservedObject var toastController: ToastController + @State private var tabBarItems = [TabBarItem]() + @State var tag: Int = 0 - public init() {} + var externalTabContainer: ExternalTabContainer { + ExternalTabContainer.tabContainer(for: "TabContainer") + } - init(toastController: ToastController) { - _toastController = StateObject(wrappedValue: toastController) + public init() { + toastController = ToastControllerDependencyKey.liveValue + store = hostAppStore + } + + init(store: StoreOf, toastController: ToastController) { + self.store = store + self.toastController = toastController } public var body: some View { - VStack(spacing: 0) { - TabBar(tab: $tab) - .padding(.bottom, 8) - - Divider() - - Group { - switch tab { - case .general: - GeneralView() - case .service: - ServiceView() - case .feature: - FeatureSettingsView() - case .customCommand: - CustomCommandView() - case .debug: - DebugSettingsView() - } - } - .frame(minHeight: 400) - .overlay(alignment: .bottom) { - VStack(spacing: 4) { - ForEach(toastController.messages) { message in - message.content - .foregroundColor(.white) - .padding(8) - .background({ - switch message.type { - case .info: return Color(nsColor: .systemIndigo) - case .error: return Color(nsColor: .systemRed) - case .warning: return Color(nsColor: .systemOrange) - } - }() as Color, in: RoundedRectangle(cornerRadius: 8)) - .shadow(color: Color.black.opacity(0.2), radius: 4) + WithPerceptionTracking { + VStack(spacing: 0) { + TabBar(tag: $tag, tabBarItems: tabBarItems) + .padding(.bottom, 8) + + Divider() + + ZStack(alignment: .center) { + GeneralView(store: store.scope(state: \.general, action: \.general)) + .tabBarItem( + tag: 0, + title: "General", + image: "app.gift" + ) + ServiceView(store: store).tabBarItem( + tag: 1, + title: "Service", + image: "globe" + ) + FeatureSettingsView().tabBarItem( + tag: 2, + title: "Feature", + image: "star.square" + ) + CustomCommandView(store: customCommandStore).tabBarItem( + tag: 3, + title: "Custom Command", + image: "command.square" + ) + + ForEach(0..: View { + @Environment(\.tabBarTabTag) var tabBarTabTag + var tag: Int + var title: String + var image: String + var content: () -> Content -public extension EnvironmentValues { - var updateChecker: UpdateChecker { - get { self[UpdateCheckerKey.self] } - set { self[UpdateCheckerKey.self] = newValue } + var body: some View { + Group { + if tag == tabBarTabTag { + content() + } else { + Color.clear + } + } + .preference( + key: TabBarItemPreferenceKey.self, + value: [.init(tag: tag, title: title, image: image)] + ) } } -enum ToastType { - case info - case warning - case error +private extension View { + func tabBarItem( + tag: Int, + title: String, + image: String + ) -> some View { + TabBarTabViewWrapper( + tag: tag, + title: title, + image: image, + content: { self } + ) + } } -struct ToastKey: EnvironmentKey { - static var defaultValue: (Text, ToastType) -> Void = { _, _ in } +private struct TabBarItem: Identifiable, Equatable { + var id: Int { tag } + var tag: Int + var title: String + var image: String } -extension EnvironmentValues { - var toast: (Text, ToastType) -> Void { - get { self[ToastKey.self] } - set { self[ToastKey.self] = newValue } +private struct TabBarItemPreferenceKey: PreferenceKey { + static var defaultValue: [TabBarItem] = [] + static func reduce(value: inout [TabBarItem], nextValue: () -> [TabBarItem]) { + value.append(contentsOf: nextValue()) } } -@MainActor -class ToastController: ObservableObject { - struct Message: Identifiable { - var id: UUID - var type: ToastType - var content: Text - } - - @Published var messages: [Message] = [] +private struct TabBarTabTagKey: EnvironmentKey { + static var defaultValue: Int = 0 +} - init(messages: [Message]) { - self.messages = messages +private extension EnvironmentValues { + var tabBarTabTag: Int { + get { self[TabBarTabTagKey.self] } + set { self[TabBarTabTagKey.self] = newValue } } +} - func toast(content: Text, type: ToastType) { - let id = UUID() - let message = Message(id: id, type: type, content: content) +struct UpdateCheckerKey: EnvironmentKey { + static var defaultValue: UpdateChecker = .init( + hostBundle: nil, + shouldAutomaticallyCheckForUpdate: false + ) +} - Task { @MainActor in - withAnimation(.easeInOut(duration: 0.2)) { - messages.append(message) - messages = messages.suffix(3) - } - try await Task.sleep(nanoseconds: 4_000_000_000) - withAnimation(.easeInOut(duration: 0.2)) { - messages.removeAll { $0.id == id } - } - } +public extension EnvironmentValues { + var updateChecker: UpdateChecker { + get { self[UpdateCheckerKey.self] } + set { self[UpdateCheckerKey.self] = newValue } } } @@ -242,11 +241,14 @@ struct TabContainer_Previews: PreviewProvider { struct TabContainer_Toasts_Previews: PreviewProvider { static var previews: some View { - TabContainer(toastController: .init(messages: [ - .init(id: UUID(), type: .info, content: Text("info")), - .init(id: UUID(), type: .error, content: Text("error")), - .init(id: UUID(), type: .warning, content: Text("warning")), - ])) + TabContainer( + store: .init(initialState: .init(), reducer: { HostApp() }), + toastController: .init(messages: [ + .init(id: UUID(), type: .info, content: Text("info")), + .init(id: UUID(), type: .error, content: Text("error")), + .init(id: UUID(), type: .warning, content: Text("warning")), + ]) + ) .frame(width: 800) } } diff --git a/Core/Sources/KeyBindingManager/KeyBindingManager.swift b/Core/Sources/KeyBindingManager/KeyBindingManager.swift new file mode 100644 index 00000000..113d1450 --- /dev/null +++ b/Core/Sources/KeyBindingManager/KeyBindingManager.swift @@ -0,0 +1,22 @@ +import Foundation +import Workspace + + +public final class KeyBindingManager { + let tabToAcceptSuggestion: TabToAcceptSuggestion + + public init() { + tabToAcceptSuggestion = .init() + } + + public func start() { + tabToAcceptSuggestion.start() + } + + @MainActor + public func stopForExit() { + tabToAcceptSuggestion.stopForExit() + } +} + + diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift new file mode 100644 index 00000000..9c81038f --- /dev/null +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -0,0 +1,325 @@ +import ActiveApplicationMonitor +import AppKit +import CGEventOverride +import CommandHandler +import Dependencies +import Foundation +import Logger +import Preferences +import SuggestionBasic +import UserDefaultsObserver +import Workspace +import XcodeInspector + +final class TabToAcceptSuggestion { + let hook: CGEventHookType = CGEventHook(eventsOfInterest: [.keyDown]) { message in + Logger.service.debug("TabToAcceptSuggestion: \(message)") + } + + @Dependency(\.workspacePool) var workspacePool + @Dependency(\.commandHandler) var commandHandler + + private var CGEventObservationTask: Task? + private var isObserving: Bool { CGEventObservationTask != nil } + private let userDefaultsObserver = UserDefaultsObserver( + object: UserDefaults.shared, forKeyPaths: [ + UserDefaultPreferenceKeys().acceptSuggestionWithTab.key, + UserDefaultPreferenceKeys().dismissSuggestionWithEsc.key, + ], context: nil + ) + private var stoppedForExit = false + + struct ObservationKey: Hashable {} + + var canTapToAcceptSuggestion: Bool { + UserDefaults.shared.value(for: \.acceptSuggestionWithTab) + } + + var canEscToDismissSuggestion: Bool { + UserDefaults.shared.value(for: \.dismissSuggestionWithEsc) + } + + @MainActor + func stopForExit() { + stoppedForExit = true + stopObservation() + } + + init() { + _ = ThreadSafeAccessToXcodeInspector.shared + + hook.add( + .init( + eventsOfInterest: [.keyDown], + convert: { [weak self] _, _, event in + self?.handleEvent(event) ?? .unchanged + } + ), + forKey: ObservationKey() + ) + } + + func start() { + Task { [weak self] in + for await _ in ActiveApplicationMonitor.shared.createInfoStream() { + guard let self else { return } + try Task.checkCancellation() + Task { @MainActor in + if ActiveApplicationMonitor.shared.activeXcode != nil { + self.startObservation() + } else { + self.stopObservation() + } + } + } + } + + userDefaultsObserver.onChange = { [weak self] in + guard let self else { return } + Task { @MainActor in + if self.canTapToAcceptSuggestion || self.canEscToDismissSuggestion { + self.startObservation() + } else { + self.stopObservation() + } + } + } + } + + @MainActor + func startObservation() { + guard !stoppedForExit else { return } + guard canTapToAcceptSuggestion || canEscToDismissSuggestion else { return } + hook.activateIfPossible() + } + + @MainActor + func stopObservation() { + hook.deactivate() + } + + func handleEvent(_ event: CGEvent) -> CGEventManipulation.Result { + let keycode = Int(event.getIntegerValueField(.keyboardEventKeycode)) + let tab = 48 + let esc = 53 + + switch keycode { + case tab: + return handleTab(event.flags) + case esc: + return handleEsc(event.flags) + default: + return .unchanged + } + } + + func handleTab(_ flags: CGEventFlags) -> CGEventManipulation.Result { + Logger.service.info("TabToAcceptSuggestion: Tab") + + guard let fileURL = ThreadSafeAccessToXcodeInspector.shared.activeDocumentURL + else { + Logger.service.info("TabToAcceptSuggestion: No active document") + return .unchanged + } + + let language = languageIdentifierFromFileURL(fileURL) + + if flags.contains(.maskHelp) { return .unchanged } + + let requiredFlagsToTrigger: CGEventFlags = { + var all = CGEventFlags() + if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierShift) { + all.insert(.maskShift) + } + if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierControl) { + all.insert(.maskControl) + } + if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierOption) { + all.insert(.maskAlternate) + } + if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierCommand) { + all.insert(.maskCommand) + } + if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierOnlyForSwift) { + if language == .builtIn(.swift) { + return all + } else { + return [] + } + } else { + return all + } + }() + + let flagsToAvoidWhenNotRequired: [CGEventFlags] = [ + .maskShift, .maskCommand, .maskHelp, .maskSecondaryFn, + ] + + guard flags.contains(requiredFlagsToTrigger) else { + Logger.service.info("TabToAcceptSuggestion: Modifier not found") + return .unchanged + } + + for flag in flagsToAvoidWhenNotRequired { + if flags.contains(flag), !requiredFlagsToTrigger.contains(flag) { + return .unchanged + } + } + + guard canTapToAcceptSuggestion else { + Logger.service.info("TabToAcceptSuggestion: Feature not available") + return .unchanged + } + + guard ThreadSafeAccessToXcodeInspector.shared.activeXcode != nil + else { + Logger.service.info("TabToAcceptSuggestion: Xcode not found") + return .unchanged + } + guard let editor = ThreadSafeAccessToXcodeInspector.shared.focusedEditor + else { + Logger.service.info("TabToAcceptSuggestion: No editor found") + return .unchanged + } + guard let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL) + else { + Logger.service.info("TabToAcceptSuggestion: No file found") + return .unchanged + } + guard let presentingSuggestion = filespace.presentingSuggestion + else { + Logger.service.info( + "TabToAcceptSuggestion: No presenting found for \(filespace.fileURL.lastPathComponent), found \(filespace.suggestions.count) suggestion, index \(filespace.suggestionIndex)." + ) + return .unchanged + } + + let editorContent = editor.getContent() + + let shouldAcceptSuggestion = Self.checkIfAcceptSuggestion( + lines: editorContent.lines, + cursorPosition: editorContent.cursorPosition, + codeMetadata: filespace.codeMetadata, + presentingSuggestionText: presentingSuggestion.text + ) + + if shouldAcceptSuggestion { + Logger.service.info("TabToAcceptSuggestion: Accept") + if flags.contains(.maskControl), + !requiredFlagsToTrigger.contains(.maskControl) + { + Task { await commandHandler.acceptActiveSuggestionLineInGroup(atIndex: nil) + } + } else { + Task { await commandHandler.acceptSuggestion() } + } + return .discarded + } else { + Logger.service.info("TabToAcceptSuggestion: Should not accept") + return .unchanged + } + } + + func handleEsc(_ flags: CGEventFlags) -> CGEventManipulation.Result { + guard + !flags.contains(.maskShift), + !flags.contains(.maskControl), + !flags.contains(.maskAlternate), + !flags.contains(.maskCommand), + !flags.contains(.maskHelp), + canEscToDismissSuggestion + else { return .unchanged } + + guard + let fileURL = ThreadSafeAccessToXcodeInspector.shared.activeDocumentURL, + ThreadSafeAccessToXcodeInspector.shared.activeXcode != nil, + let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL), + filespace.presentingSuggestion != nil + else { return .unchanged } + + Task { await commandHandler.dismissSuggestion() } + return .discarded + } +} + +extension TabToAcceptSuggestion { + static func checkIfAcceptSuggestion( + lines: [String], + cursorPosition: CursorPosition, + codeMetadata: FilespaceCodeMetadata, + presentingSuggestionText: String + ) -> Bool { + let line = cursorPosition.line + guard line >= 0, line < lines.endIndex else { + return true + } + let col = cursorPosition.character + let prefixEndIndex = lines[line].utf16.index( + lines[line].utf16.startIndex, + offsetBy: col, + limitedBy: lines[line].utf16.endIndex + ) ?? lines[line].utf16.endIndex + let prefix = String(lines[line][.. @@ -38,12 +92,17 @@ public struct LaunchAgentManager { Label \(serviceIdentifier) Program - \(executablePath) + \(executableURL.path) MachServices \(serviceIdentifier) + AssociatedBundleIdentifiers + + \(bundleIdentifier) + \(serviceIdentifier) + """ @@ -57,26 +116,10 @@ public struct LaunchAgentManager { atPath: launchAgentPath, contents: content.data(using: .utf8) ) - let buildNumber = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) - .flatMap(Int.init) - UserDefaults.standard.set(buildNumber, forKey: lastLaunchAgentVersionKey) + #if DEBUG + #else try await launchctl("load", launchAgentPath) - } - - public func removeLaunchAgent() async throws { - try await launchctl("unload", launchAgentPath) - try FileManager.default.removeItem(atPath: launchAgentPath) - } - - public func reloadLaunchAgent() async throws { - try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier) - } - - public func removeObsoleteLaunchAgent() async { - let path = launchAgentPath.replacingOccurrences(of: "ExtensionService", with: "XPCService") - if FileManager.default.fileExists(atPath: path) { - try? FileManager.default.removeItem(atPath: path) - } + #endif } } @@ -133,6 +176,7 @@ private func launchctl(_ args: String...) async throws { return try await process("/bin/launchctl", args) } -struct E: Error, LocalizedError { +private struct E: Error, LocalizedError { var errorDescription: String? } + diff --git a/Core/Sources/LegacyChatPlugin/AskChatGPT.swift b/Core/Sources/LegacyChatPlugin/AskChatGPT.swift new file mode 100644 index 00000000..b942a7de --- /dev/null +++ b/Core/Sources/LegacyChatPlugin/AskChatGPT.swift @@ -0,0 +1,24 @@ +import Foundation +import OpenAIService + +/// Quickly ask a question to ChatGPT. +public func askChatGPT( + systemPrompt: String, + question: String, + temperature: Double? = nil +) async throws -> String? { + let configuration = UserPreferenceChatGPTConfiguration() + .overriding(.init(temperature: temperature)) + let memory = AutoManagedChatGPTMemory( + systemPrompt: systemPrompt, + configuration: configuration, + functionProvider: NoChatGPTFunctionProvider(), + maxNumberOfMessages: .max + ) + let service = LegacyChatGPTService( + memory: memory, + configuration: configuration + ) + return try await service.sendAndWait(content: question) +} + diff --git a/Core/Sources/ChatPlugins/CallAIFunction.swift b/Core/Sources/LegacyChatPlugin/CallAIFunction.swift similarity index 52% rename from Core/Sources/ChatPlugins/CallAIFunction.swift rename to Core/Sources/LegacyChatPlugin/CallAIFunction.swift index 9802d282..20f7a01d 100644 --- a/Core/Sources/ChatPlugins/CallAIFunction.swift +++ b/Core/Sources/LegacyChatPlugin/CallAIFunction.swift @@ -16,8 +16,17 @@ func callAIFunction( } } let argsString = args.joined(separator: ", ") - let service = ChatGPTService( - systemPrompt: "You are now the following python function: ```# \(description)\n\(function)```\n\nOnly respond with your `return` value." + let configuration = UserPreferenceChatGPTConfiguration() + .overriding(.init(temperature: 0)) + let service = LegacyChatGPTService( + memory: AutoManagedChatGPTMemory( + systemPrompt: "You are now the following python function: ```# \(description)\n\(function)```\n\nOnly respond with your `return` value.", + configuration: configuration, + functionProvider: NoChatGPTFunctionProvider(), + maxNumberOfMessages: .max + ), + configuration: configuration ) return try await service.sendAndWait(content: argsString) } + diff --git a/Core/Sources/LegacyChatPlugin/LegacyChatPlugin.swift b/Core/Sources/LegacyChatPlugin/LegacyChatPlugin.swift new file mode 100644 index 00000000..49925e6e --- /dev/null +++ b/Core/Sources/LegacyChatPlugin/LegacyChatPlugin.swift @@ -0,0 +1,21 @@ +import Foundation +import OpenAIService + +public protocol LegacyChatPlugin: AnyObject { + /// Should be [a-zA-Z0-9]+ + static var command: String { get } + var name: String { get } + + init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: LegacyChatPluginDelegate) + func send(content: String, originalMessage: String) async + func cancel() async + func stopResponding() async +} + +public protocol LegacyChatPluginDelegate: AnyObject { + func pluginDidStart(_ plugin: LegacyChatPlugin) + func pluginDidEnd(_ plugin: LegacyChatPlugin) + func pluginDidStartResponding(_ plugin: LegacyChatPlugin) + func pluginDidEndResponding(_ plugin: LegacyChatPlugin) + func shouldStartAnotherPlugin(_ type: LegacyChatPlugin.Type, withContent: String) +} diff --git a/Core/Sources/ChatPlugins/TerminalChatPlugin.swift b/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift similarity index 68% rename from Core/Sources/ChatPlugins/TerminalChatPlugin.swift rename to Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift index 85ab6eff..3ac8bd74 100644 --- a/Core/Sources/ChatPlugins/TerminalChatPlugin.swift +++ b/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift @@ -1,18 +1,18 @@ -import Environment import Foundation import OpenAIService import Terminal +import XcodeInspector -public actor TerminalChatPlugin: ChatPlugin { +public actor TerminalChatPlugin: LegacyChatPlugin { public static var command: String { "run" } public nonisolated var name: String { "Terminal" } - let chatGPTService: any ChatGPTServiceType + let chatGPTService: any LegacyChatGPTServiceType var terminal: TerminalType = Terminal() var isCancelled = false - weak var delegate: ChatPluginDelegate? + weak var delegate: LegacyChatPluginDelegate? - public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) { + public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: LegacyChatPluginDelegate) { self.chatGPTService = chatGPTService self.delegate = delegate } @@ -34,20 +34,22 @@ public actor TerminalChatPlugin: ChatPlugin { } do { - let fileURL = try await Environment.fetchCurrentFileURL() - let projectURL = try await { - if let url = try await Environment.fetchCurrentProjectRootURLFromXcode() { - return url - } - return try await Environment.guessProjectRootURLForFile(fileURL) - }() + let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL + + var environment = [String: String]() + if let fileURL { + environment["FILE_PATH"] = fileURL.path + } + if let projectURL { + environment["PROJECT_ROOT"] = projectURL.path + } - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in history.append( .init( role: .user, - content: originalMessage, - summary: "Run command: \(content)" + content: originalMessage ) ) } @@ -60,16 +62,13 @@ public actor TerminalChatPlugin: ChatPlugin { let output = terminal.streamCommand( shell, arguments: ["-i", "-l", "-c", content], - currentDirectoryPath: projectURL.path, - environment: [ - "PROJECT_ROOT": projectURL.path, - "FILE_PATH": fileURL.path, - ] + currentDirectoryURL: projectURL, + environment: environment ) for try await content in output { if isCancelled { throw CancellationError() } - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in if history.last?.id == id { history.removeLast() } @@ -78,7 +77,7 @@ public actor TerminalChatPlugin: ChatPlugin { } } outputContent += "\n[finished]" - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in if history.last?.id == id { history.removeLast() } @@ -86,7 +85,7 @@ public actor TerminalChatPlugin: ChatPlugin { } } catch let error as Terminal.TerminationError { outputContent += "\n[error: \(error.status)]" - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in if history.last?.id == id { history.removeLast() } @@ -94,7 +93,7 @@ public actor TerminalChatPlugin: ChatPlugin { } } catch { outputContent += "\n[error: \(error.localizedDescription)]" - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in if history.last?.id == id { history.removeLast() } diff --git a/Core/Sources/LegacyChatPlugin/Translate.swift b/Core/Sources/LegacyChatPlugin/Translate.swift new file mode 100644 index 00000000..f3441a9f --- /dev/null +++ b/Core/Sources/LegacyChatPlugin/Translate.swift @@ -0,0 +1,34 @@ +import Foundation +import Preferences + +@MainActor +var translationCache = [String: String]() + +public func translate(text: String, cache: Bool = true) async -> String { + let language = UserDefaults.shared.value(for: \.chatGPTLanguage) + if language.isEmpty { return text } + + let key = "\(language)-\(text)" + if cache, let cached = await translationCache[key] { + return cached + } + + if let translated = try? await askChatGPT( + systemPrompt: """ + You are a translator. Your job is to translate the message into \(language). The reply should only contain the translated content. + User: ###${{some text}}### + Assistant: ${{translated text}} + """, + question: "###\(text)###" + ) { + if cache { + let storeTask = Task { @MainActor in + translationCache[key] = translated + } + _ = await storeTask.result + } + return translated + } + return text +} + diff --git a/Core/Sources/Logger/Logger.swift b/Core/Sources/Logger/Logger.swift deleted file mode 100644 index 92344e4d..00000000 --- a/Core/Sources/Logger/Logger.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation -import os.log - -enum LogLevel: String { - case debug - case info - case error -} - -public final class Logger { - private let subsystem: String - private let category: String - - public static let service = Logger(category: "Service") - public static let ui = Logger(category: "UI") - public static let client = Logger(category: "Client") - public static let updateChecker = Logger(category: "UpdateChecker") - public static let gitHubCopilot = Logger(category: "GitHubCopilot") - public static let codeium = Logger(category: "Codeium") - #if DEBUG - public static let temp = Logger(category: "Temp") - #endif - - public init(subsystem: String = "com.intii.CopilotForXcode", category: String) { - self.subsystem = subsystem - self.category = category - } - - func log(level: LogLevel, message: String) { - let osLogType: OSLogType - switch level { - case .debug: - osLogType = .debug - case .info: - osLogType = .info - case .error: - osLogType = .error - } - - let osLog = OSLog(subsystem: subsystem, category: category) - os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg) - } - - public func debug(_ message: String) { - log(level: .debug, message: message) - } - - public func info(_ message: String) { - log(level: .info, message: message) - } - - public func error(_ message: String) { - log(level: .error, message: message) - } - - public func error(_ error: Error) { - log(level: .error, message: error.localizedDescription) - } -} diff --git a/Core/Sources/OpenAIService/ChatGPTService.swift b/Core/Sources/OpenAIService/ChatGPTService.swift deleted file mode 100644 index f970dd2a..00000000 --- a/Core/Sources/OpenAIService/ChatGPTService.swift +++ /dev/null @@ -1,332 +0,0 @@ -import AsyncAlgorithms -import Foundation -import GPTEncoder -import Preferences - -public protocol ChatGPTServiceType: ObservableObject { - var isReceivingMessage: Bool { get async } - var history: [ChatMessage] { get async } - func send(content: String, summary: String?) async throws -> AsyncThrowingStream - func stopReceivingMessage() async - func clearHistory() async - func mutateSystemPrompt(_ newPrompt: String) async - func mutateHistory(_ mutate: (inout [ChatMessage]) -> Void) async - func markReceivingMessage(_ receiving: Bool) async -} - -public enum ChatGPTServiceError: Error, LocalizedError { - case endpointIncorrect - case responseInvalid - case otherError(String) - - public var errorDescription: String? { - switch self { - case .endpointIncorrect: - return "ChatGPT endpoint is incorrect" - case .responseInvalid: - return "Response is invalid" - case let .otherError(content): - return content - } - } -} - -public struct ChatGPTError: Error, Codable, LocalizedError { - public var error: ErrorContent - public init(error: ErrorContent) { - self.error = error - } - - public struct ErrorContent: Codable { - public var message: String - public var type: String - public var param: String? - public var code: String? - - public init(message: String, type: String, param: String? = nil, code: String? = nil) { - self.message = message - self.type = type - self.param = param - self.code = code - } - } - - public var errorDescription: String? { - error.message - } -} - -public actor ChatGPTService: ChatGPTServiceType { - public var systemPrompt: String - - public var defaultTemperature: Double { - min(max(0, UserDefaults.shared.value(for: \.chatGPTTemperature)), 2) - } - - var temperature: Double? - - public var model: String { - let value = UserDefaults.shared.value(for: \.chatGPTModel) - if value.isEmpty { return "gpt-3.5-turbo" } - return value - } - - var designatedProvider: ChatFeatureProvider? - - public var endpoint: String { - switch designatedProvider ?? UserDefaults.shared.value(for: \.chatFeatureProvider) { - case .openAI: - let baseURL = UserDefaults.shared.value(for: \.openAIBaseURL) - if baseURL.isEmpty { return "https://api.openai.com/v1/chat/completions" } - return "\(baseURL)/v1/chat/completions" - case .azureOpenAI: - let baseURL = UserDefaults.shared.value(for: \.azureOpenAIBaseURL) - let deployment = UserDefaults.shared.value(for: \.azureChatGPTDeployment) - let version = "2023-05-15" - if baseURL.isEmpty { return "" } - return "\(baseURL)/openai/deployments/\(deployment)/chat/completions?api-version=\(version)" - } - } - - public var apiKey: String { - switch designatedProvider ?? UserDefaults.shared.value(for: \.chatFeatureProvider) { - case .openAI: - return UserDefaults.shared.value(for: \.openAIAPIKey) - case .azureOpenAI: - return UserDefaults.shared.value(for: \.azureOpenAIAPIKey) - } - } - - public var maxToken: Int { - UserDefaults.shared.value(for: \.chatGPTMaxToken) - } - - public var history: [ChatMessage] = [] { - didSet { objectWillChange.send() } - } - - public internal(set) var isReceivingMessage = false { - didSet { objectWillChange.send() } - } - - var uuidGenerator: () -> String = { UUID().uuidString } - var cancelTask: Cancellable? - var buildCompletionStreamAPI: CompletionStreamAPIBuilder = OpenAICompletionStreamAPI.init - var buildCompletionAPI: CompletionAPIBuilder = OpenAICompletionAPI.init - - public init( - systemPrompt: String = "", - temperature: Double? = nil, - designatedProvider: ChatFeatureProvider? = nil - ) { - self.systemPrompt = systemPrompt - self.temperature = temperature - self.designatedProvider = designatedProvider - } - - public func send( - content: String, - summary: String? = nil - ) async throws -> AsyncThrowingStream { - guard !isReceivingMessage else { throw CancellationError() } - guard let url = URL(string: endpoint) else { throw ChatGPTServiceError.endpointIncorrect } - let newMessage = ChatMessage( - id: uuidGenerator(), - role: .user, - content: content, - summary: summary - ) - history.append(newMessage) - - let (messages, remainingTokens) = combineHistoryWithSystemPrompt() - - let requestBody = CompletionRequestBody( - model: model, - messages: messages, - temperature: temperature ?? defaultTemperature, - stream: true, - max_tokens: maxTokenForReply(model: model, remainingTokens: remainingTokens) - ) - - isReceivingMessage = true - - let api = buildCompletionStreamAPI( - apiKey, - designatedProvider ?? UserDefaults.shared.value(for: \.chatFeatureProvider), - url, - requestBody - ) - - return AsyncThrowingStream { continuation in - Task { - do { - let (trunks, cancel) = try await api() - guard isReceivingMessage else { - continuation.finish() - return - } - cancelTask = cancel - for try await trunk in trunks { - guard let delta = trunk.choices.first?.delta else { continue } - - if history.last?.id == trunk.id { - if let role = delta.role { - history[history.endIndex - 1].role = role - } - if let content = delta.content { - history[history.endIndex - 1].content.append(content) - } - } else { - history.append(.init( - id: trunk.id, - role: delta.role ?? .assistant, - content: delta.content ?? "" - )) - } - - if let content = delta.content { - continuation.yield(content) - } - - try await Task.sleep(nanoseconds: 3_500_000) - } - - continuation.finish() - isReceivingMessage = false - } catch let error as CancellationError { - isReceivingMessage = false - continuation.finish(throwing: error) - } catch let error as NSError where error.code == NSURLErrorCancelled { - isReceivingMessage = false - continuation.finish(throwing: error) - } catch { - history.append(.init( - role: .assistant, - content: error.localizedDescription - )) - isReceivingMessage = false - continuation.finish(throwing: error) - } - } - } - } - - public func sendAndWait( - content: String, - summary: String? = nil - ) async throws -> String? { - guard !isReceivingMessage else { throw CancellationError() } - guard let url = URL(string: endpoint) else { throw ChatGPTServiceError.endpointIncorrect } - let newMessage = ChatMessage( - id: uuidGenerator(), - role: .user, - content: content, - summary: summary - ) - history.append(newMessage) - - let (messages, remainingTokens) = combineHistoryWithSystemPrompt() - - let requestBody = CompletionRequestBody( - model: model, - messages: messages, - temperature: temperature ?? defaultTemperature, - stream: true, - max_tokens: maxTokenForReply(model: model, remainingTokens: remainingTokens) - ) - - isReceivingMessage = true - defer { isReceivingMessage = false } - - let api = buildCompletionAPI( - apiKey, - designatedProvider ?? UserDefaults.shared.value(for: \.chatFeatureProvider), - url, - requestBody - ) - let response = try await api() - - if let choice = response.choices.first { - history.append(.init( - id: response.id, - role: choice.message.role, - content: choice.message.content - )) - - return choice.message.content - } - - return nil - } - - public func stopReceivingMessage() { - cancelTask?() - cancelTask = nil - isReceivingMessage = false - } - - public func clearHistory() { - stopReceivingMessage() - history = [] - } - - public func mutateSystemPrompt(_ newPrompt: String) { - systemPrompt = newPrompt - } - - public func mutateHistory(_ mutate: (inout [ChatMessage]) -> Void) async { - mutate(&history) - } - - public func markReceivingMessage(_ receiving: Bool) { - isReceivingMessage = receiving - } -} - -extension ChatGPTService { - func changeBuildCompletionStreamAPI(_ builder: @escaping CompletionStreamAPIBuilder) { - buildCompletionStreamAPI = builder - } - - func changeUUIDGenerator(_ generator: @escaping () -> String) { - uuidGenerator = generator - } - - func combineHistoryWithSystemPrompt( - minimumReplyTokens: Int = 300, - maxNumberOfMessages: Int = UserDefaults.shared.value(for: \.chatGPTMaxMessageCount), - maxTokens: Int = UserDefaults.shared.value(for: \.chatGPTMaxToken), - encoder: TokenEncoder = GPTEncoder() - ) - -> (messages: [CompletionRequestBody.Message], remainingTokens: Int) - { - var all: [CompletionRequestBody.Message] = [] - var allTokensCount = encoder.encode(text: systemPrompt).count - for (index, message) in history.enumerated().reversed() { - if maxNumberOfMessages > 0, all.count >= maxNumberOfMessages { break } - if message.content.isEmpty { continue } - let tokensCount = message.tokensCount ?? encoder.encode(text: message.content).count - history[index].tokensCount = tokensCount - if tokensCount + allTokensCount > maxTokens - minimumReplyTokens { - break - } - allTokensCount += tokensCount - all.append(.init(role: message.role, content: message.content)) - } - - all.append(.init(role: .system, content: systemPrompt)) - return (all.reversed(), max(minimumReplyTokens, maxTokens - allTokensCount)) - } -} - -protocol TokenEncoder { - func encode(text: String) -> [Int] -} - -extension GPTEncoder: TokenEncoder {} - -func maxTokenForReply(model: String, remainingTokens: Int) -> Int { - guard let model = ChatGPTModel(rawValue: model) else { return remainingTokens } - return min(model.maxToken / 2, remainingTokens) -} - diff --git a/Core/Sources/OpenAIService/CompletionAPI.swift b/Core/Sources/OpenAIService/CompletionAPI.swift deleted file mode 100644 index 66194616..00000000 --- a/Core/Sources/OpenAIService/CompletionAPI.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Foundation -import Preferences - -typealias CompletionAPIBuilder = (String, ChatFeatureProvider, URL, CompletionRequestBody) - -> CompletionAPI - -protocol CompletionAPI { - func callAsFunction() async throws -> CompletionResponseBody -} - -/// https://platform.openai.com/docs/api-reference/chat/create -struct CompletionResponseBody: Codable, Equatable { - struct Message: Codable, Equatable { - var role: ChatMessage.Role - var content: String - } - - struct Choice: Codable, Equatable { - var message: Message - var index: Int - var finish_reason: String - } - - struct Usage: Codable, Equatable { - var prompt_tokens: Int - var completion_tokens: Int - var total_tokens: Int - } - - var id: String - var object: String - var created: Int - var model: String - var usage: Usage - var choices: [Choice] -} - -struct CompletionAPIError: Error, Codable, LocalizedError { - struct E: Codable { - var message: String - var type: String - var param: String - var code: String - } - - var error: E - - var errorDescription: String? { error.message } -} - -struct OpenAICompletionAPI: CompletionAPI { - var apiKey: String - var endpoint: URL - var requestBody: CompletionRequestBody - var provider: ChatFeatureProvider - - init( - apiKey: String, - provider: ChatFeatureProvider, - endpoint: URL, - requestBody: CompletionRequestBody - ) { - self.apiKey = apiKey - self.endpoint = endpoint - self.requestBody = requestBody - self.requestBody.stream = false - self.provider = provider - } - - func callAsFunction() async throws -> CompletionResponseBody { - var request = URLRequest(url: endpoint) - request.httpMethod = "POST" - let encoder = JSONEncoder() - request.httpBody = try encoder.encode(requestBody) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if !apiKey.isEmpty { - if provider == .openAI { - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - } else { - request.setValue(apiKey, forHTTPHeaderField: "api-key") - } - } - - let (result, response) = try await URLSession.shared.data(for: request) - guard let response = response as? HTTPURLResponse else { - throw ChatGPTServiceError.responseInvalid - } - - guard response.statusCode == 200 else { - let error = try? JSONDecoder().decode(CompletionAPIError.self, from: result) - throw error ?? ChatGPTServiceError - .otherError(String(data: result, encoding: .utf8) ?? "Unknown Error") - } - - return try JSONDecoder().decode(CompletionResponseBody.self, from: result) - } -} - diff --git a/Core/Sources/OpenAIService/CompletionStreamAPI.swift b/Core/Sources/OpenAIService/CompletionStreamAPI.swift deleted file mode 100644 index 964c82ce..00000000 --- a/Core/Sources/OpenAIService/CompletionStreamAPI.swift +++ /dev/null @@ -1,137 +0,0 @@ -import AsyncAlgorithms -import Foundation -import Preferences - -typealias CompletionStreamAPIBuilder = (String, ChatFeatureProvider, URL, CompletionRequestBody) -> CompletionStreamAPI - -protocol CompletionStreamAPI { - func callAsFunction() async throws -> ( - trunkStream: AsyncThrowingStream, - cancel: Cancellable - ) -} - -/// https://platform.openai.com/docs/api-reference/chat/create -struct CompletionRequestBody: Codable, Equatable { - struct Message: Codable, Equatable { - var role: ChatMessage.Role - var content: String - } - - var model: String - var messages: [Message] - var temperature: Double? - var top_p: Double? - var n: Double? - var stream: Bool? - var stop: [String]? - var max_tokens: Int? - var presence_penalty: Double? - var frequency_penalty: Double? - var logit_bias: [String: Double]? - var user: String? -} - -struct CompletionStreamDataTrunk: Codable { - var id: String - var object: String - var created: Int - var model: String - var choices: [Choice] - - struct Choice: Codable { - var delta: Delta - var index: Int - var finish_reason: String? - - struct Delta: Codable { - var role: ChatMessage.Role? - var content: String? - } - } -} - -struct OpenAICompletionStreamAPI: CompletionStreamAPI { - var apiKey: String - var endpoint: URL - var requestBody: CompletionRequestBody - var provider: ChatFeatureProvider - - init( - apiKey: String, - provider: ChatFeatureProvider, - endpoint: URL, - requestBody: CompletionRequestBody - ) { - self.apiKey = apiKey - self.endpoint = endpoint - self.requestBody = requestBody - self.requestBody.stream = true - self.provider = provider - } - - func callAsFunction() async throws -> ( - trunkStream: AsyncThrowingStream, - cancel: Cancellable - ) { - var request = URLRequest(url: endpoint) - request.httpMethod = "POST" - let encoder = JSONEncoder() - request.httpBody = try encoder.encode(requestBody) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if !apiKey.isEmpty { - if provider == .openAI { - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - } else { - request.setValue(apiKey, forHTTPHeaderField: "api-key") - } - } - - let (result, response) = try await URLSession.shared.bytes(for: request) - guard let response = response as? HTTPURLResponse else { - throw ChatGPTServiceError.responseInvalid - } - - guard response.statusCode == 200 else { - let text = try await result.lines.reduce(into: "") { partialResult, current in - partialResult += current - } - guard let data = text.data(using: .utf8) - else { throw ChatGPTServiceError.responseInvalid } - let decoder = JSONDecoder() - let error = try? decoder.decode(ChatGPTError.self, from: data) - throw error ?? ChatGPTServiceError.responseInvalid - } - - var receivingDataTask: Task? - - let stream = AsyncThrowingStream { continuation in - receivingDataTask = Task { - do { - for try await line in result.lines { - if Task.isCancelled { break } - let prefix = "data: " - guard line.hasPrefix(prefix), - let content = line.dropFirst(prefix.count).data(using: .utf8), - let trunk = try? JSONDecoder() - .decode(CompletionStreamDataTrunk.self, from: content) - else { continue } - continuation.yield(trunk) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - } - - return ( - stream, - Cancellable { - result.task.cancel() - receivingDataTask?.cancel() - } - ) - } -} - diff --git a/Core/Sources/OpenAIService/Models.swift b/Core/Sources/OpenAIService/Models.swift deleted file mode 100644 index 2668e9ef..00000000 --- a/Core/Sources/OpenAIService/Models.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -struct Cancellable { - let cancel: () -> Void - func callAsFunction() { - cancel() - } -} - -public struct ChatMessage: Equatable, Codable { - public enum Role: String, Codable, Equatable { - case system - case user - case assistant - } - - public var role: Role - public var content: String { - didSet { - tokensCount = nil - } - } - public var summary: String? - public var id: String - public var tokensCount: Int? - - public init( - id: String = UUID().uuidString, - role: Role, - content: String, - summary: String? = nil - ) { - self.role = role - self.content = content - self.summary = summary - self.id = id - } -} diff --git a/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift new file mode 100644 index 00000000..ce45eaf8 --- /dev/null +++ b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift @@ -0,0 +1,45 @@ +import Foundation +import SwiftUI + +#if canImport(LicenseManagement) + +import LicenseManagement + +#else + +public typealias PlusFeatureFlag = Int + +@dynamicMemberLookup +public struct PlusFeatureFlags { + public subscript(dynamicMember dynamicMember: String) -> PlusFeatureFlag { return 0 } + init() {} +} + +#endif + +public func withFeatureEnabled( + _ flag: KeyPath, + then: () throws -> Void +) rethrows { + #if canImport(LicenseManagement) + try LicenseManagement.withFeatureEnabled(flag, then: then) + #endif +} + +public func withFeatureEnabled( + _ flag: KeyPath, + then: () async throws -> Void +) async rethrows { + #if canImport(LicenseManagement) + try await LicenseManagement.withFeatureEnabled(flag, then: then) + #endif +} + +public func isFeatureAvailable(_ flag: KeyPath) -> Bool { + #if canImport(LicenseManagement) + return LicenseManagement.isFeatureAvailable(flag) + #else + return false + #endif +} + diff --git a/Core/Sources/Preferences/AppStorage.swift b/Core/Sources/Preferences/AppStorage.swift deleted file mode 100644 index 73bcee5f..00000000 --- a/Core/Sources/Preferences/AppStorage.swift +++ /dev/null @@ -1,125 +0,0 @@ -import Foundation - -#if canImport(SwiftUI) - -import SwiftUI - -public extension AppStorage { - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == Bool { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(wrappedValue: key.defaultValue, key.key, store: .shared) - } - - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == String { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(wrappedValue: key.defaultValue, key.key, store: .shared) - } - - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == Double { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(wrappedValue: key.defaultValue, key.key, store: .shared) - } - - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == Int { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(wrappedValue: key.defaultValue, key.key, store: .shared) - } - - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == URL { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(wrappedValue: key.defaultValue, key.key, store: .shared) - } - - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == Data { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(wrappedValue: key.defaultValue, key.key, store: .shared) - } - - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value: RawRepresentable, Value.RawValue == Int { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(wrappedValue: key.defaultValue, key.key, store: .shared) - } - - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value: RawRepresentable, Value.RawValue == String { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(wrappedValue: key.defaultValue, key.key, store: .shared) - } -} - -public extension AppStorage where Value: ExpressibleByNilLiteral { - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == Bool? { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(key.key, store: .shared) - } - - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == String? { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(key.key, store: .shared) - } - - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == Double? { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(key.key, store: .shared) - } - - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == Int? { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(key.key, store: .shared) - } - - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == URL? { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(key.key, store: .shared) - } - - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == Data? { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(key.key, store: .shared) - } -} - -public extension AppStorage { - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == R?, R : RawRepresentable, R.RawValue == String { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(key.key, store: .shared) - } - - init( - _ keyPath: KeyPath - ) where K.Value == Value, Value == R?, R : RawRepresentable, R.RawValue == Int { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - self.init(key.key, store: .shared) - } -} - -#endif diff --git a/Core/Sources/Preferences/ChatFeatureProvider.swift b/Core/Sources/Preferences/ChatFeatureProvider.swift deleted file mode 100644 index d97a4238..00000000 --- a/Core/Sources/Preferences/ChatFeatureProvider.swift +++ /dev/null @@ -1,4 +0,0 @@ -public enum ChatFeatureProvider: String, CaseIterable { - case openAI - case azureOpenAI -} diff --git a/Core/Sources/Preferences/ChatGPTModel.swift b/Core/Sources/Preferences/ChatGPTModel.swift deleted file mode 100644 index f2d365d1..00000000 --- a/Core/Sources/Preferences/ChatGPTModel.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -public enum ChatGPTModel: String { - case gpt4 = "gpt-4" - - case gpt40314 = "gpt-4-0314" - - case gpt432k = "gpt-4-32k" - - case gpt432k0314 = "gpt-4-32k-0314" - - case gpt35Turbo = "gpt-3.5-turbo" - - case gpt35Turbo0301 = "gpt-3.5-turbo-0301" -} - -public extension ChatGPTModel { - var endpoint: String { - "https://api.openai.com/v1/chat/completions" - } - - var maxToken: Int { - switch self { - case .gpt4: - return 8192 - case .gpt40314: - return 8192 - case .gpt432k: - return 32768 - case .gpt432k0314: - return 32768 - case .gpt35Turbo: - return 4096 - case .gpt35Turbo0301: - return 4096 - } - } -} - -extension ChatGPTModel: CaseIterable {} diff --git a/Core/Sources/Preferences/CustomCommand.swift b/Core/Sources/Preferences/CustomCommand.swift deleted file mode 100644 index 08a8823a..00000000 --- a/Core/Sources/Preferences/CustomCommand.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation -import CryptoKit - -public struct CustomCommand: Codable { - /// The custom command feature. - /// - /// Keep everything optional so nothing will break when the format changes. - public enum Feature: Codable { - case promptToCode(extraSystemPrompt: String?, prompt: String?, continuousMode: Bool?, generateDescription: Bool?) - case chatWithSelection(extraSystemPrompt: String?, prompt: String?, useExtraSystemPrompt: Bool?) - case customChat(systemPrompt: String?, prompt: String?) - } - - public var id: String { commandId ?? legacyId } - public var commandId: String? - public var name: String - public var feature: Feature - - public init(commandId: String, name: String, feature: Feature) { - self.commandId = commandId - self.name = name - self.feature = feature - } - - var legacyId: String { - name.sha1HexString - } -} - -private extension Digest { - var bytes: [UInt8] { Array(makeIterator()) } - var data: Data { Data(bytes) } - - var hexStr: String { - bytes.map { String(format: "%02X", $0) }.joined() - } -} - -private extension String { - var sha1HexString: String { - Insecure.SHA1.hash(data: data(using: .utf8) ?? Data()).hexStr - } -} diff --git a/Core/Sources/Preferences/Keys.swift b/Core/Sources/Preferences/Keys.swift deleted file mode 100644 index d0a916ac..00000000 --- a/Core/Sources/Preferences/Keys.swift +++ /dev/null @@ -1,285 +0,0 @@ -import Foundation - -public protocol UserDefaultPreferenceKey { - associatedtype Value - var defaultValue: Value { get } - var key: String { get } -} - -public struct PreferenceKey: UserDefaultPreferenceKey { - public let defaultValue: T - public let key: String -} - -public struct FeatureFlag: UserDefaultPreferenceKey { - public let defaultValue: Bool - public let key: String -} - -public struct UserDefaultPreferenceKeys { - public init() {} - - // MARK: Quit XPC Service On Xcode And App Quit - - public let quitXPCServiceOnXcodeAndAppQuit = PreferenceKey( - defaultValue: true, - key: "QuitXPCServiceOnXcodeAndAppQuit" - ) - - // MARK: Automatically Check For Update - - public let automaticallyCheckForUpdate = PreferenceKey( - defaultValue: false, - key: "AutomaticallyCheckForUpdate" - ) - - // MARK: Suggestion Widget Position Mode - - public let suggestionWidgetPositionMode = PreferenceKey( - defaultValue: SuggestionWidgetPositionMode.fixedToBottom, - key: "SuggestionWidgetPositionMode" - ) - - // MARK: Widget Color Scheme - - public let widgetColorScheme = PreferenceKey( - defaultValue: WidgetColorScheme.dark, - key: "WidgetColorScheme" - ) - - // MARK: Force Order Widget to Front - - public let forceOrderWidgetToFront = PreferenceKey( - defaultValue: true, - key: "ForceOrderWidgetToFront" - ) - - // MARK: Prefer Widget to Stay Inside Editor When Width Greater Than - - public let preferWidgetToStayInsideEditorWhenWidthGreaterThan = PreferenceKey( - defaultValue: 1400 as Double, - key: "PreferWidgetToStayInsideEditorWhenWidthGreaterThan" - ) -} - -// MARK: - OpenAI Account Settings - -public extension UserDefaultPreferenceKeys { - var openAIAPIKey: PreferenceKey { - .init(defaultValue: "", key: "OpenAIAPIKey") - } - - @available(*, deprecated, message: "Use `openAIBaseURL` instead.") - var chatGPTEndpoint: PreferenceKey { - .init(defaultValue: "", key: "ChatGPTEndpoint") - } - - var openAIBaseURL: PreferenceKey { - .init(defaultValue: "", key: "OpenAIBaseURL") - } - - var chatGPTModel: PreferenceKey { - .init(defaultValue: Preferences.ChatGPTModel.gpt35Turbo.rawValue, key: "ChatGPTModel") - } - - var chatGPTMaxToken: PreferenceKey { - .init(defaultValue: 4000, key: "ChatGPTMaxToken") - } - - var chatGPTLanguage: PreferenceKey { - .init(defaultValue: "", key: "ChatGPTLanguage") - } - - var chatGPTMaxMessageCount: PreferenceKey { - .init(defaultValue: 5, key: "ChatGPTMaxMessageCount") - } - - var chatGPTTemperature: PreferenceKey { - .init(defaultValue: 0.7, key: "ChatGPTTemperature") - } -} - -// MARK: - Azure OpenAI Settings - -public extension UserDefaultPreferenceKeys { - var azureOpenAIAPIKey: PreferenceKey { - .init(defaultValue: "", key: "AzureOpenAIAPIKey") - } - - var azureOpenAIBaseURL: PreferenceKey { - .init(defaultValue: "", key: "AzureOpenAIBaseURL") - } - - var azureChatGPTDeployment: PreferenceKey { - .init(defaultValue: "", key: "AzureChatGPTDeployment") - } -} - -// MARK: - GitHub Copilot Settings - -public extension UserDefaultPreferenceKeys { - var gitHubCopilotVerboseLog: PreferenceKey { - .init(defaultValue: false, key: "GitHubCopilotVerboseLog") - } - - var nodePath: PreferenceKey { - .init(defaultValue: "", key: "NodePath") - } - - var runNodeWith: PreferenceKey { - .init(defaultValue: .env, key: "RunNodeWith") - } -} - -// MARK: - Codeium Settings - -public extension UserDefaultPreferenceKeys { - var codeiumVerboseLog: PreferenceKey { - .init(defaultValue: false, key: "CodeiumVerboseLog") - } -} - -// MARK: - Prompt to Code - -public extension UserDefaultPreferenceKeys { - var promptToCodeFeatureProvider: PreferenceKey { - .init(defaultValue: .openAI, key: "PromptToCodeFeatureProvider") - } - - var promptToCodeGenerateDescription: PreferenceKey { - .init(defaultValue: true, key: "PromptToCodeGenerateDescription") - } - - var promptToCodeGenerateDescriptionInUserPreferredLanguage: PreferenceKey { - .init(defaultValue: true, key: "PromptToCodeGenerateDescriptionInUserPreferredLanguage") - } -} - -// MARK: - Suggestion - -public extension UserDefaultPreferenceKeys { - var suggestionFeatureProvider: PreferenceKey { - .init(defaultValue: .gitHubCopilot, key: "SuggestionFeatureProvider") - } - - var realtimeSuggestionToggle: PreferenceKey { - .init(defaultValue: true, key: "RealtimeSuggestionToggle") - } - - var suggestionCodeFontSize: PreferenceKey { - .init(defaultValue: 13, key: "SuggestionCodeFontSize") - } - - var disableSuggestionFeatureGlobally: PreferenceKey { - .init(defaultValue: false, key: "DisableSuggestionFeatureGlobally") - } - - var suggestionFeatureEnabledProjectList: PreferenceKey<[String]> { - .init(defaultValue: [], key: "SuggestionFeatureEnabledProjectList") - } - - var suggestionFeatureDisabledLanguageList: PreferenceKey<[String]> { - .init(defaultValue: [], key: "SuggestionFeatureDisabledLanguageList") - } - - var hideCommonPrecedingSpacesInSuggestion: PreferenceKey { - .init(defaultValue: true, key: "HideCommonPrecedingSpacesInSuggestion") - } - - var acceptSuggestionWithAccessibilityAPI: PreferenceKey { - .init(defaultValue: false, key: "AcceptSuggestionWithAccessibilityAPI") - } - - var suggestionPresentationMode: PreferenceKey { - .init(defaultValue: .floatingWidget, key: "SuggestionPresentationMode") - } - - var realtimeSuggestionDebounce: PreferenceKey { - .init(defaultValue: 0, key: "RealtimeSuggestionDebounce") - } -} - -// MARK: - Chat - -public extension UserDefaultPreferenceKeys { - var chatFeatureProvider: PreferenceKey { - .init(defaultValue: .openAI, key: "ChatFeatureProvider") - } - - var chatFontSize: PreferenceKey { - .init(defaultValue: 12, key: "ChatFontSize") - } - - var chatCodeFontSize: PreferenceKey { - .init(defaultValue: 12, key: "ChatCodeFontSize") - } - - var useGlobalChat: PreferenceKey { - .init(defaultValue: true, key: "UseGlobalChat") - } - - var embedFileContentInChatContextIfNoSelection: PreferenceKey { - .init(defaultValue: false, key: "EmbedFileContentInChatContextIfNoSelection") - } - - var maxEmbeddableFileInChatContextLineCount: PreferenceKey { - .init(defaultValue: 100, key: "MaxEmbeddableFileInChatContextLineCount") - } - - var useSelectionScopeByDefaultInChatContext: PreferenceKey { - .init(defaultValue: true, key: "UseSelectionScopeByDefaultInChatContext") - } -} - -// MARK: - Custom Commands - -public extension UserDefaultPreferenceKeys { - var customCommands: PreferenceKey<[CustomCommand]> { - .init(defaultValue: [ - .init( - commandId: "BuiltInCustomCommandExplainSelection", - name: "Explain Selection", - feature: .chatWithSelection( - extraSystemPrompt: "", - prompt: "Explain the selected code concisely, step-by-step.", - useExtraSystemPrompt: true - ) - ), - .init( - commandId: "BuiltInCustomCommandAddDocumentationToSelection", - name: "Add Documentation to Selection", - feature: .promptToCode( - extraSystemPrompt: "", - prompt: "Add documentation on top of the code. Use triple slash if the language supports it.", - continuousMode: false, - generateDescription: true - ) - ), - ], key: "CustomCommands") - } -} - -// MARK: - Feature Flags - -public extension UserDefaultPreferenceKeys { - var disableLazyVStack: FeatureFlag { - .init(defaultValue: false, key: "FeatureFlag-DisableLazyVStack") - } - - var preCacheOnFileOpen: FeatureFlag { - .init(defaultValue: true, key: "FeatureFlag-PreCacheOnFileOpen") - } - - var runNodeWithInteractiveLoggedInShell: FeatureFlag { - .init(defaultValue: true, key: "FeatureFlag-RunNodeWithInteractiveLoggedInShell") - } - - var useCustomScrollViewWorkaround: FeatureFlag { - .init(defaultValue: true, key: "FeatureFlag-UseCustomScrollViewWorkaround") - } - - var triggerActionWithAccessibilityAPI: FeatureFlag { - .init(defaultValue: true, key: "FeatureFlag-TriggerActionWithAccessibilityAPI") - } -} - diff --git a/Core/Sources/Preferences/SuggestionFeatureProvider.swift b/Core/Sources/Preferences/SuggestionFeatureProvider.swift deleted file mode 100644 index b08b84b9..00000000 --- a/Core/Sources/Preferences/SuggestionFeatureProvider.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -public enum SuggestionFeatureProvider: Int, CaseIterable { - case gitHubCopilot - case codeium -} diff --git a/Core/Sources/Preferences/UserDefaults.swift b/Core/Sources/Preferences/UserDefaults.swift deleted file mode 100644 index 207edcc5..00000000 --- a/Core/Sources/Preferences/UserDefaults.swift +++ /dev/null @@ -1,148 +0,0 @@ -import Foundation -import Configs - -public extension UserDefaults { - static var shared = UserDefaults(suiteName: userDefaultSuiteName)! - - static func setupDefaultSettings() { - shared.setupDefaultValue(for: \.quitXPCServiceOnXcodeAndAppQuit) - shared.setupDefaultValue(for: \.realtimeSuggestionToggle) - shared.setupDefaultValue(for: \.realtimeSuggestionDebounce) - shared.setupDefaultValue(for: \.automaticallyCheckForUpdate) - shared.setupDefaultValue(for: \.suggestionPresentationMode) - shared.setupDefaultValue(for: \.widgetColorScheme) - shared.setupDefaultValue(for: \.customCommands) - shared.setupDefaultValue(for: \.runNodeWith, defaultValue: .env) - shared.setupDefaultValue(for: \.openAIBaseURL, defaultValue: { - guard let url = URL(string: shared.value(for: \.chatGPTEndpoint)) else { return "" } - let scheme = url.scheme ?? "https" - guard let host = url.host else { return "" } - return "\(scheme)://\(host)" - }() as String) - } -} - -public protocol UserDefaultsStorable {} - -extension Int: UserDefaultsStorable {} -extension Double: UserDefaultsStorable {} -extension Bool: UserDefaultsStorable {} -extension String: UserDefaultsStorable {} -extension Data: UserDefaultsStorable {} -extension URL: UserDefaultsStorable {} - -extension Array: RawRepresentable where Element: Codable { - public init?(rawValue: String) { - guard let data = rawValue.data(using: .utf8), - let result = try? JSONDecoder().decode([Element].self, from: data) - else { - return nil - } - self = result - } - - public var rawValue: String { - guard let data = try? JSONEncoder().encode(self), - let result = String(data: data, encoding: .utf8) - else { - return "[]" - } - return result - } -} - -public extension UserDefaults { - // MARK: - Normal Types - - func value( - for keyPath: KeyPath - ) -> K.Value where K.Value: UserDefaultsStorable { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - return (value(forKey: key.key) as? K.Value) ?? key.defaultValue - } - - func set( - _ value: K.Value, - for keyPath: KeyPath - ) where K.Value: UserDefaultsStorable { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - set(value, forKey: key.key) - } - - func setupDefaultValue( - for keyPath: KeyPath - ) where K.Value: UserDefaultsStorable { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - if value(forKey: key.key) == nil { - set(key.defaultValue, forKey: key.key) - } - } - - func setupDefaultValue( - for keyPath: KeyPath, - defaultValue: K.Value - ) where K.Value: UserDefaultsStorable { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - if value(forKey: key.key) == nil { - set(defaultValue, forKey: key.key) - } - } - - // MARK: - Raw Representable - - func value( - for keyPath: KeyPath - ) -> K.Value where K.Value: RawRepresentable, K.Value.RawValue == String { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - guard let rawValue = value(forKey: key.key) as? String else { - return key.defaultValue - } - return K.Value(rawValue: rawValue) ?? key.defaultValue - } - - func value( - for keyPath: KeyPath - ) -> K.Value where K.Value: RawRepresentable, K.Value.RawValue == Int { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - guard let rawValue = value(forKey: key.key) as? Int else { - return key.defaultValue - } - return K.Value(rawValue: rawValue) ?? key.defaultValue - } - - func set( - _ value: K.Value, - for keyPath: KeyPath - ) where K.Value: RawRepresentable, K.Value.RawValue == String { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - set(value.rawValue, forKey: key.key) - } - - func set( - _ value: K.Value, - for keyPath: KeyPath - ) where K.Value: RawRepresentable, K.Value.RawValue == Int { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - set(value.rawValue, forKey: key.key) - } - - func setupDefaultValue( - for keyPath: KeyPath, - defaultValue: K.Value? = nil - ) where K.Value: RawRepresentable, K.Value.RawValue == String { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - if value(forKey: key.key) == nil { - set(defaultValue?.rawValue ?? key.defaultValue.rawValue, forKey: key.key) - } - } - - func setupDefaultValue( - for keyPath: KeyPath, - defaultValue: K.Value? = nil - ) where K.Value: RawRepresentable, K.Value.RawValue == Int { - let key = UserDefaultPreferenceKeys()[keyPath: keyPath] - if value(forKey: key.key) == nil { - set(defaultValue?.rawValue ?? key.defaultValue.rawValue, forKey: key.key) - } - } -} diff --git a/Core/Sources/PromptToCodeService/CopilotPromptToCodeAPI.swift b/Core/Sources/PromptToCodeService/CopilotPromptToCodeAPI.swift deleted file mode 100644 index 0db4c337..00000000 --- a/Core/Sources/PromptToCodeService/CopilotPromptToCodeAPI.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -import GitHubCopilotService -import OpenAIService -import SuggestionModel - -final class CopilotPromptToCodeAPI: PromptToCodeAPI { - var task: Task? - - func stopResponding() { - task?.cancel() - } - - func modifyCode( - code: String, - language: CodeLanguage, - indentSize: Int, - usesTabsForIndentation: Bool, - requirement: String, - projectRootURL: URL, - fileURL: URL, - allCode: String, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { - let copilotService = try GitHubCopilotSuggestionService(projectRootURL: projectRootURL) - let _ = { - let filePath = fileURL.path - let rootPath = projectRootURL.path - if let range = filePath.range(of: rootPath), - range.lowerBound == filePath.startIndex - { - let relativePath = filePath.replacingCharacters( - in: filePath.startIndex.. String { - s.split(separator: "\n").map { "// \($0)" }.joined(separator: "\n") - } - - let comment = """ - // A file to refactor the following code - // - // Code: - // ``` - \(convertToComment(code)) - // ``` - // - // Requirements: - \(convertToComment((extraSystemPrompt ?? "\n") + requirement)) - // - - - - // end of file - """ - let lineCount = comment.breakLines().count - - return .init { continuation in - self.task = Task { - do { - let result = try await copilotService.getCompletions( - fileURL: fileURL, - content: comment, - cursorPosition: .init(line: lineCount - 3, character: 0), - tabSize: indentSize, - indentSize: indentSize, - usesTabsForIndentation: usesTabsForIndentation, - ignoreSpaceOnlySuggestions: true - ) - try Task.checkCancellation() - guard let first = result.first else { throw CancellationError() } - continuation.yield((first.text, "")) - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - } - } -} - -extension String { - /// Break a string into lines. - func breakLines() -> [String] { - let lines = split(separator: "\n", omittingEmptySubsequences: false) - var all = [String]() - for (index, line) in lines.enumerated() { - if index == lines.endIndex - 1 { - all.append(String(line)) - } else { - all.append(String(line) + "\n") - } - } - return all - } -} - diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift deleted file mode 100644 index 709ec2cb..00000000 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift +++ /dev/null @@ -1,226 +0,0 @@ -import Foundation -import GitHubCopilotService -import OpenAIService -import Preferences -import SuggestionModel - -final class OpenAIPromptToCodeAPI: PromptToCodeAPI { - var service: (any ChatGPTServiceType)? - - func stopResponding() { - Task { - await service?.stopReceivingMessage() - } - } - - func modifyCode( - code: String, - language: CodeLanguage, - indentSize: Int, - usesTabsForIndentation: Bool, - requirement: String, - projectRootURL: URL, - fileURL: URL, - allCode: String, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { - let userPreferredLanguage = UserDefaults.shared.value(for: \.chatGPTLanguage) - let textLanguage = { - if !UserDefaults.shared - .value(for: \.promptToCodeGenerateDescriptionInUserPreferredLanguage) - { - return "" - } - return userPreferredLanguage.isEmpty ? "" : "in \(userPreferredLanguage)" - }() - - let rule: String = { - func generateDescription(index: Int) -> String { - let generateDescription = generateDescriptionRequirement ?? UserDefaults.shared - .value(for: \.promptToCodeGenerateDescription) - return generateDescription - ? """ - \(index). After the code block, write a clear and concise description \ - in 1-3 sentences about what you did in step 1\(textLanguage). - \(index + 1). Reply with the result. - """ - : "\(index). Reply with the result." - } - switch language { - case .builtIn(.markdown), .plaintext: - if code.isEmpty { - return """ - 1. Write the content that meets my requirements. - 2. Embed the new content in a markdown code block. - \(generateDescription(index: 3)) - """ - } else { - return """ - 1. Do what I required. - 2. Format the updated content to use the original indentation. Especially the first line. - 3. Embed the updated content in a markdown code block. - 4. You MUST never translate the content in the code block if it's not requested in the requirements. - \(generateDescription(index: 5)) - """ - } - default: - if code.isEmpty { - return """ - 1. Write the code that meets my requirements. - 2. Embed the code in a markdown code block. - \(generateDescription(index: 3)) - """ - } else { - return """ - 1. Do what I required. - 2. Format the updated code to use the original indentation. Especially the first line. - 3. Embed the updated code in a markdown code block. - \(generateDescription(index: 4)) - """ - } - } - }() - - let systemPrompt = { - switch language { - case .builtIn(.markdown), .plaintext: - if code.isEmpty { - return """ - You are good at writing in \(language.rawValue). - The active file is: \(fileURL.lastPathComponent). - \(extraSystemPrompt ?? "") - - \(rule) - """ - } else { - return """ - You are good at writing in \(language.rawValue). - The active file is: \(fileURL.lastPathComponent). - \(extraSystemPrompt ?? "") - - \(rule) - """ - } - default: - if code.isEmpty { - return """ - You are a senior programer in writing in \(language.rawValue). - The active file is: \(fileURL.lastPathComponent). - \(extraSystemPrompt ?? "") - - \(rule) - """ - } else { - return """ - You are a senior programer in writing in \(language.rawValue). - The active file is: \(fileURL.lastPathComponent). - \(extraSystemPrompt ?? "") - - \(rule) - """ - } - } - }() - - let firstMessage: String? = { - if code.isEmpty { return nil } - switch language { - case .builtIn(.markdown), .plaintext: - return """ - ``` - \(code) - ``` - """ - default: - return """ - ``` - \(code) - ``` - """ - } - }() - - let secondMessage = """ - Requirements:### - \(requirement) - ### - """ - - let chatGPTService = ChatGPTService(systemPrompt: systemPrompt, temperature: 0.3) - service = chatGPTService - if let firstMessage { - await chatGPTService.mutateHistory { history in - history.append(.init(role: .user, content: firstMessage)) - } - } - let stream = try await chatGPTService.send(content: secondMessage) - return .init { continuation in - Task { - var content = "" - var extracted = extractCodeAndDescription(from: content) - do { - for try await fragment in stream { - content.append(fragment) - extracted = extractCodeAndDescription(from: content) - if !content.isEmpty, extracted.code.isEmpty { - continuation.yield((code: content, description: "")) - } else { - continuation.yield(extracted) - } - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - } - } - - func extractCodeAndDescription(from content: String) -> (code: String, description: String) { - func extractCodeFromMarkdown(_ markdown: String) -> (code: String, endIndex: Int)? { - let codeBlockRegex = try! NSRegularExpression( - pattern: #"```(?:\w+)?[\n]([\s\S]+?)[\n]```"#, - options: .dotMatchesLineSeparators - ) - let range = NSRange(markdown.startIndex.. String { - let startIndex = markdown.index(markdown.startIndex, offsetBy: startIndex) - guard startIndex < markdown.endIndex else { return "" } - let range = startIndex.. AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let stream = try await modifyCode( + code: request.code, + requirement: request.requirement, + source: .init( + language: request.source.language, + documentURL: request.source.documentURL, + projectRootURL: request.source.projectRootURL, + content: request.source.content, + lines: request.source.lines, + range: request.range + ), + isDetached: request.isDetached, + extraSystemPrompt: request.extraSystemPrompt, + generateDescriptionRequirement: false + ) + + for try await response in stream { + continuation.yield(response) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + public init() {} + + func modifyCode( + code: String, + requirement: String, + source: PromptToCodeSource, + isDetached: Bool, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) async throws -> AsyncThrowingStream { + let userPreferredLanguage = UserDefaults.shared.value(for: \.chatGPTLanguage) + let textLanguage = { + if !UserDefaults.shared + .value(for: \.promptToCodeGenerateDescriptionInUserPreferredLanguage) + { + return "" + } + return userPreferredLanguage.isEmpty ? "" : " in \(userPreferredLanguage)" + }() + + let editor: EditorInformation = await XcodeInspector.shared.getFocusedEditorContent() + ?? .init( + editorContent: .init( + content: source.content, + lines: source.lines, + selections: [source.range], + cursorPosition: .outOfScope, + cursorOffset: -1, + lineAnnotations: [] + ), + selectedContent: code, + selectedLines: [], + documentURL: source.documentURL, + workspaceURL: source.projectRootURL, + projectRootURL: source.projectRootURL, + relativePath: "", + language: source.language + ) + + let rule: String = { + func generateDescription(index: Int) -> String { + let generateDescription = generateDescriptionRequirement ?? UserDefaults.shared + .value(for: \.promptToCodeGenerateDescription) + return generateDescription + ? """ + \(index). After the code block, write a clear and concise description \ + in 1-3 sentences about what you did in step 1\(textLanguage). + \(index + 1). Reply with the result. + """ + : "\(index). Reply with the result." + } + switch editor.language { + case .builtIn(.markdown), .plaintext: + if code.isEmpty { + return """ + 1. Write the content that meets my requirements. + 2. Embed the new content in a markdown code block. + \(generateDescription(index: 3)) + """ + } else { + return """ + 1. Do what I required. + 2. Format the updated content to use the original indentation. Especially the first line. + 3. Embed the updated content in a markdown code block. + 4. You MUST never translate the content in the code block if it's not requested in the requirements. + \(generateDescription(index: 5)) + """ + } + default: + if code.isEmpty { + return """ + 1. Write the code that meets my requirements. + 2. Embed the code in a markdown code block. + \(generateDescription(index: 3)) + """ + } else { + return """ + 1. Do what I required. + 2. Format the updated code to use the original indentation. Especially the first line. + 3. Embed the updated code in a markdown code block. + \(generateDescription(index: 4)) + """ + } + } + }() + + let systemPrompt = { + switch editor.language { + case .builtIn(.markdown), .plaintext: + if code.isEmpty { + return """ + You are good at writing in \(editor.language.rawValue). + The active file is: \(editor.documentURL.lastPathComponent). + \(extraSystemPrompt ?? "") + + \(rule) + """ + } else { + return """ + You are good at writing in \(editor.language.rawValue). + The active file is: \(editor.documentURL.lastPathComponent). + \(extraSystemPrompt ?? "") + + \(rule) + """ + } + default: + if code.isEmpty { + return """ + You are a senior programer in writing in \(editor.language.rawValue). + The active file is: \(editor.documentURL.lastPathComponent). + \(extraSystemPrompt ?? "") + + \(rule) + """ + } else { + return """ + You are a senior programer in writing in \(editor.language.rawValue). + The active file is: \(editor.documentURL.lastPathComponent). + \(extraSystemPrompt ?? "") + + \(rule) + """ + } + } + }() + + let annotations = isDetached + ? "" + : extractAnnotations(editorInformation: editor, source: source) + + let firstMessage: String? = { + if code.isEmpty { return nil } + switch editor.language { + case .builtIn(.markdown), .plaintext: + return """ + ``` + \(code) + ``` + + \(annotations) + """ + default: + return """ + ``` + \(code) + ``` + + \(annotations) + """ + } + }() + + let indentation = getCommonLeadingSpaceCount(code) + + let secondMessage = """ + I will update the code you just provided. + Every line has an indentation of \(indentation) spaces, I will keep that. + + What is your requirement? + """ + + let configuration = + UserPreferenceChatGPTConfiguration(chatModelKey: \.promptToCodeChatModelId) + .overriding(.init(temperature: 0)) + + let memory = AutoManagedChatGPTMemory( + systemPrompt: systemPrompt, + configuration: configuration, + functionProvider: NoChatGPTFunctionProvider(), + maxNumberOfMessages: .max + ) + let chatGPTService = ChatGPTService( + configuration: configuration, + functionProvider: NoChatGPTFunctionProvider() + ) + + if let firstMessage { + await memory.mutateHistory { history in + history.append(.init(role: .user, content: firstMessage)) + history.append(.init(role: .assistant, content: secondMessage)) + history.append(.init(role: .user, content: requirement)) + } + } + let stream = chatGPTService.send(memory) + + return .init { continuation in + let task = Task { + let parser = ExplanationThenCodeStreamParser() + do { + func yield(fragments: [ExplanationThenCodeStreamParser.Fragment]) { + for fragment in fragments { + switch fragment { + case let .code(code): + continuation.yield(.code(code)) + case let .explanation(explanation): + continuation.yield(.explanation(explanation)) + } + } + } + + for try await response in stream { + guard case let .partialText(fragment) = response else { continue } + try Task.checkCancellation() + await yield(fragments: parser.yield(fragment)) + } + await yield(fragments: parser.finish()) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } +} + +// MAKR: - Internal + +extension SimpleModificationAgent { + func extractCodeAndDescription(from content: String) + -> (code: String, description: String) + { + func extractCodeFromMarkdown( + _ markdown: String + ) -> (code: String, endIndex: String.Index)? { + let codeBlockRegex = try! NSRegularExpression( + pattern: #"```(?:\w+)?\R([\s\S]+?)\R```"#, + options: .dotMatchesLineSeparators + ) + let range = NSRange(markdown.startIndex.. String { + guard startIndex < markdown.endIndex else { return "" } + let range = startIndex.. Int { + let lines = code.split(whereSeparator: \.isNewline) + guard !lines.isEmpty else { return 0 } + var commonCount = Int.max + for line in lines { + let count = line.prefix(while: { $0 == " " }).count + commonCount = min(commonCount, count) + if commonCount == 0 { break } + } + return commonCount + } + + func extractAnnotations( + editorInformation: EditorInformation, + source: PromptToCodeSource + ) -> String { + guard let annotations = editorInformation.editorContent?.lineAnnotations + else { return "" } + let all = annotations + .lazy + .filter { annotation in + annotation.line >= source.range.start.line + 1 + && annotation.line <= source.range.end.line + 1 + }.map { annotation in + let relativeLine = annotation.line - source.range.start.line + return "line \(relativeLine): \(annotation.type) \(annotation.message)" + } + guard !all.isEmpty else { return "" } + return """ + line annotations found: + \(annotations.map { "- \($0)" }.joined(separator: "\n")) + """ + } +} + diff --git a/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift b/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift new file mode 100644 index 00000000..ac0fd4df --- /dev/null +++ b/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift @@ -0,0 +1,85 @@ +import Foundation +import ModificationBasic +import SuggestionBasic + +public final class PreviewModificationAgent: ModificationAgent { + public func send(_ request: Request) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let stream = try await modifyCode( + code: request.code, + requirement: request.requirement, + source: .init( + language: request.source.language, + documentURL: request.source.documentURL, + projectRootURL: request.source.projectRootURL, + content: request.source.content, + lines: request.source.lines, + range: request.range + ), + isDetached: request.isDetached, + extraSystemPrompt: request.extraSystemPrompt, + generateDescriptionRequirement: false + ) + + for try await (code, description) in stream { + continuation.yield(.code(code)) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + public init() {} + + func modifyCode( + code: String, + requirement: String, + source: PromptToCodeSource, + isDetached: Bool, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { + return AsyncThrowingStream { continuation in + Task { + let code = """ + struct Cat { + var name: String + } + + print("Hello world!") + """ + let description = "I have created a struct `Cat`." + var resultCode = "" + var resultDescription = "" + do { + for character in code { + try await Task.sleep(nanoseconds: 50_000_000) + resultCode.append(character) + continuation.yield((resultCode, resultDescription)) + } + for character in description { + try await Task.sleep(nanoseconds: 50_000_000) + resultDescription.append(character) + continuation.yield((resultCode, resultDescription)) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + public func stopResponding() {} +} + diff --git a/Core/Sources/PromptToCodeService/PromptToCodeService.swift b/Core/Sources/PromptToCodeService/PromptToCodeService.swift deleted file mode 100644 index 9a492f71..00000000 --- a/Core/Sources/PromptToCodeService/PromptToCodeService.swift +++ /dev/null @@ -1,157 +0,0 @@ -import SuggestionModel -import GitHubCopilotService -import Foundation -import OpenAIService - -public final class PromptToCodeService: ObservableObject { - var designatedPromptToCodeAPI: PromptToCodeAPI? - var promptToCodeAPI: PromptToCodeAPI { - if let designatedPromptToCodeAPI { - return designatedPromptToCodeAPI - } - - return OpenAIPromptToCodeAPI() - } - - var runningAPI: PromptToCodeAPI? - - public indirect enum HistoryNode: Equatable { - case empty - case node(code: String, description: String, previous: HistoryNode) - - mutating func enqueue(code: String, description: String) { - let current = self - self = .node(code: code, description: description, previous: current) - } - - mutating func pop() -> (code: String, description: String)? { - switch self { - case .empty: - return nil - case let .node(code, description, previous): - self = previous - return (code, description) - } - } - } - - @Published public var history: HistoryNode - @Published public var code: String - @Published public var isResponding: Bool = false - @Published public var description: String = "" - @Published public var isContinuous = false - public var canRevert: Bool { history != .empty } - public var selectionRange: CursorRange - public var language: CodeLanguage - public var indentSize: Int - public var usesTabsForIndentation: Bool - public var projectRootURL: URL - public var fileURL: URL - public var allCode: String - public var extraSystemPrompt: String? - public var generateDescriptionRequirement: Bool? - - public init( - code: String, - selectionRange: CursorRange, - language: CodeLanguage, - identSize: Int, - usesTabsForIndentation: Bool, - projectRootURL: URL, - fileURL: URL, - allCode: String, - extraSystemPrompt: String? = nil, - generateDescriptionRequirement: Bool? - ) { - self.code = code - self.selectionRange = selectionRange - self.language = language - indentSize = identSize - self.usesTabsForIndentation = usesTabsForIndentation - self.projectRootURL = projectRootURL - self.fileURL = fileURL - self.allCode = allCode - self.history = .empty - self.extraSystemPrompt = extraSystemPrompt - self.generateDescriptionRequirement = generateDescriptionRequirement - } - - public func modifyCode(prompt: String) async throws { - let api = promptToCodeAPI - runningAPI = api - isResponding = true - let toBeModified = code - history.enqueue(code: code, description: description) - code = "" - description = "" - defer { isResponding = false } - do { - let stream = try await api.modifyCode( - code: toBeModified, - language: language, - indentSize: indentSize, - usesTabsForIndentation: usesTabsForIndentation, - requirement: prompt, - projectRootURL: projectRootURL, - fileURL: fileURL, - allCode: allCode, - extraSystemPrompt: extraSystemPrompt, - generateDescriptionRequirement: generateDescriptionRequirement - ) - for try await fragment in stream { - code = fragment.code - description = fragment.description - } - if code.isEmpty, description.isEmpty { - revert() - } - } catch is CancellationError { - return - } catch { - if (error as NSError).code == NSURLErrorCancelled { - return - } - - revert() - throw error - } - } - - public func revert() { - guard let (code, description) = history.pop() else { return } - self.code = code - self.description = description - } - - public func generateCompletion() -> CodeSuggestion { - .init( - text: code, - position: selectionRange.start, - uuid: UUID().uuidString, - range: selectionRange, - displayText: code - ) - } - - public func stopResponding() { - runningAPI?.stopResponding() - isResponding = false - } -} - -protocol PromptToCodeAPI { - func modifyCode( - code: String, - language: CodeLanguage, - indentSize: Int, - usesTabsForIndentation: Bool, - requirement: String, - projectRootURL: URL, - fileURL: URL, - allCode: String, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> - - func stopResponding() -} diff --git a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift new file mode 100644 index 00000000..3e0cd400 --- /dev/null +++ b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift @@ -0,0 +1,28 @@ +import Dependencies +import Foundation +import SuggestionBasic + +public struct PromptToCodeSource { + public var language: CodeLanguage + public var documentURL: URL + public var projectRootURL: URL + public var content: String + public var lines: [String] + public var range: CursorRange + + public init( + language: CodeLanguage, + documentURL: URL, + projectRootURL: URL, + content: String, + lines: [String], + range: CursorRange + ) { + self.language = language + self.documentURL = documentURL + self.projectRootURL = projectRootURL + self.content = content + self.lines = lines + self.range = range + } +} diff --git a/Core/Sources/Service/DependencyUpdater.swift b/Core/Sources/Service/DependencyUpdater.swift index a98444db..6eb4124a 100644 --- a/Core/Sources/Service/DependencyUpdater.swift +++ b/Core/Sources/Service/DependencyUpdater.swift @@ -1,11 +1,12 @@ import CodeiumService +import Foundation import GitHubCopilotService import Logger -public struct DependencyUpdater { - public init() {} +struct DependencyUpdater { + init() {} - public func update() { + func update() { Task { await withTaskGroup(of: Void.self) { taskGroup in let gitHubCopilot = GitHubCopilotInstallationManager() @@ -39,8 +40,10 @@ public struct DependencyUpdater { } } } + let codeium = CodeiumInstallationManager() - switch codeium.checkInstallation() { + + switch await codeium.checkInstallation() { case .notInstalled: break case .installed: break case .unsupported: break diff --git a/Core/Sources/Service/GUI/ChatProvider+Service.swift b/Core/Sources/Service/GUI/ChatProvider+Service.swift deleted file mode 100644 index 0e157111..00000000 --- a/Core/Sources/Service/GUI/ChatProvider+Service.swift +++ /dev/null @@ -1,103 +0,0 @@ -import ChatService -import Combine -import Foundation -import OpenAIService -import SuggestionWidget - -extension ChatProvider { - convenience init( - service: ChatService, - fileURL: URL, - onCloseChat: @escaping () -> Void, - onSwitchContext: @escaping () -> Void - ) { - self.init() - - let cancellable = service.objectWillChange.sink { [weak self] in - guard let self else { return } - Task { @MainActor in - self.history = (await service.chatGPTService.history).map { message in - .init( - id: message.id, - isUser: message.role == .user, - text: message.summary ?? message.content - ) - } - self.isReceivingMessage = await service.chatGPTService.isReceivingMessage - self.systemPrompt = service.systemPrompt - self.extraSystemPrompt = service.extraSystemPrompt - } - } - - service.objectWillChange.send() - - onMessageSend = { [cancellable] message in - _ = cancellable - Task { - do { - _ = try await service.send(content: message) - } catch { - PresentInWindowSuggestionPresenter().presentError(error) - } - } - } - onStop = { - Task { - await service.stopReceivingMessage() - } - } - - onClear = { - Task { - await service.clearHistory() - } - } - - onClose = { - Task { - await service.stopReceivingMessage() - onCloseChat() - } - } - - self.onSwitchContext = { - onSwitchContext() - } - - onDeleteMessage = { id in - Task { - await service.deleteMessage(id: id) - } - } - - onResendMessage = { id in - Task { - do { - try await service.resendMessage(id: id) - } catch { - PresentInWindowSuggestionPresenter().presentError(error) - } - } - } - - onResetPrompt = { - Task { - await service.resetPrompt() - } - } - - onRunCustomCommand = { command in - Task { - let commandHandler = PseudoCommandHandler() - await commandHandler.handleCustomCommand(command) - } - } - - onSetAsExtraPrompt = { id in - Task { - await service.setMessageAsExtraPrompt(id: id) - } - } - } -} - diff --git a/Core/Sources/Service/GUI/ChatTabFactory.swift b/Core/Sources/Service/GUI/ChatTabFactory.swift new file mode 100644 index 00000000..fae16330 --- /dev/null +++ b/Core/Sources/Service/GUI/ChatTabFactory.swift @@ -0,0 +1,57 @@ +import BuiltinExtension +import ChatGPTChatTab +import ChatService +import ChatTab +import Foundation +import PromptToCodeService +import SuggestionBasic +import SuggestionWidget +import XcodeInspector + +enum ChatTabFactory { + static func chatTabBuilderCollection() -> [ChatTabBuilderCollection] { + let chatGPTChatTab = folderIfNeeded( + ChatGPTChatTab.chatBuilders(), + title: ChatGPTChatTab.name + ) + + let (defaultChatTab, othersChatTabs) = chatTabsFromExtensions() + + if let defaultChatTab { + return [defaultChatTab] + othersChatTabs + [chatGPTChatTab].compactMap(\.self) + } else { + return [chatGPTChatTab].compactMap(\.self) + othersChatTabs + } + } + + private static func folderIfNeeded( + _ builders: [any ChatTabBuilder], + title: String + ) -> ChatTabBuilderCollection? { + if builders.count > 1 { + return .folder(title: title, kinds: builders.map(ChatTabKind.init)) + } + if let first = builders.first { return .kind(ChatTabKind(first)) } + return nil + } + + static func chatTabsFromExtensions() + -> (default: ChatTabBuilderCollection?, others: [ChatTabBuilderCollection]) + { + let extensions = BuiltinExtensionManager.shared.extensions + let chatTabTypes = extensions.flatMap(\.chatTabTypes) + var defaultChatTab: ChatTabBuilderCollection? + var otherChatTabs = [ChatTabBuilderCollection]() + for chatTabType in chatTabTypes { + if chatTabType.isDefaultChatTabReplacement { + defaultChatTab = folderIfNeeded(chatTabType.chatBuilders(), title: chatTabType.name) + } else if let tab = folderIfNeeded( + chatTabType.chatBuilders(), + title: chatTabType.name + ) { + otherChatTabs.append(tab) + } + } + return (defaultChatTab, otherChatTabs) + } +} diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift new file mode 100644 index 00000000..dfbd719a --- /dev/null +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -0,0 +1,422 @@ +import ActiveApplicationMonitor +import AppActivator +import AppKit +import BuiltinExtension +import ChatGPTChatTab +import ChatTab +import ComposableArchitecture +import Dependencies +import Logger +import Preferences +import SuggestionBasic +import SuggestionWidget + +#if canImport(ChatTabPersistent) +import ChatTabPersistent +#endif + +@Reducer +struct GUI { + @ObservableState + struct State { + var suggestionWidgetState = Widget.State() + + var chatTabGroup: SuggestionWidget.ChatPanel.ChatTabGroup { + get { suggestionWidgetState.chatPanelState.chatTabGroup } + set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue } + } + + var promptToCodeGroup: PromptToCodeGroup.State { + get { suggestionWidgetState.panelState.content.promptToCodeGroup } + set { suggestionWidgetState.panelState.content.promptToCodeGroup = newValue } + } + + #if canImport(ChatTabPersistent) + var isChatTabRestoreFinished: Bool = false + var persistentState: ChatTabPersistent.State { + get { + .init( + chatTabInfo: chatTabGroup.tabInfo, + isRestoreFinished: isChatTabRestoreFinished, + selectedChatTapId: chatTabGroup.selectedTabId + ) + } + set { + chatTabGroup.tabInfo = newValue.chatTabInfo + isChatTabRestoreFinished = newValue.isRestoreFinished + chatTabGroup.selectedTabId = newValue.selectedChatTapId + } + } + #endif + } + + enum Action { + case start + case openChatPanel(forceDetach: Bool, activateThisApp: Bool) + case createAndSwitchToChatGPTChatTabIfNeeded + case createAndSwitchToChatTabIfNeededMatching( + check: (any ChatTab) -> Bool, + kind: ChatTabKind? + ) + case sendCustomCommandToActiveChat(CustomCommand) + case toggleWidgetsHotkeyPressed + + case suggestionWidget(Widget.Action) + + static func promptToCodeGroup(_ action: PromptToCodeGroup.Action) -> Self { + .suggestionWidget(.panel(.sharedPanel(.promptToCodeGroup(action)))) + } + + #if canImport(ChatTabPersistent) + case persistent(ChatTabPersistent.Action) + #endif + } + + @Dependency(\.chatTabPool) var chatTabPool + @Dependency(\.activateThisApp) var activateThisApp + + public enum Debounce: Hashable { + case updateChatTabOrder + } + + var body: some ReducerOf { + CombineReducers { + Scope(state: \.suggestionWidgetState, action: \.suggestionWidget) { + Widget() + } + + Scope( + state: \.chatTabGroup, + action: \.suggestionWidget.chatPanel + ) { + Reduce { _, action in + switch action { + case let .createNewTapButtonClicked(kind): + return .run { send in + if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) { + await send(.appendAndSelectTab(chatTabInfo)) + } + } + + case let .closeTabButtonClicked(id): + return .run { _ in + chatTabPool.removeTab(of: id) + } + + case let .chatTab(.element(_, .openNewTab(builder))): + return .run { send in + if let (_, chatTabInfo) = await chatTabPool + .createTab(from: builder.chatTabBuilder) + { + await send(.appendAndSelectTab(chatTabInfo)) + } + } + + default: + return .none + } + } + } + + #if canImport(ChatTabPersistent) + Scope(state: \.persistentState, action: \.persistent) { + ChatTabPersistent() + } + #endif + + Reduce { state, action in + switch action { + case .start: + #if canImport(ChatTabPersistent) + return .run { send in + await send(.persistent(.restoreChatTabs)) + } + #else + return .none + #endif + + case let .openChatPanel(forceDetach, activate): + return .run { send in + await send( + .suggestionWidget( + .chatPanel(.presentChatPanel(forceDetach: forceDetach)) + ) + ) + await send(.suggestionWidget(.updateKeyWindow(.chatPanel))) + + if activate { + activateThisApp() + } + } + + case .createAndSwitchToChatGPTChatTabIfNeeded: + return .run { send in + await send(.createAndSwitchToChatTabIfNeededMatching( + check: { $0 is ChatGPTChatTab }, + kind: nil + )) + } + + case let .createAndSwitchToChatTabIfNeededMatching(check, kind): + if let selectedTabInfo = state.chatTabGroup.selectedTabInfo, + let tab = chatTabPool.getTab(of: selectedTabInfo.id), + check(tab) + { + // Already in ChatGPT tab + return .none + } + + if let firstChatGPTTabInfo = state.chatTabGroup.tabInfo.first(where: { + if let tab = chatTabPool.getTab(of: $0.id) { + return check(tab) + } + return false + }) { + return .run { send in + await send(.suggestionWidget(.chatPanel(.tabClicked( + id: firstChatGPTTabInfo.id + )))) + } + } + return .run { send in + if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) { + await send( + .suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo))) + ) + } + } + + case let .sendCustomCommandToActiveChat(command): + if let info = state.chatTabGroup.selectedTabInfo, + let tab = chatTabPool.getTab(of: info.id), + tab.handleCustomCommand(command) + { + return .run { send in + await send(.openChatPanel(forceDetach: false, activateThisApp: false)) + } + } + + for info in state.chatTabGroup.tabInfo { + if let chatTab = chatTabPool.getTab(of: info.id), + chatTab.handleCustomCommand(command) + { + state.chatTabGroup.selectedTabId = chatTab.id + return .run { send in + await send(.openChatPanel( + forceDetach: false, + activateThisApp: false + )) + } + } + } + + return .run { send in + guard let (chatTab, chatTabInfo) = await chatTabPool.createTab(for: nil) + else { return } + await send(.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))) + await send(.openChatPanel(forceDetach: false, activateThisApp: false)) + _ = chatTab.handleCustomCommand(command) + } + + case .toggleWidgetsHotkeyPressed: + return .run { send in + await send(.suggestionWidget(.circularWidget(.widgetClicked))) + } + + case let .suggestionWidget(.chatPanel(.chatTab(.element(id, .tabContentUpdated)))): + #if canImport(ChatTabPersistent) + // when a tab is updated, persist it. + return .run { send in + await send(.persistent(.chatTabUpdated(id: id))) + } + #else + return .none + #endif + + case let .suggestionWidget(.chatPanel(.closeTabButtonClicked(id))): + #if canImport(ChatTabPersistent) + // when a tab is closed, remove it from persistence. + return .run { send in + await send(.persistent(.chatTabClosed(id: id))) + } + #else + return .none + #endif + + case .suggestionWidget: + return .none + + #if canImport(ChatTabPersistent) + case .persistent: + return .none + #endif + } + } + }.onChange(of: \.chatTabGroup.tabInfo) { old, new in + Reduce { _, _ in + guard old.map(\.id) != new.map(\.id) else { + return .none + } + #if canImport(ChatTabPersistent) + return .run { send in + await send(.persistent(.chatOrderChanged)) + }.debounce(id: Debounce.updateChatTabOrder, for: 1, scheduler: DispatchQueue.main) + #else + return .none + #endif + } + } + } +} + +@MainActor +public final class GraphicalUserInterfaceController { + let store: StoreOf + let widgetController: SuggestionWidgetController + let widgetDataSource: WidgetDataSource + let chatTabPool: ChatTabPool + + class WeakStoreHolder { + weak var store: StoreOf? + } + + init() { + let chatTabPool = ChatTabPool() + let suggestionDependency = SuggestionWidgetControllerDependency() + let setupDependency: (inout DependencyValues) -> Void = { dependencies in + dependencies.suggestionWidgetControllerDependency = suggestionDependency + dependencies.suggestionWidgetUserDefaultsObservers = .init() + dependencies.chatTabPool = chatTabPool + dependencies.chatTabBuilderCollection = ChatTabFactory.chatTabBuilderCollection + + #if canImport(ChatTabPersistent) && canImport(ProChatTabs) + dependencies.restoreChatTabInPool = { + await chatTabPool.restore($0) + } + #endif + } + let store = StoreOf( + initialState: .init(), + reducer: { GUI() }, + withDependencies: setupDependency + ) + self.store = store + self.chatTabPool = chatTabPool + widgetDataSource = .init() + + widgetController = SuggestionWidgetController( + store: store.scope( + state: \.suggestionWidgetState, + action: \.suggestionWidget + ), + chatTabPool: chatTabPool, + dependency: suggestionDependency + ) + + chatTabPool.createStore = { id in + store.scope( + state: { state in + state.chatTabGroup.tabInfo[id: id] ?? .init(id: id, title: "") + }, + action: { childAction in + .suggestionWidget(.chatPanel(.chatTab(.element( + id: id, + action: childAction + )))) + } + ) + } + + suggestionDependency.suggestionWidgetDataSource = widgetDataSource + suggestionDependency.onOpenChatClicked = { + Task { + PseudoCommandHandler().openChat(forceDetach: false, activateThisApp: true) + } + } + suggestionDependency.onOpenModificationButtonClicked = { + Task { + guard let content = await PseudoCommandHandler().getEditorContent(sourceEditor: nil) + else { return } + _ = try await WindowBaseCommandHandler().promptToCode(editor: content) + } + } + suggestionDependency.onCustomCommandClicked = { command in + Task { + let commandHandler = PseudoCommandHandler() + await commandHandler.handleCustomCommand(command) + } + } + } + + func start() { + store.send(.start) + } + + public func openGlobalChat() { + PseudoCommandHandler().openChat(forceDetach: true) + } +} + +extension ChatTabPool { + @MainActor + func createTab( + id: String = UUID().uuidString, + from builder: ChatTabBuilder + ) async -> (any ChatTab, ChatTabInfo)? { + let id = id + let info = ChatTabInfo(id: id, title: "") + guard let chatTap = await builder.build(store: createStore(id)) else { return nil } + setTab(chatTap, forId: id) + return (chatTap, info) + } + + @MainActor + func createTab( + for kind: ChatTabKind? + ) async -> (any ChatTab, ChatTabInfo)? { + let id = UUID().uuidString + let info = ChatTabInfo(id: id, title: "") + let builder = kind?.builder ?? { + for ext in BuiltinExtensionManager.shared.extensions { + guard let tab = ext.chatTabTypes.first(where: { $0.isDefaultChatTabReplacement }) + else { continue } + return tab.defaultChatBuilder() + } + return ChatGPTChatTab.defaultBuilder() + }() + guard let chatTap = await builder.build(store: createStore(id)) else { return nil } + setTab(chatTap, forId: id) + return (chatTap, info) + } + + #if canImport(ChatTabPersistent) + @MainActor + func restore( + _ data: ChatTabPersistent.RestorableTabData + ) async -> (any ChatTab, ChatTabInfo)? { + switch data.name { + case ChatGPTChatTab.name: + guard let builder = try? await ChatGPTChatTab.restore(from: data.data) + else { fallthrough } + return await createTab(id: data.id, from: builder) + default: + let chatTabTypes = BuiltinExtensionManager.shared.extensions.flatMap(\.chatTabTypes) + for type in chatTabTypes { + if type.name == data.name { + do { + let builder = try await type.restore(from: data.data) + return await createTab(id: data.id, from: builder) + } catch { + Logger.service.error("Failed to restore chat tab \(data.name): \(error)") + break + } + } + } + } + + guard let builder = try? await EmptyChatTab.restore(from: data.data) else { return nil } + return await createTab(id: data.id, from: builder) + } + #endif +} + diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift deleted file mode 100644 index 0acbdc85..00000000 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift +++ /dev/null @@ -1,39 +0,0 @@ -import AppKit -import Environment -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 - suggestionWidget.dataSource = WidgetDataSource.shared - suggestionWidget.onOpenChatClicked = { [weak self] in - Task { - let uri = try await Environment.fetchFocusedElementURI() - let dataSource = WidgetDataSource.shared - await dataSource.createChatIfNeeded(for: uri) - self?.suggestionWidget.presentChatRoom(fileURL: uri) - } - } - suggestionWidget.onCustomCommandClicked = { command in - Task { - let commandHandler = PseudoCommandHandler() - await commandHandler.handleCustomCommand(command) - } - } - } - } - - public func openGlobalChat() { - UserDefaults.shared.set(true, for: \.useGlobalChat) - let dataSource = WidgetDataSource.shared - let fakeFileURL = URL(fileURLWithPath: "/") - Task { - await dataSource.createChatIfNeeded(for: fakeFileURL) - suggestionWidget.presentDetachedGlobalChat() - } - } -} diff --git a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift deleted file mode 100644 index 6cc9abab..00000000 --- a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift +++ /dev/null @@ -1,81 +0,0 @@ -import ActiveApplicationMonitor -import Combine -import PromptToCodeService -import SuggestionWidget - -extension PromptToCodeProvider { - convenience init( - service: PromptToCodeService, - name: String?, - onClosePromptToCode: @escaping () -> Void - ) { - self.init( - code: service.code, - language: service.language.rawValue, - description: "", - startLineIndex: service.selectionRange.start.line, - startLineColumn: service.selectionRange.start.character, - name: name - ) - - var cancellables = Set() - - service.$code.sink(receiveValue: set(\.code)).store(in: &cancellables) - service.$isResponding.sink(receiveValue: set(\.isResponding)).store(in: &cancellables) - service.$description.sink(receiveValue: set(\.description)).store(in: &cancellables) - service.$isContinuous.sink(receiveValue: set(\.isContinuous)).store(in: &cancellables) - service.$history.map { $0 != .empty } - .sink(receiveValue: set(\.canRevert)).store(in: &cancellables) - - onCancelTapped = { [cancellables] in - _ = cancellables - service.stopResponding() - onClosePromptToCode() - } - - onRevertTapped = { - service.revert() - } - - onRequirementSent = { [weak self] requirement in - Task { [weak self] in - do { - try await service.modifyCode(prompt: requirement) - } catch is CancellationError { - return - } catch { - Task { @MainActor [weak self] in - self?.errorMessage = error.localizedDescription - } - } - } - } - - onStopRespondingTap = { - service.stopResponding() - } - - onAcceptSuggestionTapped = { - Task { @ServiceActor in - let handler = PseudoCommandHandler() - await handler.acceptSuggestion() - if let app = ActiveApplicationMonitor.previousActiveApplication, app.isXcode { - app.activate() - } - } - } - - onContinuousToggleClick = { - service.isContinuous.toggle() - } - } - - func set(_ keyPath: WritableKeyPath) -> (T) -> Void { - return { [weak self] value in - Task { @MainActor [weak self] in - self?[keyPath: keyPath] = value - } - } - } -} - 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/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index 5c67c2c4..ae1b6371 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -1,221 +1,37 @@ import ActiveApplicationMonitor +import AppActivator +import AppKit import ChatService +import ComposableArchitecture import Foundation import GitHubCopilotService import OpenAIService import PromptToCodeService -import SuggestionModel +import SuggestionBasic import SuggestionWidget -@ServiceActor -final class WidgetDataSource { - static let shared = WidgetDataSource() - - final class Chat { - let chatService: ChatService - let provider: ChatProvider - public init(chatService: ChatService, provider: ChatProvider) { - self.chatService = chatService - self.provider = provider - } - } - - final class PromptToCode { - let promptToCodeService: PromptToCodeService - let provider: PromptToCodeProvider - public init( - promptToCodeService: PromptToCodeService, - provider: PromptToCodeProvider - ) { - self.promptToCodeService = promptToCodeService - self.provider = provider - } - } - - private(set) var globalChat: Chat? - private(set) var chats = [URL: Chat]() - private(set) var promptToCodes = [URL: PromptToCode]() - - private init() {} - - @discardableResult - func createChatIfNeeded(for url: URL) -> ChatService { - let build = { - let service = ChatService(chatGPTService: ChatGPTService()) - let provider = ChatProvider( - service: service, - fileURL: url, - onCloseChat: { [weak self] in - if UserDefaults.shared.value(for: \.useGlobalChat) { - self?.globalChat = nil - } else { - self?.removeChat(for: url) - } - let presenter = PresentInWindowSuggestionPresenter() - presenter.closeChatRoom(fileURL: url) - if let app = ActiveApplicationMonitor.previousActiveApplication, app.isXcode { - app.activate() - } - }, - onSwitchContext: { [weak self] in - let useGlobalChat = UserDefaults.shared.value(for: \.useGlobalChat) - UserDefaults.shared.set(!useGlobalChat, for: \.useGlobalChat) - self?.createChatIfNeeded(for: url) - let presenter = PresentInWindowSuggestionPresenter() - presenter.presentChatRoom(fileURL: url) - } - ) - return Chat(chatService: service, provider: provider) - } - - let useGlobalChat = UserDefaults.shared.value(for: \.useGlobalChat) - if useGlobalChat { - if let globalChat { - return globalChat.chatService - } - let newChat = build() - globalChat = newChat - return newChat.chatService - } else { - if let chat = chats[url] { - return chat.chatService - } - let newChat = build() - chats[url] = newChat - return newChat.chatService - } - } - - @discardableResult - func createPromptToCode( - for url: URL, - projectURL: URL, - selectedCode: String, - allCode: String, - selectionRange: CursorRange, - language: CodeLanguage, - identSize: Int = 4, - usesTabsForIndentation: Bool = false, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool?, - name: String? - ) async -> PromptToCodeService { - let build = { - let service = PromptToCodeService( - code: selectedCode, - selectionRange: selectionRange, - language: language, - identSize: identSize, - usesTabsForIndentation: usesTabsForIndentation, - projectRootURL: projectURL, - fileURL: url, - allCode: allCode, - extraSystemPrompt: extraSystemPrompt, - generateDescriptionRequirement: generateDescriptionRequirement - ) - let provider = PromptToCodeProvider( - service: service, - name: name, - onClosePromptToCode: { [weak self] in - self?.removePromptToCode(for: url) - let presenter = PresentInWindowSuggestionPresenter() - presenter.closePromptToCode(fileURL: url) - if let app = ActiveApplicationMonitor.previousActiveApplication, app.isXcode { - app.activate() - } - } - ) - return PromptToCode(promptToCodeService: service, provider: provider) - } - - let newPromptToCode = build() - promptToCodes[url] = newPromptToCode - return newPromptToCode.promptToCodeService - } - - func removeChat(for url: URL) { - chats[url] = nil - } - - func removePromptToCode(for url: URL) { - promptToCodes[url] = nil - } - - func cleanup(for url: URL) { - removeChat(for: url) - removePromptToCode(for: url) - } -} +@MainActor +final class WidgetDataSource {} extension WidgetDataSource: SuggestionWidgetDataSource { - func suggestionForFile(at url: URL) async -> SuggestionProvider? { - for workspace in workspaces.values { + func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion? { + for workspace in Service.shared.workspacePool.workspaces.values { if let filespace = workspace.filespaces[url], let suggestion = filespace.presentingSuggestion { return .init( code: suggestion.text, - language: filespace.language, + language: filespace.language.rawValue, startLineIndex: suggestion.position.line, suggestionCount: filespace.suggestions.count, currentSuggestionIndex: filespace.suggestionIndex, - onSelectPreviousSuggestionTapped: { - Task { @ServiceActor in - let handler = PseudoCommandHandler() - await handler.presentPreviousSuggestion() - } - }, - onSelectNextSuggestionTapped: { - Task { @ServiceActor in - let handler = PseudoCommandHandler() - await handler.presentNextSuggestion() - } - }, - onRejectSuggestionTapped: { - Task { @ServiceActor in - let handler = PseudoCommandHandler() - await handler.rejectSuggestions() - if let app = ActiveApplicationMonitor.previousActiveApplication, - app.isXcode - { - app.activate() - } - } - }, - onAcceptSuggestionTapped: { - Task { @ServiceActor in - let handler = PseudoCommandHandler() - await handler.acceptSuggestion() - if let app = ActiveApplicationMonitor.previousActiveApplication, - app.isXcode - { - app.activate() - } - } - } + replacingRange: suggestion.range, + replacingLines: suggestion.replacingLines, + descriptions: suggestion.descriptions ) } } return nil } - - func chatForFile(at url: URL) async -> ChatProvider? { - let useGlobalChat = UserDefaults.shared.value(for: \.useGlobalChat) - if useGlobalChat { - if let globalChat { - return globalChat.provider - } - } else { - if let chat = chats[url] { - return chat.provider - } - } - - return nil - } - - func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? { - return promptToCodes[url]?.provider - } } diff --git a/Core/Sources/Service/GlobalShortcutManager.swift b/Core/Sources/Service/GlobalShortcutManager.swift new file mode 100644 index 00000000..a3bb32b4 --- /dev/null +++ b/Core/Sources/Service/GlobalShortcutManager.swift @@ -0,0 +1,71 @@ +import AppKit +import Combine +import Foundation +import KeyboardShortcuts +import XcodeInspector + +extension KeyboardShortcuts.Name { + static let showHideWidget = Self("ShowHideWidget") +} + +@MainActor +final class GlobalShortcutManager { + let guiController: GraphicalUserInterfaceController + private var activeAppChangeTask: Task? + + nonisolated init(guiController: GraphicalUserInterfaceController) { + self.guiController = guiController + } + + func start() { + KeyboardShortcuts.userDefaults = .shared + setupShortcutIfNeeded() + + KeyboardShortcuts.onKeyUp(for: .showHideWidget) { [guiController] in + let isXCodeActive = XcodeInspector.shared.activeXcode != nil + + if !isXCodeActive, + !guiController.store.state.suggestionWidgetState.chatPanelState.isPanelDisplayed, + UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) + { + guiController.store.send(.openChatPanel(forceDetach: true, activateThisApp: true)) + } else { + guiController.store.send(.toggleWidgetsHotkeyPressed) + } + } + + activeAppChangeTask?.cancel() + activeAppChangeTask = Task.detached { [weak self] in + let notifications = NotificationCenter.default + .notifications(named: .activeApplicationDidChange) + for await _ in notifications { + guard let self else { return } + try Task.checkCancellation() + if !UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) { + let app = await XcodeInspector.shared.activeApplication + let shouldBeEnabled = if let app, app.isXcode || app.isExtensionService { + true + } else { + false + } + if shouldBeEnabled { + await self.setupShortcutIfNeeded() + } else { + await self.removeShortcutIfNeeded() + } + } else { + await self.setupShortcutIfNeeded() + } + } + } + } + + func setupShortcutIfNeeded() { + KeyboardShortcuts.enable(.showHideWidget) + } + + func removeShortcutIfNeeded() { + KeyboardShortcuts.disable(.showHideWidget) + } +} + diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index a5eae5e7..39770260 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -2,191 +2,133 @@ import ActiveApplicationMonitor import AppKit import AsyncAlgorithms import AXExtension -import AXNotificationStream -import CGEventObserver -import Environment import Foundation import Logger import Preferences import QuartzCore +import Workspace import XcodeInspector -@ServiceActor -public class RealtimeSuggestionController { - public nonisolated static let shared = RealtimeSuggestionController() - var eventObserver: CGEventObserverType = CGEventObserver(eventsOfInterest: [ - .keyUp, - .keyDown, - .rightMouseDown, - .leftMouseDown, - ]) - private var task: Task? +public actor RealtimeSuggestionController { + private var xcodeChangeObservationTask: Task? private var inflightPrefetchTask: Task? - private var windowChangeObservationTask: Task? - private var activeApplicationMonitorTask: Task? private var editorObservationTask: Task? - private var focusedUIElement: AXUIElement? private var sourceEditor: SourceEditor? - var isCommentMode: Bool { - UserDefaults.shared.value(for: \.suggestionPresentationMode) == .comment - } - - private nonisolated init() { - Task { [weak self] in - - if let app = ActiveApplicationMonitor.activeXcode { - await self?.handleXcodeChanged(app) - await self?.startHIDObservation(by: 1) - } - var previousApp = ActiveApplicationMonitor.activeXcode - for await app in ActiveApplicationMonitor.createStream() { - guard let self else { return } - try Task.checkCancellation() - defer { previousApp = app } - - if let app = ActiveApplicationMonitor.activeXcode, app != previousApp { - await self.handleXcodeChanged(app) - } - - if ActiveApplicationMonitor.activeXcode != nil { - await startHIDObservation(by: 1) - } else { - await stopHIDObservation(by: 1) - } - } - } - } + init() {} - private func startHIDObservation(by listener: AnyHashable) { - Logger.service.info("Add auto trigger listener: \(listener).") - - if task == nil { - task = Task { [weak self, eventObserver] in - for await event in eventObserver.createStream() { - guard let self else { return } - await self.handleHIDEvent(event: event) - } - } - } - eventObserver.activateIfPossible() + deinit { + inflightPrefetchTask?.cancel() + editorObservationTask?.cancel() } - private func stopHIDObservation(by listener: AnyHashable) { - Logger.service.info("Remove auto trigger listener: \(listener).") - task?.cancel() - task = nil - eventObserver.deactivate() + nonisolated + func start() { + Task { await observeXcodeChange() } } - private func handleXcodeChanged(_ app: NSRunningApplication) { - windowChangeObservationTask?.cancel() - windowChangeObservationTask = nil - observeXcodeWindowChangeIfNeeded(app) - } + private func observeXcodeChange() { + xcodeChangeObservationTask?.cancel() - private func observeXcodeWindowChangeIfNeeded(_ app: NSRunningApplication) { - guard windowChangeObservationTask == nil else { return } - handleFocusElementChange() - windowChangeObservationTask = Task { [weak self] in - let notifications = AXNotificationStream( - app: app, - notificationNames: kAXFocusedUIElementChangedNotification, - kAXMainWindowChangedNotification - ) - for await _ in notifications { + xcodeChangeObservationTask = Task { [weak self] in + for await _ in NotificationCenter.default + .notifications(named: .focusedEditorDidChange) + { guard let self else { return } try Task.checkCancellation() - self.handleFocusElementChange() + guard let editor = await XcodeInspector.shared.focusedEditor else { continue } + await self.handleFocusElementChange(editor) } } } - private func handleFocusElementChange() { - guard let activeXcode = ActiveApplicationMonitor.activeXcode else { return } - let application = AXUIElementCreateApplication(activeXcode.processIdentifier) - guard let focusElement = application.focusedElement else { return } - let focusElementType = focusElement.description - focusedUIElement = focusElement - - Task { // Notify suggestion service for open file. - try await Task.sleep(nanoseconds: 500_000_000) - let fileURL = try await Environment.fetchCurrentFileURL() - _ = try await Workspace.fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) - } + private func handleFocusElementChange(_ sourceEditor: SourceEditor) { + self.sourceEditor = sourceEditor - guard focusElementType == "Source Editor" else { return } - sourceEditor = SourceEditor(runningApplication: activeXcode, element: focusElement) + let notificationsFromEditor = sourceEditor.axNotifications editorObservationTask?.cancel() editorObservationTask = nil editorObservationTask = Task { [weak self] in - let notificationsFromEditor = AXNotificationStream( - app: activeXcode, - element: focusElement, - notificationNames: kAXValueChangedNotification, kAXSelectedTextChangedNotification - ) + if let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL { + await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( + fileURL: fileURL, + sourceEditor: sourceEditor + ) + } - for await notification in notificationsFromEditor { - guard let self else { return } - try Task.checkCancellation() + let valueChange = await notificationsFromEditor.notifications() + .filter { $0.kind == .valueChanged } + let selectedTextChanged = await notificationsFromEditor.notifications() + .filter { $0.kind == .selectedTextChanged } + + await withTaskGroup(of: Void.self) { [weak self] group in + group.addTask { [weak self] in + let handler = { [weak self] in + guard let self else { return } + await cancelInFlightTasks() + await self.triggerPrefetchDebounced() + await self.notifyEditingFileChange(editor: sourceEditor.element) + } - switch notification.name { - case kAXValueChangedNotification: - await cancelInFlightTasks() - self.triggerPrefetchDebounced() - await self.notifyEditingFileChange(editor: focusElement) - case kAXSelectedTextChangedNotification: - guard let sourceEditor else { continue } - await PseudoCommandHandler() - .invalidateRealtimeSuggestionsIfNeeded(sourceEditor: sourceEditor) - default: - continue + if #available(macOS 13.0, *) { + for await _ in valueChange._throttle(for: .milliseconds(200)) { + if Task.isCancelled { return } + await handler() + } + } else { + for await _ in valueChange { + if Task.isCancelled { return } + await handler() + } + } } + group.addTask { + let handler = { + guard let fileURL = await XcodeInspector.shared.activeDocumentURL + else { return } + await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( + fileURL: fileURL, + sourceEditor: sourceEditor + ) + } + + if #available(macOS 13.0, *) { + for await _ in selectedTextChanged._throttle(for: .milliseconds(200)) { + if Task.isCancelled { return } + await handler() + } + } else { + for await _ in selectedTextChanged { + if Task.isCancelled { return } + await handler() + } + } + } + + await group.waitForAll() } } - Task { // Get cache ready for real-time suggestions. + Task { @WorkspaceActor in // Get cache ready for real-time suggestions. guard UserDefaults.shared.value(for: \.preCacheOnFileOpen) else { return } - let fileURL = try await Environment.fetchCurrentFileURL() - let (_, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + guard await XcodeInspector.shared.activeApplication?.isXcode ?? false else { return } + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + else { return } + let (_, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) - if filespace.uti == nil { + if filespace.codeMetadata.uti == nil { Logger.service.info("Generate cache for file.") // avoid the command get called twice - filespace.uti = "" + filespace.codeMetadata.uti = "" do { - try await Environment.triggerAction("Real-time Suggestions") + try await XcodeInspector.shared.latestActiveXcode? + .triggerCopilotCommand(name: "Prepare for Real-time Suggestions") } catch { - if filespace.uti?.isEmpty ?? true { - filespace.uti = nil - } - } - } - } - } - - func handleHIDEvent(event: CGEvent) async { - guard await Environment.isXcodeActive() else { return } - - let keycode = Int(event.getIntegerValueField(.keyboardEventKeycode)) - let escape = 0x35 - - // Escape should cancel in-flight tasks. - // Except that when the completion panel is presented, it should trigger prefetch instead. - 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) + if filespace.codeMetadata.uti?.isEmpty ?? true { + filespace.codeMetadata.uti = nil } } } @@ -194,31 +136,27 @@ public class RealtimeSuggestionController { } func triggerPrefetchDebounced(force: Bool = false) { - inflightPrefetchTask = Task { @ServiceActor in - try? await Task.sleep(nanoseconds: UInt64(( - UserDefaults.shared.value(for: \.realtimeSuggestionDebounce) - ) * 1_000_000_000)) + inflightPrefetchTask = Task(priority: .utility) { @WorkspaceActor in + try? await Task.sleep(nanoseconds: UInt64( + max(UserDefaults.shared.value(for: \.realtimeSuggestionDebounce), 0.15) + * 1_000_000_000 + )) + + if Task.isCancelled { return } guard UserDefaults.shared.value(for: \.realtimeSuggestionToggle) else { return } if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally), - let fileURL = try? await Environment.fetchCurrentFileURL(), - let (workspace, _) = try? await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + let fileURL = await XcodeInspector.shared.activeDocumentURL, + let (workspace, _) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) { let isEnabled = workspace.isSuggestionFeatureEnabled if !isEnabled { return } } if Task.isCancelled { return } - 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) } @@ -226,7 +164,7 @@ public class RealtimeSuggestionController { func cancelInFlightTasks(excluding: Task? = nil) async { inflightPrefetchTask?.cancel() - + let workspaces = await Service.shared.workspacePool.workspaces // cancel in-flight tasks await withTaskGroup(of: Void.self) { group in for (_, workspace) in workspaces { @@ -241,17 +179,17 @@ public class RealtimeSuggestionController { /// Looks like the Xcode will keep the panel around until content is changed, /// not sure how to observe that it's hidden. func isCompletionPanelPresenting() -> Bool { - guard let activeXcode = ActiveApplicationMonitor.activeXcode else { return false } + guard let activeXcode = ActiveApplicationMonitor.shared.activeXcode else { return false } let application = AXUIElementCreateApplication(activeXcode.processIdentifier) return application.focusedWindow?.child(identifier: "_XC_COMPLETION_TABLE_") != nil } func notifyEditingFileChange(editor: AXUIElement) async { - guard let fileURL = try? await Environment.fetchCurrentFileURL(), - let (workspace, filespace) = try? await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + guard let fileURL = await XcodeInspector.shared.activeDocumentURL, + let (workspace, _) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } - workspace.notifyUpdateFile(filespace: filespace, content: editor.value) + await workspace.didUpdateFilespace(fileURL: fileURL, content: editor.value) } } diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index c6b9a832..b011bd78 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -1,40 +1,46 @@ import ActiveApplicationMonitor import AppKit import AXExtension +import BuiltinExtension import Foundation import Logger +import Workspace import XcodeInspector public final class ScheduledCleaner { - public init() { - // occasionally cleanup workspaces. + weak var service: Service? + + init() {} + + func start() { Task { @ServiceActor in while !Task.isCancelled { try await Task.sleep(nanoseconds: 10 * 60 * 1_000_000_000) - cleanUp() + await cleanUp() } } - // cleanup when Xcode becomes inactive Task { @ServiceActor in - for await app in ActiveApplicationMonitor.createStream() { + for await app in ActiveApplicationMonitor.shared.createInfoStream() { try Task.checkCancellation() if let app, !app.isXcode { - cleanUp() + await cleanUp() } } } } @ServiceActor - func cleanUp() { - let workspaceInfos = XcodeInspector.shared.xcodes.reduce( + func cleanUp() async { + guard let service else { return } + + let workspaceInfos = await XcodeInspector.shared.xcodes.reduce( into: [ XcodeAppInstanceInspector.WorkspaceIdentifier: XcodeAppInstanceInspector.WorkspaceInfo ]() ) { result, xcode in - let infos = xcode.workspaces + let infos = xcode.realtimeWorkspaces for (id, info) in infos { if let existed = result[id] { result[id] = existed.combined(with: info) @@ -43,14 +49,18 @@ public final class ScheduledCleaner { } } } - for (url, workspace) in workspaces { + for (url, workspace) in service.workspacePool.workspaces { if workspace.isExpired, workspaceInfos[.url(url)] == nil { Logger.service.info("Remove idle workspace") - for url in workspace.filespaces.keys { - WidgetDataSource.shared.cleanup(for: url) - } - workspace.cleanUp(availableTabs: []) - workspaces[url] = nil + _ = await Task { @MainActor in + service.guiController.store.send( + .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: Array( + workspace.filespaces.keys + ))) + ) + }.result + await workspace.cleanUp(availableTabs: []) + await service.workspacePool.removeWorkspace(url: url) } else { let tabs = (workspaceInfos[.url(url)]?.tabs ?? []) .union(workspaceInfos[.unknown]?.tabs ?? []) @@ -62,20 +72,26 @@ public final class ScheduledCleaner { availableTabs: tabs ) { Logger.service.info("Remove idle filespace") - WidgetDataSource.shared.cleanup(for: url) + _ = await Task { @MainActor in + service.guiController.store.send( + .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: [url])) + ) + }.result } } // cleanup workspace - workspace.cleanUp(availableTabs: tabs) + await workspace.cleanUp(availableTabs: tabs) } } + + #if canImport(ProService) + await service.proService.cleanUp(workspaceInfos: workspaceInfos) + #endif } - + @ServiceActor public func closeAllChildProcesses() async { - for (_, workspace) in workspaces { - await workspace.terminateSuggestionService() - } + BuiltinExtensionManager.shared.terminate() } } diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift new file mode 100644 index 00000000..b1702924 --- /dev/null +++ b/Core/Sources/Service/Service.swift @@ -0,0 +1,189 @@ +import BuiltinExtension +import CodeiumService +import Combine +import CommandHandler +import Dependencies +import Foundation +import GitHubCopilotService +import KeyBindingManager +import Logger +import OverlayWindow +import SuggestionService +import Toast +import Workspace +import WorkspaceSuggestionService +import XcodeInspector +import XcodeThemeController +import XPCShared +#if canImport(ProService) +import ProService +#endif + +@globalActor public enum ServiceActor { + public actor TheActor {} + public static let shared = TheActor() +} + +/// The running extension service. +public final class Service { + @MainActor + public static let shared = Service() + + @Dependency(\.workspacePool) var workspacePool + @MainActor + public let guiController: GraphicalUserInterfaceController + public let commandHandler: CommandHandler + public let realtimeSuggestionController: RealtimeSuggestionController + public let scheduledCleaner: ScheduledCleaner + let globalShortcutManager: GlobalShortcutManager + let keyBindingManager: KeyBindingManager + let xcodeThemeController: XcodeThemeController = .init() + let overlayWindowController: OverlayWindowController + + #if canImport(ProService) + let proService: ProService + #endif + + @Dependency(\.toast) var toast + var cancellable = Set() + + @MainActor + private init() { + @Dependency(\.workspacePool) var workspacePool + let commandHandler = PseudoCommandHandler() + UniversalCommandHandler.shared.commandHandler = commandHandler + self.commandHandler = commandHandler + + realtimeSuggestionController = .init() + scheduledCleaner = .init() + overlayWindowController = .init() + + #if canImport(ProService) + proService = ProService() + #endif + + BuiltinExtensionManager.shared.addExtensions([ + GitHubCopilotExtension(workspacePool: workspacePool), + CodeiumExtension(workspacePool: workspacePool), + ]) + + let guiController = GraphicalUserInterfaceController() + self.guiController = guiController + globalShortcutManager = .init(guiController: guiController) + keyBindingManager = .init() + + workspacePool.registerPlugin { + SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } + } + workspacePool.registerPlugin { + GitHubCopilotWorkspacePlugin(workspace: $0) + } + workspacePool.registerPlugin { + CodeiumWorkspacePlugin(workspace: $0) + } + workspacePool.registerPlugin { + BuiltinExtensionWorkspacePlugin(workspace: $0) + } + + scheduledCleaner.service = self + } + + @MainActor + public func start() { + scheduledCleaner.start() + realtimeSuggestionController.start() + guiController.start() + xcodeThemeController.start() + #if canImport(ProService) + proService.start() + #endif + overlayWindowController.start() + DependencyUpdater().update() + globalShortcutManager.start() + keyBindingManager.start() + + Task.detached { [weak self] in + let notifications = NotificationCenter.default + .notifications(named: .activeDocumentURLDidChange) + var previousURL: URL? + for await _ in notifications { + guard self != nil else { return } + let url = await XcodeInspector.shared.activeDocumentURL + if let url, url != previousURL, url != .init(fileURLWithPath: "/") { + previousURL = url + @Dependency(\.workspacePool) var workspacePool + _ = try await workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: url) + } + } + } + } + + @MainActor + public func prepareForExit() async { + Logger.service.info("Prepare for exit.") + keyBindingManager.stopForExit() + #if canImport(ProService) + proService.prepareForExit() + #endif + await scheduledCleaner.closeAllChildProcesses() + } +} + +public extension Service { + func handleXPCServiceRequests( + endpoint: String, + requestBody: Data, + reply: @escaping (Data?, Error?) -> Void + ) { + do { + #if canImport(ProService) + try proService.handleXPCServiceRequests( + endpoint: endpoint, + requestBody: requestBody, + reply: reply + ) + #endif + + try ExtensionServiceRequests.GetExtensionOpenChatHandlers.handle( + endpoint: endpoint, + requestBody: requestBody, + reply: reply + ) { _ in + BuiltinExtensionManager.shared.extensions.reduce(into: []) { result, ext in + let tabs = ext.chatTabTypes + for tab in tabs { + if tab.canHandleOpenChatCommand { + result.append(.init( + bundleIdentifier: ext.extensionIdentifier, + id: tab.name, + tabName: tab.name, + isBuiltIn: true + )) + } + } + } + } + + try ExtensionServiceRequests.GetSuggestionLineAcceptedCode.handle( + endpoint: endpoint, + requestBody: requestBody, + reply: reply + ) { request in + let editor = request.editorContent + let handler = WindowBaseCommandHandler() + let updatedContent = try? await handler + .acceptSuggestionLine(editor: editor) + return updatedContent + } + } catch is XPCRequestHandlerHitError { + return + } catch { + reply(nil, error) + return + } + + reply(nil, XPCRequestNotHandledError()) + } +} + 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..c1d38d78 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -1,16 +1,36 @@ import ActiveApplicationMonitor import AppKit -import Environment +import BuiltinExtension +import CodeiumService +import CommandHandler +import ComposableArchitecture +import enum CopilotForXcodeKit.SuggestionServiceError +import Dependencies +import Logger +import ModificationBasic +import PlusFeatureFlag import Preferences +import PromptToCodeCustomization +import SuggestionBasic import SuggestionInjector -import SuggestionModel +import Terminal +import Toast +import Workspace +import WorkspaceSuggestionService import XcodeInspector import XPCShared +#if canImport(BrowserChatTab) +import BrowserChatTab +#endif + /// It's used to run some commands without really triggering the menu bar item. /// /// For example, we can use it to generate real-time suggestions without Apple Scripts. -struct PseudoCommandHandler { +struct PseudoCommandHandler: CommandHandler { + static var lastTimeCommandFailedToTriggerWithAccessibilityAPI = Date(timeIntervalSince1970: 0) + private var toast: ToastController { ToastControllerDependencyKey.liveValue } + func presentPreviousSuggestion() async { let handler = WindowBaseCommandHandler() _ = try? await handler.presentPreviousSuggestion(editor: .init( @@ -18,6 +38,7 @@ struct PseudoCommandHandler { lines: [], uti: "", cursorPosition: .outOfScope, + cursorOffset: -1, selections: [], tabSize: 0, indentSize: 0, @@ -32,6 +53,7 @@ struct PseudoCommandHandler { lines: [], uti: "", cursorPosition: .outOfScope, + cursorOffset: -1, selections: [], tabSize: 0, indentSize: 0, @@ -39,42 +61,87 @@ struct PseudoCommandHandler { )) } + @WorkspaceActor func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async { + guard let filespace = await getFilespace(), + let (workspace, _) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return } + + if Task.isCancelled { return } + // Can't use handler if content is not available. - guard - let editor = await getEditorContent(sourceEditor: sourceEditor), - let filespace = await getFilespace() + guard let editor = await getEditorContent(sourceEditor: sourceEditor) else { return } - if await filespace.validateSuggestions( + let fileURL = filespace.fileURL + let presenter = PresentInWindowSuggestionPresenter() + + presenter.markAsProcessing(true) + defer { presenter.markAsProcessing(false) } + + if filespace.presentingSuggestion != nil { + // Check if the current suggestion is still valid. + if filespace.validateSuggestions( + lines: editor.lines, + cursorPosition: editor.cursorPosition + ) { + return + } else { + presenter.discardSuggestion(fileURL: filespace.fileURL) + } + } + + let snapshot = FilespaceSuggestionSnapshot( lines: editor.lines, cursorPosition: editor.cursorPosition - ) { - return - } else { - PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: filespace.fileURL) - } + ) - // 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) + guard filespace.suggestionSourceSnapshot != snapshot else { return } + + do { + try await workspace.generateSuggestions( + forFileAt: fileURL, + editor: editor + ) + if let sourceEditor { + let editorContent = sourceEditor.getContent() + _ = filespace.validateSuggestions( + lines: editorContent.lines, + cursorPosition: editorContent.cursorPosition + ) + } + if filespace.presentingSuggestion != nil { + presenter.presentSuggestion(fileURL: fileURL) + } else { + presenter.discardSuggestion(fileURL: fileURL) + } + } catch let error as SuggestionServiceError { + switch error { + case let .notice(error): + presenter.presentErrorMessage(error.localizedDescription) + case .silent: + Logger.service.error(error.localizedDescription) + return + } + } catch { + Logger.service.error(error.localizedDescription) + return } } - func invalidateRealtimeSuggestionsIfNeeded(sourceEditor: SourceEditor) async { - guard let fileURL = try? await Environment.fetchCurrentFileURL(), - let (_, filespace) = try? await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) else { return } + @WorkspaceActor + func invalidateRealtimeSuggestionsIfNeeded(fileURL: URL, sourceEditor: SourceEditor) async { + guard let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } + + if filespace.presentingSuggestion == nil { + return // skip if there's no suggestion presented. + } - if await !filespace.validateSuggestions( - lines: sourceEditor.content.lines, - cursorPosition: sourceEditor.content.cursorPosition + let content = sourceEditor.getContent() + if !filespace.validateSuggestions( + lines: content.lines, + cursorPosition: content.cursorPosition ) { PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: fileURL) } @@ -87,6 +154,7 @@ struct PseudoCommandHandler { lines: [], uti: "", cursorPosition: .outOfScope, + cursorOffset: -1, selections: [], tabSize: 0, indentSize: 0, @@ -101,12 +169,13 @@ struct PseudoCommandHandler { } switch command.feature { // editor content is not required. - case .customChat, .chatWithSelection: + case .customChat, .chatWithSelection, .singleRoundDialog: return .init( content: "", lines: [], uti: "", cursorPosition: .outOfScope, + cursorOffset: -1, selections: [], tabSize: 0, indentSize: 0, @@ -118,7 +187,8 @@ struct PseudoCommandHandler { } }() else { do { - try await Environment.triggerAction(command.name) + try await XcodeInspector.shared.latestActiveXcode? + .triggerCopilotCommand(name: command.name) } catch { let presenter = PresentInWindowSuggestionPresenter() presenter.presentError(error) @@ -135,15 +205,47 @@ struct PseudoCommandHandler { } } - func acceptSuggestion() async { - if UserDefaults.shared.value(for: \.acceptSuggestionWithAccessibilityAPI) { - guard let xcode = ActiveApplicationMonitor.activeXcode ?? ActiveApplicationMonitor - .latestXcode else { return } + func acceptModification() async { + do { + if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { + throw CancellationError() + } + do { + try await XcodeInspector.shared.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Modification") + } catch { + do { + try await XcodeInspector.shared.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Prompt to Code") + } catch { + let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI + let now = Date() + if now.timeIntervalSince(last) > 60 * 60 { + Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now + toast.toast(content: """ + The app is using a fallback solution to accept suggestions. \ + For better experience, please restart Xcode to re-activate the Copilot \ + menu item. + """, type: .warning, duration: 10) + } + + throw error + } + } + } catch { + guard let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.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, + cursorOffset + ) = await getFileContent(sourceEditor: nil) else { PresentInWindowSuggestionPresenter() .presentErrorMessage("Unable to get file content.") @@ -151,125 +253,455 @@ struct PseudoCommandHandler { } let handler = WindowBaseCommandHandler() do { - guard let result = try await handler.acceptSuggestion(editor: .init( + guard let result = try await handler.acceptPromptToCode(editor: .init( content: content, lines: lines, uti: "", cursorPosition: cursorPosition, + cursorOffset: cursorOffset, selections: [], tabSize: 0, indentSize: 0, usesTabsForIndentation: false )) else { return } - let oldPosition = focusElement.selectedTextRange - let oldScrollPosition = focusElement.parent?.verticalScrollBar?.doubleValue - - let error = AXUIElementSetAttributeValue( - focusElement, - kAXValueAttribute as CFString, - result.content as CFTypeRef - ) + try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) + } catch { + PresentInWindowSuggestionPresenter().presentError(error) + } + } + } - if error != AXError.success { - PresentInWindowSuggestionPresenter() - .presentErrorMessage("Fail to set editor content.") - } + func presentModification(state: Shared) async { + let store = await Service.shared.guiController.store + await store.send(.promptToCodeGroup(.createPromptToCode(.init( + promptToCodeState: state, + instruction: nil, + commandName: nil, + isContinuous: false + ), sendImmediately: false))) + } - if let selection = result.newSelection { - var range = convertCursorRangeToRange(selection, in: result.content) - if let value = AXValueCreate(.cfRange, &range) { - AXUIElementSetAttributeValue( - focusElement, - kAXSelectedTextRangeAttribute as CFString, - value - ) - } - } else if let oldPosition { - var range = CFRange( - location: oldPosition.lowerBound, - length: 0 - ) - if let value = AXValueCreate(.cfRange, &range) { - AXUIElementSetAttributeValue( - focusElement, - kAXSelectedTextRangeAttribute as CFString, - value - ) - } + func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async { + do { + if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { + throw CancellationError() + } + do { + try await XcodeInspector.shared.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Suggestion Line") + } catch { + let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI + let now = Date() + if now.timeIntervalSince(last) > 60 * 60 { + Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now + toast.toast(content: """ + The app is using a fallback solution to accept suggestions. \ + For better experience, please restart Xcode to re-activate the Copilot \ + menu item. + """, type: .warning, duration: 10) } - if let oldScrollPosition, let scrollBar = focusElement.parent?.verticalScrollBar { - AXUIElementSetAttributeValue( - scrollBar, - kAXValueAttribute as CFString, - oldScrollPosition as CFTypeRef - ) - } + throw error + } + } catch { + guard let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode else { return } + let application = AXUIElementCreateApplication(xcode.processIdentifier) + guard let focusElement = application.focusedElement, + focusElement.description == "Source Editor" + else { return } + guard let ( + content, + lines, + _, + cursorPosition, + cursorOffset + ) = await getFileContent(sourceEditor: nil) + else { + PresentInWindowSuggestionPresenter() + .presentErrorMessage("Unable to get file content.") + return + } + let handler = WindowBaseCommandHandler() + do { + guard let result = try await handler.acceptSuggestion(editor: .init( + content: content, + lines: lines, + uti: "", + cursorPosition: cursorPosition, + cursorOffset: cursorOffset, + selections: [], + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false + )) else { return } + try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) } catch { PresentInWindowSuggestionPresenter().presentError(error) } - } else { + } + } + + func acceptSuggestion() async { + do { + if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { + throw CancellationError() + } do { - try await Environment.triggerAction("Accept Suggestion") + try await XcodeInspector.shared.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Suggestion") + } catch { + let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI + let now = Date() + if now.timeIntervalSince(last) > 60 * 60 { + Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now + toast.toast(content: """ + The app is using a fallback solution to accept suggestions. \ + For better experience, please restart Xcode to re-activate the Copilot \ + menu item. + """, type: .warning, duration: 10) + } + + throw error + } + } catch { + guard let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode else { return } + let application = AXUIElementCreateApplication(xcode.processIdentifier) + guard let focusElement = application.focusedElement, + focusElement.description == "Source Editor" + else { return } + guard let ( + content, + lines, + _, + cursorPosition, + cursorOffset + ) = await getFileContent(sourceEditor: nil) + else { + PresentInWindowSuggestionPresenter() + .presentErrorMessage("Unable to get file content.") return + } + let handler = WindowBaseCommandHandler() + do { + guard let result = try await handler.acceptSuggestion(editor: .init( + content: content, + lines: lines, + uti: "", + cursorPosition: cursorPosition, + cursorOffset: cursorOffset, + selections: [], + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false + )) else { return } + + try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) } catch { PresentInWindowSuggestionPresenter().presentError(error) } } } + + func dismissSuggestion() async { + guard let documentURL = await XcodeInspector.shared.activeDocumentURL else { return } + PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL) + guard let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: documentURL) else { return } + await filespace.reset() + } + + func openChat(forceDetach: Bool, activateThisApp: Bool = true) { + switch UserDefaults.shared.value(for: \.openChatMode) { + case .chatPanel: + for ext in BuiltinExtensionManager.shared.extensions { + guard let tab = ext.chatTabTypes.first(where: { $0.isDefaultChatTabReplacement }) + else { continue } + Task { @MainActor in + let store = Service.shared.guiController.store + await store.send( + .createAndSwitchToChatTabIfNeededMatching( + check: { $0.name == tab.name }, + kind: .init(tab.defaultChatBuilder()) + ) + ).finish() + store.send(.openChatPanel( + forceDetach: forceDetach, + activateThisApp: activateThisApp + )) + } + return + } + Task { @MainActor in + let store = Service.shared.guiController.store + await store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish() + store.send(.openChatPanel( + forceDetach: forceDetach, + activateThisApp: activateThisApp + )) + } + case .browser: + let urlString = UserDefaults.shared.value(for: \.openChatInBrowserURL) + let openInApp = { + if !UserDefaults.shared.value(for: \.openChatInBrowserInInAppBrowser) { + return false + } + return isFeatureAvailable(\.browserTab) + }() + guard let url = URL(string: urlString) else { + let alert = NSAlert() + alert.messageText = "Invalid URL" + alert.informativeText = "The URL provided is not valid." + alert.alertStyle = .warning + alert.runModal() + return + } + + if openInApp { + #if canImport(BrowserChatTab) + Task { @MainActor in + let store = Service.shared.guiController.store + await store.send(.createAndSwitchToChatTabIfNeededMatching( + check: { + func match(_ tabURL: URL?) -> Bool { + guard let tabURL else { return false } + return tabURL == url + || tabURL.absoluteString.hasPrefix(url.absoluteString) + } + + guard let tab = $0 as? BrowserChatTab, + match(tab.url) else { return false } + return true + }, + kind: .init(BrowserChatTab.urlChatBuilder(url: url)) + )).finish() + store.send(.openChatPanel( + forceDetach: forceDetach, + activateThisApp: activateThisApp + )) + } + #endif + } else { + Task { + NSWorkspace.shared.open(url) + } + } + case let .builtinExtension(extensionIdentifier, id, _): + guard let ext = BuiltinExtensionManager.shared.extensions + .first(where: { $0.extensionIdentifier == extensionIdentifier }), + let tab = ext.chatTabTypes.first(where: { $0.name == id }) + else { return } + Task { @MainActor in + let store = Service.shared.guiController.store + await store.send( + .createAndSwitchToChatTabIfNeededMatching( + check: { $0.name == id }, + kind: .init(tab.defaultChatBuilder()) + ) + ).finish() + store.send(.openChatPanel( + forceDetach: forceDetach, + activateThisApp: activateThisApp + )) + } + case let .externalExtension(extensionIdentifier, id, _): + guard let ext = BuiltinExtensionManager.shared.extensions + .first(where: { $0.extensionIdentifier == "plus" }), + let tab = ext.chatTabTypes + .first(where: { $0.name == "\(extensionIdentifier).\(id)" }) + else { return } + Task { @MainActor in + let store = Service.shared.guiController.store + await store.send( + .createAndSwitchToChatTabIfNeededMatching( + check: { $0.name == "\(extensionIdentifier).\(id)" }, + kind: .init(tab.defaultChatBuilder()) + ) + ).finish() + store.send(.openChatPanel( + forceDetach: forceDetach, + activateThisApp: activateThisApp + )) + } + } + } + + @MainActor + func sendChatMessage(_ message: String) async { + let store = Service.shared.guiController.store + await store.send(.sendCustomCommandToActiveChat(CustomCommand( + commandId: "", + name: "", + feature: .chatWithSelection( + extraSystemPrompt: nil, + prompt: message, + useExtraSystemPrompt: nil + ), + ignoreExistingAttachments: false, + attachments: [] + ))).finish() + } + + @WorkspaceActor + func presentSuggestions(_ suggestions: [SuggestionBasic.CodeSuggestion]) async { + guard let filespace = await getFilespace() else { return } + filespace.setSuggestions(suggestions) + PresentInWindowSuggestionPresenter().presentSuggestion(fileURL: filespace.fileURL) + } + + func toast(_ message: String, as type: ToastType) { + Task { @MainActor in + let store = Service.shared.guiController.store + store.send(.suggestionWidget(.toastPanel(.toast(.toast(message, type, nil))))) + } + } + + func presentFile(at fileURL: URL, line: Int?) async { + let terminal = Terminal() + do { + if let line { + _ = try await terminal.runCommand( + "/bin/bash", + arguments: [ + "-c", + "xed -l \(line) ${TARGET_FILE}", + ], + environment: ["TARGET_FILE": fileURL.path], + ) + } else { + _ = try await terminal.runCommand( + "/bin/bash", + arguments: [ + "-c", + "xed ${TARGET_FILE}", + ], + environment: ["TARGET_FILE": fileURL.path], + ) + } + } catch { + print(error) + } + } } extension PseudoCommandHandler { + /// When Xcode commands are not available, we can fallback to directly + /// set the value of the editor with Accessibility API. + func injectUpdatedCodeWithAccessibilityAPI( + _ result: UpdatedContent, + focusElement: AXUIElement + ) throws { + let oldPosition = focusElement.selectedTextRange + let oldScrollPosition = focusElement.parent?.verticalScrollBar?.doubleValue + + let error = AXUIElementSetAttributeValue( + focusElement, + kAXValueAttribute as CFString, + result.content as CFTypeRef + ) + + if error != AXError.success { + PresentInWindowSuggestionPresenter() + .presentErrorMessage("Fail to set editor content.") + } + + // recover selection range + + if let selection = result.newSelections.first { + var range = SourceEditor.convertCursorRangeToRange(selection, in: result.content) + if let value = AXValueCreate(.cfRange, &range) { + AXUIElementSetAttributeValue( + focusElement, + kAXSelectedTextRangeAttribute as CFString, + value + ) + } + } else if let oldPosition { + var range = CFRange( + location: oldPosition.lowerBound, + length: 0 + ) + if let value = AXValueCreate(.cfRange, &range) { + AXUIElementSetAttributeValue( + focusElement, + kAXSelectedTextRangeAttribute as CFString, + value + ) + } + } + + // recover scroll position + + if let oldScrollPosition, + let scrollBar = focusElement.parent?.verticalScrollBar + { + AXUIElementSetAttributeValue( + scrollBar, + kAXValueAttribute as CFString, + oldScrollPosition as CFTypeRef + ) + } + } + func getFileContent(sourceEditor: AXUIElement?) async -> ( content: String, lines: [String], selections: [CursorRange], - cursorPosition: CursorPosition + cursorPosition: CursorPosition, + cursorOffset: Int )? { - guard let xcode = ActiveApplicationMonitor.activeXcode - ?? ActiveApplicationMonitor.latestXcode else { return nil } + guard let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode else { return nil } let application = AXUIElementCreateApplication(xcode.processIdentifier) guard let focusElement = sourceEditor ?? application.focusedElement, focusElement.description == "Source Editor" else { return nil } guard let selectionRange = focusElement.selectedTextRange else { return nil } let content = focusElement.value - let split = content.breakLines() - let range = convertRangeToCursorRange(selectionRange, in: content) - return (content, split, [range], range.start) + let split = content.breakLines(appendLineBreakToLastLine: false) + let range = SourceEditor.convertRangeToCursorRange(selectionRange, in: content) + return (content, split, [range], range.start, selectionRange.lowerBound) } func getFileURL() async -> URL? { - try? await Environment.fetchCurrentFileURL() + XcodeInspector.shared.realtimeActiveDocumentURL } - @ServiceActor + @WorkspaceActor func getFilespace() async -> Filespace? { guard let fileURL = await getFileURL(), - let (_, filespace) = try? await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return nil } return filespace } - @ServiceActor + @WorkspaceActor func getEditorContent(sourceEditor: SourceEditor?) async -> EditorContent? { - guard let filespace = await getFilespace(), let sourceEditor else { return nil } - let content = sourceEditor.content - let uti = filespace.uti ?? "" - let tabSize = filespace.tabSize ?? 4 - let indentSize = filespace.indentSize ?? 4 - let usesTabsForIndentation = filespace.usesTabsForIndentation ?? false + guard let filespace = await getFilespace(), + let sourceEditor = await { + if let sourceEditor { sourceEditor } + else { await XcodeInspector.shared.latestFocusedEditor } + }() + else { return nil } + if Task.isCancelled { return nil } + let content = sourceEditor.getContent() + let uti = filespace.codeMetadata.uti ?? "" + let tabSize = filespace.codeMetadata.tabSize ?? 4 + let indentSize = filespace.codeMetadata.indentSize ?? 4 + let usesTabsForIndentation = filespace.codeMetadata.usesTabsForIndentation ?? false return .init( content: content.content, lines: content.lines, uti: uti, cursorPosition: content.cursorPosition, + cursorOffset: content.cursorOffset, selections: content.selections.map { .init(start: $0.start, end: $0.end) }, @@ -279,70 +711,33 @@ extension PseudoCommandHandler { ) } - func convertCursorRangeToRange( - _ cursorRange: CursorRange, - in content: String - ) -> CFRange { - let lines = content.breakLines() - var countS = 0 - var countE = 0 - var range = CFRange(location: 0, length: 0) - for (i, line) in lines.enumerated() { - if i == cursorRange.start.line { - countS = countS + cursorRange.start.character - range.location = countS - } - if i == cursorRange.end.line { - countE = countE + cursorRange.end.character - range.length = max(countE - range.location, 0) - break - } - countS += line.count - countE += line.count - } - return range - } + func handleAcceptSuggestionLineCommand(editor: EditorContent) async throws -> CodeSuggestion? { + guard let _ = XcodeInspector.shared.realtimeActiveDocumentURL + else { return nil } - func convertRangeToCursorRange( - _ range: ClosedRange, - in content: String - ) -> CursorRange { - let lines = content.breakLines() - guard !lines.isEmpty else { return CursorRange(start: .zero, end: .zero) } - var countS = 0 - var countE = 0 - var cursorRange = CursorRange(start: .zero, end: .outOfScope) - for (i, line) in lines.enumerated() { - if countS <= range.lowerBound, range.lowerBound < countS + line.count { - cursorRange.start = .init(line: i, character: range.lowerBound - countS) - } - if countE <= range.upperBound, range.upperBound < countE + line.count { - cursorRange.end = .init(line: i, character: range.upperBound - countE) - break - } - countS += line.count - countE += line.count - } - if cursorRange.end == .outOfScope { - cursorRange.end = .init(line: lines.endIndex - 1, character: lines.last?.count ?? 0) - } - return cursorRange + return try await acceptSuggestionLineInGroup( + atIndex: 0, + editor: editor + ) } -} -public extension String { - /// Break a string into lines. - func breakLines() -> [String] { - let lines = split(separator: "\n", omittingEmptySubsequences: false) - var all = [String]() - for (index, line) in lines.enumerated() { - if index == lines.endIndex - 1 { - all.append(String(line)) - } else { - all.append(String(line) + "\n") - } - } - return all + func acceptSuggestionLineInGroup( + atIndex index: Int?, + editor: EditorContent + ) async throws -> CodeSuggestion? { + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + else { return nil } + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + + guard var acceptedSuggestion = await workspace.acceptSuggestion( + forFileAt: fileURL, + editor: editor + ) else { return nil } + + let text = acceptedSuggestion.text + acceptedSuggestion.text = String(text.splitByNewLine().first ?? "") + return acceptedSuggestion } } diff --git a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift index aac57389..bc2742c9 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift @@ -1,4 +1,4 @@ -import SuggestionModel +import SuggestionBasic import XPCShared protocol SuggestionCommandHandler { @@ -11,15 +11,18 @@ protocol SuggestionCommandHandler { @ServiceActor func rejectSuggestion(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor + func acceptSuggestionLine(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor + func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor func presentRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor func generateRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor - func chatWithSelection(editor: EditorContent) async throws -> UpdatedContent? - @ServiceActor func promptToCode(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor func customCommand(id: String, editor: EditorContent) async throws -> UpdatedContent? } + diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index a899c840..53b0c833 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -1,16 +1,22 @@ +import AppKit import ChatService -import SuggestionModel -import GitHubCopilotService -import Environment +import ComposableArchitecture +import CustomCommandTemplateProcessor import Foundation +import GitHubCopilotService import LanguageServerProtocol import Logger +import ModificationBasic import OpenAIService +import SuggestionBasic import SuggestionInjector import SuggestionWidget +import UserNotifications +import Workspace +import WorkspaceSuggestionService +import XcodeInspector import XPCShared -@ServiceActor struct WindowBaseCommandHandler: SuggestionCommandHandler { nonisolated init() {} @@ -30,30 +36,24 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + @WorkspaceActor private func _presentSuggestions(editor: EditorContent) async throws { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } - let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + else { return } + let (workspace, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(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 } - try await workspace.generateSuggestions( forFileAt: fileURL, editor: editor ) - + try Task.checkCancellation() if filespace.presentingSuggestion != nil { @@ -74,12 +74,12 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + @WorkspaceActor private func _presentNextSuggestion(editor: EditorContent) async throws { - presenter.markAsProcessing(true) - defer { presenter.markAsProcessing(false) } - let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + else { return } + let (workspace, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) workspace.selectNextSuggestion(forFileAt: fileURL) if filespace.presentingSuggestion != nil { @@ -100,12 +100,12 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + @WorkspaceActor private func _presentPreviousSuggestion(editor: EditorContent) async throws { - presenter.markAsProcessing(true) - defer { presenter.markAsProcessing(false) } - let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + else { return } + let (workspace, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) workspace.selectPreviousSuggestion(forFileAt: fileURL) if filespace.presentingSuggestion != nil { @@ -126,70 +126,61 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + @WorkspaceActor private func _rejectSuggestion(editor: EditorContent) async throws { - presenter.markAsProcessing(true) - defer { presenter.markAsProcessing(false) } - let fileURL = try await Environment.fetchCurrentFileURL() - - if WidgetDataSource.shared.promptToCodes[fileURL]?.promptToCodeService != nil { - WidgetDataSource.shared.removePromptToCode(for: fileURL) - presenter.closePromptToCode(fileURL: fileURL) - return - } + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + else { return } - let (workspace, _) = try await Workspace.fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) workspace.rejectSuggestion(forFileAt: fileURL, editor: editor) presenter.discardSuggestion(fileURL: fileURL) } + @WorkspaceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? { - presenter.markAsProcessing(true) - defer { presenter.markAsProcessing(false) } - - let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, _) = try await Workspace.fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + else { return nil } + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) let injector = SuggestionInjector() var lines = editor.lines var cursorPosition = editor.cursorPosition var extraInfo = SuggestionInjector.ExtraInfo() - if let service = WidgetDataSource.shared.promptToCodes[fileURL]?.promptToCodeService { - let suggestion = CodeSuggestion( - text: service.code, - position: service.selectionRange.start, - uuid: UUID().uuidString, - range: service.selectionRange, - displayText: service.code - ) - + if let acceptedSuggestion = workspace.acceptSuggestion( + forFileAt: fileURL, + editor: editor + ) { injector.acceptSuggestion( intoContentWithoutSuggestion: &lines, cursorPosition: &cursorPosition, - completion: suggestion, + completion: acceptedSuggestion, extraInfo: &extraInfo ) - if service.isContinuous { - service.selectionRange = .init( - start: service.selectionRange.start, - end: cursorPosition - ) - presenter.presentPromptToCode(fileURL: fileURL) - } else { - WidgetDataSource.shared.removePromptToCode(for: fileURL) - presenter.closePromptToCode(fileURL: fileURL) - } + presenter.discardSuggestion(fileURL: fileURL) return .init( content: String(lines.joined(separator: "")), - newSelection: .init(start: service.selectionRange.start, end: cursorPosition), + newSelection: .cursor(cursorPosition), modifications: extraInfo.modifications ) - } else if let acceptedSuggestion = workspace.acceptSuggestion( - forFileAt: fileURL, - editor: editor - ) { + } + + return nil + } + + func acceptSuggestionLine(editor: EditorContent) async throws -> UpdatedContent? { + if let acceptedSuggestion = try await PseudoCommandHandler() + .handleAcceptSuggestionLineCommand(editor: editor) + { + let injector = SuggestionInjector() + var lines = editor.lines + var cursorPosition = editor.cursorPosition + var extraInfo = SuggestionInjector.ExtraInfo() + injector.acceptSuggestion( intoContentWithoutSuggestion: &lines, cursorPosition: &cursorPosition, @@ -197,8 +188,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { extraInfo: &extraInfo ) - presenter.discardSuggestion(fileURL: fileURL) - return .init( content: String(lines.joined(separator: "")), newSelection: .cursor(cursorPosition), @@ -209,6 +198,85 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? { + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + else { return nil } + + let injector = SuggestionInjector() + var lines = editor.lines + var cursorPosition = editor.cursorPosition + var extraInfo = SuggestionInjector.ExtraInfo() + + let store = await Service.shared.guiController.store + + if let promptToCode = await MainActor + .run(body: { store.state.promptToCodeGroup.activePromptToCode }) + { + if promptToCode.promptToCodeState.isAttachedToTarget, + promptToCode.promptToCodeState.source.documentURL != fileURL + { + return nil + } + + let suggestions = promptToCode.promptToCodeState.snippets + .map { snippet in + let range = { + if promptToCode.promptToCodeState.isAttachedToTarget { + return snippet.attachedRange + } + return editor.selections.first.map { + CursorRange(start: $0.start, end: $0.end) + } ?? CursorRange( + start: editor.cursorPosition, + end: editor.cursorPosition + ) + }() + return CodeSuggestion( + id: snippet.id.uuidString, + text: snippet.modifiedCode, + position: range.start, + range: range + ) + } + + injector.acceptSuggestions( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursorPosition, + completions: suggestions, + extraInfo: &extraInfo + ) + + for (id, range) in extraInfo.modificationRanges { + _ = await MainActor.run { + store.send( + .promptToCodeGroup(.updatePromptToCodeRange( + id: promptToCode.id, + snippetId: .init(uuidString: id) ?? .init(), + range: range + )) + ) + } + } + + _ = await MainActor.run { + store.send( + .promptToCodeGroup(.discardAcceptedPromptToCodeIfNotContinuous( + id: promptToCode.id + )) + ) + } + + return .init( + content: String(lines.joined(separator: "")), + newSelections: extraInfo.modificationRanges.values + .sorted(by: { $0.start.line <= $1.start.line }), + modifications: extraInfo.modifications + ) + } + + return nil + } + func presentRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? { Task { try? await prepareCache(editor: editor) @@ -216,14 +284,17 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + @WorkspaceActor func prepareCache(editor: EditorContent) async throws -> UpdatedContent? { - let fileURL = try await Environment.fetchCurrentFileURL() - let (_, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) - filespace.uti = editor.uti - filespace.tabSize = editor.tabSize - filespace.indentSize = editor.indentSize - filespace.usesTabsForIndentation = editor.usesTabsForIndentation + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + else { return nil } + let (_, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + filespace.codeMetadata.uti = editor.uti + filespace.codeMetadata.tabSize = editor.tabSize + filespace.codeMetadata.indentSize = editor.indentSize + filespace.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + filespace.codeMetadata.guessLineEnding(from: editor.lines.first) return nil } @@ -231,22 +302,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return try await presentSuggestions(editor: editor) } - func chatWithSelection(editor: EditorContent) async throws -> UpdatedContent? { - Task { - do { - try await startChat( - specifiedSystemPrompt: nil, - extraSystemPrompt: nil, - sendingMessageImmediately: nil, - name: nil - ) - } catch { - presenter.presentError(error) - } - } - return nil - } - func promptToCode(editor: EditorContent) async throws -> UpdatedContent? { Task { do { @@ -288,21 +343,11 @@ extension WindowBaseCommandHandler { else { throw CommandNotFoundError() } switch command.feature { - case let .chatWithSelection(extraSystemPrompt, prompt, useExtraSystemPrompt): - let updatePrompt = useExtraSystemPrompt ?? true - try await startChat( - specifiedSystemPrompt: nil, - extraSystemPrompt: updatePrompt ? extraSystemPrompt : nil, - sendingMessageImmediately: prompt, - name: command.name - ) - case let .customChat(systemPrompt, prompt): - try await startChat( - specifiedSystemPrompt: systemPrompt, - extraSystemPrompt: "", - sendingMessageImmediately: prompt, - name: command.name - ) + case .chatWithSelection, .customChat: + Task { @MainActor in + Service.shared.guiController.store + .send(.sendCustomCommandToActiveChat(command)) + } case let .promptToCode(extraSystemPrompt, prompt, continuousMode, generateDescription): try await presentPromptToCode( editor: editor, @@ -312,9 +357,22 @@ extension WindowBaseCommandHandler { generateDescription: generateDescription, name: command.name ) + case let .singleRoundDialog( + systemPrompt, + overwriteSystemPrompt, + prompt, + receiveReplyInNotification + ): + try await executeSingleRoundDialog( + systemPrompt: systemPrompt, + overwriteSystemPrompt: overwriteSystemPrompt ?? false, + prompt: prompt ?? "", + receiveReplyInNotification: receiveReplyInNotification ?? false + ) } } + @WorkspaceActor func presentPromptToCode( editor: EditorContent, extraSystemPrompt: String?, @@ -323,35 +381,56 @@ extension WindowBaseCommandHandler { generateDescription: Bool?, name: String? ) async throws { - presenter.markAsProcessing(true) - defer { presenter.markAsProcessing(false) } - let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, _) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) - guard workspace.isSuggestionFeatureEnabled else { + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + else { return } + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + guard workspace.suggestionPlugin?.isSuggestionFeatureEnabled ?? false else { presenter.presentErrorMessage("Prompt to code is disabled for this project") return } let codeLanguage = languageIdentifierFromFileURL(fileURL) - let (code, selection) = { - guard var selection = editor.selections.last, - selection.start != selection.end - else { return ("", .cursor(editor.cursorPosition)) } - + let selections: [CursorRange] = { + if let firstSelection = editor.selections.first, + let lastSelection = editor.selections.last + { + let range = CursorRange( + start: firstSelection.start, + end: lastSelection.end + ) + return [range] + } + return [] + }() + + let snippets = selections.map { selection in + guard selection.start != selection.end else { + return ModificationSnippet( + startLineIndex: selection.start.line, + originalCode: "", + modifiedCode: "", + description: "", + error: "", + attachedRange: selection + ) + } + var selection = selection let isMultipleLine = selection.start.line != selection.end.line let isSpaceOnlyBeforeStartPositionOnTheSameLine = { guard selection.start.line >= 0, selection.start.line < editor.lines.count else { return false } let line = editor.lines[selection.start.line] - guard selection.start.character > 0, selection.start.character < line.count else { - return false - } - let substring = - line[line.startIndex.. 0, + selection.start.character < line.utf16.count + else { return false } + let substring = line[line.utf16.startIndex..<(line.index( + line.utf16.startIndex, + offsetBy: selection.start.character, + limitedBy: line.utf16.endIndex + ) ?? line.utf16.endIndex)] return substring.allSatisfy { $0.isWhitespace } }() @@ -360,72 +439,95 @@ extension WindowBaseCommandHandler { // indentation. selection.start = .init(line: selection.start.line, character: 0) } - return ( - editor.selectedCode(in: selection), - .init( - start: .init(line: selection.start.line, character: selection.start.character), - end: .init(line: selection.end.line, character: selection.end.character) - ) + let selectedCode = editor.selectedCode(in: .init( + start: selection.start, + end: selection.end + )) + return ModificationSnippet( + startLineIndex: selection.start.line, + originalCode: selectedCode, + modifiedCode: selectedCode, + description: "", + error: "", + attachedRange: .init(start: selection.start, end: selection.end) ) - }() as (String, CursorRange) - - let promptToCode = await WidgetDataSource.shared.createPromptToCode( - for: fileURL, - projectURL: workspace.projectRootURL, - selectedCode: code, - allCode: editor.content, - selectionRange: selection, - language: codeLanguage, - extraSystemPrompt: extraSystemPrompt, - generateDescriptionRequirement: generateDescription, - name: name - ) + } + + let store = await Service.shared.guiController.store + + let customCommandTemplateProcessor = CustomCommandTemplateProcessor() - promptToCode.isContinuous = isContinuous - if let prompt, !prompt.isEmpty { - Task { try await promptToCode.modifyCode(prompt: prompt) } + let newExtraSystemPrompt: String? = if let extraSystemPrompt { + await customCommandTemplateProcessor.process(extraSystemPrompt) + } else { + nil + } + + let newPrompt: String? = if let prompt { + await customCommandTemplateProcessor.process(prompt) + } else { + nil } - presenter.presentPromptToCode(fileURL: fileURL) + _ = await MainActor.run { + store.send(.promptToCodeGroup(.activateOrCreatePromptToCode(.init( + promptToCodeState: Shared(.init( + source: .init( + language: codeLanguage, + documentURL: fileURL, + projectRootURL: workspace.projectRootURL, + content: editor.content, + lines: editor.lines + ), + snippets: IdentifiedArray(uniqueElements: snippets), + extraSystemPrompt: newExtraSystemPrompt ?? "", + isAttachedToTarget: true + )), + instruction: newPrompt, + commandName: name, + isContinuous: isContinuous + )))) + } } - - private func startChat( - specifiedSystemPrompt: String?, - extraSystemPrompt: String?, - sendingMessageImmediately: String?, - name: String? + + func executeSingleRoundDialog( + systemPrompt: String?, + overwriteSystemPrompt: Bool, + prompt: String, + receiveReplyInNotification: Bool ) async throws { - presenter.markAsProcessing(true) - defer { presenter.markAsProcessing(false) } + guard !prompt.isEmpty else { return } - let focusedElementURI = try await Environment.fetchFocusedElementURI() + let service = ChatService() - let chat = WidgetDataSource.shared.createChatIfNeeded(for: focusedElementURI) + let result = try await service.handleSingleRoundDialogCommand( + systemPrompt: systemPrompt, + overwriteSystemPrompt: overwriteSystemPrompt, + prompt: prompt + ) - chat.mutateSystemPrompt(specifiedSystemPrompt) - chat.mutateExtraSystemPrompt(extraSystemPrompt ?? "") + guard receiveReplyInNotification else { return } - Task { - let customCommandPrefix = { - if let name { return "[\(name)] " } - return "" - }() - - if specifiedSystemPrompt != nil || extraSystemPrompt != nil { - await chat.chatGPTService.mutateHistory { history in - history.append(.init( - role: .assistant, - content: "", - summary: "\(customCommandPrefix)System prompt is updated." - )) - } - } + let granted = try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert]) - if let sendingMessageImmediately, !sendingMessageImmediately.isEmpty { - try await chat.send(content: sendingMessageImmediately) + if granted { + let content = UNMutableNotificationContent() + content.title = "Reply" + content.body = result + let request = UNNotificationRequest( + identifier: "reply", + content: content, + trigger: nil + ) + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + presenter.presentError(error) } + } else { + presenter.presentErrorMessage("Notification permission is not granted.") } - - presenter.presentChatRoom(fileURL: focusedElementURI) } } + diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInCommentSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInCommentSuggestionPresenter.swift deleted file mode 100644 index 39d111e1..00000000 --- a/Core/Sources/Service/SuggestionPresenter/PresentInCommentSuggestionPresenter.swift +++ /dev/null @@ -1,72 +0,0 @@ -import SuggestionModel -import Foundation -import SuggestionInjector -import XPCShared - -struct PresentInCommentSuggestionPresenter { - func presentSuggestion( - for filespace: Filespace, - in workspace: Workspace, - originalContent: String, - lines: [String], - cursorPosition: CursorPosition - ) async throws -> UpdatedContent? { - let injector = SuggestionInjector() - var lines = lines - var cursorPosition = cursorPosition - var extraInfo = SuggestionInjector.ExtraInfo() - - injector.rejectCurrentSuggestions( - from: &lines, - cursorPosition: &cursorPosition, - extraInfo: &extraInfo - ) - - guard let completion = await filespace.presentingSuggestion else { - return .init( - content: originalContent, - newSelection: .cursor(cursorPosition), - modifications: extraInfo.modifications - ) - } - - await injector.proposeSuggestion( - intoContentWithoutSuggestion: &lines, - completion: completion, - index: filespace.suggestionIndex, - count: filespace.suggestions.count, - extraInfo: &extraInfo - ) - - return .init( - content: String(lines.joined(separator: "")), - newSelection: .cursor(cursorPosition), - modifications: extraInfo.modifications - ) - } - - func discardSuggestion( - for filespace: Filespace, - in workspace: Workspace, - originalContent: String, - lines: [String], - cursorPosition: CursorPosition - ) async throws -> UpdatedContent? { - let injector = SuggestionInjector() - var lines = lines - var cursorPosition = cursorPosition - var extraInfo = SuggestionInjector.ExtraInfo() - - injector.rejectCurrentSuggestions( - from: &lines, - cursorPosition: &cursorPosition, - extraInfo: &extraInfo - ) - - return .init( - content: String(lines.joined(separator: "")), - newSelection: .cursor(cursorPosition), - modifications: extraInfo.modifications - ) - } -} diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift index 4e09de2b..7069422b 100644 --- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -1,27 +1,27 @@ import ChatService -import SuggestionModel import Foundation import OpenAIService +import SuggestionBasic import SuggestionWidget struct PresentInWindowSuggestionPresenter { func presentSuggestion(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget - controller.suggestCode(fileURL: fileURL) + let controller = Service.shared.guiController.widgetController + controller.suggestCode() } } func discardSuggestion(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget - controller.discardSuggestion(fileURL: fileURL) + let controller = Service.shared.guiController.widgetController + controller.discardSuggestion() } } func markAsProcessing(_ isProcessing: Bool) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget + let controller = Service.shared.guiController.widgetController controller.markAsProcessing(isProcessing) } } @@ -30,43 +30,23 @@ struct PresentInWindowSuggestionPresenter { if error is CancellationError { return } if let urlError = error as? URLError, urlError.code == URLError.cancelled { return } Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget + let controller = Service.shared.guiController.widgetController controller.presentError(error.localizedDescription) } } func presentErrorMessage(_ message: String) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget + let controller = Service.shared.guiController.widgetController controller.presentError(message) } } - func closeChatRoom(fileURL: URL) { - Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget - controller.closeChatRoom(fileURL: fileURL) - } - } - func presentChatRoom(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget - controller.presentChatRoom(fileURL: fileURL) - } - } - - func presentPromptToCode(fileURL: URL) { - Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget - controller.presentPromptToCode(fileURL: fileURL) - } - } - - func closePromptToCode(fileURL: URL) { - Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget - controller.discardPromptToCode(fileURL: fileURL) + let controller = Service.shared.guiController + controller.store.send(.openChatPanel(forceDetach: false, activateThisApp: true)) } } } + diff --git a/Core/Sources/Service/Workspace.swift b/Core/Sources/Service/Workspace.swift deleted file mode 100644 index 4f6357b8..00000000 --- a/Core/Sources/Service/Workspace.swift +++ /dev/null @@ -1,435 +0,0 @@ -import ChatService -import Environment -import Foundation -import GitHubCopilotService -import Logger -import Preferences -import SuggestionInjector -import SuggestionModel -import SuggestionService -import UserDefaultsObserver -import XcodeInspector -import XPCShared - -// MARK: - Filespace - -@ServiceActor -final class Filespace { - struct Snapshot: Equatable { - var linesHash: Int - var cursorPosition: CursorPosition - } - - let fileURL: URL - private(set) lazy var language: String = languageIdentifierFromFileURL(fileURL).rawValue - var suggestions: [CodeSuggestion] = [] { - didSet { refreshUpdateTime() } - } - - // stored for pseudo command handler - var uti: String? - var tabSize: Int? - var indentSize: Int? - var usesTabsForIndentation: Bool? - // --------------------------------- - - var suggestionIndex: Int = 0 - var suggestionSourceSnapshot: Snapshot = .init(linesHash: -1, cursorPosition: .outOfScope) - var presentingSuggestion: CodeSuggestion? { - guard suggestions.endIndex > suggestionIndex, suggestionIndex >= 0 else { return nil } - return suggestions[suggestionIndex] - } - - private(set) var lastSuggestionUpdateTime: Date = Environment.now() - var isExpired: Bool { - Environment.now().timeIntervalSince(lastSuggestionUpdateTime) > 60 * 3 - } - - let fileSaveWatcher: FileSaveWatcher - - fileprivate init(fileURL: URL, onSave: @escaping (Filespace) -> Void) { - self.fileURL = fileURL - fileSaveWatcher = .init(fileURL: fileURL) - fileSaveWatcher.changeHandler = { [weak self] in - guard let self else { return } - onSave(self) - } - } - - func reset(resetSnapshot: Bool = true) { - suggestions = [] - suggestionIndex = 0 - if resetSnapshot { - suggestionSourceSnapshot = .init(linesHash: -1, cursorPosition: .outOfScope) - } - } - - func refreshUpdateTime() { - lastSuggestionUpdateTime = Environment.now() - } - - func validateSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { - if cursorPosition.line != suggestionSourceSnapshot.cursorPosition.line { - reset() - return false - } - - guard cursorPosition.line >= 0, cursorPosition.line < lines.count else { - reset() - return false - } - - let editingLine = lines[cursorPosition.line].dropLast(1) // dropping \n - let suggestionFirstLine = presentingSuggestion?.text.split(separator: "\n").first ?? "" - if !suggestionFirstLine.hasPrefix(editingLine) { - reset() - return false - } - - return true - } -} - -// MARK: - Workspace - -@ServiceActor -final class Workspace { - struct SuggestionFeatureDisabledError: Error, LocalizedError { - var errorDescription: String? { - "Suggestion feature is disabled for this project." - } - } - - let projectRootURL: URL - let openedFileRecoverableStorage: OpenedFileRecoverableStorage - var lastSuggestionUpdateTime = Environment.now() - var isExpired: Bool { - Environment.now().timeIntervalSince(lastSuggestionUpdateTime) > 60 * 60 * 1 - } - - private(set) var filespaces = [URL: Filespace]() - var isRealtimeSuggestionEnabled: Bool { - UserDefaults.shared.value(for: \.realtimeSuggestionToggle) - } - - let userDefaultsObserver = UserDefaultsObserver( - object: UserDefaults.shared, forKeyPaths: [ - UserDefaultPreferenceKeys().suggestionFeatureEnabledProjectList.key, - UserDefaultPreferenceKeys().disableSuggestionFeatureGlobally.key, - ], context: nil - ) - - private var _suggestionService: SuggestionServiceType? - - private var suggestionService: SuggestionServiceType? { - // Check if the workspace is disabled. - let isSuggestionDisabledGlobally = UserDefaults.shared - .value(for: \.disableSuggestionFeatureGlobally) - if isSuggestionDisabledGlobally { - let enabledList = UserDefaults.shared.value(for: \.suggestionFeatureEnabledProjectList) - if !enabledList.contains(where: { path in projectRootURL.path.hasPrefix(path) }) { - // If it's disable, remove the service - _suggestionService = nil - return nil - } - } - - if _suggestionService == nil { - _suggestionService = Environment.createSuggestionService(projectRootURL) { - [weak self] _ in - guard let self else { return } - for (_, filespace) in filespaces { - notifyOpenFile(filespace: filespace) - } - } - } - return _suggestionService - } - - var isSuggestionFeatureEnabled: Bool { - let isSuggestionDisabledGlobally = UserDefaults.shared - .value(for: \.disableSuggestionFeatureGlobally) - if isSuggestionDisabledGlobally { - let enabledList = UserDefaults.shared.value(for: \.suggestionFeatureEnabledProjectList) - if !enabledList.contains(where: { path in projectRootURL.path.hasPrefix(path) }) { - return false - } - } - return true - } - - private init(projectRootURL: URL) { - self.projectRootURL = projectRootURL - openedFileRecoverableStorage = .init(projectRootURL: projectRootURL) - - userDefaultsObserver.onChange = { [weak self] in - guard let self else { return } - _ = self.suggestionService - } - - let openedFiles = openedFileRecoverableStorage.openedFiles - for fileURL in openedFiles { - _ = createFilespaceIfNeeded(fileURL: fileURL) - } - } - - func refreshUpdateTime() { - lastSuggestionUpdateTime = Environment.now() - } - - func canAutoTriggerGetSuggestions( - forFileAt fileURL: URL, - lines: [String], - cursorPosition: CursorPosition - ) -> Bool { - guard isRealtimeSuggestionEnabled else { return false } - guard let filespace = filespaces[fileURL] else { return true } - if lines.hashValue != filespace.suggestionSourceSnapshot.linesHash { return true } - if cursorPosition != filespace.suggestionSourceSnapshot.cursorPosition { return true } - return false - } - - /// This is the only way to create a workspace and a filespace. - static func fetchOrCreateWorkspaceIfNeeded(fileURL: URL) async throws - -> (workspace: Workspace, filespace: Filespace) - { - // If we know which project is opened. - if let currentProjectURL = try await Environment.fetchCurrentProjectRootURLFromXcode() { - if let existed = workspaces[currentProjectURL] { - 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 - // quick open window in displayed. - for workspace in workspaces.values { - if let filespace = workspace.filespaces[fileURL] { - return (workspace, filespace) - } - } - - // 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 = { - if let existed = workspaces[workspaceURL] { - return existed - } - // Reuse existed workspace if possible - for (_, workspace) in workspaces { - if fileURL.path.hasPrefix(workspace.projectRootURL.path) { - return workspace - } - } - return Workspace(projectRootURL: workspaceURL) - }() - - let filespace = workspace.createFilespaceIfNeeded(fileURL: fileURL) - workspaces[workspaceURL] = workspace - workspace.refreshUpdateTime() - return (workspace, filespace) - } - - private func createFilespaceIfNeeded(fileURL: URL) -> Filespace { - let existedFilespace = filespaces[fileURL] - let filespace = existedFilespace ?? .init(fileURL: fileURL, onSave: { [weak self] - filespace in - guard let self else { return } - notifySaveFile(filespace: filespace) - }) - if filespaces[fileURL] == nil { - filespaces[fileURL] = filespace - } - if existedFilespace == nil { - notifyOpenFile(filespace: filespace) - } else { - filespace.refreshUpdateTime() - } - return filespace - } -} - -// MARK: - Suggestion - -extension Workspace { - @discardableResult - func generateSuggestions( - forFileAt fileURL: URL, - editor: EditorContent - ) async throws -> [CodeSuggestion] { - refreshUpdateTime() - - let filespace = createFilespaceIfNeeded(fileURL: fileURL) - - if filespaces[fileURL] == nil { - filespaces[fileURL] = filespace - } - - if !editor.uti.isEmpty { - filespace.uti = editor.uti - filespace.tabSize = editor.tabSize - filespace.indentSize = editor.indentSize - filespace.usesTabsForIndentation = editor.usesTabsForIndentation - } - - let snapshot = Filespace.Snapshot( - linesHash: editor.lines.hashValue, - cursorPosition: editor.cursorPosition - ) - - filespace.suggestionSourceSnapshot = snapshot - - guard let suggestionService else { throw SuggestionFeatureDisabledError() } - let completions = try await suggestionService.getSuggestions( - fileURL: fileURL, - content: editor.lines.joined(separator: ""), - cursorPosition: editor.cursorPosition, - tabSize: editor.tabSize, - indentSize: editor.indentSize, - usesTabsForIndentation: editor.usesTabsForIndentation, - ignoreSpaceOnlySuggestions: true - ) - - filespace.suggestions = completions - filespace.suggestionIndex = 0 - - return completions - } - - func selectNextSuggestion(forFileAt fileURL: URL) { - refreshUpdateTime() - guard let filespace = filespaces[fileURL], - filespace.suggestions.count > 1 - else { return } - filespace.suggestionIndex += 1 - if filespace.suggestionIndex >= filespace.suggestions.endIndex { - filespace.suggestionIndex = 0 - } - } - - func selectPreviousSuggestion(forFileAt fileURL: URL) { - refreshUpdateTime() - guard let filespace = filespaces[fileURL], - filespace.suggestions.count > 1 - else { return } - filespace.suggestionIndex -= 1 - if filespace.suggestionIndex < 0 { - filespace.suggestionIndex = filespace.suggestions.endIndex - 1 - } - } - - func rejectSuggestion(forFileAt fileURL: URL, editor: EditorContent?) { - refreshUpdateTime() - - if let editor, !editor.uti.isEmpty { - filespaces[fileURL]?.uti = editor.uti - filespaces[fileURL]?.tabSize = editor.tabSize - filespaces[fileURL]?.indentSize = editor.indentSize - filespaces[fileURL]?.usesTabsForIndentation = editor.usesTabsForIndentation - } - Task { - await suggestionService?.notifyRejected(filespaces[fileURL]?.suggestions ?? []) - } - filespaces[fileURL]?.reset(resetSnapshot: false) - } - - func acceptSuggestion(forFileAt fileURL: URL, editor: EditorContent?) -> CodeSuggestion? { - refreshUpdateTime() - guard let filespace = filespaces[fileURL], - !filespace.suggestions.isEmpty, - filespace.suggestionIndex >= 0, - filespace.suggestionIndex < filespace.suggestions.endIndex - else { return nil } - - if let editor, !editor.uti.isEmpty { - filespaces[fileURL]?.uti = editor.uti - filespaces[fileURL]?.tabSize = editor.tabSize - filespaces[fileURL]?.indentSize = editor.indentSize - filespaces[fileURL]?.usesTabsForIndentation = editor.usesTabsForIndentation - } - - var allSuggestions = filespace.suggestions - let suggestion = allSuggestions.remove(at: filespace.suggestionIndex) - - Task { - await suggestionService?.notifyAccepted(suggestion) - await suggestionService?.notifyRejected(allSuggestions) - } - - filespaces[fileURL]?.reset() - - return suggestion - } - - func notifyOpenFile(filespace: Filespace) { - refreshUpdateTime() - openedFileRecoverableStorage.openFile(fileURL: filespace.fileURL) - Task { - try await suggestionService?.notifyOpenTextDocument( - fileURL: filespace.fileURL, - content: try String(contentsOf: filespace.fileURL, encoding: .utf8) - ) - } - } - - func notifyUpdateFile(filespace: Filespace, content: String) { - filespace.refreshUpdateTime() - refreshUpdateTime() - Task { - try await suggestionService?.notifyChangeTextDocument( - fileURL: filespace.fileURL, - content: content - ) - } - } - - func notifySaveFile(filespace: Filespace) { - filespace.refreshUpdateTime() - refreshUpdateTime() - Task { - try await suggestionService?.notifySaveTextDocument(fileURL: filespace.fileURL) - } - } -} - -// MARK: - Cleanup - -extension Workspace { - func cleanUp(availableTabs: Set) { - for (fileURL, _) in filespaces { - if isFilespaceExpired(fileURL: fileURL, availableTabs: availableTabs) { - Task { - try await suggestionService?.notifyCloseTextDocument(fileURL: fileURL) - } - openedFileRecoverableStorage.closeFile(fileURL: fileURL) - filespaces[fileURL] = nil - } - } - } - - func isFilespaceExpired(fileURL: URL, availableTabs: Set) -> Bool { - let filename = fileURL.lastPathComponent - if availableTabs.contains(filename) { return false } - guard let filespace = filespaces[fileURL] else { return true } - return filespace.isExpired - } - - func cancelInFlightRealtimeSuggestionRequests() async { - guard let suggestionService else { return } - await suggestionService.cancelRequest() - } - - func terminateSuggestionService() async { - await _suggestionService?.terminate() - } -} diff --git a/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift new file mode 100644 index 00000000..d154aade --- /dev/null +++ b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift @@ -0,0 +1,32 @@ +import Foundation +import SuggestionProvider +import Workspace +import WorkspaceSuggestionService + +extension Workspace { + @WorkspaceActor + func cleanUp(availableTabs: Set) { + for (fileURL, _) in filespaces { + if isFilespaceExpired(fileURL: fileURL, availableTabs: availableTabs) { + openedFileRecoverableStorage.closeFile(fileURL: fileURL) + closeFilespace(fileURL: fileURL) + } + } + } + + func isFilespaceExpired(fileURL: URL, availableTabs: Set) -> Bool { + let filename = fileURL.lastPathComponent + if availableTabs.contains(filename) { return false } + guard let filespace = filespaces[fileURL] else { return true } + return filespace.isExpired + } + + func cancelInFlightRealtimeSuggestionRequests() async { + guard let suggestionService else { return } + await suggestionService.cancelRequest(workspaceInfo: .init( + workspaceURL: workspaceURL, + projectURL: projectRootURL + )) + } +} + diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 036afbb0..b8c70126 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -1,5 +1,4 @@ import AppKit -import Environment import Foundation import GitHubCopilotService import LanguageServerProtocol @@ -7,14 +6,6 @@ import Logger import Preferences import XPCShared -@globalActor public enum ServiceActor { - public actor TheActor {} - public static let shared = TheActor() -} - -@ServiceActor -var workspaces = [URL: Workspace]() - public class XPCService: NSObject, XPCServiceProtocol { // MARK: - Service @@ -46,22 +37,14 @@ public class XPCService: NSObject, XPCServiceProtocol { let task = Task { 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) return } try Task.checkCancellation() - reply(try JSONEncoder().encode(updatedContent), nil) + try reply(JSONEncoder().encode(updatedContent), nil) } catch { Logger.service.error("\(file):\(line) \(error.localizedDescription)") reply(nil, NSError.from(error)) @@ -69,7 +52,7 @@ public class XPCService: NSObject, XPCServiceProtocol { } Task { - await RealtimeSuggestionController.shared.cancelInFlightTasks(excluding: task) + await Service.shared.realtimeSuggestionController.cancelInFlightTasks(excluding: task) } return task } @@ -119,6 +102,15 @@ public class XPCService: NSObject, XPCServiceProtocol { } } + public func getPromptToCodeAcceptedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.acceptPromptToCode(editor: editor) + } + } + public func getRealtimeSuggestedCode( editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void @@ -148,13 +140,13 @@ public class XPCService: NSObject, XPCServiceProtocol { } } - public func chatWithSelection( + public func openChat( editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void ) { - replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in - try await handler.chatWithSelection(editor: editor) - } + let handler = PseudoCommandHandler() + handler.openChat(forceDetach: false) + reply(nil, nil) } public func promptToCode( @@ -184,13 +176,55 @@ public class XPCService: NSObject, XPCServiceProtocol { return } Task { @ServiceActor in - await RealtimeSuggestionController.shared.cancelInFlightTasks() - UserDefaults.shared.set( - !UserDefaults.shared.value(for: \.realtimeSuggestionToggle), - for: \.realtimeSuggestionToggle - ) + await Service.shared.realtimeSuggestionController.cancelInFlightTasks() + let on = !UserDefaults.shared.value(for: \.realtimeSuggestionToggle) + UserDefaults.shared.set(on, for: \.realtimeSuggestionToggle) + Task { @MainActor in + Service.shared.guiController.store + .send(.suggestionWidget(.toastPanel(.toast(.toast( + "Real-time suggestion is turned \(on ? "on" : "off")", + .info, + nil + ))))) + } reply(nil) } } + + public func postNotification(name: String, withReply reply: @escaping () -> Void) { + reply() + NotificationCenter.default.post(name: .init(name), object: nil) + } + + public func quit(reply: @escaping () -> Void) { + Task { + await Service.shared.prepareForExit() + reply() + } + } + + // MARK: - Requests + + public func send( + endpoint: String, + requestBody: Data, + reply: @escaping (Data?, Error?) -> Void + ) { + Task { + await Service.shared.handleXPCServiceRequests( + endpoint: endpoint, + requestBody: requestBody, + reply: reply + ) + } + } +} + +struct NoAccessToAccessibilityAPIError: Error, LocalizedError { + var errorDescription: String? { + "Accessibility API permission is not granted. Please enable in System Settings.app." + } + + init() {} } diff --git a/Core/Sources/ServiceUpdateMigration/MigrateTo135.swift b/Core/Sources/ServiceUpdateMigration/MigrateTo135.swift new file mode 100644 index 00000000..40b58c94 --- /dev/null +++ b/Core/Sources/ServiceUpdateMigration/MigrateTo135.swift @@ -0,0 +1,47 @@ +import Foundation +import GitHubCopilotService + +func migrateFromLowerThanOrEqualToVersion135() throws { + // 0. Create the application support folder if it doesn't exist + + let urls = try GitHubCopilotBaseService.createFoldersIfNeeded() + + // 1. Move the undefined folder in application support into a sub folder called `GitHub + // Copilot/support` + + let undefinedFolderURL = urls.applicationSupportURL.appendingPathComponent("undefined") + var isUndefinedADirectory: ObjCBool = false + let isUndefinedExisted = FileManager.default.fileExists( + atPath: undefinedFolderURL.path, + isDirectory: &isUndefinedADirectory + ) + if isUndefinedExisted, isUndefinedADirectory.boolValue { + try FileManager.default.moveItem( + at: undefinedFolderURL, + to: urls.supportURL.appendingPathComponent("undefined") + ) + } + + // 2. Copy the GitHub copilot language service to `GitHub Copilot/executable` + + let copilotFolderURL = urls.executableURL.appendingPathComponent("copilot") + var copilotIsFolder: ObjCBool = false + let executable = Bundle.main.resourceURL?.appendingPathComponent("copilot") + if let executable, + FileManager.default.fileExists(atPath: executable.path, isDirectory: &copilotIsFolder), + !FileManager.default.fileExists(atPath: copilotFolderURL.path) + { + try FileManager.default.copyItem( + at: executable, + to: urls.executableURL.appendingPathComponent("copilot") + ) + } + + // 3. Use chmod to change the permission of the executable to 755 + + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: copilotFolderURL.path + ) +} + diff --git a/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift b/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift new file mode 100644 index 00000000..b926b482 --- /dev/null +++ b/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift @@ -0,0 +1,116 @@ +import AIModel +import Foundation +import Keychain +import Preferences + +func migrateTo240( + defaults: UserDefaults = .shared, + keychain: KeychainType = Keychain.apiKey +) throws { + let finishedMigrationKey = "MigrateTo240Finished" + if defaults.bool(forKey: finishedMigrationKey) { return } + + do { + let chatModelOpenAIId = UUID().uuidString + let chatModelAzureOpenAIId = UUID().uuidString + let embeddingModelOpenAIId = UUID().uuidString + let embeddingModelAzureOpenAIId = UUID().uuidString + + let openAIAPIKeyName = "OpenAI" + let openAIAPIKey = defaults.deprecatedValue(for: \.openAIAPIKey) + if !openAIAPIKey.isEmpty { + try keychain.update(openAIAPIKey, key: openAIAPIKeyName) + } + + let azureOpenAIAPIKeyName = "Azure OpenAI" + let azureOpenAIAPIKey = defaults.deprecatedValue(for: \.azureOpenAIAPIKey) + if !azureOpenAIAPIKey.isEmpty { + try keychain.update(azureOpenAIAPIKey, key: azureOpenAIAPIKeyName) + } + + defaults.set({ + let openAIModel = ChatGPTModel(rawValue: defaults.deprecatedValue(for: \.chatGPTModel)) + + let openAI = ChatModel( + id: chatModelOpenAIId, + name: "OpenAI", + format: .openAI, + info: .init( + apiKeyName: openAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.openAIBaseURL), + maxTokens: openAIModel?.maxToken ?? defaults + .deprecatedValue(for: \.chatGPTMaxToken), + modelName: openAIModel?.rawValue ?? defaults + .deprecatedValue(for: \.chatGPTModel) + ) + ) + let azureOpenAI = ChatModel( + id: chatModelAzureOpenAIId, + name: "Azure OpenAI", + format: .azureOpenAI, + info: .init( + apiKeyName: azureOpenAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.azureOpenAIBaseURL), + maxTokens: defaults.deprecatedValue(for: \.chatGPTMaxToken), + modelName: defaults + .deprecatedValue(for: \.azureChatGPTDeployment) + ) + ) + + return [openAI, azureOpenAI] + }(), for: \.chatModels) + + defaults.set({ + if defaults.deprecatedValue(for: \.chatFeatureProvider) == .azureOpenAI { + return chatModelAzureOpenAIId + } + return chatModelOpenAIId + }(), for: \.defaultChatFeatureChatModelId) + + defaults.set({ + let openAIModel = OpenAIEmbeddingModel( + rawValue: defaults.deprecatedValue(for: \.embeddingModel) + ) + + let openAI = EmbeddingModel( + id: embeddingModelOpenAIId, + name: "OpenAI", + format: .openAI, + info: .init( + apiKeyName: openAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.openAIBaseURL), + maxTokens: openAIModel?.maxToken ?? 8191, + modelName: openAIModel?.rawValue ?? defaults.deprecatedValue(for: \.embeddingModel) + ) + ) + + let azureOpenAI = EmbeddingModel( + id: embeddingModelAzureOpenAIId, + name: "Azure OpenAI", + format: .azureOpenAI, + info: .init( + apiKeyName: azureOpenAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.azureOpenAIBaseURL), + maxTokens: 8191, + modelName: defaults + .deprecatedValue(for: \.azureEmbeddingDeployment) + ) + ) + + return [openAI, azureOpenAI] + }(), for: \.embeddingModels) + + defaults.set({ + if defaults.deprecatedValue(for: \.embeddingFeatureProvider) == .azureOpenAI { + return embeddingModelAzureOpenAIId + } + return embeddingModelOpenAIId + }(), for: \.defaultChatFeatureEmbeddingModelId) + + defaults.set(true, forKey: finishedMigrationKey) + } catch { + print(error.localizedDescription) + throw error + } +} + diff --git a/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift b/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift index 0908730f..6da2bcf3 100644 --- a/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift +++ b/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift @@ -1,7 +1,5 @@ import Configs import Foundation -import GitHubCopilotService -import KeychainAccess import Preferences extension UserDefaultPreferenceKeys { @@ -24,71 +22,12 @@ public struct ServiceUpdateMigrator { } func migrate(from oldVersion: String, to currentVersion: String) async throws { - guard let old = Int(oldVersion) else { return } + guard let old = Int(oldVersion), old != 0 else { return } if old <= 135 { try migrateFromLowerThanOrEqualToVersion135() } - - if old < 170 { - try migrateFromLowerThanOrEqualToVersion170() + if old < 240 { + try migrateTo240() } } } - -func migrateFromLowerThanOrEqualToVersion135() throws { - // 0. Create the application support folder if it doesn't exist - - let urls = try GitHubCopilotBaseService.createFoldersIfNeeded() - - // 1. Move the undefined folder in application support into a sub folder called `GitHub - // Copilot/support` - - let undefinedFolderURL = urls.applicationSupportURL.appendingPathComponent("undefined") - var isUndefinedADirectory: ObjCBool = false - let isUndefinedExisted = FileManager.default.fileExists( - atPath: undefinedFolderURL.path, - isDirectory: &isUndefinedADirectory - ) - if isUndefinedExisted, isUndefinedADirectory.boolValue { - try FileManager.default.moveItem( - at: undefinedFolderURL, - to: urls.supportURL.appendingPathComponent("undefined") - ) - } - - // 2. Copy the GitHub copilot language service to `GitHub Copilot/executable` - - let copilotFolderURL = urls.executableURL.appendingPathComponent("copilot") - var copilotIsFolder: ObjCBool = false - let executable = Bundle.main.resourceURL?.appendingPathComponent("copilot") - if let executable, - FileManager.default.fileExists(atPath: executable.path, isDirectory: &copilotIsFolder), - !FileManager.default.fileExists(atPath: copilotFolderURL.path) - { - try FileManager.default.copyItem( - at: executable, - to: urls.executableURL.appendingPathComponent("copilot") - ) - } - - // 3. Use chmod to change the permission of the executable to 755 - - try FileManager.default.setAttributes( - [.posixPermissions: 0o755], - ofItemAtPath: copilotFolderURL.path - ) -} - -func migrateFromLowerThanOrEqualToVersion170() throws { - let oldKeychain = Keychain(service: keychainService, accessGroup: keychainAccessGroup) - let newKeychain = oldKeychain.attributes([ - kSecUseDataProtectionKeychain as String: true, - ]) - - if (try? oldKeychain.contains("codeiumKey")) ?? false, - let key = try? oldKeychain.getString("codeiumKey") - { - try newKeychain.set(key, key: "codeiumAuthKey") - } -} - diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Core/Sources/SuggestionInjector/SuggestionInjector.swift deleted file mode 100644 index ac57de17..00000000 --- a/Core/Sources/SuggestionInjector/SuggestionInjector.swift +++ /dev/null @@ -1,272 +0,0 @@ -import SuggestionModel -import Foundation - -let suggestionStart = "/*========== Copilot Suggestion" -let suggestionEnd = "*///======== End of Copilot Suggestion" - -// NOTE: Every lines from Xcode Extension has a line break at its end, even the last line. -// NOTE: Copilot's completion always start at character 0, no matter where the cursor is. - -public struct SuggestionInjector { - public init() {} - - public struct ExtraInfo { - public var didChangeContent = false - public var didChangeCursorPosition = false - public var suggestionRange: ClosedRange? - public var modifications: [Modification] = [] - public init() {} - } - - public func rejectCurrentSuggestions( - from content: inout [String], - cursorPosition: inout CursorPosition, - extraInfo: inout ExtraInfo - ) { - var ranges = [ClosedRange]() - var suggestionStartIndex = -1 - - // find ranges of suggestion comments - for (index, line) in content.enumerated() { - if line.hasPrefix(suggestionStart) { - suggestionStartIndex = index - } - if suggestionStartIndex >= 0, line.hasPrefix(suggestionEnd) { - ranges.append(.init(uncheckedBounds: (suggestionStartIndex, index))) - suggestionStartIndex = -1 - } - } - - let reversedRanges = ranges.reversed() - - extraInfo.modifications.append(contentsOf: reversedRanges.map(Modification.deleted)) - extraInfo.didChangeContent = !ranges.isEmpty - - // remove the lines from bottom to top - for range in reversedRanges { - for i in stride(from: range.upperBound, through: range.lowerBound, by: -1) { - if i <= cursorPosition.line, cursorPosition.line >= 0 { - cursorPosition = .init( - line: cursorPosition.line - 1, - character: i == cursorPosition.line ? 0 : cursorPosition.character - ) - extraInfo.didChangeCursorPosition = true - } - content.remove(at: i) - } - } - - extraInfo.suggestionRange = nil - } - - public func proposeSuggestion( - intoContentWithoutSuggestion content: inout [String], - completion: CodeSuggestion, - index: Int, - count: Int, - extraInfo: inout ExtraInfo - ) { - // assemble suggestion comment - let start = completion.range.start - let startText = "\(suggestionStart) \(index + 1)/\(count)" - var lines = [startText + "\n"] - lines.append(contentsOf: completion.text.breakLines(appendLineBreakToLastLine: true)) - lines.append(suggestionEnd + "\n") - - // if suggestion is empty, returns without modifying the code - guard lines.count > 2 else { return } - - // replace the common prefix of the first line with space and carrot - let existedLine = start.line < content.endIndex ? content[start.line] : nil - let commonPrefix = longestCommonPrefix(of: lines[1], and: existedLine ?? "") - - if !commonPrefix.isEmpty { - let replacingText = { - switch (commonPrefix.hasSuffix("\n"), commonPrefix.count) { - case (false, let count): - return String(repeating: " ", count: count - 1) + "^" - case (true, let count) where count > 1: - return String(repeating: " ", count: count - 2) + "^\n" - case (true, _): - return "\n" - } - }() - - lines[1].replaceSubrange( - lines[1].startIndex..<( - lines[1].index( - lines[1].startIndex, - offsetBy: commonPrefix.count, - limitedBy: lines[1].endIndex - ) ?? lines[1].endIndex - ), - with: replacingText - ) - } - - // if the suggestion is only appending new lines and spaces, return without modification - if completion.text.dropFirst(commonPrefix.count) - .allSatisfy({ $0.isWhitespace || $0.isNewline }) { return } - - // determine if it's inserted to the current line or the next line - let lineIndex = start.line + { - guard let existedLine else { return 0 } - if existedLine.isEmptyOrNewLine { return 1 } - if commonPrefix.isEmpty { return 0 } - return 1 - }() - if content.endIndex < lineIndex { - extraInfo.didChangeContent = true - extraInfo.suggestionRange = content.endIndex...content.endIndex + lines.count - 1 - extraInfo.modifications.append(.inserted(content.endIndex, lines)) - content.append(contentsOf: lines) - } else { - extraInfo.didChangeContent = true - extraInfo.suggestionRange = lineIndex...lineIndex + lines.count - 1 - extraInfo.modifications.append(.inserted(lineIndex, lines)) - content.insert(contentsOf: lines, at: lineIndex) - } - } - - public func acceptSuggestion( - intoContentWithoutSuggestion content: inout [String], - cursorPosition: inout CursorPosition, - completion: CodeSuggestion, - extraInfo: inout ExtraInfo - ) { - extraInfo.didChangeContent = true - extraInfo.didChangeCursorPosition = true - extraInfo.suggestionRange = nil - let start = completion.range.start - let end = completion.range.end - let suggestionContent = completion.text - - let _ = start.line < content.endIndex ? content[start.line] : nil - - let firstRemovedLine = content[safe: start.line] - let lastRemovedLine = content[safe: end.line] - let startLine = max(0, start.line) - let endLine = max(start.line, min(end.line, content.endIndex - 1)) - if startLine < content.endIndex { - extraInfo.modifications.append(.deleted(startLine...endLine)) - content.removeSubrange(startLine...endLine) - } - - var toBeInserted = suggestionContent.breakLines(appendLineBreakToLastLine: true) - - // prepending prefix text not in range if needed. - if let firstRemovedLine, - !firstRemovedLine.isEmptyOrNewLine, - start.character > 0, - start.character < firstRemovedLine.count, - !toBeInserted.isEmpty - { - let leftoverRange = firstRemovedLine.startIndex..<(firstRemovedLine.index( - firstRemovedLine.startIndex, - offsetBy: start.character, - limitedBy: firstRemovedLine.endIndex - ) ?? firstRemovedLine.endIndex) - var leftover = firstRemovedLine[leftoverRange] - if leftover.hasSuffix("\n") { - leftover.removeLast(1) - } - toBeInserted[0].insert( - contentsOf: leftover, - at: toBeInserted[0].startIndex - ) - } - - // appending suffix text not in range if needed. - let cursorCol = toBeInserted[toBeInserted.endIndex - 1].count - 1 - let skipAppendingDueToContinueTyping = { - guard let first = toBeInserted.first?.dropLast(1), !first.isEmpty else { return false } - let droppedLast = lastRemovedLine?.dropLast(1) - guard let droppedLast, !droppedLast.isEmpty else { return false } - return first.hasPrefix(droppedLast) - }() - if let lastRemovedLine, - !skipAppendingDueToContinueTyping, - !lastRemovedLine.isEmptyOrNewLine, - end.character >= 0, - end.character - 1 < lastRemovedLine.count, - !toBeInserted.isEmpty - { - let leftoverRange = (lastRemovedLine.index( - lastRemovedLine.startIndex, - offsetBy: end.character, - limitedBy: lastRemovedLine.endIndex - ) ?? lastRemovedLine.endIndex).. Result { - fatalError() - } -} - -extension String { - /// Break a string into lines. - func breakLines(appendLineBreakToLastLine: Bool = false) -> [String] { - let lines = split(separator: "\n", omittingEmptySubsequences: false) - var all = [String]() - for (index, line) in lines.enumerated() { - if !appendLineBreakToLastLine, index == lines.endIndex - 1 { - all.append(String(line)) - } else { - all.append(String(line) + "\n") - } - } - return all - } - - var isEmptyOrNewLine: Bool { - isEmpty || self == "\n" - } -} - -func longestCommonPrefix(of a: String, and b: String) -> String { - let length = min(a.count, b.count) - - var prefix = "" - for i in 0.. Element? { - indices.contains(index) ? self[index] : nil - } -} diff --git a/Core/Sources/SuggestionModel/CodeSuggestion.swift b/Core/Sources/SuggestionModel/CodeSuggestion.swift deleted file mode 100644 index 0d7b1ec4..00000000 --- a/Core/Sources/SuggestionModel/CodeSuggestion.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -public struct CodeSuggestion: Codable, Equatable { - public init( - text: String, - position: CursorPosition, - uuid: String, - range: CursorRange, - displayText: String - ) { - self.text = text - self.position = position - self.uuid = uuid - self.range = range - self.displayText = displayText - } - - /// The new code to be inserted and the original code on the first line. - public var text: String - /// The position of the cursor before generating the completion. - public var position: CursorPosition - /// An id. - public var uuid: String - /// The range of the original code that should be replaced. - public var range: CursorRange - /// The new code to be inserted. - public var displayText: String -} diff --git a/Core/Sources/SuggestionModel/ExportedFromLSP.swift b/Core/Sources/SuggestionModel/ExportedFromLSP.swift deleted file mode 100644 index 2239c839..00000000 --- a/Core/Sources/SuggestionModel/ExportedFromLSP.swift +++ /dev/null @@ -1,44 +0,0 @@ -import LanguageServerProtocol - -public typealias CursorPosition = LanguageServerProtocol.Position - -public extension CursorPosition { - static let zero = CursorPosition(line: 0, character: 0) - static var outOfScope: CursorPosition { .init(line: -1, character: -1) } -} - -public struct CursorRange: Codable, Hashable, Sendable { - static let zero = CursorRange(start: .zero, end: .zero) - - public var start: CursorPosition - public var end: CursorPosition - - public init(start: Position, end: Position) { - self.start = start - self.end = end - } - - public init(startPair: (Int, Int), endPair: (Int, Int)) { - self.start = Position(startPair) - self.end = Position(endPair) - } - - public func contains(_ position: Position) -> Bool { - return position > start && position < end - } - - public func intersects(_ other: LSPRange) -> Bool { - return contains(other.start) || contains(other.end) - } - - public var isEmpty: Bool { - return start == end - } -} - -public extension CursorRange { - static var outOfScope: CursorRange { .init(start: .outOfScope, end: .outOfScope) } - static func cursor(_ position: CursorPosition) -> CursorRange { - return .init(start: position, end: position) - } -} diff --git a/Core/Sources/SuggestionService/CodeiumSuggestionProvider.swift b/Core/Sources/SuggestionService/CodeiumSuggestionProvider.swift deleted file mode 100644 index eb03b335..00000000 --- a/Core/Sources/SuggestionService/CodeiumSuggestionProvider.swift +++ /dev/null @@ -1,83 +0,0 @@ -import CodeiumService -import Foundation -import Preferences -import SuggestionModel - -actor CodeiumSuggestionProvider: SuggestionServiceProvider { - let projectRootURL: URL - let onServiceLaunched: (SuggestionServiceType) -> Void - var codeiumService: CodeiumSuggestionServiceType? - - init(projectRootURL: URL, onServiceLaunched: @escaping (SuggestionServiceType) -> Void) { - self.projectRootURL = projectRootURL - self.onServiceLaunched = onServiceLaunched - } - - func createCodeiumServiceIfNeeded() throws -> CodeiumSuggestionServiceType { - if let codeiumService { return codeiumService } - let newService = try CodeiumSuggestionService( - projectRootURL: projectRootURL, - onServiceLaunched: { [weak self] in - if let self { self.onServiceLaunched(self) } - } - ) - codeiumService = newService - - return newService - } -} - -extension CodeiumSuggestionProvider { - func getSuggestions( - fileURL: URL, - content: String, - cursorPosition: SuggestionModel.CursorPosition, - tabSize: Int, - indentSize: Int, - usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool - ) async throws -> [SuggestionModel.CodeSuggestion] { - try await (try createCodeiumServiceIfNeeded()).getCompletions( - fileURL: fileURL, - content: content, - cursorPosition: cursorPosition, - tabSize: tabSize, - indentSize: indentSize, - usesTabsForIndentation: usesTabsForIndentation, - ignoreSpaceOnlySuggestions: ignoreSpaceOnlySuggestions - ) - } - - func notifyAccepted(_ suggestion: SuggestionModel.CodeSuggestion) async { - await (try? createCodeiumServiceIfNeeded())?.notifyAccepted(suggestion) - } - - func notifyRejected(_: [SuggestionModel.CodeSuggestion]) async {} - - func notifyOpenTextDocument(fileURL: URL, content: String) async throws { - try await (try? createCodeiumServiceIfNeeded())? - .notifyOpenTextDocument(fileURL: fileURL, content: content) - } - - func notifyChangeTextDocument(fileURL: URL, content: String) async throws { - try await (try? createCodeiumServiceIfNeeded())? - .notifyChangeTextDocument(fileURL: fileURL, content: content) - } - - func notifyCloseTextDocument(fileURL: URL) async throws { - try await (try? createCodeiumServiceIfNeeded())? - .notifyCloseTextDocument(fileURL: fileURL) - } - - func notifySaveTextDocument(fileURL: URL) async throws {} - - func cancelRequest() async { - await (try? createCodeiumServiceIfNeeded())? - .cancelRequest() - } - - func terminate() async { - (try? createCodeiumServiceIfNeeded())?.terminate() - } -} - diff --git a/Core/Sources/SuggestionService/GitHubCopilotSuggestionProvider.swift b/Core/Sources/SuggestionService/GitHubCopilotSuggestionProvider.swift deleted file mode 100644 index 3996c541..00000000 --- a/Core/Sources/SuggestionService/GitHubCopilotSuggestionProvider.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation -import GitHubCopilotService -import Preferences -import SuggestionModel - -actor GitHubCopilotSuggestionProvider: SuggestionServiceProvider { - let projectRootURL: URL - let onServiceLaunched: (SuggestionServiceType) -> Void - var gitHubCopilotService: GitHubCopilotSuggestionServiceType? - - init(projectRootURL: URL, onServiceLaunched: @escaping (SuggestionServiceType) -> Void) { - self.projectRootURL = projectRootURL - self.onServiceLaunched = onServiceLaunched - } - - func createGitHubCopilotServiceIfNeeded() throws -> GitHubCopilotSuggestionServiceType { - if let gitHubCopilotService { return gitHubCopilotService } - let newService = try GitHubCopilotSuggestionService(projectRootURL: projectRootURL) - gitHubCopilotService = newService - Task { - try await Task.sleep(nanoseconds: 1_000_000_000) - onServiceLaunched(self) - } - return newService - } -} - -extension GitHubCopilotSuggestionProvider { - func getSuggestions( - fileURL: URL, - content: String, - cursorPosition: SuggestionModel.CursorPosition, - tabSize: Int, - indentSize: Int, - usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool - ) async throws -> [SuggestionModel.CodeSuggestion] { - try await (try createGitHubCopilotServiceIfNeeded()).getCompletions( - fileURL: fileURL, - content: content, - cursorPosition: cursorPosition, - tabSize: tabSize, - indentSize: indentSize, - usesTabsForIndentation: usesTabsForIndentation, - ignoreSpaceOnlySuggestions: ignoreSpaceOnlySuggestions - ) - } - - func notifyAccepted(_ suggestion: SuggestionModel.CodeSuggestion) async { - await (try? createGitHubCopilotServiceIfNeeded())?.notifyAccepted(suggestion) - } - - func notifyRejected(_ suggestions: [SuggestionModel.CodeSuggestion]) async { - await (try? createGitHubCopilotServiceIfNeeded())?.notifyRejected(suggestions) - } - - func notifyOpenTextDocument(fileURL: URL, content: String) async throws { - try await (try? createGitHubCopilotServiceIfNeeded())? - .notifyOpenTextDocument(fileURL: fileURL, content: content) - } - - func notifyChangeTextDocument(fileURL: URL, content: String) async throws { - try await (try? createGitHubCopilotServiceIfNeeded())? - .notifyChangeTextDocument(fileURL: fileURL, content: content) - } - - func notifyCloseTextDocument(fileURL: URL) async throws { - try await (try? createGitHubCopilotServiceIfNeeded())? - .notifyCloseTextDocument(fileURL: fileURL) - } - - func notifySaveTextDocument(fileURL: URL) async throws { - try await (try? createGitHubCopilotServiceIfNeeded())? - .notifySaveTextDocument(fileURL: fileURL) - } - - func cancelRequest() async { - await (try? createGitHubCopilotServiceIfNeeded())? - .cancelRequest() - } - - func terminate() async { - await (try? createGitHubCopilotServiceIfNeeded())?.terminate() - } -} - diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index 4213121a..335f0c83 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -1,137 +1,115 @@ -import AppKit +import BuiltinExtension +import CodeiumService +import enum CopilotForXcodeKit.SuggestionServiceError +import struct CopilotForXcodeKit.WorkspaceInfo import Foundation +import GitHubCopilotService import Preferences -import SuggestionModel +import SuggestionBasic +import SuggestionProvider import UserDefaultsObserver +import Workspace -public protocol SuggestionServiceType { - func getSuggestions( - fileURL: URL, - content: String, - cursorPosition: CursorPosition, - tabSize: Int, - indentSize: Int, - usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool - ) async throws -> [CodeSuggestion] - - func notifyAccepted(_ suggestion: CodeSuggestion) async - func notifyRejected(_ suggestions: [CodeSuggestion]) async - func notifyOpenTextDocument(fileURL: URL, content: String) async throws - func notifyChangeTextDocument(fileURL: URL, content: String) async throws - func notifyCloseTextDocument(fileURL: URL) async throws - func notifySaveTextDocument(fileURL: URL) async throws - func cancelRequest() async - func terminate() async -} +#if canImport(ProExtension) +import ProExtension +#endif -protocol SuggestionServiceProvider: SuggestionServiceType {} +public protocol SuggestionServiceType: SuggestionServiceProvider {} public actor SuggestionService: SuggestionServiceType { - let projectRootURL: URL - let onServiceLaunched: (SuggestionServiceType) -> Void - let providerChangeObserver = UserDefaultsObserver( - object: UserDefaults.shared, - forKeyPaths: [UserDefaultPreferenceKeys().suggestionFeatureProvider.key], - context: nil - ) + public typealias Middleware = SuggestionServiceMiddleware + public typealias EventHandler = SuggestionServiceEventHandler + public var configuration: SuggestionProvider.SuggestionServiceConfiguration { + get async { await suggestionProvider.configuration } + } - lazy var suggestionProvider: SuggestionServiceProvider = buildService() + let middlewares: [Middleware] + let eventHandlers: [EventHandler] - var serviceType: SuggestionFeatureProvider { - UserDefaults.shared.value(for: \.suggestionFeatureProvider) - } + let suggestionProvider: SuggestionServiceProvider - public init(projectRootURL: URL, onServiceLaunched: @escaping (SuggestionServiceType) -> Void) { - self.projectRootURL = projectRootURL - self.onServiceLaunched = onServiceLaunched + public init( + provider: any SuggestionServiceProvider, + middlewares: [Middleware] = SuggestionServiceMiddlewareContainer.middlewares, + eventHandlers: [EventHandler] = SuggestionServiceEventHandlerContainer.handlers + ) { + suggestionProvider = provider + self.middlewares = middlewares + self.eventHandlers = eventHandlers + } - providerChangeObserver.onChange = { [weak self] in - Task { [weak self] in - guard let self else { return } - await rebuildService() - } + public static func service( + for serviceType: SuggestionFeatureProvider = UserDefaults.shared + .value(for: \.suggestionFeatureProvider) + ) -> SuggestionService { + #if canImport(ProExtension) + if let provider = ProExtension.suggestionProviderFactory(serviceType) { + return SuggestionService(provider: provider) } - } + #endif - func buildService() -> SuggestionServiceProvider { switch serviceType { - case .codeium: - return CodeiumSuggestionProvider( - projectRootURL: projectRootURL, - onServiceLaunched: onServiceLaunched + case .builtIn(.codeium): + let provider = BuiltinExtensionSuggestionServiceProvider( + extension: CodeiumExtension.self ) - case .gitHubCopilot: - return GitHubCopilotSuggestionProvider( - projectRootURL: projectRootURL, - onServiceLaunched: onServiceLaunched + return SuggestionService(provider: provider) + case .builtIn(.gitHubCopilot), .extension: + let provider = BuiltinExtensionSuggestionServiceProvider( + extension: GitHubCopilotExtension.self ) + return SuggestionService(provider: provider) } } - - func rebuildService() { - suggestionProvider = buildService() - } } public extension SuggestionService { func getSuggestions( - fileURL: URL, - content: String, - cursorPosition: SuggestionModel.CursorPosition, - tabSize: Int, - indentSize: Int, - usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool - ) async throws -> [SuggestionModel.CodeSuggestion] { - let language = languageIdentifierFromFileURL(fileURL) - if UserDefaults.shared.value(for: \.suggestionFeatureDisabledLanguageList) - .contains(where: { $0 == language.rawValue }) - { - return [] - } - - return try await suggestionProvider.getSuggestions( - fileURL: fileURL, - content: content, - cursorPosition: cursorPosition, - tabSize: tabSize, - indentSize: indentSize, - usesTabsForIndentation: usesTabsForIndentation, - ignoreSpaceOnlySuggestions: ignoreSpaceOnlySuggestions - ) - } - - func notifyAccepted(_ suggestion: SuggestionModel.CodeSuggestion) async { - await suggestionProvider.notifyAccepted(suggestion) - } - - func notifyRejected(_ suggestions: [SuggestionModel.CodeSuggestion]) async { - await suggestionProvider.notifyRejected(suggestions) - } - - func notifyOpenTextDocument(fileURL: URL, content: String) async throws { - try await suggestionProvider.notifyOpenTextDocument(fileURL: fileURL, content: content) - } - - func notifyChangeTextDocument(fileURL: URL, content: String) async throws { - try await suggestionProvider.notifyChangeTextDocument(fileURL: fileURL, content: content) - } + _ request: SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async throws -> [SuggestionBasic.CodeSuggestion] { + do { + var getSuggestion = suggestionProvider.getSuggestions(_:workspaceInfo:) + let configuration = await configuration + + for middleware in middlewares.reversed() { + getSuggestion = { [getSuggestion] request, workspaceInfo in + try await middleware.getSuggestion( + request, + configuration: configuration, + next: { [getSuggestion] request in + try await getSuggestion(request, workspaceInfo) + } + ) + } + } - func notifyCloseTextDocument(fileURL: URL) async throws { - try await suggestionProvider.notifyCloseTextDocument(fileURL: fileURL) + return try await getSuggestion(request, workspaceInfo) + } catch let error as SuggestionServiceError { + throw error + } catch { + throw SuggestionServiceError.silent(error) + } } - func notifySaveTextDocument(fileURL: URL) async throws { - try await suggestionProvider.notifySaveTextDocument(fileURL: fileURL) + func notifyAccepted( + _ suggestion: SuggestionBasic.CodeSuggestion, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + eventHandlers.forEach { $0.didAccept(suggestion, workspaceInfo: workspaceInfo) } + await suggestionProvider.notifyAccepted(suggestion, workspaceInfo: workspaceInfo) } - func cancelRequest() async { - await suggestionProvider.cancelRequest() + func notifyRejected( + _ suggestions: [SuggestionBasic.CodeSuggestion], + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + eventHandlers.forEach { $0.didReject(suggestions, workspaceInfo: workspaceInfo) } + await suggestionProvider.notifyRejected(suggestions, workspaceInfo: workspaceInfo) } - func terminate() async { - await suggestionProvider.terminate() + func cancelRequest(workspaceInfo: CopilotForXcodeKit.WorkspaceInfo) async { + await suggestionProvider.cancelRequest(workspaceInfo: workspaceInfo) } } diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift new file mode 100644 index 00000000..022b424c --- /dev/null +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -0,0 +1,116 @@ +import AppKit +import ChatTab +import ComposableArchitecture +import Foundation +import SwiftUI + +final class ChatPanelWindow: WidgetWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + + private let storeObserver = NSObject() + private let store: StoreOf + + var minimizeWindow: () -> Void = {} + + var isDetached: Bool { + store.withState { $0.isDetached } + } + + override var defaultCollectionBehavior: NSWindow.CollectionBehavior { + [ + .fullScreenAuxiliary, + .transient, + .fullScreenPrimary, + .fullScreenAllowsTiling, + ] + } + + init( + store: StoreOf, + chatTabPool: ChatTabPool, + minimizeWindow: @escaping () -> Void + ) { + self.store = store + self.minimizeWindow = minimizeWindow + super.init( + contentRect: .init(x: 0, y: 0, width: 300, height: 400), + styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView, .closable], + backing: .buffered, + defer: false + ) + + titleVisibility = .hidden + addTitlebarAccessoryViewController({ + let controller = NSTitlebarAccessoryViewController() + let view = NSHostingView(rootView: ChatTitleBar(store: store)) + controller.view = view + view.frame = .init(x: 0, y: 0, width: 100, height: 40) + controller.layoutAttribute = .right + return controller + }()) + titlebarAppearsTransparent = true + isReleasedWhenClosed = false + level = widgetLevel(1) + hasShadow = true + contentView = NSHostingView( + rootView: ChatWindowView( + store: store, + toggleVisibility: { [weak self] isDisplayed in + guard let self else { return } + self.isPanelDisplayed = isDisplayed + } + ) + .environment(\.chatTabPool, chatTabPool) + ) + setIsVisible(true) + isPanelDisplayed = false + + var wasDetached = false + storeObserver.observe { [weak self] in + guard let self else { return } + let isDetached = store.isDetached + guard isDetached != wasDetached else { return } + wasDetached = isDetached + Task { @MainActor in + if UserDefaults.shared.value(for: \.disableFloatOnTopWhenTheChatPanelIsDetached) { + self.setFloatOnTop(!isDetached) + } else { + self.setFloatOnTop(true) + } + } + } + } + + func centerInActiveSpaceIfNeeded() { + guard !isOnActiveSpace else { return } + center() + } + + var isWindowHidden: Bool = false { + didSet { + alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 + } + } + + var isPanelDisplayed: Bool = false { + didSet { + alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 + } + } + + override var alphaValue: CGFloat { + didSet { + ignoresMouseEvents = alphaValue <= 0 + } + } + + override func miniaturize(_: Any?) { + minimizeWindow() + } + + override func close() { + store.send(.closeActiveTabClicked) + } +} + diff --git a/Core/Sources/SuggestionWidget/ChatProvider.swift b/Core/Sources/SuggestionWidget/ChatProvider.swift deleted file mode 100644 index 11895239..00000000 --- a/Core/Sources/SuggestionWidget/ChatProvider.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation -import Preferences -import SwiftUI - -public final class ChatProvider: ObservableObject { - public typealias MessageID = String - let id = UUID() - @Published public var history: [ChatMessage] = [] - @Published public var isReceivingMessage = false - public var systemPrompt = "" - public var extraSystemPrompt = "" - public var onMessageSend: (String) -> Void - public var onStop: () -> Void - public var onClear: () -> Void - public var onClose: () -> Void - public var onSwitchContext: () -> Void - public var onDeleteMessage: (MessageID) -> Void - public var onResendMessage: (MessageID) -> Void - public var onResetPrompt: () -> Void - public var onRunCustomCommand: (CustomCommand) -> Void = { _ in } - public var onSetAsExtraPrompt: (MessageID) -> Void - - public init( - history: [ChatMessage] = [], - isReceivingMessage: Bool = false, - onMessageSend: @escaping (String) -> Void = { _ in }, - onStop: @escaping () -> Void = {}, - onClear: @escaping () -> Void = {}, - onClose: @escaping () -> Void = {}, - onSwitchContext: @escaping () -> Void = {}, - onDeleteMessage: @escaping (MessageID) -> Void = { _ in }, - onResendMessage: @escaping (MessageID) -> Void = { _ in }, - onResetPrompt: @escaping () -> Void = {}, - onRunCustomCommand: @escaping (CustomCommand) -> Void = { _ in }, - onSetAsExtraPrompt: @escaping (MessageID) -> Void = { _ in } - ) { - self.history = history - self.isReceivingMessage = isReceivingMessage - self.onMessageSend = onMessageSend - self.onStop = onStop - self.onClear = onClear - self.onClose = onClose - self.onSwitchContext = onSwitchContext - self.onDeleteMessage = onDeleteMessage - self.onResendMessage = onResendMessage - self.onResetPrompt = onResetPrompt - self.onRunCustomCommand = onRunCustomCommand - self.onSetAsExtraPrompt = onSetAsExtraPrompt - } - - public func send(_ message: String) { onMessageSend(message) } - public func stop() { onStop() } - public func clear() { onClear() } - public func close() { onClose() } - public func switchContext() { onSwitchContext() } - public func deleteMessage(id: MessageID) { onDeleteMessage(id) } - public func resendMessage(id: MessageID) { onResendMessage(id) } - public func resetPrompt() { onResetPrompt() } - public func triggerCustomCommand(_ command: CustomCommand) { - onRunCustomCommand(command) - } - public func setAsExtraPrompt(id: MessageID) { onSetAsExtraPrompt(id) } -} - -public struct ChatMessage: Equatable { - public var id: String - public var isUser: Bool - public var text: String - - public init(id: String, isUser: Bool, text: String) { - self.id = id - self.isUser = isUser - self.text = text - } -} - diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 2a875f89..58c6f4d7 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -1,47 +1,438 @@ import ActiveApplicationMonitor import AppKit +import ChatGPTChatTab +import ChatTab +import ComposableArchitecture import SwiftUI +import SharedUIComponents private let r: Double = 8 -@MainActor -final class ChatWindowViewModel: ObservableObject { - @Published var chat: ChatProvider? - @Published var colorScheme: ColorScheme - @Published var isPanelDisplayed = false - @Published var chatPanelInASeparateWindow = false +struct ChatWindowView: View { + let store: StoreOf + let toggleVisibility: (Bool) -> Void + + var body: some View { + WithPerceptionTracking { + let _ = store.chatTabGroup.selectedTabId // force re-evaluation + VStack(spacing: 0) { + Rectangle().fill(.regularMaterial).frame(height: 28) + + Divider() + + ChatTabBar(store: store) + .frame(height: 26) + .clipped() + + Divider() - public init(chat: ChatProvider? = nil, colorScheme: ColorScheme = .dark) { - self.chat = chat - self.colorScheme = colorScheme + ChatTabContainer(store: store) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .xcodeStyleFrame() + .ignoresSafeArea(edges: .top) + .onChange(of: store.isPanelDisplayed) { isDisplayed in + toggleVisibility(isDisplayed) + } + .preferredColorScheme(store.colorScheme) + } } } -struct ChatWindowView: View { - @ObservedObject var viewModel: ChatWindowViewModel +struct ChatTitleBar: View { + let store: StoreOf + @State var isHovering = false + + var body: some View { + WithPerceptionTracking { + HStack(spacing: 6) { + Button(action: { + store.send(.closeActiveTabClicked) + }) { + EmptyView() + } + .opacity(0) + .keyboardShortcut("w", modifiers: [.command]) + + Button( + action: { + store.send(.hideButtonClicked) + } + ) { + Image(systemName: "minus") + .foregroundStyle(.black.opacity(0.5)) + .font(Font.system(size: 8).weight(.heavy)) + } + .opacity(0) + .keyboardShortcut("m", modifiers: [.command]) + + Spacer() + + TrafficLightButton( + isHovering: isHovering, + isActive: store.isDetached, + color: Color(nsColor: .systemCyan), + action: { + store.send(.toggleChatPanelDetachedButtonClicked) + } + ) { + Image(systemName: "pin.fill") + .foregroundStyle(.black.opacity(0.5)) + .font(Font.system(size: 6).weight(.black)) + .transformEffect(.init(translationX: 0, y: 0.5)) + } + } + .buttonStyle(.plain) + .padding(.trailing, 8) + .onHover(perform: { hovering in + isHovering = hovering + }) + } + } + + struct TrafficLightButton: View { + let isHovering: Bool + let isActive: Bool + let color: Color + let action: () -> Void + let icon: () -> Icon + + @Environment(\.controlActiveState) var controlActiveState + + var body: some View { + Button(action: { + action() + }) { + Circle() + .fill( + controlActiveState == .key && isActive + ? color + : Color(nsColor: .separatorColor) + ) + .frame( + width: Style.trafficLightButtonSize, + height: Style.trafficLightButtonSize + ) + .overlay { + Circle().stroke(lineWidth: 0.5).foregroundColor(.black.opacity(0.2)) + } + .overlay { + if isHovering { + icon() + } + } + } + .focusable(false) + } + } +} + +extension View { + func hideScrollIndicator() -> some View { + if #available(macOS 13.0, *) { + return scrollIndicators(.hidden) + } else { + return self + } + } +} + +struct ChatTabBar: View { + let store: StoreOf + + struct TabBarState: Equatable { + var tabInfo: IdentifiedArray + var selectedTabId: String + } var body: some View { - Group { - if let chat = viewModel.chat { - ChatPanel(chat: chat) - .background { - Button(action: { - viewModel.isPanelDisplayed = false - if let app = ActiveApplicationMonitor.previousActiveApplication, - app.isXcode - { - app.activate() + HStack(spacing: 0) { + Divider() + Tabs(store: store) + CreateButton(store: store) + } + .background { + Button(action: { store.send(.switchToNextTab) }) { EmptyView() } + .opacity(0) + .keyboardShortcut("]", modifiers: [.command, .shift]) + Button(action: { store.send(.switchToPreviousTab) }) { EmptyView() } + .opacity(0) + .keyboardShortcut("[", modifiers: [.command, .shift]) + } + } + + struct Tabs: View { + let store: StoreOf + @State var draggingTabId: String? + @Environment(\.chatTabPool) var chatTabPool + + var body: some View { + WithPerceptionTracking { + let tabInfo = store.chatTabGroup.tabInfo + let selectedTabId = store.chatTabGroup.selectedTabId + ?? store.chatTabGroup.tabInfo.first?.id + ?? "" + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 0) { + ForEach(tabInfo, id: \.id) { info in + WithPerceptionTracking { + if let tab = chatTabPool.getTab(of: info.id) { + ChatTabBarButton( + store: store, + info: info, + content: { tab.tabItem }, + icon: { tab.icon }, + isSelected: info.id == selectedTabId + ) + .contextMenu { + tab.menu + } + .id(info.id) + .onDrag { + draggingTabId = info.id + return NSItemProvider(object: info.id as NSString) + } + .onDrop( + of: [.text], + delegate: ChatTabBarDropDelegate( + store: store, + tabs: tabInfo, + itemId: info.id, + draggingTabId: $draggingTabId + ) + ) + + } else { + ChatTabBarButton( + store: store, + info: info, + content: { Text("Not Found") }, + icon: { Image(systemName: "questionmark.diamond") }, + isSelected: info.id == selectedTabId + ) + } + } } - }) { - EmptyView() } - .keyboardShortcut("M", modifiers: [.command]) } + .hideScrollIndicator() + .onChange(of: selectedTabId) { id in + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(id) + } + } + } } } - .opacity(viewModel.isPanelDisplayed ? 1 : 0) - .frame(minWidth: Style.panelWidth, minHeight: Style.panelHeight) - .preferredColorScheme(viewModel.colorScheme) + } + + struct CreateButton: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + let collection = store.chatTabGroup.tabCollection + Menu { + ForEach(0.. + let tabs: IdentifiedArray + let itemId: String + @Binding var draggingTabId: String? + + func dropUpdated(info: DropInfo) -> DropProposal? { + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + draggingTabId = nil + return true + } + + func dropEntered(info: DropInfo) { + guard itemId != draggingTabId else { return } + let from = tabs.firstIndex { $0.id == draggingTabId } + let to = tabs.firstIndex { $0.id == itemId } + guard let from, let to, from != to else { return } + store.send(.moveChatTab(from: from, to: to)) + } +} + +struct ChatTabBarButton: View { + let store: StoreOf + let info: ChatTabInfo + let content: () -> Content + let icon: () -> Icon + let isSelected: Bool + @State var isHovered: Bool = false + + var body: some View { + HStack(spacing: 0) { + HStack(spacing: 4) { + icon().foregroundColor(.secondary) + content() + } + .font(.callout) + .lineLimit(1) + .frame(maxWidth: 120) + .padding(.horizontal, 28) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.tabClicked(id: info.id)) + } + .overlay(alignment: .leading) { + Button(action: { + store.send(.closeTabButtonClicked(id: info.id)) + }) { + Image(systemName: "xmark") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .padding(2) + .padding(.leading, 8) + .opacity(isHovered ? 1 : 0) + } + .onHover { isHovered = $0 } + .animation(.linear(duration: 0.1), value: isHovered) + .animation(.linear(duration: 0.1), value: isSelected) + + Divider().padding(.vertical, 6) + } + .background(isSelected ? Color(nsColor: .selectedControlColor) : Color.clear) + .frame(maxHeight: .infinity) + } +} + +struct ChatTabContainer: View { + let store: StoreOf + @Environment(\.chatTabPool) var chatTabPool + + var body: some View { + WithPerceptionTracking { + let tabInfo = store.chatTabGroup.tabInfo + let selectedTabId = store.chatTabGroup.selectedTabId + ?? store.chatTabGroup.tabInfo.first?.id + ?? "" + + ZStack { + if tabInfo.isEmpty { + Text("Empty") + } else { + ForEach(tabInfo) { tabInfo in + if let tab = chatTabPool.getTab(of: tabInfo.id) { + let isActive = tab.id == selectedTabId + tab.body + .opacity(isActive ? 1 : 0) + .disabled(!isActive) + .allowsHitTesting(isActive) + .frame(maxWidth: .infinity, maxHeight: .infinity) + // move it out of window + .rotationEffect( + isActive ? .zero : .degrees(90), + anchor: .topLeading + ) + } else { + Text("404 Not Found") + } + } + } + } + } + } +} + +struct CreateOtherChatTabMenuStyle: MenuStyle { + func makeBody(configuration: Configuration) -> some View { + Image(systemName: "chevron.down") + .resizable() + .frame(width: 7, height: 4) + .frame(maxHeight: .infinity) + .padding(.leading, 4) + .padding(.trailing, 8) + .foregroundColor(.secondary) + } +} + +struct ChatWindowView_Previews: PreviewProvider { + static let pool = ChatTabPool([ + "2": EmptyChatTab(id: "2"), + "3": EmptyChatTab(id: "3"), + "4": EmptyChatTab(id: "4"), + "5": EmptyChatTab(id: "5"), + "6": EmptyChatTab(id: "6"), + "7": EmptyChatTab(id: "7"), + ]) + + static func createStore() -> StoreOf { + StoreOf( + initialState: .init( + chatTabGroup: .init( + tabInfo: [ + .init(id: "2", title: "Empty-2"), + .init(id: "3", title: "Empty-3"), + .init(id: "4", title: "Empty-4"), + .init(id: "5", title: "Empty-5"), + .init(id: "6", title: "Empty-6"), + .init(id: "7", title: "Empty-7"), + ] as IdentifiedArray, + selectedTabId: "2" + ), + isPanelDisplayed: true + ), + reducer: { ChatPanel() } + ) + } + + static var previews: some View { + ChatWindowView(store: createStore(), toggleVisibility: { _ in }) + .xcodeStyleFrame() + .padding() + .environment(\.chatTabPool, pool) } } diff --git a/Core/Sources/SuggestionWidget/CopyButton.swift b/Core/Sources/SuggestionWidget/CopyButton.swift deleted file mode 100644 index fced0be4..00000000 --- a/Core/Sources/SuggestionWidget/CopyButton.swift +++ /dev/null @@ -1,34 +0,0 @@ -import AppKit -import SwiftUI - -struct CopyButton: View { - var copy: () -> Void - @State var isCopied = false - var body: some View { - Button(action: { - withAnimation(.linear(duration: 0.1)) { - isCopied = true - } - copy() - Task { - try await Task.sleep(nanoseconds: 1_000_000_000) - withAnimation(.linear(duration: 0.1)) { - isCopied = false - } - } - }) { - Image(systemName: isCopied ? "checkmark.circle" : "doc.on.doc") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - .frame(width: 20, height: 20, alignment: .center) - .foregroundColor(.secondary) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: 4, style: .circular) - ) - .padding(4) - } - .buttonStyle(.borderless) - } -} diff --git a/Core/Sources/SuggestionWidget/CustomTextEditor.swift b/Core/Sources/SuggestionWidget/CustomTextEditor.swift deleted file mode 100644 index de750c04..00000000 --- a/Core/Sources/SuggestionWidget/CustomTextEditor.swift +++ /dev/null @@ -1,71 +0,0 @@ -import SwiftUI - -struct CustomTextEditor: NSViewRepresentable { - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - @Binding var text: String - let font: NSFont - let onSubmit: () -> Void - - func makeNSView(context: Context) -> NSScrollView { - let textView = (context.coordinator.theTextView.documentView as! NSTextView) - textView.delegate = context.coordinator - textView.string = text - textView.font = font - textView.allowsUndo = true - textView.drawsBackground = false - - return context.coordinator.theTextView - } - - func updateNSView(_ nsView: NSScrollView, context: Context) { - let textView = (context.coordinator.theTextView.documentView as! NSTextView) - guard textView.string != text else { return } - textView.string = text - } -} - -extension CustomTextEditor { - class Coordinator: NSObject, NSTextViewDelegate { - var view: CustomTextEditor - var theTextView = NSTextView.scrollableTextView() - var affectedCharRange: NSRange? - - init(_ view: CustomTextEditor) { - self.view = view - } - - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { - return - } - - view.text = textView.string - } - - func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - if commandSelector == #selector(NSTextView.insertNewline(_:)) { - if let event = NSApplication.shared.currentEvent, - !event.modifierFlags.contains(.shift), - event.keyCode == 36 - { - view.onSubmit() - return true - } - } - - return false - } - - func textView( - _ textView: NSTextView, - shouldChangeTextIn affectedCharRange: NSRange, - replacementString: String? - ) -> Bool { - return true - } - } -} - diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift new file mode 100644 index 00000000..28bf5bfc --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift @@ -0,0 +1,299 @@ +import ActiveApplicationMonitor +import AppKit +import ChatTab +import ComposableArchitecture +import SwiftUI + +public enum ChatTabBuilderCollection: Equatable { + case folder(title: String, kinds: [ChatTabKind]) + case kind(ChatTabKind) +} + +public struct ChatTabKind: Equatable { + public var builder: any ChatTabBuilder + var title: String { builder.title } + + public init(_ builder: any ChatTabBuilder) { + self.builder = builder + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.title == rhs.title + } +} + +@Reducer +public struct ChatPanel { + public struct ChatTabGroup: Equatable { + public var tabInfo: IdentifiedArray + public var tabCollection: [ChatTabBuilderCollection] + public var selectedTabId: String? + + public var selectedTabInfo: ChatTabInfo? { + guard let id = selectedTabId else { return tabInfo.first } + return tabInfo[id: id] + } + + init( + tabInfo: IdentifiedArray = [], + tabCollection: [ChatTabBuilderCollection] = [], + selectedTabId: String? = nil + ) { + self.tabInfo = tabInfo + self.tabCollection = tabCollection + self.selectedTabId = selectedTabId + } + } + + @ObservableState + public struct State: Equatable { + public var chatTabGroup = ChatTabGroup() + var colorScheme: ColorScheme = .light + public internal(set) var isPanelDisplayed = false + var isDetached = false + var isFullScreen = false + } + + public enum Action: Equatable { + // Window + case hideButtonClicked + case closeActiveTabClicked + case toggleChatPanelDetachedButtonClicked + case detachChatPanel + case attachChatPanel + case enterFullScreen + case exitFullScreen + case presentChatPanel(forceDetach: Bool) + + // Tabs + case updateChatTabInfo(IdentifiedArray) + case createNewTapButtonHovered + case closeTabButtonClicked(id: String) + case createNewTapButtonClicked(kind: ChatTabKind?) + case tabClicked(id: String) + case appendAndSelectTab(ChatTabInfo) + case switchToNextTab + case switchToPreviousTab + case moveChatTab(from: Int, to: Int) + case focusActiveChatTab + + case chatTab(IdentifiedActionOf) + } + + @Dependency(\.chatTabPool) var chatTabPool + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + @Dependency(\.xcodeInspector) var xcodeInspector + @Dependency(\.activatePreviousActiveXcode) var activatePreviouslyActiveXcode + @Dependency(\.activateThisApp) var activateExtensionService + @Dependency(\.chatTabBuilderCollection) var chatTabBuilderCollection + + @MainActor func toggleFullScreen() { + let window = suggestionWidgetControllerDependency.windowsController?.windows + .chatPanelWindow + window?.toggleFullScreen(nil) + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .hideButtonClicked: + state.isPanelDisplayed = false + + if state.isFullScreen { + return .run { _ in + await MainActor.run { toggleFullScreen() } + activatePreviouslyActiveXcode() + } + } + + return .run { _ in + activatePreviouslyActiveXcode() + } + + case .closeActiveTabClicked: + if let id = state.chatTabGroup.selectedTabId { + return .run { send in + await send(.closeTabButtonClicked(id: id)) + } + } + + state.isPanelDisplayed = false + return .none + + case .toggleChatPanelDetachedButtonClicked: + if state.isFullScreen, state.isDetached { + return .run { send in + await send(.attachChatPanel) + } + } + + state.isDetached.toggle() + return .none + + case .detachChatPanel: + state.isDetached = true + return .none + + case .attachChatPanel: + if state.isFullScreen { + return .run { send in + await MainActor.run { toggleFullScreen() } + try await Task.sleep(nanoseconds: 1_000_000_000) + await send(.attachChatPanel) + } + } + + state.isDetached = false + return .none + + case .enterFullScreen: + state.isFullScreen = true + return .run { send in + await send(.detachChatPanel) + } + + case .exitFullScreen: + state.isFullScreen = false + return .none + + case let .presentChatPanel(forceDetach): + if forceDetach { + state.isDetached = true + } + state.isPanelDisplayed = true + return .run { send in + if forceDetach { + await suggestionWidgetControllerDependency.windowsController?.windows + .chatPanelWindow + .centerInActiveSpaceIfNeeded() + } + await send(.focusActiveChatTab) + } + + case let .updateChatTabInfo(chatTabInfo): + let previousSelectedIndex = state.chatTabGroup.tabInfo + .firstIndex(where: { $0.id == state.chatTabGroup.selectedTabId }) + state.chatTabGroup.tabInfo = chatTabInfo + if !chatTabInfo.contains(where: { $0.id == state.chatTabGroup.selectedTabId }) { + if let previousSelectedIndex { + let proposedSelectedIndex = previousSelectedIndex - 1 + if proposedSelectedIndex >= 0, + proposedSelectedIndex < chatTabInfo.endIndex + { + state.chatTabGroup.selectedTabId = chatTabInfo[proposedSelectedIndex].id + } else { + state.chatTabGroup.selectedTabId = chatTabInfo.first?.id + } + } else { + state.chatTabGroup.selectedTabId = nil + } + } + return .none + + case let .closeTabButtonClicked(id): + let firstIndex = state.chatTabGroup.tabInfo.firstIndex { $0.id == id } + let nextIndex = { + guard let firstIndex else { return 0 } + let nextIndex = firstIndex - 1 + return max(nextIndex, 0) + }() + state.chatTabGroup.tabInfo.removeAll { $0.id == id } + chatTabPool.getTab(of: id)?.close() + if state.chatTabGroup.tabInfo.isEmpty { + state.isPanelDisplayed = false + } + if nextIndex < state.chatTabGroup.tabInfo.count { + state.chatTabGroup.selectedTabId = state.chatTabGroup.tabInfo[nextIndex].id + } else { + state.chatTabGroup.selectedTabId = nil + } + return .none + + case .createNewTapButtonHovered: + state.chatTabGroup.tabCollection = chatTabBuilderCollection() + return .none + + case .createNewTapButtonClicked: + return .none // handled elsewhere + + case let .tabClicked(id): + guard state.chatTabGroup.tabInfo.contains(where: { $0.id == id }) else { + state.chatTabGroup.selectedTabId = nil + return .none + } + state.chatTabGroup.selectedTabId = id + return .run { send in + await send(.focusActiveChatTab) + } + + case let .appendAndSelectTab(tab): + guard !state.chatTabGroup.tabInfo.contains(where: { $0.id == tab.id }) + else { return .none } + state.chatTabGroup.tabInfo.append(tab) + state.chatTabGroup.selectedTabId = tab.id + return .run { send in + await send(.focusActiveChatTab) + } + + case .switchToNextTab: + let selectedId = state.chatTabGroup.selectedTabId + guard let index = state.chatTabGroup.tabInfo + .firstIndex(where: { $0.id == selectedId }) + else { return .none } + let nextIndex = index + 1 + if nextIndex >= state.chatTabGroup.tabInfo.endIndex { + return .none + } + let targetId = state.chatTabGroup.tabInfo[nextIndex].id + state.chatTabGroup.selectedTabId = targetId + return .run { send in + await send(.focusActiveChatTab) + } + + case .switchToPreviousTab: + let selectedId = state.chatTabGroup.selectedTabId + guard let index = state.chatTabGroup.tabInfo + .firstIndex(where: { $0.id == selectedId }) + else { return .none } + let previousIndex = index - 1 + if previousIndex < 0 || previousIndex >= state.chatTabGroup.tabInfo.endIndex { + return .none + } + let targetId = state.chatTabGroup.tabInfo[previousIndex].id + state.chatTabGroup.selectedTabId = targetId + return .run { send in + await send(.focusActiveChatTab) + } + + case let .moveChatTab(from, to): + guard from >= 0, from < state.chatTabGroup.tabInfo.endIndex, to >= 0, + to <= state.chatTabGroup.tabInfo.endIndex + else { + return .none + } + let tab = state.chatTabGroup.tabInfo[from] + state.chatTabGroup.tabInfo.remove(at: from) + state.chatTabGroup.tabInfo.insert(tab, at: to) + return .none + + case .focusActiveChatTab: + let id = state.chatTabGroup.selectedTabInfo?.id + guard let id else { return .none } + return .run { send in + await send(.chatTab(.element(id: id, action: .focus))) + } + + case let .chatTab(.element(id, .close)): + return .run { send in + await send(.closeTabButtonClicked(id: id)) + } + + case .chatTab: + return .none + } + }.forEach(\.chatTabGroup.tabInfo, action: \.chatTab) { + ChatTabItem() + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift new file mode 100644 index 00000000..8b173c30 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift @@ -0,0 +1,89 @@ +import ActiveApplicationMonitor +import ComposableArchitecture +import Preferences +import SuggestionBasic +import SwiftUI + +@Reducer +public struct CircularWidget { + public struct IsProcessingCounter: Equatable { + var expirationDate: TimeInterval + } + + @ObservableState + public struct State: Equatable { + var isProcessingCounters = [IsProcessingCounter]() + var isProcessing: Bool + var isDisplayingContent: Bool + var isContentEmpty: Bool + var isChatPanelDetached: Bool + var isChatOpen: Bool + } + + public enum Action: Equatable { + case widgetClicked + case detachChatPanelToggleClicked + case openChatButtonClicked + case openModificationButtonClicked + case runCustomCommandButtonClicked(CustomCommand) + case markIsProcessing + case endIsProcessing + case _forceEndIsProcessing + } + + struct CancelAutoEndIsProcessKey: Hashable {} + + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .detachChatPanelToggleClicked: + return .none // handled elsewhere + + case .openChatButtonClicked: + return .run { _ in + suggestionWidgetControllerDependency.onOpenChatClicked() + } + + case .openModificationButtonClicked: + return .run { _ in + suggestionWidgetControllerDependency.onOpenModificationButtonClicked() + } + + case let .runCustomCommandButtonClicked(command): + return .run { _ in + suggestionWidgetControllerDependency.onCustomCommandClicked(command) + } + + case .widgetClicked: + return .none // handled elsewhere + + case .markIsProcessing: + let deadline = Date().timeIntervalSince1970 + 20 + state.isProcessingCounters.append(IsProcessingCounter(expirationDate: deadline)) + state.isProcessing = true + return .run { send in + try await Task.sleep(nanoseconds: 20 * 1_000_000_000) + try Task.checkCancellation() + await send(._forceEndIsProcessing) + }.cancellable(id: CancelAutoEndIsProcessKey(), cancelInFlight: true) + + case .endIsProcessing: + if !state.isProcessingCounters.isEmpty { + state.isProcessingCounters.removeFirst() + } + state.isProcessingCounters + .removeAll(where: { $0.expirationDate < Date().timeIntervalSince1970 }) + state.isProcessing = !state.isProcessingCounters.isEmpty + return .none + + case ._forceEndIsProcessing: + state.isProcessingCounters.removeAll() + state.isProcessing = false + return .none + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift new file mode 100644 index 00000000..d844b336 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -0,0 +1,186 @@ +import ComposableArchitecture +import Foundation +import PromptToCodeService +import SuggestionBasic +import XcodeInspector + +@Reducer +public struct PromptToCodeGroup { + @ObservableState + public struct State { + public var promptToCodes: IdentifiedArrayOf = [] + public var activeDocumentURL: PromptToCodePanel.State.ID? = XcodeInspector.shared + .realtimeActiveDocumentURL + public var selectedTabId: URL? + public var activePromptToCode: PromptToCodePanel.State? { + get { + guard let selectedTabId else { return promptToCodes.first } + return promptToCodes[id: selectedTabId] ?? promptToCodes.first + } + set { + selectedTabId = newValue?.id + if let id = selectedTabId { + promptToCodes[id: id] = newValue + } + } + } + } + + public enum Action { + /// Activate the prompt to code if it exists or create it if it doesn't + case activateOrCreatePromptToCode(PromptToCodePanel.State) + case createPromptToCode(PromptToCodePanel.State, sendImmediately: Bool) + case updatePromptToCodeRange( + id: PromptToCodePanel.State.ID, + snippetId: UUID, + range: CursorRange + ) + case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCodePanel.State.ID) + case updateActivePromptToCode(documentURL: URL) + case discardExpiredPromptToCode(documentURLs: [URL]) + case tabClicked(id: URL) + case closeTabButtonClicked(id: URL) + case switchToNextTab + case switchToPreviousTab + case promptToCode(IdentifiedActionOf) + case activePromptToCode(PromptToCodePanel.Action) + } + + @Dependency(\.activatePreviousActiveXcode) var activatePreviousActiveXcode + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .activateOrCreatePromptToCode(s): + if let promptToCode = state.activePromptToCode, s.id == promptToCode.id { + state.selectedTabId = promptToCode.id + return .run { send in + await send(.promptToCode(.element( + id: promptToCode.id, + action: .focusOnTextField + ))) + } + } + return .run { send in + await send(.createPromptToCode(s, sendImmediately: false)) + } + case let .createPromptToCode(newPromptToCode, sendImmediately): + var newPromptToCode = newPromptToCode + newPromptToCode.isActiveDocument = newPromptToCode.id == state.activeDocumentURL + state.promptToCodes.append(newPromptToCode) + state.selectedTabId = newPromptToCode.id + return .run { [newPromptToCode] send in + if sendImmediately, + !newPromptToCode.contextInputController.instruction.string.isEmpty + { + await send(.promptToCode(.element( + id: newPromptToCode.id, + action: .modifyCodeButtonTapped + ))) + } + }.cancellable( + id: PromptToCodePanel.CancellationKey.modifyCode(newPromptToCode.id), + cancelInFlight: true + ) + + case let .updatePromptToCodeRange(id, snippetId, range): + if let p = state.promptToCodes[id: id], p.promptToCodeState.isAttachedToTarget { + state.promptToCodes[id: id]?.promptToCodeState.snippets[id: snippetId]? + .attachedRange = range + } + return .none + + case let .discardAcceptedPromptToCodeIfNotContinuous(id): + for itemId in state.promptToCodes.ids { + if itemId == id, state.promptToCodes[id: itemId]?.clickedButton == .accept { + state.promptToCodes.remove(id: itemId) + } else { + state.promptToCodes[id: itemId]?.clickedButton = nil + } + } + return .none + + case let .updateActivePromptToCode(documentURL): + state.activeDocumentURL = documentURL + for index in state.promptToCodes.indices { + state.promptToCodes[index].isActiveDocument = + state.promptToCodes[index].id == documentURL + } + return .none + + case let .discardExpiredPromptToCode(documentURLs): + for url in documentURLs { + state.promptToCodes.remove(id: url) + } + return .none + + case let .tabClicked(id): + state.selectedTabId = id + return .none + + case let .closeTabButtonClicked(id): + return .run { send in + await send(.promptToCode(.element( + id: id, + action: .cancelButtonTapped + ))) + } + + case .switchToNextTab: + if let selectedTabId = state.selectedTabId, + let index = state.promptToCodes.index(id: selectedTabId) + { + let nextIndex = (index + 1) % state.promptToCodes.count + state.selectedTabId = state.promptToCodes[nextIndex].id + } + return .none + + case .switchToPreviousTab: + if let selectedTabId = state.selectedTabId, + let index = state.promptToCodes.index(id: selectedTabId) + { + let previousIndex = (index - 1 + state.promptToCodes.count) % state + .promptToCodes.count + state.selectedTabId = state.promptToCodes[previousIndex].id + } + return .none + + case .promptToCode: + return .none + + case .activePromptToCode: + return .none + } + } + .ifLet(\.activePromptToCode, action: \.activePromptToCode) { + PromptToCodePanel() + } + .forEach(\.promptToCodes, action: \.promptToCode, element: { + PromptToCodePanel() + }) + + Reduce { state, action in + switch action { + case let .promptToCode(.element(id, .cancelButtonTapped)): + state.promptToCodes.remove(id: id) + let isEmpty = state.promptToCodes.isEmpty + return .run { _ in + if isEmpty { + activatePreviousActiveXcode() + } + } + case .activePromptToCode(.cancelButtonTapped): + guard let id = state.selectedTabId else { return .none } + state.promptToCodes.remove(id: id) + let isEmpty = state.promptToCodes.isEmpty + return .run { _ in + if isEmpty { + activatePreviousActiveXcode() + } + } + default: return .none + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift new file mode 100644 index 00000000..cb68435f --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift @@ -0,0 +1,364 @@ +import AppKit +import ChatBasic +import ComposableArchitecture +import CustomAsyncAlgorithms +import Dependencies +import Foundation +import ModificationBasic +import Preferences +import PromptToCodeCustomization +import PromptToCodeService +import SuggestionBasic +import XcodeInspector + +@Reducer +public struct PromptToCodePanel { + @ObservableState + public struct State: Identifiable { + public enum FocusField: Equatable { + case textField + } + + public enum ClickedButton: Equatable { + case accept + case acceptAndContinue + } + + @Shared public var promptToCodeState: ModificationState + @ObservationStateIgnored + public var contextInputController: PromptToCodeContextInputController + + public var id: URL { promptToCodeState.source.documentURL } + + public var commandName: String? + public var isContinuous: Bool + public var focusedField: FocusField? = .textField + + public var filename: String { + promptToCodeState.source.documentURL.lastPathComponent + } + + public var canRevert: Bool { !promptToCodeState.history.isEmpty } + + public var generateDescriptionRequirement: Bool + + public var clickedButton: ClickedButton? + + public var isActiveDocument: Bool = false + + public var snippetPanels: IdentifiedArrayOf { + get { + IdentifiedArrayOf( + uniqueElements: promptToCodeState.snippets.map { + PromptToCodeSnippetPanel.State(snippet: $0) + } + ) + } + set { + promptToCodeState.snippets = IdentifiedArrayOf( + uniqueElements: newValue.map(\.snippet) + ) + } + } + + public init( + promptToCodeState: Shared, + instruction: String?, + commandName: String? = nil, + isContinuous: Bool = false, + generateDescriptionRequirement: Bool = UserDefaults.shared + .value(for: \.promptToCodeGenerateDescription) + ) { + _promptToCodeState = promptToCodeState + self.isContinuous = isContinuous + self.generateDescriptionRequirement = generateDescriptionRequirement + self.commandName = commandName + contextInputController = PromptToCodeCustomization + .contextInputControllerFactory(promptToCodeState) + focusedField = .textField + contextInputController.instruction = instruction + .map(NSAttributedString.init(string:)) ?? .init() + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case focusOnTextField + case selectionRangeToggleTapped + case modifyCodeButtonTapped + case revertButtonTapped + case stopRespondingButtonTapped + case modifyCodeFinished + case modifyCodeCancelled + case cancelButtonTapped + case acceptButtonTapped + case acceptAndContinueButtonTapped + case revealFileButtonClicked + case statusUpdated([String]) + case referencesUpdated([ChatMessage.Reference]) + case snippetPanel(IdentifiedActionOf) + } + + @Dependency(\.commandHandler) var commandHandler + @Dependency(\.activateThisApp) var activateThisApp + @Dependency(\.activatePreviousActiveXcode) var activatePreviousActiveXcode + + enum CancellationKey: Hashable { + case modifyCode(State.ID) + } + + public var body: some ReducerOf { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .snippetPanel: + return .none + + case .focusOnTextField: + state.focusedField = .textField + return .none + + case .selectionRangeToggleTapped: + state.promptToCodeState.isAttachedToTarget.toggle() + return .none + + case .modifyCodeButtonTapped: + guard !state.promptToCodeState.isGenerating else { return .none } + let copiedState = state + let contextInputController = state.contextInputController + state.promptToCodeState.isGenerating = true + state.promptToCodeState.pushHistory(instruction: .init( + attributedString: contextInputController.instruction + )) + state.promptToCodeState.references = [] + let snippets = state.promptToCodeState.snippets + + return .run { send in + do { + let context = await contextInputController.resolveContext( + forDocumentURL: copiedState.promptToCodeState.source.documentURL, + onStatusChange: { await send(.statusUpdated($0)) } + ) + await send(.referencesUpdated(context.references)) + let agentFactory = context.agent ?? { SimpleModificationAgent() } + _ = try await withThrowingTaskGroup(of: Void.self) { group in + for (index, snippet) in snippets.enumerated() { + if index > 3 { // at most 3 at a time + _ = try await group.next() + } + group.addTask { + try await Task + .sleep(nanoseconds: UInt64.random(in: 0...1_000_000_000)) + let agent = agentFactory() + let stream = agent.send(.init( + code: snippet.originalCode, + requirement: context.instruction, + source: .init( + language: copiedState.promptToCodeState.source.language, + documentURL: copiedState.promptToCodeState.source + .documentURL, + projectRootURL: copiedState.promptToCodeState.source + .projectRootURL, + content: copiedState.promptToCodeState.source.content, + lines: copiedState.promptToCodeState.source.lines + ), + isDetached: !copiedState.promptToCodeState + .isAttachedToTarget, + extraSystemPrompt: copiedState.promptToCodeState + .extraSystemPrompt, + range: snippet.attachedRange, + references: context.references, + topics: context.topics + )).map { + switch $0 { + case let .code(code): + return (code: code, description: "") + case let .explanation(explanation): + return (code: "", description: explanation) + } + }.timedDebounce(for: 0.4) { lhs, rhs in + ( + code: lhs.code + rhs.code, + description: lhs.description + rhs.description + ) + } + + do { + for try await response in stream { + try Task.checkCancellation() + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeChunkReceived( + code: response.code, + description: response.description + ) + ))) + } + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeFinished + ))) + } catch is CancellationError { + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeFinished + ))) + throw CancellationError() + } catch { + if (error as NSError).code == NSURLErrorCancelled { + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeFinished + ))) + throw CancellationError() + } + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeFailed( + error: error.localizedDescription + ) + ))) + } + } + } + + try await group.waitForAll() + } + + await send(.modifyCodeFinished) + } catch is CancellationError { + try Task.checkCancellation() + await send(.modifyCodeCancelled) + } catch { + await send(.modifyCodeFinished) + } + }.cancellable(id: CancellationKey.modifyCode(state.id), cancelInFlight: true) + + case .revertButtonTapped: + if let instruction = state.promptToCodeState.popHistory() { + state.contextInputController.instruction = instruction + } + return .none + + case .stopRespondingButtonTapped: + state.promptToCodeState.isGenerating = false + state.promptToCodeState.status = [] + return .cancel(id: CancellationKey.modifyCode(state.id)) + + case .modifyCodeFinished: + state.contextInputController.instruction = .init("") + state.promptToCodeState.isGenerating = false + state.promptToCodeState.status = [] + + if state.promptToCodeState.snippets.allSatisfy({ snippet in + snippet.modifiedCode.isEmpty && snippet.description.isEmpty && snippet + .error == nil + }) { + // if both code and description are empty, we treat it as failed + return .run { send in + await send(.revertButtonTapped) + } + } + return .none + + case .modifyCodeCancelled: + state.promptToCodeState.isGenerating = false + return .none + + case .cancelButtonTapped: + return .cancel(id: CancellationKey.modifyCode(state.id)) + + case .acceptButtonTapped: + state.clickedButton = .accept + return .run { _ in + await commandHandler.acceptModification() + activatePreviousActiveXcode() + } + + case .acceptAndContinueButtonTapped: + state.clickedButton = .acceptAndContinue + return .run { _ in + await commandHandler.acceptModification() + activateThisApp() + } + + case .revealFileButtonClicked: + let url = state.promptToCodeState.source.documentURL + let startLine = state.snippetPanels.first?.snippet.attachedRange.start.line ?? 0 + return .run { _ in + await commandHandler.presentFile(at: url, line: startLine) + } + + case let .statusUpdated(status): + state.promptToCodeState.status = status + return .none + + case let .referencesUpdated(references): + state.promptToCodeState.references = references + return .none + } + } + + Reduce { _, _ in .none }.forEach(\.snippetPanels, action: \.snippetPanel) { + PromptToCodeSnippetPanel() + } + } +} + +@Reducer +public struct PromptToCodeSnippetPanel { + @ObservableState + public struct State: Identifiable { + public var id: UUID { snippet.id } + var snippet: ModificationSnippet + } + + public enum Action { + case modifyCodeFinished + case modifyCodeChunkReceived(code: String, description: String) + case modifyCodeFailed(error: String) + case copyCodeButtonTapped + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .modifyCodeFinished: + return .none + + case let .modifyCodeChunkReceived(code, description): + state.snippet.modifiedCode += code + state.snippet.description += description + return .none + + case let .modifyCodeFailed(error): + state.snippet.error = error + return .none + + case .copyCodeButtonTapped: + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(state.snippet.modifiedCode, forType: .string) + return .none + } + } + } +} + +final class DefaultPromptToCodeContextInputControllerDelegate: PromptToCodeContextInputControllerDelegate { + let store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + func modifyCodeButtonClicked() { + Task { + await store.send(.modifyCodeButtonTapped) + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift new file mode 100644 index 00000000..9f38210e --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift @@ -0,0 +1,56 @@ +import ComposableArchitecture +import Preferences +import SwiftUI + +@Reducer +public struct SharedPanel { + public struct Content { + public var promptToCodeGroup = PromptToCodeGroup.State() + var suggestion: PresentingCodeSuggestion? + var error: String? + } + + @ObservableState + public struct State { + var content: Content = .init() + var colorScheme: ColorScheme = .light + var alignTopToAnchor = false + var isPanelDisplayed: Bool = false + var isEmpty: Bool { + if content.error != nil { return false } + if !content.promptToCodeGroup.promptToCodes.isEmpty { return false } + if content.suggestion != nil, + UserDefaults.shared + .value(for: \.suggestionPresentationMode) == .floatingWidget { return false } + return true + } + + var opacity: Double { + guard isPanelDisplayed else { return 0 } + guard !isEmpty else { return 0 } + return 1 + } + } + + public enum Action { + case errorMessageCloseButtonTapped + case promptToCodeGroup(PromptToCodeGroup.Action) + } + + public var body: some ReducerOf { + Scope(state: \.content.promptToCodeGroup, action: \.promptToCodeGroup) { + PromptToCodeGroup() + } + + Reduce { state, action in + switch action { + case .errorMessageCloseButtonTapped: + state.content.error = nil + return .none + case .promptToCodeGroup: + return .none + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift new file mode 100644 index 00000000..7baef1df --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift @@ -0,0 +1,29 @@ +import ComposableArchitecture +import Foundation +import SwiftUI + +@Reducer +public struct SuggestionPanel { + @ObservableState + public struct State: Equatable { + var content: PresentingCodeSuggestion? + var colorScheme: ColorScheme = .light + var alignTopToAnchor = false + var isPanelDisplayed: Bool = false + var isPanelOutOfFrame: Bool = false + var opacity: Double { + guard isPanelDisplayed else { return 0 } + if isPanelOutOfFrame { return 0 } + guard content != nil else { return 0 } + return 1 + } + } + + public enum Action: Equatable { + case noAction + } + + public var body: some ReducerOf { + Reduce { _, _ in .none } + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift new file mode 100644 index 00000000..14ac9d4b --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift @@ -0,0 +1,36 @@ +import ComposableArchitecture +import Preferences +import SwiftUI +import Toast + +@Reducer +public struct ToastPanel { + @ObservableState + public struct State: Equatable { + var toast: Toast.State = .init() + var colorScheme: ColorScheme = .light + var alignTopToAnchor = false + } + + public enum Action: Equatable { + case start + case toast(Toast.Action) + } + + public var body: some ReducerOf { + Scope(state: \.toast, action: \.toast) { + Toast() + } + + Reduce { state, action in + switch action { + case .start: + return .run { send in + await send(.toast(.start)) + } + case .toast: + return .none + } + } + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift new file mode 100644 index 00000000..493628fc --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift @@ -0,0 +1,371 @@ +import ActiveApplicationMonitor +import AppActivator +import AsyncAlgorithms +import ComposableArchitecture +import Foundation +import Logger +import Preferences +import SwiftUI +import Toast +import XcodeInspector + +@Reducer +public struct Widget { + public struct WindowState: Equatable { + var alphaValue: Double = 0 + var frame: CGRect = .zero + } + + public enum WindowCanBecomeKey: Equatable { + case sharedPanel + case chatPanel + } + + @ObservableState + public struct State { + var focusingDocumentURL: URL? + public var colorScheme: ColorScheme = .light + + var toastPanel = ToastPanel.State() + + // MARK: Panels + + public var panelState = WidgetPanel.State() + + // MARK: ChatPanel + + public var chatPanelState = ChatPanel.State() + + // MARK: CircularWidget + + public struct CircularWidgetState: Equatable { + var isProcessingCounters = [CircularWidget.IsProcessingCounter]() + var isProcessing: Bool = false + } + + public var circularWidgetState = CircularWidgetState() + var _internalCircularWidgetState: CircularWidget.State { + get { + .init( + isProcessingCounters: circularWidgetState.isProcessingCounters, + isProcessing: circularWidgetState.isProcessing, + isDisplayingContent: { + if chatPanelState.isPanelDisplayed { + return true + } + if panelState.sharedPanelState.isPanelDisplayed, + !panelState.sharedPanelState.isEmpty + { + return true + } + if panelState.suggestionPanelState.isPanelDisplayed, + panelState.suggestionPanelState.content != nil + { + return true + } + return false + }(), + isContentEmpty: chatPanelState.chatTabGroup.tabInfo.isEmpty + && panelState.sharedPanelState.isEmpty, + isChatPanelDetached: chatPanelState.isDetached, + isChatOpen: chatPanelState.isPanelDisplayed + ) + } + set { + circularWidgetState = .init( + isProcessingCounters: newValue.isProcessingCounters, + isProcessing: newValue.isProcessing + ) + } + } + + public init() {} + } + + private enum CancelID { + case observeActiveApplicationChange + case observeCompletionPanelChange + case observeWindowChange + case observeEditorChange + case observeUserDefaults + } + + public enum Action { + case startup + case observeActiveApplicationChange + case observeColorSchemeChange + + case updateActiveApplication + case updateColorScheme + + case updatePanelStateToMatch(WidgetLocation) + case updateFocusingDocumentURL + case setFocusingDocumentURL(to: URL?) + case updateKeyWindow(WindowCanBecomeKey) + + case toastPanel(ToastPanel.Action) + case panel(WidgetPanel.Action) + case chatPanel(ChatPanel.Action) + case circularWidget(CircularWidget.Action) + } + + var windowsController: WidgetWindowsController? { + suggestionWidgetControllerDependency.windowsController + } + + @Dependency(\.suggestionWidgetUserDefaultsObservers) var userDefaultsObservers + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + @Dependency(\.xcodeInspector) var xcodeInspector + @Dependency(\.mainQueue) var mainQueue + @Dependency(\.activateThisApp) var activateThisApp + @Dependency(\.activatePreviousActiveApp) var activatePreviousActiveApp + + public enum DebounceKey: Hashable { + case updateWindowOpacity + } + + public init() {} + + public var body: some ReducerOf { + Scope(state: \.toastPanel, action: \.toastPanel) { + ToastPanel() + } + + Scope(state: \._internalCircularWidgetState, action: \.circularWidget) { + CircularWidget() + } + + Reduce { state, action in + switch action { + case .circularWidget(.detachChatPanelToggleClicked): + return .run { send in + await send(.chatPanel(.toggleChatPanelDetachedButtonClicked)) + } + + case .circularWidget(.widgetClicked): + let wasDisplayingContent = state._internalCircularWidgetState.isDisplayingContent + if wasDisplayingContent { + state.panelState.sharedPanelState.isPanelDisplayed = false + state.panelState.suggestionPanelState.isPanelDisplayed = false + state.chatPanelState.isPanelDisplayed = false + } else { + state.panelState.sharedPanelState.isPanelDisplayed = true + state.panelState.suggestionPanelState.isPanelDisplayed = true + state.chatPanelState.isPanelDisplayed = true + } + + let isDisplayingContent = state._internalCircularWidgetState.isDisplayingContent + let hasChat = state.chatPanelState.chatTabGroup.selectedTabInfo != nil + let hasPromptToCode = state.panelState.sharedPanelState.content + .promptToCodeGroup.activePromptToCode != nil + + return .run { send in + if isDisplayingContent { + if hasPromptToCode { + await send(.updateKeyWindow(.sharedPanel)) + } else if hasChat { + await send(.updateKeyWindow(.chatPanel)) + } + await send(.chatPanel(.focusActiveChatTab)) + } + + if isDisplayingContent, !(await NSApplication.shared.isActive) { + activateThisApp() + } else if !isDisplayingContent { + activatePreviousActiveApp() + } + } + + default: return .none + } + } + + Scope(state: \.panelState, action: \.panel) { + WidgetPanel() + } + + Scope(state: \.chatPanelState, action: \.chatPanel) { + ChatPanel() + } + + Reduce { state, action in + switch action { + case .chatPanel(.presentChatPanel): + let isDetached = state.chatPanelState.isDetached + return .run { _ in + await windowsController?.updateWindowLocation( + animated: false, + immediately: false + ) + await windowsController?.updateWindowOpacity(immediately: false) + if isDetached { + Task { @MainActor in + windowsController?.windows.chatPanelWindow.isWindowHidden = false + } + } + } + + case .chatPanel(.toggleChatPanelDetachedButtonClicked): + let isDetached = state.chatPanelState.isDetached + return .run { _ in + await windowsController?.updateWindowLocation( + animated: !isDetached, + immediately: false + ) + await windowsController?.updateWindowOpacity(immediately: false) + } + default: return .none + } + } + + Reduce { state, action in + switch action { + case .startup: + return .merge( + .run { send in + await send(.toastPanel(.start)) + await send(.observeActiveApplicationChange) + await send(.observeColorSchemeChange) + } + ) + + case .observeActiveApplicationChange: + return .run { send in + let stream = AsyncStream { continuation in + let task = Task { + let notifications = NotificationCenter.default + .notifications(named: .activeApplicationDidChange) + for await _ in notifications { + try Task.checkCancellation() + if let app = await XcodeInspector.shared.activeApplication { + continuation.yield(app) + } + } + } + continuation.onTermination = { _ in + task.cancel() + } + } + + var previousAppIdentifier: pid_t? + for await app in stream { + try Task.checkCancellation() + if app.processIdentifier != previousAppIdentifier { + await send(.updateActiveApplication) + } + previousAppIdentifier = app.processIdentifier + } + }.cancellable(id: CancelID.observeActiveApplicationChange, cancelInFlight: true) + + case .observeColorSchemeChange: + return .run { send in + await send(.updateColorScheme) + let stream = AsyncStream { continuation in + userDefaultsObservers.colorSchemeChangeObserver.onChange = { + continuation.yield() + } + + userDefaultsObservers.systemColorSchemeChangeObserver.onChange = { + continuation.yield() + } + + continuation.onTermination = { _ in + userDefaultsObservers.colorSchemeChangeObserver.onChange = {} + userDefaultsObservers.systemColorSchemeChangeObserver.onChange = {} + } + } + + for await _ in stream { + try Task.checkCancellation() + await send(.updateColorScheme) + } + }.cancellable(id: CancelID.observeUserDefaults, cancelInFlight: true) + + case .updateActiveApplication: + return .none + + case .updateColorScheme: + let widgetColorScheme = UserDefaults.shared.value(for: \.widgetColorScheme) + let systemColorScheme: ColorScheme = NSApp.effectiveAppearance.name == .darkAqua + ? .dark + : .light + + let scheme: ColorScheme = { + switch (widgetColorScheme, systemColorScheme) { + case (.system, .dark), (.dark, _): + return .dark + case (.system, .light), (.light, _): + return .light + case (.system, _): + return .light + } + }() + + state.colorScheme = scheme + state.toastPanel.colorScheme = scheme + state.panelState.sharedPanelState.colorScheme = scheme + state.panelState.suggestionPanelState.colorScheme = scheme + state.chatPanelState.colorScheme = scheme + return .none + + case .updateFocusingDocumentURL: + return .run { send in + await send(.setFocusingDocumentURL( + to: xcodeInspector.realtimeActiveDocumentURL + )) + } + + case let .setFocusingDocumentURL(url): + state.focusingDocumentURL = url + return .none + + case let .updatePanelStateToMatch(widgetLocation): + state.panelState.sharedPanelState.alignTopToAnchor = widgetLocation + .defaultPanelLocation + .alignPanelTop + + if let suggestionPanelLocation = widgetLocation.suggestionPanelLocation { + state.panelState.suggestionPanelState.isPanelOutOfFrame = false + state.panelState.suggestionPanelState + .alignTopToAnchor = suggestionPanelLocation + .alignPanelTop + } else { + state.panelState.suggestionPanelState.isPanelOutOfFrame = true + } + + state.toastPanel.alignTopToAnchor = widgetLocation + .defaultPanelLocation + .alignPanelTop + + return .none + + case let .updateKeyWindow(window): + return .run { _ in + await MainActor.run { + switch window { + case .chatPanel: + windowsController?.windows.chatPanelWindow + .makeKeyAndOrderFront(nil) + case .sharedPanel: + windowsController?.windows.sharedPanelWindow + .makeKeyAndOrderFront(nil) + } + } + } + + case .toastPanel: + return .none + + case .circularWidget: + return .none + + case .panel: + return .none + + case .chatPanel: + return .none + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift new file mode 100644 index 00000000..7d911f75 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift @@ -0,0 +1,149 @@ +import AppKit +import ComposableArchitecture +import Foundation + +@Reducer +public struct WidgetPanel { + @ObservableState + public struct State { + public var content: SharedPanel.Content { + get { sharedPanelState.content } + set { + sharedPanelState.content = newValue + suggestionPanelState.content = newValue.suggestion + } + } + + // MARK: SharedPanel + + var sharedPanelState = SharedPanel.State() + + // MARK: SuggestionPanel + + var suggestionPanelState = SuggestionPanel.State() + } + + public enum Action { + case presentSuggestion + case presentSuggestionProvider(PresentingCodeSuggestion, displayContent: Bool) + case presentError(String) + case presentPromptToCode(PromptToCodePanel.State) + case displayPanelContent + case discardSuggestion + case removeDisplayedContent + case switchToAnotherEditorAndUpdateContent + + case sharedPanel(SharedPanel.Action) + case suggestionPanel(SuggestionPanel.Action) + } + + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + @Dependency(\.xcodeInspector) var xcodeInspector + @Dependency(\.activateThisApp) var activateThisApp + var windows: WidgetWindows? { suggestionWidgetControllerDependency.windowsController?.windows } + + public var body: some ReducerOf { + Scope(state: \.suggestionPanelState, action: \.suggestionPanel) { + SuggestionPanel() + } + + Scope(state: \.sharedPanelState, action: \.sharedPanel) { + SharedPanel() + } + + Reduce { state, action in + switch action { + case .presentSuggestion: + return .run { send in + guard let fileURL = await xcodeInspector.activeDocumentURL, + let provider = await fetchSuggestionProvider(fileURL: fileURL) + else { return } + await send(.presentSuggestionProvider(provider, displayContent: true)) + } + + case let .presentSuggestionProvider(provider, displayContent): + state.content.suggestion = provider + if displayContent { + return .run { send in + await send(.displayPanelContent) + }.animation(.easeInOut(duration: 0.2)) + } + return .none + + case let .presentError(errorDescription): + state.content.error = errorDescription + return .run { send in + await send(.displayPanelContent) + }.animation(.easeInOut(duration: 0.2)) + + case let .presentPromptToCode(initialState): + return .run { send in + await send(.sharedPanel(.promptToCodeGroup(.createPromptToCode( + initialState, + sendImmediately: true + )))) + } + + case .displayPanelContent: + if !state.sharedPanelState.isEmpty { + state.sharedPanelState.isPanelDisplayed = true + } + + if state.suggestionPanelState.content != nil { + state.suggestionPanelState.isPanelDisplayed = true + } + + return .none + + case .discardSuggestion: + state.content.suggestion = nil + return .none + + case .switchToAnotherEditorAndUpdateContent: + return .run { send in + guard let fileURL = xcodeInspector.realtimeActiveDocumentURL + else { return } + + await send(.sharedPanel( + .promptToCodeGroup( + .updateActivePromptToCode(documentURL: fileURL) + ) + )) + } + + case .removeDisplayedContent: + state.content.error = nil + state.content.suggestion = nil + return .none + + case .sharedPanel(.promptToCodeGroup(.activateOrCreatePromptToCode)), + .sharedPanel(.promptToCodeGroup(.createPromptToCode)): + let hasPromptToCode = !state.content.promptToCodeGroup.promptToCodes.isEmpty + return .run { send in + await send(.displayPanelContent) + + if hasPromptToCode { + activateThisApp() + await MainActor.run { + windows?.sharedPanelWindow.makeKey() + } + } + }.animation(.easeInOut(duration: 0.2)) + + case .sharedPanel: + return .none + + case .suggestionPanel: + return .none + } + } + } + + func fetchSuggestionProvider(fileURL: URL) async -> PresentingCodeSuggestion? { + guard let provider = await suggestionWidgetControllerDependency + .suggestionWidgetDataSource? + .suggestionForFile(at: fileURL) else { return nil } + return provider + } +} + diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift new file mode 100644 index 00000000..5ca16f76 --- /dev/null +++ b/Core/Sources/SuggestionWidget/ModuleDependency.swift @@ -0,0 +1,89 @@ +import ActiveApplicationMonitor +import AppKit +import ChatTab +import ComposableArchitecture +import Dependencies +import Foundation +import Preferences +import SwiftUI +import UserDefaultsObserver +import XcodeInspector + +public final class SuggestionWidgetControllerDependency { + public var suggestionWidgetDataSource: SuggestionWidgetDataSource? + public var onOpenChatClicked: () -> Void = {} + public var onOpenModificationButtonClicked: () -> Void = {} + public var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } + var windowsController: WidgetWindowsController? + + public init() {} +} + +public final class WidgetUserDefaultsObservers { + let presentationModeChangeObserver = UserDefaultsObserver( + object: UserDefaults.shared, + forKeyPaths: [ + UserDefaultPreferenceKeys().suggestionPresentationMode.key, + ], context: nil + ) + let colorSchemeChangeObserver = UserDefaultsObserver( + object: UserDefaults.shared, forKeyPaths: [ + UserDefaultPreferenceKeys().widgetColorScheme.key, + ], context: nil + ) + let systemColorSchemeChangeObserver = UserDefaultsObserver( + object: UserDefaults.standard, forKeyPaths: ["AppleInterfaceStyle"], context: nil + ) + + public init() {} +} + +struct SuggestionWidgetControllerDependencyKey: DependencyKey { + static let liveValue = SuggestionWidgetControllerDependency() +} + +struct UserDefaultsDependencyKey: DependencyKey { + static let liveValue = WidgetUserDefaultsObservers() +} + +struct XcodeInspectorKey: DependencyKey { + static let liveValue = XcodeInspector.shared +} + +struct ActiveApplicationMonitorKey: DependencyKey { + static let liveValue = ActiveApplicationMonitor.shared +} + +struct ChatTabBuilderCollectionKey: DependencyKey { + static let liveValue: () -> [ChatTabBuilderCollection] = { [] } +} + +public extension DependencyValues { + var suggestionWidgetControllerDependency: SuggestionWidgetControllerDependency { + get { self[SuggestionWidgetControllerDependencyKey.self] } + set { self[SuggestionWidgetControllerDependencyKey.self] = newValue } + } + + var suggestionWidgetUserDefaultsObservers: WidgetUserDefaultsObservers { + get { self[UserDefaultsDependencyKey.self] } + set { self[UserDefaultsDependencyKey.self] = newValue } + } + + var chatTabBuilderCollection: () -> [ChatTabBuilderCollection] { + get { self[ChatTabBuilderCollectionKey.self] } + set { self[ChatTabBuilderCollectionKey.self] = newValue } + } +} + +extension DependencyValues { + var xcodeInspector: XcodeInspector { + get { self[XcodeInspectorKey.self] } + set { self[XcodeInspectorKey.self] = newValue } + } + + var activeApplicationMonitor: ActiveApplicationMonitor { + get { self[ActiveApplicationMonitorKey.self] } + set { self[ActiveApplicationMonitorKey.self] = newValue } + } +} + diff --git a/Core/Sources/SuggestionWidget/PromptToCodePanelGroupView.swift b/Core/Sources/SuggestionWidget/PromptToCodePanelGroupView.swift new file mode 100644 index 00000000..739fe6b7 --- /dev/null +++ b/Core/Sources/SuggestionWidget/PromptToCodePanelGroupView.swift @@ -0,0 +1,141 @@ +import ComposableArchitecture +import Foundation +import SwiftUI + +struct PromptToCodePanelGroupView: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + PromptToCodeTabBar(store: store) + .frame(height: 26) + + Divider() + + if let store = self.store.scope( + state: \.activePromptToCode, + action: \.activePromptToCode + ) { + PromptToCodePanelView(store: store) + } + } + .background(.ultraThickMaterial) + .xcodeStyleFrame() + } + } +} + +struct PromptToCodeTabBar: View { + let store: StoreOf + + struct TabInfo: Equatable, Identifiable { + var id: URL + var tabTitle: String + var isProcessing: Bool + } + + var body: some View { + HStack(spacing: 0) { + Tabs(store: store) + } + .background { + Button(action: { store.send(.switchToNextTab) }) { EmptyView() } + .opacity(0) + .keyboardShortcut("]", modifiers: [.command, .shift]) + Button(action: { store.send(.switchToPreviousTab) }) { EmptyView() } + .opacity(0) + .keyboardShortcut("[", modifiers: [.command, .shift]) + } + } + + struct Tabs: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + let tabInfo = store.promptToCodes.map { + TabInfo( + id: $0.id, + tabTitle: $0.filename, + isProcessing: $0.promptToCodeState.isGenerating + ) + } + let selectedTabId = store.selectedTabId + ?? store.promptToCodes.first?.id + + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 0) { + ForEach(tabInfo) { info in + WithPerceptionTracking { + PromptToCodeTabBarButton( + store: store, + info: info, + isSelected: info.id == store.selectedTabId + ) + .id(info.id) + } + } + } + } + .hideScrollIndicator() + .onChange(of: selectedTabId) { id in + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(id) + } + } + } + } + } + } +} + +struct PromptToCodeTabBarButton: View { + let store: StoreOf + let info: PromptToCodeTabBar.TabInfo + let isSelected: Bool + @State var isHovered: Bool = false + + var body: some View { + HStack(spacing: 0) { + HStack(spacing: 4) { + if info.isProcessing { + ProgressView() + .controlSize(.small) + } + Text(info.tabTitle) + .truncationMode(.middle) + .allowsTightening(true) + } + .font(.callout) + .lineLimit(1) + .frame(maxWidth: 120) + .padding(.horizontal, 28) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.tabClicked(id: info.id)) + } + .overlay(alignment: .leading) { + Button(action: { + store.send(.closeTabButtonClicked(id: info.id)) + }) { + Image(systemName: "xmark") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .padding(2) + .padding(.leading, 8) + .opacity(isHovered ? 1 : 0) + } + .onHover { isHovered = $0 } + .animation(.linear(duration: 0.1), value: isHovered) + .animation(.linear(duration: 0.1), value: isSelected) + + Divider().padding(.vertical, 6) + } + .background(isSelected ? Color(nsColor: .selectedControlColor) : Color.clear) + .frame(maxHeight: .infinity) + } +} + diff --git a/Core/Sources/SuggestionWidget/PromptToCodeProvider.swift b/Core/Sources/SuggestionWidget/PromptToCodeProvider.swift deleted file mode 100644 index 63e7f3f7..00000000 --- a/Core/Sources/SuggestionWidget/PromptToCodeProvider.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation -import SwiftUI - -public final class PromptToCodeProvider: ObservableObject { - let id = UUID() - let name: String? - - @Published public var code: String - @Published public var language: String - @Published public var description: String - @Published public var isResponding: Bool - @Published public var startLineIndex: Int - @Published public var startLineColumn: Int - @Published public var requirement: String - @Published public var errorMessage: String - @Published public var canRevert: Bool - @Published public var isContinuous: Bool - - public var onRevertTapped: () -> Void - public var onStopRespondingTap: () -> Void - public var onCancelTapped: () -> Void - public var onAcceptSuggestionTapped: () -> Void - public var onRequirementSent: (String) -> Void - public var onContinuousToggleClick: () -> Void - - public init( - code: String = "", - language: String = "", - description: String = "", - isResponding: Bool = false, - startLineIndex: Int = 0, - startLineColumn: Int = 0, - requirement: String = "", - errorMessage: String = "", - canRevert: Bool = false, - isContinuous: Bool = false, - name: String? = nil, - onRevertTapped: @escaping () -> Void = {}, - onStopRespondingTap: @escaping () -> Void = {}, - onCancelTapped: @escaping () -> Void = {}, - onAcceptSuggestionTapped: @escaping () -> Void = {}, - onRequirementSent: @escaping (String) -> Void = { _ in }, - onContinuousToggleClick: @escaping () -> Void = {} - ) { - self.code = code - self.language = language - self.description = description - self.isResponding = isResponding - self.startLineIndex = startLineIndex - self.startLineColumn = startLineColumn - self.requirement = requirement - self.errorMessage = errorMessage - self.canRevert = canRevert - self.isContinuous = isContinuous - self.name = name - self.onRevertTapped = onRevertTapped - self.onStopRespondingTap = onStopRespondingTap - self.onCancelTapped = onCancelTapped - self.onAcceptSuggestionTapped = onAcceptSuggestionTapped - self.onRequirementSent = onRequirementSent - self.onContinuousToggleClick = onContinuousToggleClick - } - - func revert() { - onRevertTapped() - errorMessage = "" - } - func stopResponding() { - onStopRespondingTap() - errorMessage = "" - } - func cancel() { onCancelTapped() } - func sendRequirement() { - guard !isResponding else { return } - guard !requirement.isEmpty else { return } - onRequirementSent(requirement) - requirement = "" - errorMessage = "" - } - - func acceptSuggestion() { onAcceptSuggestionTapped() } - - func toggleContinuous() { onContinuousToggleClick() } -} diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift new file mode 100644 index 00000000..a00b2fee --- /dev/null +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -0,0 +1,185 @@ +import ComposableArchitecture +import Preferences +import SwiftUI + +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 { + var store: StoreOf + + struct OverallState: Equatable { + var isPanelDisplayed: Bool + var opacity: Double + var colorScheme: ColorScheme + var alignTopToAnchor: Bool + } + + var body: some View { + GeometryReader { geometry in + WithPerceptionTracking { + VStack(spacing: 0) { + if !store.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } + + DynamicContent(store: store) + .frame(maxWidth: .infinity, maxHeight: geometry.size.height) + .fixedSize(horizontal: false, vertical: true) + .allowsHitTesting(store.isPanelDisplayed) + .layoutPriority(1) + + if store.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } + } + .preferredColorScheme(store.colorScheme) + .opacity(store.opacity) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: store.isPanelDisplayed + ) + .frame(maxWidth: Style.panelWidth, maxHeight: .infinity) + } + } + } + + struct DynamicContent: View { + let store: StoreOf + + @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode + + var body: some View { + WithPerceptionTracking { + ZStack(alignment: .topLeading) { + if let errorMessage = store.content.error { + error(errorMessage) + } else if !store.content.promptToCodeGroup.promptToCodes.isEmpty { + promptToCode() + } else if let suggestionProvider = store.content.suggestion { + suggestion(suggestionProvider) + } + } + } + } + + @ViewBuilder + func error(_ error: String) -> some View { + ErrorPanelView(description: error) { + store.send( + .errorMessageCloseButtonTapped, + animation: .easeInOut(duration: 0.2) + ) + } + } + + @ViewBuilder + func promptToCode() -> some View { + PromptToCodePanelGroupView(store: store.scope( + state: \.content.promptToCodeGroup, + action: \.promptToCodeGroup + )) + } + + @ViewBuilder + func suggestion(_ suggestion: PresentingCodeSuggestion) -> some View { + switch suggestionPresentationMode { + case .nearbyTextCursor: + EmptyView() + case .floatingWidget: + CodeBlockSuggestionPanelView(suggestion: suggestion) + } + } + } +} + +struct CommandButtonStyle: ButtonStyle { + var color: Color + var cornerRadius: Double = 4 + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.vertical, 4) + .padding(.horizontal, 8) + .foregroundColor(.white) + .background( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(color.opacity(configuration.isPressed ? 0.8 : 1)) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + ) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) + } + } +} + +// MARK: - Previews + +struct SharedPanelView_Error_Preview: PreviewProvider { + static var previews: some View { + SharedPanelView(store: .init( + initialState: .init( + content: .init(error: "This is an error\nerror"), + colorScheme: .light, + isPanelDisplayed: true + ), + reducer: { SharedPanel() } + )) + .frame(width: 450, height: 200) + } +} + +struct SharedPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { + static var previews: some View { + SharedPanelView(store: .init( + initialState: .init( + content: .init( + suggestion: .init( + code: """ + - (void)addSubview:(UIView *)view { + [self addSubview:view]; + } + """, + language: "objective-c", + startLineIndex: 8, + suggestionCount: 2, + currentSuggestionIndex: 0, + replacingRange: .zero, + replacingLines: [""] + ) + ), + colorScheme: .dark, + isPanelDisplayed: true + ), + reducer: { SharedPanel() } + )) + .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..c2f1436b 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -1,13 +1,19 @@ import AppKit import MarkdownUI +import SharedUIComponents import SwiftUI enum Style { - static let panelHeight: Double = 500 + static let panelHeight: Double = 560 static let panelWidth: Double = 454 - static let widgetHeight: Double = 24 - static var widgetWidth: Double { widgetHeight } + static let inlineSuggestionMinWidth: Double = 540 + static let inlineSuggestionMaxHeight: Double = 400 + static let widgetHeight: Double = 30 + static var widgetWidth: Double = 8 static let widgetPadding: Double = 4 + static let indicatorBottomPadding: Double = 40 + static let chatWindowTitleBarHeight: Double = 24 + static let trafficLightButtonSize: Double = 12 } extension Color { @@ -25,7 +31,7 @@ extension Color { if appearance.isDarkMode { return #colorLiteral(red: 0.2284317913, green: 0.2145925438, blue: 0.3214019983, alpha: 1) } - return #colorLiteral(red: 0.896820749, green: 0.8709097223, blue: 0.9766687925, alpha: 1) + return #colorLiteral(red: 0.9458052187, green: 0.9311983998, blue: 0.9906365955, alpha: 1) })) } } @@ -40,21 +46,6 @@ extension NSAppearance { } } -extension View { - func xcodeStyleFrame() -> some View { - clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(Color.black.opacity(0.3), style: .init(lineWidth: 1)) - ) - .overlay( - RoundedRectangle(cornerRadius: 7, style: .continuous) - .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) - .padding(1) - ) - } -} - extension MarkdownUI.Theme { static func custom(fontSize: Double) -> MarkdownUI.Theme { .gitHub.text { @@ -87,7 +78,36 @@ extension MarkdownUI.Theme { } } } - .markdownMargin(top: 0, bottom: 16) + .markdownMargin(top: 4, bottom: 16) + } + } + + static func functionCall(fontSize: Double) -> MarkdownUI.Theme { + .gitHub.text { + ForegroundColor(.secondary) + BackgroundColor(Color.clear) + FontSize(fontSize - 1) + } + .list { configuration in + configuration.label + .markdownMargin(top: 4, bottom: 4) + } + .paragraph { configuration in + configuration.label + .markdownMargin(top: 0, bottom: 4) + } + .codeBlock { configuration in + configuration.label + .relativeLineSpacing(.em(0.225)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + } + .padding(16) + .background(Color(nsColor: .textBackgroundColor).opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .markdownMargin(top: 4, bottom: 4) } } } + diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift deleted file mode 100644 index 6a035138..00000000 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift +++ /dev/null @@ -1,633 +0,0 @@ -import AppKit -import MarkdownUI -import SwiftUI - -private let r: Double = 8 - -struct ChatPanel: View { - let chat: ChatProvider - @Namespace var inputAreaNamespace - @State var typedMessage = "" - - var body: some View { - VStack(spacing: 0) { - ChatPanelToolbar(chat: chat) - Divider() - ChatPanelMessages( - chat: chat - ) - Divider() - ChatPanelInputArea( - chat: chat, - typedMessage: $typedMessage - ) - } - .background(.regularMaterial) - .xcodeStyleFrame() - } -} - -struct ChatPanelToolbar: View { - @ObservedObject var chat: ChatProvider - @AppStorage(\.useGlobalChat) var useGlobalChat - - var body: some View { - HStack { - Button(action: { - chat.close() - }) { - Image(systemName: "xmark") - .padding(4) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .keyboardShortcut("w", modifiers: [.command]) - - Spacer() - - Toggle(isOn: .init(get: { - useGlobalChat - }, set: { _ in - chat.switchContext() - })) { EmptyView() } - .toggleStyle(GlobalChatSwitchToggleStyle()) - } - .padding(.leading, 4) - .padding(.trailing, 8) - .padding(.vertical, 4) - .background(.regularMaterial) - } -} - -struct ChatPanelMessages: View { - @ObservedObject var chat: ChatProvider - - var body: some View { - List { - Group { - Spacer() - - if chat.isReceivingMessage { - StopRespondingButton(chat: chat) - .padding(.vertical, 4) - .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) - } - - if chat.history.isEmpty { - Text("New Chat") - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical) - .scaleEffect(x: -1, y: -1, anchor: .center) - .foregroundStyle(.secondary) - .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) - } - - ForEach(chat.history.reversed(), id: \.id) { message in - let text = message.text.isEmpty && !message.isUser ? "..." : message - .text - - if message.isUser { - UserMessage(id: message.id, text: text, chat: chat) - .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) - .padding(.vertical, 4) - } else { - BotMessage(id: message.id, text: text, chat: chat) - .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) - .padding(.vertical, 4) - } - } - .listItemTint(.clear) - - Spacer() - } - .scaleEffect(x: -1, y: 1, anchor: .center) - } - .id("\(chat.history.count), \(chat.isReceivingMessage)") - .listStyle(.plain) - .scaleEffect(x: 1, y: -1, anchor: .center) - } -} - -private struct StopRespondingButton: View { - let chat: ChatProvider - - var body: some View { - Button(action: { - chat.stop() - }) { - HStack(spacing: 4) { - Image(systemName: "stop.fill") - Text("Stop Responding") - } - .padding(8) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: r, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: r, style: .continuous) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - } - .buttonStyle(.borderless) - .scaleEffect(x: -1, y: -1, anchor: .center) - .frame(maxWidth: .infinity, alignment: .center) - } -} - -private struct UserMessage: View { - let id: String - let text: String - let chat: ChatProvider - @Environment(\.colorScheme) var colorScheme - @AppStorage(\.chatFontSize) var chatFontSize - @AppStorage(\.chatCodeFontSize) var chatCodeFontSize - - var body: some View { - Markdown(text) - .textSelection(.enabled) - .markdownTheme(.custom(fontSize: chatFontSize)) - .markdownCodeSyntaxHighlighter( - ChatCodeSyntaxHighlighter( - brightMode: colorScheme != .dark, - fontSize: chatCodeFontSize - ) - ) - .frame(alignment: .leading) - .padding() - .background { - RoundedCorners(tl: r, tr: r, bl: r, br: 0) - .fill(Color.userChatContentBackground) - } - .overlay { - RoundedCorners(tl: r, tr: r, bl: r, br: 0) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - .padding(.leading) - .padding(.trailing, 8) - .scaleEffect(x: -1, y: -1, anchor: .center) - .shadow(color: .black.opacity(0.1), radius: 2) - .frame(maxWidth: .infinity, alignment: .leading) - .contextMenu { - Button("Copy") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - - Button("Send Again") { - chat.resendMessage(id: id) - } - - Button("Set as Extra System Prompt") { - chat.setAsExtraPrompt(id: id) - } - - Divider() - - Button("Delete") { - chat.deleteMessage(id: id) - } - } - } -} - -private struct BotMessage: View { - let id: String - let text: String - let chat: ChatProvider - @Environment(\.colorScheme) var colorScheme - @AppStorage(\.chatFontSize) var chatFontSize - @AppStorage(\.chatCodeFontSize) var chatCodeFontSize - - var body: some View { - HStack(alignment: .bottom, spacing: 2) { - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - .scaleEffect(x: -1, y: -1, anchor: .center) - - Markdown(text) - .textSelection(.enabled) - .markdownTheme(.custom(fontSize: chatFontSize)) - .markdownCodeSyntaxHighlighter( - ChatCodeSyntaxHighlighter( - brightMode: colorScheme != .dark, - fontSize: chatCodeFontSize - ) - ) - .frame(alignment: .trailing) - .padding() - .background { - RoundedCorners(tl: r, tr: r, bl: 0, br: r) - .fill(Color.contentBackground) - } - .overlay { - RoundedCorners(tl: r, tr: r, bl: 0, br: r) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - .padding(.leading, 8) - .scaleEffect(x: -1, y: -1, anchor: .center) - .shadow(color: .black.opacity(0.1), radius: 2) - .contextMenu { - Button("Copy") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - - Button("Set as Extra System Prompt") { - chat.setAsExtraPrompt(id: id) - } - - Divider() - - Button("Delete") { - chat.deleteMessage(id: id) - } - } - } - .frame(maxWidth: .infinity, alignment: .trailing) - .padding(.trailing, 2) - } -} - -struct ChatPanelInputArea: View { - @ObservedObject var chat: ChatProvider - @Binding var typedMessage: String - @FocusState var isInputAreaFocused: Bool - - var body: some View { - HStack { - clearButton - textEditor - } - .onAppear { - isInputAreaFocused = true - } - .padding(8) - .background(.ultraThickMaterial) - .contextMenu { - ChatContextMenu(chat: chat) - } - } - - var clearButton: some View { - Button(action: { - chat.clear() - }) { - Group { - if #available(macOS 13.0, *) { - Image(systemName: "eraser.line.dashed.fill") - } else { - Image(systemName: "trash.fill") - } - } - .padding(6) - .background { - Circle().fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - Circle() - .stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - } - .buttonStyle(.plain) - } - - var textEditor: some View { - HStack(spacing: 0) { - ZStack(alignment: .center) { - // a hack to support dynamic height of TextEditor - Text(typedMessage.isEmpty ? "Hi" : typedMessage).opacity(0) - .font(.system(size: 14)) - .frame(maxWidth: .infinity, maxHeight: 400) - .padding(.top, 1) - .padding(.bottom, 2) - .padding(.horizontal, 4) - - CustomTextEditor( - text: $typedMessage, - font: .systemFont(ofSize: 14), - onSubmit: { submitText() } - ) - .padding(.top, 1) - .padding(.bottom, -1) - } - .focused($isInputAreaFocused) - .padding(8) - .fixedSize(horizontal: false, vertical: true) - - Button(action: { - submitText() - }) { - Image(systemName: "paperplane.fill") - .padding(8) - } - .buttonStyle(.plain) - .disabled(chat.isReceivingMessage) - .keyboardShortcut(KeyEquivalent.return, modifiers: []) - } - .frame(maxWidth: .infinity) - .background { - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - .background { - Button(action: { - typedMessage += "\n" - }) { - EmptyView() - } - .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) - } - } - - func submitText() { - if typedMessage.isEmpty { return } - chat.send(typedMessage) - typedMessage = "" - } -} - -struct ChatContextMenu: View { - let chat: ChatProvider - @AppStorage(\.customCommands) var customCommands - - var body: some View { - Group { - currentSystemPrompt - currentExtraSystemPrompt - resetPrompt - - Divider() - - customCommandMenu - } - } - - @ViewBuilder - var currentSystemPrompt: some View { - Text("System Prompt:") - Text({ - var text = chat.systemPrompt - if text.isEmpty { text = "N/A" } - if text.count > 30 { text = String(text.prefix(30)) + "..." } - return text - }() as String) - } - - @ViewBuilder - var currentExtraSystemPrompt: some View { - Text("Extra Prompt:") - Text({ - var text = chat.extraSystemPrompt - if text.isEmpty { text = "N/A" } - if text.count > 30 { text = String(text.prefix(30)) + "..." } - return text - }() as String) - } - - var resetPrompt: some View { - Button("Reset System Prompt") { - chat.resetPrompt() - } - } - - var customCommandMenu: some View { - Menu("Custom Commands") { - ForEach( - customCommands.filter { - switch $0.feature { - case .chatWithSelection, .customChat: return true - case .promptToCode: return false - } - }, - id: \.name - ) { command in - Button(action: { - chat.triggerCustomCommand(command) - }) { - Text(command.name) - } - } - } - } -} - -struct RoundedCorners: Shape { - var tl: CGFloat = 0.0 - var tr: CGFloat = 0.0 - var bl: CGFloat = 0.0 - var br: CGFloat = 0.0 - - func path(in rect: CGRect) -> Path { - Path { path in - - let w = rect.size.width - let h = rect.size.height - - // Make sure we do not exceed the size of the rectangle - let tr = min(min(self.tr, h / 2), w / 2) - let tl = min(min(self.tl, h / 2), w / 2) - let bl = min(min(self.bl, h / 2), w / 2) - let br = min(min(self.br, h / 2), w / 2) - - path.move(to: CGPoint(x: w / 2.0, y: 0)) - path.addLine(to: CGPoint(x: w - tr, y: 0)) - path.addArc( - center: CGPoint(x: w - tr, y: tr), - radius: tr, - startAngle: Angle(degrees: -90), - endAngle: Angle(degrees: 0), - clockwise: false - ) - path.addLine(to: CGPoint(x: w, y: h - br)) - path.addArc( - center: CGPoint(x: w - br, y: h - br), - radius: br, - startAngle: Angle(degrees: 0), - endAngle: Angle(degrees: 90), - clockwise: false - ) - path.addLine(to: CGPoint(x: bl, y: h)) - path.addArc( - center: CGPoint(x: bl, y: h - bl), - radius: bl, - startAngle: Angle(degrees: 90), - endAngle: Angle(degrees: 180), - clockwise: false - ) - path.addLine(to: CGPoint(x: 0, y: tl)) - path.addArc( - center: CGPoint(x: tl, y: tl), - radius: tl, - startAngle: Angle(degrees: 180), - endAngle: Angle(degrees: 270), - clockwise: false - ) - path.closeSubpath() - } - } -} - -struct GlobalChatSwitchToggleStyle: ToggleStyle { - func makeBody(configuration: Configuration) -> some View { - HStack(spacing: 4) { - Text(configuration.isOn ? "Shared Conversation" : "Local Conversation") - .foregroundStyle(.tertiary) - - RoundedRectangle(cornerRadius: 10, style: .circular) - .foregroundColor(configuration.isOn ? Color.indigo : .gray.opacity(0.5)) - .frame(width: 30, height: 20, alignment: .center) - .overlay( - Circle() - .fill(.regularMaterial) - .padding(.all, 2) - .overlay( - Image( - systemName: configuration - .isOn ? "globe" : "doc.circle" - ) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12, height: 12, alignment: .center) - .foregroundStyle(.secondary) - ) - .offset(x: configuration.isOn ? 5 : -5, y: 0) - .animation(.linear(duration: 0.1), value: configuration.isOn) - ) - .onTapGesture { configuration.isOn.toggle() } - .overlay { - RoundedRectangle(cornerRadius: 10, style: .circular) - .stroke(.black.opacity(0.2), lineWidth: 1) - } - } - } -} - -// MARK: - Previews - -struct ChatPanel_Preview: PreviewProvider { - static let history: [ChatMessage] = [ - .init( - id: "1", - isUser: true, - text: "**Hello**" - ), - .init( - id: "2", - isUser: false, - text: """ - ```swift - func foo() {} - ``` - **Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you? - """ - ), - .init(id: "5", isUser: false, text: "Yooo"), - .init(id: "4", isUser: true, text: "Yeeeehh"), - .init( - id: "3", - isUser: true, - text: #""" - Please buy me a coffee! - | Coffee | Milk | - |--------|------| - | Espresso | No | - | Latte | Yes | - - ```swift - func foo() {} - ``` - ```objectivec - - (void)bar {} - ``` - """# - ), - ] - - static var previews: some View { - ChatPanel(chat: .init( - history: ChatPanel_Preview.history, - isReceivingMessage: true - )) - .frame(width: 450, height: 700) - .colorScheme(.dark) - } -} - -struct ChatPanel_EmptyChat_Preview: PreviewProvider { - static var previews: some View { - ChatPanel(chat: .init( - history: [], - isReceivingMessage: false - )) - .padding() - .frame(width: 450, height: 600) - .colorScheme(.dark) - } -} - -struct ChatCodeSyntaxHighlighter: CodeSyntaxHighlighter { - let brightMode: Bool - let fontSize: Double - - init(brightMode: Bool, fontSize: Double) { - self.brightMode = brightMode - self.fontSize = fontSize - } - - func highlightCode(_ content: String, language: String?) -> Text { - let content = highlightedCodeBlock( - code: content, - language: language ?? "", - brightMode: brightMode, - fontSize: fontSize - ) - return Text(AttributedString(content)) - } -} - -struct ChatPanel_InputText_Preview: PreviewProvider { - static var previews: some View { - ChatPanel(chat: .init( - history: ChatPanel_Preview.history, - isReceivingMessage: false - )) - .padding() - .frame(width: 450, height: 600) - .colorScheme(.dark) - } -} - -struct ChatPanel_InputMultilineText_Preview: PreviewProvider { - static var previews: some View { - ChatPanel( - chat: .init( - history: ChatPanel_Preview.history, - isReceivingMessage: false - ), - typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum." - ) - .padding() - .frame(width: 450, height: 600) - .colorScheme(.dark) - } -} - -struct ChatPanel_Light_Preview: PreviewProvider { - static var previews: some View { - ChatPanel(chat: .init( - history: ChatPanel_Preview.history, - isReceivingMessage: true - )) - .padding() - .frame(width: 450, height: 600) - .colorScheme(.light) - } -} - diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift deleted file mode 100644 index 207a65ed..00000000 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ /dev/null @@ -1,176 +0,0 @@ -import SwiftUI - -struct CodeBlockSuggestionPanel: View { - @ObservedObject var suggestion: SuggestionProvider - @Environment(\.colorScheme) var colorScheme - @AppStorage(\.suggestionCodeFontSize) var fontSize - - struct ToolBar: View { - @ObservedObject var suggestion: SuggestionProvider - - var body: some View { - HStack { - Button(action: { - suggestion.selectPreviousSuggestion() - }) { - Image(systemName: "chevron.left") - }.buttonStyle(.plain) - - Text( - "\(suggestion.currentSuggestionIndex + 1) / \(suggestion.suggestionCount)" - ) - .monospacedDigit() - - Button(action: { - suggestion.selectNextSuggestion() - }) { - Image(systemName: "chevron.right") - }.buttonStyle(.plain) - - Spacer() - - Button(action: { - suggestion.rejectSuggestion() - }) { - Text("Reject") - }.buttonStyle(CommandButtonStyle(color: .gray)) - - Button(action: { - suggestion.acceptSuggestion() - }) { - Text("Accept") - }.buttonStyle(CommandButtonStyle(color: .indigo)) - } - .padding() - .foregroundColor(.secondary) - .background(.regularMaterial) - } - } - - var body: some View { - VStack(spacing: 0) { - CustomScrollView { - CodeBlock( - code: suggestion.code, - language: suggestion.language, - startLineIndex: suggestion.startLineIndex, - colorScheme: colorScheme, - fontSize: fontSize - ) - .frame(maxWidth: .infinity) - } - .background(Color.contentBackground) - - ToolBar(suggestion: suggestion) - } - .xcodeStyleFrame() - } -} - -// MARK: - Previews - -struct CodeBlockSuggestionPanel_Dark_Preview: PreviewProvider { - static var previews: some View { - CodeBlockSuggestionPanel(suggestion: SuggestionProvider( - code: """ - LazyVGrid(columns: [GridItem(.fixed(30)), GridItem(.flexible())]) { - ForEach(0.. Color in + if syncHighlightTheme { + if colorScheme == .light, + let color = codeBackgroundColorLight.value?.swiftUIColor + { + return color + } else if let color = codeBackgroundColorDark.value?.swiftUIColor { + return color + } + } + return Color.contentBackground + }()) + } + } + + Description(descriptions: suggestion.descriptions) + + Divider() + + if suggestionDisplayCompactMode { + CompactToolBar(suggestion: suggestion) + } else { + ToolBar(suggestion: suggestion) + } + } + .xcodeStyleFrame(cornerRadius: { + switch suggestionPresentationMode { + case .nearbyTextCursor: + if #available(macOS 26.0, *) { + return 8 + } else { + return 6 + } + case .floatingWidget: return nil + } + }()) + } + } + + @MainActor + func extractCode() -> ( + code: String, + originalCode: String, + dimmedCharacterCount: AsyncCodeBlock.DimmedCharacterCount + ) { + var range = suggestion.replacingRange + range.end = .init(line: range.end.line - range.start.line, character: range.end.character) + range.start = .init(line: 0, character: range.start.character) + let codeInRange = EditorInformation.code(in: suggestion.replacingLines, inside: range) + let leftover = { + if range.end.line >= 0, range.end.line < suggestion.replacingLines.endIndex { + let lastLine = suggestion.replacingLines[range.end.line] + if range.end.character < lastLine.utf16.count { + let startIndex = lastLine.utf16.index( + lastLine.utf16.startIndex, + offsetBy: range.end.character + ) + var leftover = String(lastLine.utf16.suffix(from: startIndex)) + if leftover?.last?.isNewline ?? false { + leftover?.removeLast(1) + } + return leftover ?? "" + } + } + return "" + }() + + let prefix = { + if range.start.line >= 0, range.start.line < suggestion.replacingLines.endIndex { + let firstLine = suggestion.replacingLines[range.start.line] + if range.start.character < firstLine.utf16.count { + let endIndex = firstLine.utf16.index( + firstLine.utf16.startIndex, + offsetBy: range.start.character + ) + let prefix = String(firstLine.utf16.prefix(upTo: endIndex)) + return prefix ?? "" + } + } + return "" + }() + + let code = prefix + suggestion.code + leftover + + let typedCount = suggestion.startLineIndex == textCursorTracker.cursorPosition.line + ? textCursorTracker.cursorPosition.character + : 0 + + return ( + code, + codeInRange.code, + .init(prefix: typedCount, suffix: leftover.utf16.count) + ) + } +} + +// MARK: - Previews + +#Preview("Code Block Suggestion Panel") { + CodeBlockSuggestionPanelView(suggestion: PresentingCodeSuggestion( + code: """ + LazyVGrid(columns: [GridItem(.fixed(30)), GridItem(.flexible())]) { + ForEach(0.. Void var body: some View { ZStack(alignment: .topTrailing) { @@ -14,10 +14,7 @@ struct ErrorPanel: View { .background(Color.red) // close button - Button(action: { - viewModel.isPanelDisplayed = false - viewModel.content = nil - }) { + Button(action: onCloseButtonTap) { Image(systemName: "xmark") .padding([.leading, .bottom], 16) .padding([.top, .trailing], 8) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift deleted file mode 100644 index 41cc1937..00000000 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ /dev/null @@ -1,293 +0,0 @@ -import MarkdownUI -import SwiftUI - -struct PromptToCodePanel: View { - @ObservedObject var provider: PromptToCodeProvider - - var body: some View { - VStack(spacing: 0) { - PromptToCodePanelContent(provider: provider) - .overlay(alignment: .topTrailing) { - if !provider.code.isEmpty { - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(provider.code, forType: .string) - } - .padding(.trailing, 2) - .padding(.top, 2) - } - } - .overlay(alignment: .bottom) { - HStack { - if provider.isResponding { - Button(action: { - provider.stopResponding() - }) { - HStack(spacing: 4) { - Image(systemName: "stop.fill") - Text("Stop") - } - .padding(8) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: 6, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - } - .buttonStyle(.plain) - } - - let isRespondingButCodeIsReady = provider.isResponding - && !provider.code.isEmpty - && !provider.description.isEmpty - - if !provider.isResponding || isRespondingButCodeIsReady { - HStack { - Toggle( - "Continuous Mode", - isOn: .init( - get: { provider.isContinuous }, - set: { _ in provider.toggleContinuous() } - ) - ) - .toggleStyle(.checkbox) - - Button(action: { - provider.cancel() - }) { - Text("Cancel") - } - .buttonStyle(CommandButtonStyle(color: .gray)) - .keyboardShortcut("w", modifiers: [.command]) - - if !provider.code.isEmpty { - Button(action: { - provider.acceptSuggestion() - }) { - Text("Accept(⌘ + ⏎)") - } - .buttonStyle(CommandButtonStyle(color: .indigo)) - .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) - } - } - .padding(8) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: 6, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - } - } - .padding(.bottom, 8) - } - - PromptToCodePanelToolbar(provider: provider) - } - .background(Color.contentBackground) - .xcodeStyleFrame() - } -} - -struct PromptToCodePanelContent: View { - @ObservedObject var provider: PromptToCodeProvider - @Environment(\.colorScheme) var colorScheme - @AppStorage(\.suggestionCodeFontSize) var fontSize - - var body: some View { - CustomScrollView { - VStack(spacing: 0) { - Spacer(minLength: 60) - - if !provider.errorMessage.isEmpty { - Text(provider.errorMessage) - .multilineTextAlignment(.leading) - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background( - Color.red, - in: RoundedRectangle(cornerRadius: 8, style: .continuous) - ) - .scaleEffect(x: -1, y: -1, anchor: .center) - } - - if !provider.description.isEmpty { - Markdown(provider.description) - .textSelection(.enabled) - .markdownTheme(.gitHub.text { - BackgroundColor(Color.clear) - }) - .padding() - .frame(maxWidth: .infinity) - .scaleEffect(x: -1, y: -1, anchor: .center) - } - - if provider.code.isEmpty { - Text( - provider.isResponding - ? "Thinking..." - : "Enter your requirement to generate code." - ) - .foregroundColor(.secondary) - .padding() - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .scaleEffect(x: -1, y: -1, anchor: .center) - } else { - CodeBlock( - code: provider.code, - language: provider.language, - startLineIndex: provider.startLineIndex, - colorScheme: colorScheme, - firstLinePrecedingSpaceCount: provider.startLineColumn, - fontSize: fontSize - ) - .frame(maxWidth: .infinity) - .scaleEffect(x: -1, y: -1, anchor: .center) - } - - if let name = provider.name { - Text(name) - .font(.footnote) - .foregroundColor(.secondary) - .padding(.top, 12) - .scaleEffect(x: -1, y: -1, anchor: .center) - } - } - .scaleEffect(x: -1, y: 1, anchor: .center) - } - .scaleEffect(x: 1, y: -1, anchor: .center) - } -} - -struct PromptToCodePanelToolbar: View { - @ObservedObject var provider: PromptToCodeProvider - @FocusState var isInputAreaFocused: Bool - - var body: some View { - HStack { - Button(action: { - provider.revert() - }) { - Group { - Image(systemName: "arrow.uturn.backward") - } - .padding(6) - .background { - Circle().fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - Circle() - .stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - } - .buttonStyle(.plain) - .disabled(provider.isResponding || !provider.canRevert) - - HStack(spacing: 0) { - ZStack(alignment: .center) { - // a hack to support dynamic height of TextEditor - Text(provider.requirement.isEmpty ? "Hi" : provider.requirement).opacity(0) - .font(.system(size: 14)) - .frame(maxWidth: .infinity, maxHeight: 400) - .padding(.top, 1) - .padding(.bottom, 2) - .padding(.horizontal, 4) - - CustomTextEditor( - text: $provider.requirement, - font: .systemFont(ofSize: 14), - onSubmit: { provider.sendRequirement() } - ) - .padding(.top, 1) - .padding(.bottom, -1) - } - .focused($isInputAreaFocused) - .padding(8) - .fixedSize(horizontal: false, vertical: true) - - Button(action: { - provider.sendRequirement() - }) { - Image(systemName: "paperplane.fill") - .padding(8) - } - .buttonStyle(.plain) - .disabled(provider.isResponding) - .keyboardShortcut(KeyEquivalent.return, modifiers: []) - } - .frame(maxWidth: .infinity) - .background { - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - .background { - Button(action: { - provider.requirement += "\n" - }) { - EmptyView() - } - .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) - } - } - .onAppear { - isInputAreaFocused = true - } - .padding(8) - .background(.ultraThickMaterial) - } -} - -// MARK: - Previews - -struct PromptToCodePanel_Bright_Preview: PreviewProvider { - static var previews: some View { - PromptToCodePanel(provider: PromptToCodeProvider( - code: """ - ForEach(0.. + @FocusState var isTextFieldFocused: Bool + + var body: some View { + WithPerceptionTracking { + PromptToCodeCustomization.CustomizedUI( + state: store.$promptToCodeState, + delegate: DefaultPromptToCodeContextInputControllerDelegate(store: store), + contextInputController: store.contextInputController, + isInputFieldFocused: _isTextFieldFocused + ) { customizedViews in + VStack(spacing: 0) { + TopBar(store: store) + + Content(store: store) + .safeAreaInset(edge: .bottom) { + VStack { + StatusBar(store: store) + + ActionBar(store: store) + + if let inputField = customizedViews.contextInputField { + inputField + } else { + Toolbar(store: store) + } + } + } + } + } + } + .task { + await MainActor.run { + isTextFieldFocused = true + } + } + } +} + +extension PromptToCodePanelView { + struct TopBar: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + if let previousStep = store.promptToCodeState.history.last { + Button(action: { + store.send(.revertButtonTapped) + }, label: { + HStack(spacing: 4) { + Text(Image(systemName: "arrow.uturn.backward.circle.fill")) + .foregroundStyle(.secondary) + Text(previousStep.instruction.string) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(.secondary) + Spacer() + } + .contentShape(Rectangle()) + }) + .buttonStyle(.plain) + .disabled(store.promptToCodeState.isGenerating) + .padding(6) + + Divider() + } + } + .animation(.linear(duration: 0.1), value: store.promptToCodeState.history.count) + } + } + + struct SelectionRangeButton: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + Button(action: { + store.send(.selectionRangeToggleTapped, animation: .linear(duration: 0.1)) + }) { + let attachedToFilename = store.filename + let isAttached = store.promptToCodeState.isAttachedToTarget + let color: Color = isAttached ? .accentColor : .secondary.opacity(0.6) + HStack(spacing: 4) { + Image( + systemName: isAttached ? "link" : "character.cursor.ibeam" + ) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .frame(width: 20, height: 20, alignment: .center) + .foregroundColor(.white) + .background( + color, + in: RoundedRectangle( + cornerRadius: 4, + style: .continuous + ) + ) + + if isAttached { + HStack(spacing: 4) { + Text(attachedToFilename) + .lineLimit(1) + .truncationMode(.middle) + }.foregroundColor(.primary) + } else { + Text("current selection").foregroundColor(.secondary) + } + } + .padding(2) + .padding(.trailing, 4) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(color, lineWidth: 1) + } + .background { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(color.opacity(0.2)) + } + .padding(2) + } + .keyboardShortcut("j", modifiers: [.command]) + .buttonStyle(.plain) + } + } + } + } + + struct StatusBar: View { + let store: StoreOf + @State var isAllStatusesPresented = false + var body: some View { + WithPerceptionTracking { + if store.promptToCodeState.isGenerating, !store.promptToCodeState.status.isEmpty { + if let firstStatus = store.promptToCodeState.status.first { + let count = store.promptToCodeState.status.count + Button(action: { + isAllStatusesPresented = true + }) { + HStack { + Text(firstStatus) + .lineLimit(1) + .font(.caption) + + Text("\(count)") + .lineLimit(1) + .font(.caption) + .background( + Circle() + .fill(Color.secondary.opacity(0.3)) + .frame(width: 12, height: 12) + ) + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(maxWidth: 400) + .popover(isPresented: $isAllStatusesPresented, arrowEdge: .top) { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 16) { + ForEach(store.promptToCodeState.status, id: \.self) { status in + HStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .controlSize(.small) + Text(status) + } + } + } + .padding() + } + } + .onChange(of: store.promptToCodeState.isGenerating) { isGenerating in + if !isGenerating { + isAllStatusesPresented = false + } + } + } + } + } + } + } + + struct ActionBar: View { + let store: StoreOf + + var body: some View { + HStack { + ReferencesButton(store: store) + StopRespondingButton(store: store) + ActionButtons(store: store) + } + } + + struct StopRespondingButton: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + if store.promptToCodeState.isGenerating { + Button(action: { + store.send(.stopRespondingButtonTapped) + }) { + HStack(spacing: 4) { + Image(systemName: "stop.fill") + Text("Stop") + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + .buttonStyle(.plain) + } + } + } + } + + struct ReferencesButton: View { + let store: StoreOf + @State var isReferencesPresented = false + @State var isReferencesHovered = false + + var body: some View { + if !store.promptToCodeState.references.isEmpty { + Button(action: { + isReferencesPresented.toggle() + }, label: { + HStack(spacing: 4) { + Image(systemName: "doc.text.magnifyingglass") + Text("\(store.promptToCodeState.references.count)") + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + }) + .buttonStyle(.plain) + .popover(isPresented: $isReferencesPresented, arrowEdge: .trailing) { + ReferenceList(store: store) + } + .onHover { hovering in + withAnimation { + isReferencesHovered = hovering + } + } + } + } + } + + struct ActionButtons: View { + @Perception.Bindable var store: StoreOf + @AppStorage(\.chatModels) var chatModels + @AppStorage(\.promptToCodeChatModelId) var defaultChatModelId + + var body: some View { + WithPerceptionTracking { + let isResponding = store.promptToCodeState.isGenerating + let isCodeEmpty = store.promptToCodeState.snippets + .allSatisfy(\.modifiedCode.isEmpty) + var isRespondingButCodeIsReady: Bool { + isResponding && !isCodeEmpty + } + if !isResponding || isRespondingButCodeIsReady { + HStack { + Menu { + WithPerceptionTracking { + Toggle( + "Always accept and continue", + isOn: $store.isContinuous + .animation(.easeInOut(duration: 0.1)) + ) + .toggleStyle(.checkbox) + } + + chatModelMenu + } label: { + Image(systemName: "gearshape.fill") + .resizable() + .scaledToFit() + .foregroundStyle(.secondary) + .frame(width: 16) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button(action: { + store.send(.cancelButtonTapped) + }) { + Text("Cancel") + } + .buttonStyle(CommandButtonStyle(color: .gray)) + .keyboardShortcut("w", modifiers: [.command]) + + if store.isActiveDocument { + if !isCodeEmpty { + AcceptButton(store: store) + } + } else { + RevealButton(store: store) + } + } + .fixedSize() + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .animation( + .easeInOut(duration: 0.1), + value: store.promptToCodeState.snippets + ) + } + } + } + + @ViewBuilder + var chatModelMenu: some View { + let allModels = chatModels + + Menu("Chat Model") { + Button(action: { + defaultChatModelId = "" + }) { + HStack { + Text("Same as chat feature") + if defaultChatModelId.isEmpty { + Image(systemName: "checkmark") + } + } + } + + if !allModels.contains(where: { $0.id == defaultChatModelId }), + !defaultChatModelId.isEmpty + { + Button(action: { + defaultChatModelId = allModels.first?.id ?? "" + }) { + HStack { + Text( + (allModels.first?.name).map { "\($0) (Default)" } + ?? "No model found" + ) + Image(systemName: "checkmark") + } + } + } + + ForEach(allModels, id: \.id) { model in + Button(action: { + defaultChatModelId = model.id + }) { + HStack { + Text(model.name) + if model.id == defaultChatModelId { + Image(systemName: "checkmark") + } + } + } + } + } + } + } + + struct RevealButton: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + Button(action: { + store.send(.revealFileButtonClicked) + }) { + Text("Jump to File(⌘ + ⏎)") + } + .buttonStyle(CommandButtonStyle(color: .accentColor)) + .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) + } + } + } + + struct AcceptButton: View { + let store: StoreOf + @Environment(\.modifierFlags) var modifierFlags + + struct TheButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background( + Rectangle() + .fill(Color.accentColor.opacity(configuration.isPressed ? 0.8 : 1)) + ) + } + } + + var body: some View { + WithPerceptionTracking { + let defaultModeIsContinuous = store.isContinuous + let isAttached = store.promptToCodeState.isAttachedToTarget + + HStack(spacing: 0) { + Button(action: { + switch ( + modifierFlags.contains(.option), + defaultModeIsContinuous + ) { + case (true, true): + store.send(.acceptButtonTapped) + case (false, true): + store.send(.acceptAndContinueButtonTapped) + case (true, false): + store.send(.acceptAndContinueButtonTapped) + case (false, false): + store.send(.acceptButtonTapped) + } + }) { + Group { + switch ( + isAttached, + modifierFlags.contains(.option), + defaultModeIsContinuous + ) { + case (true, true, true): + Text("Accept(⌥ + ⌘ + ⏎)") + case (true, false, true): + Text("Accept and Continue(⌘ + ⏎)") + case (true, true, false): + Text("Accept and Continue(⌥ + ⌘ + ⏎)") + case (true, false, false): + Text("Accept(⌘ + ⏎)") + case (false, true, true): + Text("Replace(⌥ + ⌘ + ⏎)") + case (false, false, true): + Text("Replace and Continue(⌘ + ⏎)") + case (false, true, false): + Text("Replace and Continue(⌥ + ⌘ + ⏎)") + case (false, false, false): + Text("Replace(⌘ + ⏎)") + } + } + .padding(.vertical, 4) + .padding(.leading, 8) + .padding(.trailing, 4) + } + .buttonStyle(TheButtonStyle()) + .keyboardShortcut( + KeyEquivalent.return, + modifiers: modifierFlags + .contains(.option) ? [.command, .option] : [.command] + ) + + Divider() + + Menu { + WithPerceptionTracking { + if defaultModeIsContinuous { + Button(action: { + store.send(.acceptButtonTapped) + }) { + Text("Accept(⌥ + ⌘ + ⏎)") + } + } else { + Button(action: { + store.send(.acceptAndContinueButtonTapped) + }) { + Text("Accept and Continue(⌥ + ⌘ + ⏎)") + } + } + } + } label: { + Text(Image(systemName: "chevron.down")) + .font(.footnote.weight(.bold)) + .scaleEffect(0.8) + .foregroundStyle(.white.opacity(0.8)) + .frame(maxHeight: .infinity) + .padding(.leading, 1) + .padding(.trailing, 2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .fixedSize() + + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.accentColor) + ) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) + } + } + } + } + } + + struct Content: View { + let store: StoreOf + + @Environment(\.colorScheme) var colorScheme + @AppStorage(\.syncPromptToCodeHighlightTheme) var syncHighlightTheme + @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight + @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark + @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight + @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark + + var codeForegroundColor: Color? { + if syncHighlightTheme { + if colorScheme == .light, + let color = codeForegroundColorLight.value?.swiftUIColor + { + return color + } else if let color = codeForegroundColorDark.value?.swiftUIColor { + return color + } + } + return nil + } + + var codeBackgroundColor: Color { + if syncHighlightTheme { + if colorScheme == .light, + let color = codeBackgroundColorLight.value?.swiftUIColor + { + return color + } else if let color = codeBackgroundColorDark.value?.swiftUIColor { + return color + } + } + return Color.contentBackground + } + + var body: some View { + WithPerceptionTracking { + ScrollView { + WithPerceptionTracking { + VStack(spacing: 0) { + VStack(spacing: 0) { + let language = store.promptToCodeState.source.language + let isAttached = store.promptToCodeState.isAttachedToTarget + let lastId = store.promptToCodeState.snippets.last?.id + let isGenerating = store.promptToCodeState.isGenerating + ForEach(store.scope( + state: \.snippetPanels, + action: \.snippetPanel + )) { snippetStore in + WithPerceptionTracking { + SnippetPanelView( + store: snippetStore, + language: language, + codeForegroundColor: codeForegroundColor ?? .primary, + codeBackgroundColor: codeBackgroundColor, + isAttached: isAttached, + isGenerating: isGenerating + ) + + if snippetStore.id != lastId { + Divider() + } + } + } + } + + Spacer(minLength: 56) + } + } + } + .background(codeBackgroundColor) + } + } + + struct SnippetPanelView: View { + let store: StoreOf + let language: CodeLanguage + let codeForegroundColor: Color + let codeBackgroundColor: Color + let isAttached: Bool + let isGenerating: Bool + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 0) { + SnippetTitleBar( + store: store, + language: language, + codeForegroundColor: codeForegroundColor, + isAttached: isAttached + ) + + DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + + CodeContent( + store: store, + language: language, + isGenerating: isGenerating, + codeForegroundColor: codeForegroundColor + ) + + ErrorMessage(store: store) + } + } + } + } + + struct SnippetTitleBar: View { + let store: StoreOf + let language: CodeLanguage + let codeForegroundColor: Color + let isAttached: Bool + var body: some View { + WithPerceptionTracking { + HStack { + Text(language.rawValue) + .foregroundStyle(codeForegroundColor) + .font(.callout.bold()) + .lineLimit(1) + if isAttached { + Text(String(describing: store.snippet.attachedRange)) + .foregroundStyle(codeForegroundColor.opacity(0.5)) + .font(.callout) + } + Spacer() + CopyCodeButton(store: store) + } + .padding(.leading, 8) + } + } + } + + struct CopyCodeButton: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + if !store.snippet.modifiedCode.isEmpty { + DraggableCopyButton { + store.withState { + $0.snippet.modifiedCode + } + } + } + } + } + } + + struct ErrorMessage: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + if let errorMessage = store.snippet.error, !errorMessage.isEmpty { + ( + Text(Image(systemName: "exclamationmark.triangle.fill")) + + Text(" ") + + Text(errorMessage) + ) + .multilineTextAlignment(.leading) + .foregroundColor(.red) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + } + } + } + + struct DescriptionContent: View { + let store: StoreOf + let codeForegroundColor: Color? + + var body: some View { + WithPerceptionTracking { + if !store.snippet.description.isEmpty { + Markdown(store.snippet.description) + .textSelection(.enabled) + .markdownTheme(.gitHub.text { + BackgroundColor(Color.clear) + ForegroundColor(codeForegroundColor) + }) + .padding(.horizontal) + .padding(.vertical, 4) + .frame(maxWidth: .infinity) + } + } + } + } + + struct CodeContent: View { + let store: StoreOf + let language: CodeLanguage + let isGenerating: Bool + let codeForegroundColor: Color? + + @AppStorage(\.wrapCodeInPromptToCode) var wrapCode + + var body: some View { + WithPerceptionTracking { + if !store.snippet.modifiedCode.isEmpty { + let wrapCode = wrapCode || + [CodeLanguage.plaintext, .builtIn(.markdown), .builtIn(.shellscript), + .builtIn(.tex)].contains(language) + if wrapCode { + CodeBlockInContent( + store: store, + language: language, + codeForegroundColor: codeForegroundColor, + presentAllContent: !isGenerating + ) + } else { + MinScrollView { + CodeBlockInContent( + store: store, + language: language, + codeForegroundColor: codeForegroundColor, + presentAllContent: !isGenerating + ) + } + .modify { + if #available(macOS 13.0, *) { + $0.scrollIndicators(.hidden) + } else { + $0 + } + } + } + } else { + if isGenerating { + Text("Thinking...") + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } else { + Text("Enter your requirements to generate code.") + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + } + } + } + + struct MinWidthPreferenceKey: PreferenceKey { + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } + + static var defaultValue: CGFloat = 0 + } + + struct MinScrollView: View { + @ViewBuilder let content: Content + @State var minWidth: CGFloat = 0 + + var body: some View { + ScrollView(.horizontal) { + content + .frame(minWidth: minWidth) + } + .overlay { + GeometryReader { proxy in + Color.clear.preference( + key: MinWidthPreferenceKey.self, + value: proxy.size.width + ) + } + } + .onPreferenceChange(MinWidthPreferenceKey.self) { + minWidth = $0 + } + } + } + + struct CodeBlockInContent: View { + let store: StoreOf + let language: CodeLanguage + let codeForegroundColor: Color? + let presentAllContent: Bool + + @Environment(\.colorScheme) var colorScheme + @AppStorage(\.promptToCodeCodeFont) var codeFont + @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) var hideCommonPrecedingSpaces + + var body: some View { + WithPerceptionTracking { + let startLineIndex = store.snippet.attachedRange.start.line + AsyncDiffCodeBlock( + code: store.snippet.modifiedCode, + originalCode: store.snippet.originalCode, + language: language.rawValue, + startLineIndex: startLineIndex, + scenario: "promptToCode", + font: codeFont.value.nsFont, + droppingLeadingSpaces: hideCommonPrecedingSpaces, + proposedForegroundColor: codeForegroundColor, + skipLastOnlyRemovalSection: !presentAllContent + ) + .frame(maxWidth: CGFloat.infinity) + } + } + } + } + } + + struct Toolbar: View { + let store: StoreOf + @FocusState var focusField: PromptToCodePanel.State.FocusField? + + var body: some View { + HStack { + HStack(spacing: 0) { + if let contextInputController = store.contextInputController + as? DefaultPromptToCodeContextInputController + { + InputField( + store: store, + contextInputField: contextInputController, + focusField: $focusField + ) + SendButton(store: store) + } + } + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .controlColor), lineWidth: 1) + } + .background { + Button(action: { + ( + store.contextInputController + as? DefaultPromptToCodeContextInputController + )?.appendNewLineToPromptButtonTapped() + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) + } + .background { + Button(action: { focusField = .textField }) { + EmptyView() + } + .keyboardShortcut("l", modifiers: [.command]) + } + } + .padding(8) + .background(.ultraThickMaterial) + } + + struct InputField: View { + @Perception.Bindable var store: StoreOf + @Perception.Bindable var contextInputField: DefaultPromptToCodeContextInputController + var focusField: FocusState.Binding + + var body: some View { + WithPerceptionTracking { + AutoresizingCustomTextEditor( + text: $contextInputField.instructionString, + font: .systemFont(ofSize: 14), + isEditable: !store.promptToCodeState.isGenerating, + maxHeight: 400, + onSubmit: { store.send(.modifyCodeButtonTapped) } + ) + .opacity(store.promptToCodeState.isGenerating ? 0.5 : 1) + .disabled(store.promptToCodeState.isGenerating) + .focused(focusField, equals: PromptToCodePanel.State.FocusField.textField) + .bind($store.focusedField, to: focusField) + } + .padding(8) + .fixedSize(horizontal: false, vertical: true) + } + } + + struct SendButton: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + Button(action: { + store.send(.modifyCodeButtonTapped) + }) { + Image(systemName: "paperplane.fill") + .padding(8) + } + .buttonStyle(.plain) + .disabled(store.promptToCodeState.isGenerating) + .keyboardShortcut(KeyEquivalent.return, modifiers: []) + } + } + } + } + + struct ReferenceList: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach( + 0.. Void + + var body: some View { + Button(action: onClick) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + ReferenceIcon(kind: reference.kind) + .layoutPriority(2) + Text(reference.title) + .truncationMode(.middle) + .lineLimit(1) + .layoutPriority(1) + .foregroundStyle(isUsed ? .primary : .secondary) + } + Text(reference.content) + .lineLimit(3) + .truncationMode(.tail) + .foregroundStyle(.tertiary) + .foregroundStyle(isUsed ? .secondary : .tertiary) + } + .padding(.vertical, 4) + .padding(.leading, 4) + .padding(.trailing) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay { + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + .buttonStyle(.plain) + } + } + } + + struct ReferenceIcon: View { + let kind: ChatMessage.Reference.Kind + + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill({ + switch kind { + case let .symbol(symbol, _, _, _): + switch symbol { + case .class: + Color.purple + case .struct: + Color.purple + case .enum: + Color.purple + case .actor: + Color.purple + case .protocol: + Color.purple + case .extension: + Color.indigo + case .case: + Color.green + case .property: + Color.teal + case .typealias: + Color.orange + case .function: + Color.teal + case .method: + Color.blue + } + case .text: + Color.gray + case .webpage: + Color.blue + case .textFile: + Color.gray + case .other: + Color.gray + case .error: + Color.red + } + }()) + .frame(width: 26, height: 14) + .overlay(alignment: .center) { + Group { + switch kind { + case let .symbol(symbol, _, _, _): + switch symbol { + case .class: + Text("C") + case .struct: + Text("S") + case .enum: + Text("E") + case .actor: + Text("A") + case .protocol: + Text("Pr") + case .extension: + Text("Ex") + case .case: + Text("K") + case .property: + Text("P") + case .typealias: + Text("T") + case .function: + Text("𝑓") + case .method: + Text("M") + } + case .text: + Text("Txt") + case .webpage: + Text("Web") + case .other: + Text("*") + case .textFile: + Text("Txt") + case .error: + Text("Err") + } + } + .font(.system(size: 10).monospaced()) + .foregroundColor(.white) + } + } + } +} + +// MARK: - Previews + +#Preview("Multiple Snippets") { + PromptToCodePanelView(store: .init(initialState: .init( + promptToCodeState: Shared(ModificationState( + source: .init( + language: CodeLanguage.builtIn(.swift), + documentURL: URL( + fileURLWithPath: "path/to/file-name-is-super-long-what-should-we-do-with-it-hah-longer-longer.txt" + ), + projectRootURL: URL(fileURLWithPath: "path/to/file.txt"), + content: "", + lines: [] + ), + history: [ + .init(snippets: [ + .init( + startLineIndex: 8, + originalCode: "print(foo)", + modifiedCode: "print(bar)", + description: "", + error: "Error", + attachedRange: CursorRange( + start: .init(line: 8, character: 0), + end: .init(line: 12, character: 2) + ) + ), + ], instruction: .init("Previous instruction"), references: []), + ], + snippets: [ + .init( + startLineIndex: 8, + originalCode: "print(foo)", + modifiedCode: "print(bar)\nprint(baz)", + description: "", + error: "Error", + attachedRange: CursorRange( + start: .init(line: 8, character: 0), + end: .init(line: 12, character: 2) + ) + ), + .init( + startLineIndex: 13, + originalCode: """ + struct Foo { + var foo: Int + } + """, + modifiedCode: """ + struct Bar { + var bar: String + } + """, + description: "Cool", + error: nil, + attachedRange: CursorRange( + start: .init(line: 13, character: 0), + end: .init(line: 12, character: 2) + ) + ), + ], + extraSystemPrompt: "", + isAttachedToTarget: true, + references: [ + ChatMessage.Reference( + title: "Foo", + content: "struct Foo { var foo: Int }", + kind: .symbol( + .struct, + uri: "file:///path/to/file.txt", + startLine: 13, + endLine: 13 + ) + ), + ], + )), + instruction: nil, + commandName: "Generate Code" + ), reducer: { PromptToCodePanel() })) + .frame(maxWidth: 450, maxHeight: Style.panelHeight) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 500, height: 500, alignment: .center) +} + +#Preview("Detached With Long File Name") { + PromptToCodePanelView(store: .init(initialState: .init( + promptToCodeState: Shared(ModificationState( + source: .init( + language: CodeLanguage.builtIn(.swift), + documentURL: URL( + fileURLWithPath: "path/to/file-name-is-super-long-what-should-we-do-with-it-hah.txt" + ), + projectRootURL: URL(fileURLWithPath: "path/to/file.txt"), + content: "", + lines: [] + ), + snippets: [ + .init( + startLineIndex: 8, + originalCode: "print(foo)", + modifiedCode: "print(bar)", + description: "", + error: "Error", + attachedRange: CursorRange( + start: .init(line: 8, character: 0), + end: .init(line: 12, character: 2) + ) + ), + .init( + startLineIndex: 13, + originalCode: """ + struct Bar { + var foo: Int + } + """, + modifiedCode: """ + struct Bar { + var foo: String + } + """, + description: "Cool", + error: nil, + attachedRange: CursorRange( + start: .init(line: 13, character: 0), + end: .init(line: 12, character: 2) + ) + ), + ], + extraSystemPrompt: "", + isAttachedToTarget: false + )), + instruction: nil, + commandName: "Generate Code" + ), reducer: { PromptToCodePanel() })) + .frame(maxWidth: 450, maxHeight: Style.panelHeight) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 500, height: 500, alignment: .center) +} + +#Preview("Generating") { + PromptToCodePanelView(store: .init(initialState: .init( + promptToCodeState: Shared(ModificationState( + source: .init( + language: CodeLanguage.builtIn(.swift), + documentURL: URL( + fileURLWithPath: "path/to/file-name-is-super-long-what-should-we-do-with-it-hah.txt" + ), + projectRootURL: URL(fileURLWithPath: "path/to/file.txt"), + content: "", + lines: [] + ), + snippets: [ + .init( + startLineIndex: 8, + originalCode: "print(foo)", + modifiedCode: "print(bar)", + description: "", + error: "Error", + attachedRange: CursorRange( + start: .init(line: 8, character: 0), + end: .init(line: 12, character: 2) + ) + ), + .init( + startLineIndex: 13, + originalCode: """ + struct Bar { + var foo: Int + } + """, + modifiedCode: """ + struct Bar { + var foo: String + } + """, + description: "Cool", + error: nil, + attachedRange: CursorRange( + start: .init(line: 13, character: 0), + end: .init(line: 12, character: 2) + ) + ), + ], + extraSystemPrompt: "", + isAttachedToTarget: true, + isGenerating: true, + status: ["Status 1", "Status 2"] + )), + instruction: nil, + commandName: "Generate Code" + ), reducer: { PromptToCodePanel() })) + .frame(maxWidth: 450, maxHeight: Style.panelHeight) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 500, height: 500, alignment: .center) +} + diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift new file mode 100644 index 00000000..c7aca342 --- /dev/null +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift @@ -0,0 +1,103 @@ +import ComposableArchitecture +import Dependencies +import Foundation +import SwiftUI +import Toast + +struct ToastPanelView: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 4) { + if !store.alignTopToAnchor { + Spacer() + .allowsHitTesting(false) + } + + ForEach(store.toast.messages) { message in + HStack { + message.content + .foregroundColor(.white) + .textSelection(.enabled) + + + if !message.buttons.isEmpty { + HStack { + ForEach( + Array(message.buttons.enumerated()), + id: \.offset + ) { _, button in + Button(action: button.action) { + button.label + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background { + RoundedRectangle(cornerRadius: 4) + .stroke(Color.white, lineWidth: 1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .allowsHitTesting(true) + } + } + } + } + .padding(8) + .frame(maxWidth: .infinity) + .background({ + switch message.type { + case .info: return Color.accentColor + case .error: return Color(nsColor: .systemRed) + case .warning: return Color(nsColor: .systemOrange) + } + }() as Color, in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color.black.opacity(0.1), lineWidth: 1) + } + } + + if store.alignTopToAnchor { + Spacer() + .allowsHitTesting(false) + } + } + .colorScheme(store.colorScheme) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +#Preview { + ToastPanelView(store: .init(initialState: .init( + toast: .init(messages: [ + ToastController.Message( + id: UUID(), + type: .info, + content: Text("Info message"), + buttons: [ + .init(label: Text("Dismiss"), action: {}), + .init(label: Text("More info"), action: {}), + ] + ), + ToastController.Message( + id: UUID(), + type: .error, + content: Text("Error message"), + buttons: [.init(label: Text("Dismiss"), action: {})] + ), + ToastController.Message( + id: UUID(), + type: .warning, + content: Text("Warning message"), + buttons: [.init(label: Text("Dismiss"), action: {})] + ), + ]) + ), reducer: { + ToastPanel() + })) +} + diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift index e265dd33..b25eb0e9 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift @@ -1,169 +1,76 @@ -import Environment +import ComposableArchitecture +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)" - } - } - } - - enum ActiveTab { - case suggestion - } - - @Published var content: Content? { - didSet { - requestApplicationPolicyUpdate?(self) - } - } - - @Published var activeTab: ActiveTab { - didSet { - requestApplicationPolicyUpdate?(self) - } - } - - @Published var isPanelDisplayed: Bool - @Published var alignTopToAnchor = false - @Published var colorScheme: ColorScheme - - var requestApplicationPolicyUpdate: ((SuggestionPanelViewModel) -> Void)? - - public init( - content: Content? = nil, - isPanelDisplayed: Bool = false, - activeTab: ActiveTab = .suggestion, - colorScheme: ColorScheme = .dark, - requestApplicationPolicyUpdate: ((SuggestionPanelViewModel) -> Void)? = nil - ) { - self.content = content - self.isPanelDisplayed = isPanelDisplayed - self.activeTab = activeTab - self.colorScheme = colorScheme - self.requestApplicationPolicyUpdate = requestApplicationPolicyUpdate - } -} - struct SuggestionPanelView: View { - @ObservedObject var viewModel: SuggestionPanelViewModel + let store: StoreOf + + struct OverallState: Equatable { + var isPanelDisplayed: Bool + var opacity: Double + var colorScheme: ColorScheme + var isPanelOutOfFrame: Bool + var alignTopToAnchor: Bool + } var body: some View { - VStack(spacing: 0) { - if !viewModel.alignTopToAnchor { - Spacer() - .frame(minHeight: 0, maxHeight: .infinity) - .allowsHitTesting(false) - } - - VStack { - if let content = viewModel.content { - if case .suggestion = viewModel.activeTab { - 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) - } - } - .frame(maxWidth: .infinity, maxHeight: Style.panelHeight) - .fixedSize(horizontal: false, vertical: true) - .allowsHitTesting(viewModel.isPanelDisplayed) - } + WithPerceptionTracking { + VStack(spacing: 0) { + if !store.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) } - } - .frame(maxWidth: .infinity) - if viewModel.alignTopToAnchor { - Spacer() - .frame(minHeight: 0, maxHeight: .infinity) - .allowsHitTesting(false) - } - } - .preferredColorScheme(viewModel.colorScheme) - .opacity({ - guard viewModel.isPanelDisplayed else { return 0 } - guard viewModel.content != nil else { return 0 } - return 1 - }()) - .animation(.easeInOut(duration: 0.2), value: viewModel.content?.contentHash) - .animation(.easeInOut(duration: 0.2), value: viewModel.activeTab) - .animation(.easeInOut(duration: 0.2), value: viewModel.isPanelDisplayed) - .frame(maxWidth: Style.panelWidth, maxHeight: Style.panelHeight) - } -} - -struct CommandButtonStyle: ButtonStyle { - let color: Color + Content(store: store) + .allowsHitTesting( + store.isPanelDisplayed && !store.isPanelOutOfFrame + ) + .frame(maxWidth: .infinity) - 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)) + if store.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } } + .preferredColorScheme(store.colorScheme) + .opacity(store.opacity) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: store.isPanelDisplayed + ) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: store.isPanelOutOfFrame + ) + .frame( + maxWidth: Style.inlineSuggestionMinWidth, + maxHeight: Style.inlineSuggestionMaxHeight + ) + } } -} - -// 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]; + struct Content: View { + let store: StoreOf + @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode + + var body: some View { + WithPerceptionTracking { + if let content = store.content { + ZStack(alignment: .topLeading) { + switch suggestionPresentationMode { + case .nearbyTextCursor: + CodeBlockSuggestionPanelView(suggestion: content) + case .floatingWidget: + EmptyView() + } + } + .frame(maxWidth: .infinity, maxHeight: Style.inlineSuggestionMaxHeight) + .fixedSize(horizontal: false, vertical: true) } - """, - language: "objective-c", - startLineIndex: 8, - suggestionCount: 2, - currentSuggestionIndex: 0 - )), - isPanelDisplayed: true, - activeTab: .suggestion, - colorScheme: .dark - )) - .frame(width: 450, height: 200) - .background { - HStack { - Color.red - Color.green - Color.blue } } } diff --git a/Core/Sources/SuggestionWidget/SuggestionProvider.swift b/Core/Sources/SuggestionWidget/SuggestionProvider.swift deleted file mode 100644 index 559540c7..00000000 --- a/Core/Sources/SuggestionWidget/SuggestionProvider.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation -import SwiftUI - -public final class SuggestionProvider: ObservableObject { - @Published public var code: String = "" - @Published public var language: String = "" - @Published public var startLineIndex: Int = 0 - @Published public var suggestionCount: Int = 0 - @Published public var currentSuggestionIndex: Int = 0 - @Published public var commonPrecedingSpaceCount = 0 - - public var onSelectPreviousSuggestionTapped: () -> Void - public var onSelectNextSuggestionTapped: () -> Void - public var onRejectSuggestionTapped: () -> Void - public var onAcceptSuggestionTapped: () -> Void - - public init( - code: String = "", - language: String = "", - startLineIndex: Int = 0, - suggestionCount: Int = 0, - currentSuggestionIndex: Int = 0, - onSelectPreviousSuggestionTapped: @escaping () -> Void = {}, - onSelectNextSuggestionTapped: @escaping () -> Void = {}, - onRejectSuggestionTapped: @escaping () -> Void = {}, - onAcceptSuggestionTapped: @escaping () -> Void = {} - ) { - self.code = code - self.language = language - self.startLineIndex = startLineIndex - self.suggestionCount = suggestionCount - self.currentSuggestionIndex = currentSuggestionIndex - self.onSelectPreviousSuggestionTapped = onSelectPreviousSuggestionTapped - self.onSelectNextSuggestionTapped = onSelectNextSuggestionTapped - self.onRejectSuggestionTapped = onRejectSuggestionTapped - self.onAcceptSuggestionTapped = onAcceptSuggestionTapped - } - - func selectPreviousSuggestion() { onSelectPreviousSuggestionTapped() } - func selectNextSuggestion() { onSelectNextSuggestionTapped() } - func rejectSuggestion() { onRejectSuggestionTapped() } - func acceptSuggestion() { onAcceptSuggestionTapped() } -} diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 88087ec9..09a0ae7a 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -1,9 +1,9 @@ import ActiveApplicationMonitor import AppKit import AsyncAlgorithms -import AXNotificationStream +import ChatTab import Combine -import Environment +import ComposableArchitecture import Preferences import SwiftUI import UserDefaultsObserver @@ -11,683 +11,64 @@ import XcodeInspector @MainActor public final class SuggestionWidgetController: NSObject { - private lazy var fullscreenDetector = { - let it = CanBecomeKeyWindow( - contentRect: .zero, - styleMask: .borderless, - backing: .buffered, - defer: false - ) - it.isReleasedWhenClosed = false - it.isOpaque = false - it.backgroundColor = .clear - it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - it.hasShadow = false - it.setIsVisible(true) - it.canBecomeKeyChecker = { false } - return it - }() - - private lazy var widgetWindow = { - let it = CanBecomeKeyWindow( - contentRect: .zero, - styleMask: .borderless, - backing: .buffered, - defer: false - ) - it.isReleasedWhenClosed = false - it.isOpaque = false - it.backgroundColor = .clear - it.level = .init(19) - it.collectionBehavior = [.fullScreenAuxiliary] - it.hasShadow = true - it.contentView = NSHostingView( - rootView: WidgetView( - viewModel: widgetViewModel, - panelViewModel: suggestionPanelViewModel, - chatWindowViewModel: chatWindowViewModel, - onOpenChatClicked: { [weak self] in - self?.onOpenChatClicked() - }, - onCustomCommandClicked: { [weak self] command in - self?.onCustomCommandClicked(command) - } - ) - ) - it.setIsVisible(true) - it.canBecomeKeyChecker = { false } - return it - }() - - private lazy var tabWindow = { - let it = CanBecomeKeyWindow( - contentRect: .zero, - styleMask: .borderless, - backing: .buffered, - defer: false - ) - it.isReleasedWhenClosed = false - it.isOpaque = false - it.backgroundColor = .clear - it.level = .init(19) - it.collectionBehavior = [.fullScreenAuxiliary] - it.hasShadow = true - it.contentView = NSHostingView( - rootView: TabView(chatWindowViewModel: chatWindowViewModel) - ) - it.setIsVisible(true) - it.canBecomeKeyChecker = { false } - return it - }() - - private lazy var panelWindow = { - let it = CanBecomeKeyWindow( - contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), - styleMask: .borderless, - backing: .buffered, - defer: false - ) - it.isReleasedWhenClosed = false - it.isOpaque = false - it.backgroundColor = .clear - it.level = .init(NSWindow.Level.floating.rawValue + 1) - it.collectionBehavior = [.fullScreenAuxiliary] - it.hasShadow = true - it.contentView = NSHostingView( - rootView: SuggestionPanelView(viewModel: suggestionPanelViewModel) - ) - it.setIsVisible(true) - it.canBecomeKeyChecker = { [suggestionPanelViewModel] in - if case .promptToCode = suggestionPanelViewModel.content { return true } - return false - } - return it - }() - - private lazy var chatWindow = { - let it = ChatWindow( - contentRect: .zero, - styleMask: [.resizable], - backing: .buffered, - defer: false - ) - it.isReleasedWhenClosed = false - it.isOpaque = false - it.backgroundColor = .clear - it.level = .floating - it.collectionBehavior = [.fullScreenAuxiliary] - it.hasShadow = true - it.contentView = NSHostingView( - rootView: ChatWindowView(viewModel: chatWindowViewModel) - ) - it.setIsVisible(true) - it.delegate = self - return it - }() - - let widgetViewModel = WidgetViewModel() - let suggestionPanelViewModel = SuggestionPanelViewModel() - let chatWindowViewModel = ChatWindowViewModel() - - private var presentationModeChangeObserver = UserDefaultsObserver( - object: UserDefaults.shared, - forKeyPaths: [ - UserDefaultPreferenceKeys().suggestionPresentationMode.key, - ], context: nil - ) - private var colorSchemeChangeObserver = UserDefaultsObserver( - object: UserDefaults.shared, forKeyPaths: [ - UserDefaultPreferenceKeys().widgetColorScheme.key, - ], context: nil - ) - private var systemColorSchemeChangeObserver = UserDefaultsObserver( - object: UserDefaults.standard, forKeyPaths: ["AppleInterfaceStyle"], context: nil - ) - private var windowChangeObservationTask: Task? - private var activeApplicationMonitorTask: Task? - private var sourceEditorMonitorTask: Task? - private var fullscreenDetectingTask: Task? - private var currentFileURL: URL? - private var colorScheme: ColorScheme = .light + let store: StoreOf + let chatTabPool: ChatTabPool + let windowsController: WidgetWindowsController private var cancellable = Set() - public var onOpenChatClicked: () -> Void = {} - public var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } - public var dataSource: SuggestionWidgetDataSource? + public let dependency: SuggestionWidgetControllerDependency - override public nonisolated init() { - super.init() - #warning( - "TODO: A test is initializing this class for unknown reasons, try a better way to avoid this." - ) - if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } + public init( + store: StoreOf, + chatTabPool: ChatTabPool, + dependency: SuggestionWidgetControllerDependency + ) { + self.dependency = dependency + self.store = store + self.chatTabPool = chatTabPool + windowsController = .init(store: store, chatTabPool: chatTabPool) - Task { @MainActor in - 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 - observeXcodeWindowChangeIfNeeded(app) - } - await updateContentForActiveEditor() - updateWindowLocation() - orderFront() - } else { - if ActiveApplicationMonitor.activeApplication?.bundleIdentifier != Bundle - .main.bundleIdentifier - { - self.widgetWindow.alphaValue = 0 - self.panelWindow.alphaValue = 0 - self.tabWindow.alphaValue = 0 - if !chatWindowViewModel.chatPanelInASeparateWindow { - self.chatWindow.alphaValue = 0 - } - } - } - } - } - } - - Task { @MainActor in - fullscreenDetectingTask = Task { [weak self] in - let sequence = NSWorkspace.shared.notificationCenter - .notifications(named: NSWorkspace.activeSpaceDidChangeNotification) - _ = self?.fullscreenDetector - for await _ in sequence { - try Task.checkCancellation() - guard let self else { return } - guard let activeXcode = ActiveApplicationMonitor.activeXcode else { continue } - guard fullscreenDetector.isOnActiveSpace else { continue } - let app = AXUIElementCreateApplication(activeXcode.processIdentifier) - if app.focusedWindow != nil { - orderFront() - } - } - } - } - - Task { @MainActor in - presentationModeChangeObserver.onChange = { [weak self] in - guard let self else { return } - self.updateWindowLocation() - } - } + super.init() - Task { @MainActor in - chatWindowViewModel.$chatPanelInASeparateWindow.dropFirst().removeDuplicates() - .sink { [weak self] _ in - guard let self else { return } - Task { @MainActor in - self.updateWindowLocation(animated: true) - } - }.store(in: &cancellable) - } + if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } - Task { @MainActor in - let updateColorScheme = { @MainActor [weak self] in - guard let self else { return } - let widgetColorScheme = UserDefaults.shared.value(for: \.widgetColorScheme) - let systemColorScheme: ColorScheme = NSApp.effectiveAppearance.name == .darkAqua - ? .dark - : .light - self.colorScheme = { - switch (widgetColorScheme, systemColorScheme) { - case (.system, .dark), (.dark, _): - return .dark - case (.system, .light), (.light, _): - return .light - case (.system, _): - return .light - } - }() - self.suggestionPanelViewModel.colorScheme = self.colorScheme - self.chatWindowViewModel.colorScheme = self.colorScheme - Task { - await self.updateContentForActiveEditor() - } - } + dependency.windowsController = windowsController - updateColorScheme() - colorSchemeChangeObserver.onChange = { - updateColorScheme() - } - systemColorSchemeChangeObserver.onChange = { - updateColorScheme() - } + store.send(.startup) + Task { + await windowsController.start() } } - - func orderFront() { - widgetWindow.orderFrontRegardless() - tabWindow.orderFrontRegardless() - panelWindow.orderFrontRegardless() - chatWindow.orderFrontRegardless() - } } // MARK: - Handle Events public extension SuggestionWidgetController { - func suggestCode(fileURL: URL) { - Task { - markAsProcessing(true) - defer { markAsProcessing(false) } - if let suggestion = await dataSource?.suggestionForFile(at: fileURL) { - suggestionPanelViewModel.content = .suggestion(suggestion) - suggestionPanelViewModel.isPanelDisplayed = true - } - } + func suggestCode() { + store.send(.panel(.presentSuggestion)) } - func discardSuggestion(fileURL: URL) { - Task { - await updateContentForActiveEditor(fileURL: fileURL) - } - } - - func markAsProcessing(_ isProcessing: Bool) { - if isProcessing { - widgetViewModel.markIsProcessing() - } else { - widgetViewModel.endIsProcessing() - } - } - - func presentError(_ errorDescription: String) { - suggestionPanelViewModel.content = .error(errorDescription) - suggestionPanelViewModel.isPanelDisplayed = true - } - - func presentChatRoom(fileURL: URL) { - Task { - markAsProcessing(true) - defer { markAsProcessing(false) } - if let chat = await dataSource?.chatForFile(at: fileURL) { - chatWindowViewModel.chat = chat - chatWindowViewModel.isPanelDisplayed = true - - if chatWindowViewModel.chatPanelInASeparateWindow { - self.updateWindowLocation() - } - - Task { @MainActor in - // looks like we need a delay. - try await Task.sleep(nanoseconds: 150_000_000) - NSApplication.shared.activate(ignoringOtherApps: true) - } - } - } - } - - func presentDetachedGlobalChat() { - chatWindowViewModel.chatPanelInASeparateWindow = true - Task { - if let chat = await dataSource?.chatForFile(at: URL(fileURLWithPath: "/")) { - chatWindowViewModel.chat = chat - chatWindowViewModel.isPanelDisplayed = true - - if chatWindowViewModel.chatPanelInASeparateWindow { - self.updateWindowLocation() - } - - Task { @MainActor in - chatWindow.alphaValue = 1 - // looks like we need a delay. - try await Task.sleep(nanoseconds: 150_000_000) - NSApplication.shared.activate(ignoringOtherApps: true) - } - } - } - } - - func closeChatRoom(fileURL: URL) { - Task { - await updateContentForActiveEditor(fileURL: fileURL) - } - } - - func presentPromptToCode(fileURL: URL) { - Task { - markAsProcessing(true) - defer { markAsProcessing(false) } - if let provider = await dataSource?.promptToCodeForFile(at: fileURL) { - suggestionPanelViewModel.content = .promptToCode(provider) - suggestionPanelViewModel.isPanelDisplayed = true - - Task { @MainActor in - // looks like we need a delay. - try await Task.sleep(nanoseconds: 150_000_000) - NSApplication.shared.activate(ignoringOtherApps: true) - panelWindow.makeKey() - } + func discardSuggestion() { + store.withState { state in + if state.panelState.content.suggestion != nil { + store.send(.panel(.discardSuggestion)) } } } - func discardPromptToCode(fileURL: URL) { - Task { - await updateContentForActiveEditor(fileURL: fileURL) - } - } -} - -// MARK: - Private - -extension SuggestionWidgetController { - private func observeXcodeWindowChangeIfNeeded(_ app: NSRunningApplication) { - guard windowChangeObservationTask == nil else { return } - observeEditorChangeIfNeeded(app) - windowChangeObservationTask = Task { [weak self] in - let notifications = AXNotificationStream( - app: app, - notificationNames: - kAXApplicationActivatedNotification, - kAXMovedNotification, - kAXResizedNotification, - kAXMainWindowChangedNotification, - kAXFocusedWindowChangedNotification, - kAXFocusedUIElementChangedNotification, - kAXWindowMovedNotification, - kAXWindowResizedNotification, - kAXWindowMiniaturizedNotification, - kAXWindowDeminiaturizedNotification - ) - for await notification in notifications { - guard let self else { return } - try Task.checkCancellation() - - self.updateWindowLocation(animated: false) - - if [ - kAXFocusedUIElementChangedNotification, - kAXApplicationActivatedNotification, - ].contains(notification.name) { - sourceEditorMonitorTask?.cancel() - sourceEditorMonitorTask = nil - observeEditorChangeIfNeeded(app) - - guard let fileURL = try? await Environment.fetchFocusedElementURI() else { - continue - } - - guard fileURL != currentFileURL else { continue } - currentFileURL = fileURL - widgetViewModel.currentFileURL = currentFileURL - await updateContentForActiveEditor(fileURL: fileURL) - } - } - } - } - - private func observeEditorChangeIfNeeded(_ app: NSRunningApplication) { - guard sourceEditorMonitorTask == nil else { return } - let appElement = AXUIElementCreateApplication(app.processIdentifier) - if let focusedElement = appElement.focusedElement, - focusedElement.description == "Source Editor", - let scrollView = focusedElement.parent, - let scrollBar = scrollView.verticalScrollBar - { - sourceEditorMonitorTask = Task { [weak self] in - let selectionRangeChange = AXNotificationStream( - app: app, - element: focusedElement, - notificationNames: kAXSelectedTextChangedNotification - ) - let scroll = AXNotificationStream( - app: app, - element: scrollBar, - notificationNames: kAXValueChangedNotification - ) - - if #available(macOS 13.0, *) { - for await _ in merge( - selectionRangeChange.debounce(for: Duration.milliseconds(500)), - scroll - ) { - guard let self else { return } - guard ActiveApplicationMonitor.activeXcode != 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 } - try Task.checkCancellation() - let mode = UserDefaults.shared.value(for: \.suggestionWidgetPositionMode) - if mode != .alignToTextCursor { break } - self.updateWindowLocation(animated: false) - } - } - } - } - } - - /// Update the window location. - /// - /// - note: It's possible to get the scroll view's position by getting position on the focus - /// element. - private func updateWindowLocation(animated: Bool = false) { - guard UserDefaults.shared.value(for: \.suggestionPresentationMode) == .floatingWidget - else { - panelWindow.alphaValue = 0 - widgetWindow.alphaValue = 0 - tabWindow.alphaValue = 0 - chatWindow.alphaValue = 0 - return - } - - let detachChat = chatWindowViewModel.chatPanelInASeparateWindow - - if let widgetFrames = { - if let application = XcodeInspector.shared.latestActiveXcode?.appElement { - if let focusElement = application.focusedElement, - focusElement.description == "Source Editor", - let parent = focusElement.parent, - let frame = parent.rect, - let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), - let firstScreen = NSScreen.main - { - let mode = UserDefaults.shared.value(for: \.suggestionWidgetPositionMode) - switch mode { - case .fixedToBottom: - return UpdateLocationStrategy.FixedToBottom().framesForWindows( - editorFrame: frame, - mainScreen: screen, - activeScreen: firstScreen - ) - case .alignToTextCursor: - return UpdateLocationStrategy.AlignToTextCursor().framesForWindows( - editorFrame: frame, - mainScreen: screen, - activeScreen: firstScreen, - editor: focusElement - ) - } - } else if var window = application.focusedWindow, - var frame = application.focusedWindow?.rect, - !["menu bar", "menu bar item"].contains(window.description), - frame.size.height > 300, - let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), - let firstScreen = NSScreen.main - { - if ["open_quickly"].contains(window.identifier) - || ["alert"].contains(window.label) - { - // fallback to use workspace window - guard let workspaceWindow = application.windows - .first(where: { $0.identifier == "Xcode.WorkspaceWindow" }), - let rect = workspaceWindow.rect - else { return (.zero, .zero, .zero, false) } - - window = workspaceWindow - frame = rect - } - - if ["Xcode.WorkspaceWindow"].contains(window.identifier) { - // extra padding to bottom so buttons won't be covered - frame.size.height -= 40 - } else { - // move a bit away from the window so buttons won't be covered - frame.origin.x -= Style.widgetPadding + Style.widgetWidth / 2 - frame.size.width += Style.widgetPadding * 2 + Style.widgetWidth - } - - return UpdateLocationStrategy.FixedToBottom().framesForWindows( - editorFrame: frame, - mainScreen: screen, - activeScreen: firstScreen, - preferredInsideEditorMinWidth: 9_999_999_999 // never - ) - } - } - return nil - }() { - widgetWindow.setFrame(widgetFrames.widgetFrame, display: false, animate: animated) - panelWindow.setFrame(widgetFrames.panelFrame, display: false, animate: animated) - tabWindow.setFrame(widgetFrames.tabFrame, display: false, animate: animated) - suggestionPanelViewModel.alignTopToAnchor = widgetFrames.alignPanelTopToAnchor - if detachChat { - if chatWindow.alphaValue == 0 { - chatWindow.setFrame(panelWindow.frame, display: false, animate: animated) - } - } else { - chatWindow.setFrame(panelWindow.frame, display: false, animate: animated) - } - } - - if let app = ActiveApplicationMonitor.activeApplication, app.isXcode { - let application = AXUIElementCreateApplication(app.processIdentifier) - /// We need this to hide the windows when Xcode is minimized. - let noFocus = application.focusedWindow == nil - panelWindow.alphaValue = noFocus ? 0 : 1 - widgetWindow.alphaValue = noFocus ? 0 : 1 - tabWindow.alphaValue = noFocus ? 0 : 1 - - if detachChat { - chatWindow.alphaValue = chatWindowViewModel.chat != nil ? 1 : 0 - } else { - chatWindow.alphaValue = noFocus ? 0 : 1 - } - } else if let app = ActiveApplicationMonitor.activeApplication, - 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") - }() - - panelWindow.alphaValue = noFocus ? 0 : 1 - widgetWindow.alphaValue = noFocus ? 0 : 1 - tabWindow.alphaValue = noFocus ? 0 : 1 - if detachChat { - chatWindow.alphaValue = chatWindowViewModel.chat != nil ? 1 : 0 - } else { - chatWindow.alphaValue = noFocus && !chatWindow.isKeyWindow ? 0 : 1 - } - } else { - panelWindow.alphaValue = 0 - widgetWindow.alphaValue = 0 - tabWindow.alphaValue = 0 - if !detachChat { - chatWindow.alphaValue = 0 - } - } - } - - private func updateContentForActiveEditor(fileURL: URL? = nil) async { - guard let fileURL = await { - if let fileURL { return fileURL } - return try? await Environment.fetchCurrentFileURL() - }() else { - suggestionPanelViewModel.content = nil - chatWindowViewModel.chat = nil - return - } - - if let chat = await dataSource?.chatForFile(at: fileURL) { - if chatWindowViewModel.chat?.id != chat.id { - chatWindowViewModel.chat = chat - } - } else { - chatWindowViewModel.chat = nil - } - - if let provider = await dataSource?.promptToCodeForFile(at: fileURL) { - if case let .promptToCode(currentProvider) = suggestionPanelViewModel.content, - currentProvider.id == provider.id { return } - suggestionPanelViewModel.content = .promptToCode(provider) - } else if let suggestion = await dataSource?.suggestionForFile(at: fileURL) { - suggestionPanelViewModel.content = .suggestion(suggestion) - } else { - suggestionPanelViewModel.content = nil - } - } -} - -extension SuggestionWidgetController: NSWindowDelegate { - public func windowWillMove(_ notification: Notification) { - guard (notification.object as? NSWindow) === chatWindow else { return } - Task { @MainActor in - await Task.yield() - chatWindowViewModel.chatPanelInASeparateWindow = true - } - } - - public func windowDidBecomeKey(_ notification: Notification) { - guard (notification.object as? NSWindow) === chatWindow else { return } - let screenFrame = NSScreen.screens.first(where: { $0.frame.origin == .zero })? - .frame ?? .zero - var mouseLocation = NSEvent.mouseLocation - let windowFrame = chatWindow.frame - if mouseLocation.y > windowFrame.maxY - 40, - mouseLocation.y < windowFrame.maxY, - mouseLocation.x > windowFrame.minX, - mouseLocation.x < windowFrame.maxX - { - mouseLocation.y = screenFrame.size.height - mouseLocation.y - if let cgEvent = CGEvent( - mouseEventSource: nil, - mouseType: .leftMouseDown, - mouseCursorPosition: mouseLocation, - mouseButton: .left - ), - let event = NSEvent(cgEvent: cgEvent) - { - chatWindow.performDrag(with: event) + #warning("TODO: Make a progress controller that doesn't use TCA.") + func markAsProcessing(_ isProcessing: Bool) { + store.withState { state in + if isProcessing, !state.circularWidgetState.isProcessing { + store.send(.circularWidget(.markIsProcessing)) + } else if !isProcessing, state.circularWidgetState.isProcessing { + store.send(.circularWidget(.endIsProcessing)) } } } -} - -class CanBecomeKeyWindow: NSWindow { - var canBecomeKeyChecker: () -> Bool = { true } - override var canBecomeKey: Bool { canBecomeKeyChecker() } - override var canBecomeMain: Bool { canBecomeKeyChecker() } -} -class ChatWindow: NSWindow { - override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { true } - - override func mouseDown(with event: NSEvent) { - let windowFrame = frame - let currentLocation = event.locationInWindow - if currentLocation.y > windowFrame.size.height - 40, - currentLocation.y < windowFrame.size.height, - currentLocation.x > 0, - currentLocation.x < windowFrame.width - { - performDrag(with: event) - } + func presentError(_ errorDescription: String) { + store.send(.toastPanel(.toast(.toast(errorDescription, .error, nil)))) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift index 90082602..2269d095 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift @@ -1,14 +1,12 @@ import Foundation public protocol SuggestionWidgetDataSource { - func suggestionForFile(at url: URL) async -> SuggestionProvider? - func chatForFile(at url: URL) async -> ChatProvider? - func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? + func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion? } struct MockWidgetDataSource: SuggestionWidgetDataSource { - func suggestionForFile(at url: URL) async -> SuggestionProvider? { - return SuggestionProvider( + func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion? { + return PresentingCodeSuggestion( code: """ func test() { let x = 1 @@ -19,15 +17,10 @@ struct MockWidgetDataSource: SuggestionWidgetDataSource { language: "swift", startLineIndex: 1, suggestionCount: 3, - currentSuggestionIndex: 0 + currentSuggestionIndex: 0, + replacingRange: .zero, + replacingLines: [] ) } - - func chatForFile(at url: URL) async -> ChatProvider? { - return nil - } - - func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? { - return nil - } } + diff --git a/Core/Sources/SuggestionWidget/SyntaxHighlighting.swift b/Core/Sources/SuggestionWidget/SyntaxHighlighting.swift deleted file mode 100644 index ce856445..00000000 --- a/Core/Sources/SuggestionWidget/SyntaxHighlighting.swift +++ /dev/null @@ -1,208 +0,0 @@ -import AppKit -import Foundation -import Highlightr -import Splash -import SwiftUI -import XPCShared - -func highlightedCodeBlock( - code: String, - language: String, - brightMode: Bool, - fontSize: Double -) -> NSAttributedString { - switch language { - case "swift": - let plainTextColor = brightMode - ? .black - : #colorLiteral(red: 0.6509803922, green: 0.6980392157, blue: 0.7529411765, alpha: 1) - let highlighter = - SyntaxHighlighter( - format: AttributedStringOutputFormat(theme: .init( - font: .init(size: fontSize), - plainTextColor: plainTextColor, - tokenColors: brightMode - ? [ - .keyword: #colorLiteral(red: 0.6078431373, green: 0.137254902, blue: 0.5764705882, alpha: 1), - .string: #colorLiteral(red: 0.1371159852, green: 0.3430536985, blue: 0.362406373, alpha: 1), - .type: #colorLiteral(red: 0.2456904352, green: 0.5002114773, blue: 0.5297455192, alpha: 1), - .call: #colorLiteral(red: 0.1960784314, green: 0.4274509804, blue: 0.4549019608, alpha: 1), - .number: #colorLiteral(red: 0.4385872483, green: 0.4995297194, blue: 0.5483990908, alpha: 1), - .comment: #colorLiteral(red: 0.3647058824, green: 0.4235294118, blue: 0.4745098039, alpha: 1), - .property: #colorLiteral(red: 0.1960784314, green: 0.4274509804, blue: 0.4549019608, alpha: 1), - .dotAccess: #colorLiteral(red: 0.1960784314, green: 0.4274509804, blue: 0.4549019608, alpha: 1), - .preprocessing: #colorLiteral(red: 0.3921568627, green: 0.2196078431, blue: 0.1254901961, alpha: 1), - ] : [ - .keyword: #colorLiteral(red: 0.8258609176, green: 0.5708742738, blue: 0.8922662139, alpha: 1), - .string: #colorLiteral(red: 0.6253595352, green: 0.7963448763, blue: 0.5427476764, alpha: 1), - .type: #colorLiteral(red: 0.9221783876, green: 0.7978314757, blue: 0.5575165749, alpha: 1), - .call: #colorLiteral(red: 0.4466812611, green: 0.742190659, blue: 0.9515134692, alpha: 1), - .number: #colorLiteral(red: 0.8620631099, green: 0.6468816996, blue: 0.4395158887, alpha: 1), - .comment: #colorLiteral(red: 0.4233166873, green: 0.4612616301, blue: 0.5093258619, alpha: 1), - .property: #colorLiteral(red: 0.906378448, green: 0.5044228435, blue: 0.5263597369, alpha: 1), - .dotAccess: #colorLiteral(red: 0.906378448, green: 0.5044228435, blue: 0.5263597369, alpha: 1), - .preprocessing: #colorLiteral(red: 0.3776347041, green: 0.8792117238, blue: 0.4709561467, alpha: 1), - ] - )) - ) - let formatted = NSMutableAttributedString(attributedString: highlighter.highlight(code)) - formatted.addAttributes( - [.font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)], - range: NSRange(location: 0, length: formatted.length) - ) - func leadingSpacesInCode(_ code: String) -> Int { - var leadingSpaces = 0 - for char in code { - if char == " " { - leadingSpaces += 1 - } else { - break - } - } - return leadingSpaces - } - - // Workaround: Splash has a bug that will insert an extra space at the beginning. - let leadingSpaces = leadingSpacesInCode(code) - let leadingSpacesFormatted = leadingSpacesInCode(formatted.string) - let diff = leadingSpacesFormatted - leadingSpaces - if diff > 0 { - formatted.mutableString.replaceCharacters( - in: .init(location: 0, length: diff), - with: "" - ) - } - // End of workaround. - - return formatted - default: - var language = language - // Workaround: Highlightr uses a different identifier for Objective-C. - if language.lowercased().hasPrefix("objective"), language.lowercased().hasSuffix("c") { - language = "objectivec" - } - func unhighlightedCode() -> NSAttributedString { - return NSAttributedString( - string: code, - attributes: [ - .foregroundColor: brightMode ? NSColor.black : NSColor.white, - .font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular), - ] - ) - } - guard let highlighter = Highlightr() else { - return unhighlightedCode() - } - highlighter.setTheme(to: brightMode ? "xcode" : "atom-one-dark") - highlighter.theme.setCodeFont(.monospacedSystemFont(ofSize: fontSize, weight: .regular)) - guard let formatted = highlighter.highlight(code, as: language) else { - return unhighlightedCode() - } - if formatted.string == "undefined" { - return unhighlightedCode() - } - return formatted - } -} - -func highlighted( - code: String, - language: String, - brightMode: Bool, - droppingLeadingSpaces: Bool, - fontSize: Double -) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { - let formatted = highlightedCodeBlock( - code: code, - language: language, - brightMode: brightMode, - fontSize: fontSize - ) - let middleDotColor = brightMode - ? NSColor.black.withAlphaComponent(0.1) - : NSColor.white.withAlphaComponent(0.1) - return convertToCodeLines( - formatted, - middleDotColor: middleDotColor, - droppingLeadingSpaces: droppingLeadingSpaces - ) -} - -func convertToCodeLines( - _ formattedCode: NSAttributedString, - middleDotColor: NSColor, - droppingLeadingSpaces: Bool -) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { - let input = formattedCode.string - func isEmptyLine(_ line: String) -> Bool { - if line.isEmpty { return true } - guard let regex = try? NSRegularExpression(pattern: #"^\s*\n?$"#) else { return false } - if regex.firstMatch( - in: line, - options: [], - range: NSMakeRange(0, line.utf16.count) - ) != nil { - return true - } - return false - } - - let separatedInput = input.components(separatedBy: "\n") - let commonLeadingSpaceCount = { - if !droppingLeadingSpaces { return 0 } - let split = separatedInput - var result = 0 - outerLoop: for i in stride(from: 40, through: 4, by: -4) { - for line in split { - if isEmptyLine(line) { continue } - if i >= line.count { continue outerLoop } - if !line.hasPrefix(.init(repeating: " ", count: i)) { continue outerLoop } - } - result = i - break - } - return result - }() - var output = [NSAttributedString]() - var start = 0 - for sub in separatedInput { - let range = NSMakeRange(start, sub.utf16.count) - let attributedString = formattedCode.attributedSubstring(from: range) - let mutable = NSMutableAttributedString(attributedString: attributedString) - - // remove leading spaces - if commonLeadingSpaceCount > 0 { - let leadingSpaces = String(repeating: " ", count: commonLeadingSpaceCount) - if mutable.string.hasPrefix(leadingSpaces) { - mutable.replaceCharacters( - in: NSRange(location: 0, length: commonLeadingSpaceCount), - with: "" - ) - } else if isEmptyLine(mutable.string) { - mutable.mutableString.setString("") - } - } - - // use regex to replace all spaces to a middle dot - do { - let regex = try NSRegularExpression(pattern: "[ ]*", options: []) - let result = regex.matches( - in: mutable.string, - range: NSRange(location: 0, length: mutable.mutableString.length) - ) - for r in result { - let range = r.range - mutable.replaceCharacters( - in: range, - with: String(repeating: "·", count: range.length) - ) - mutable.addAttributes([ - .foregroundColor: middleDotColor, - ], range: range) - } - } catch {} - output.append(mutable) - start += range.length + 1 - } - return (output, commonLeadingSpaceCount) -} diff --git a/Core/Sources/SuggestionWidget/TabView.swift b/Core/Sources/SuggestionWidget/TabView.swift deleted file mode 100644 index 7582896a..00000000 --- a/Core/Sources/SuggestionWidget/TabView.swift +++ /dev/null @@ -1,32 +0,0 @@ -import SwiftUI - -struct TabView: View { - @ObservedObject var chatWindowViewModel: ChatWindowViewModel - - var body: some View { - Button(action: { - chatWindowViewModel.chatPanelInASeparateWindow = false - }, label: { - Image(systemName: "ellipsis.bubble.fill") - .frame(width: Style.widgetWidth, height: Style.widgetHeight) - .background( - Color.userChatContentBackground, - in: Circle() - ) - }) - .buttonStyle(.plain) - .opacity(chatWindowViewModel.chatPanelInASeparateWindow ? 1 : 0) - .preferredColorScheme(chatWindowViewModel.colorScheme) - .frame(maxWidth: Style.widgetWidth, maxHeight: Style.widgetHeight) - } -} - -struct TabView_Preview: PreviewProvider { - static var previews: some View { - VStack { - TabView(chatWindowViewModel: .init()) - } - .frame(width: 30) - .background(Color.black) - } -} diff --git a/Core/Sources/SuggestionWidget/TextCursorTracker.swift b/Core/Sources/SuggestionWidget/TextCursorTracker.swift new file mode 100644 index 00000000..6de2dc29 --- /dev/null +++ b/Core/Sources/SuggestionWidget/TextCursorTracker.swift @@ -0,0 +1,89 @@ +import Foundation +import Perception +import SuggestionBasic +import SwiftUI +import XcodeInspector + +/// A passive tracker that observe the changes of the source editor content. +@Perceptible +final class TextCursorTracker { + @MainActor + var cursorPosition: CursorPosition { content.cursorPosition } + @MainActor + var currentLine: String { + if content.cursorPosition.line >= 0, content.cursorPosition.line < content.lines.count { + content.lines[content.cursorPosition.line] + } else { + "" + } + } + + @MainActor + var content: SourceEditor.Content = .init( + content: "", + lines: [], + selections: [], + cursorPosition: .zero, + cursorOffset: 0, + lineAnnotations: [] + ) + + @PerceptionIgnored var eventObservationTask: Task? + + init() { + observeAppChange() + } + + deinit { + eventObservationTask?.cancel() + } + + var isPreview: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } + + private func observeAppChange() { + if isPreview { return } + Task { [weak self] in + let notifications = NotificationCenter.default + .notifications(named: .focusedEditorDidChange) + for await _ in notifications { + guard let self else { return } + guard let editor = await XcodeInspector.shared.focusedEditor else { continue } + await self.observeAXNotifications(editor) + } + } + } + + private func observeAXNotifications(_ editor: SourceEditor) async { + if isPreview { return } + eventObservationTask?.cancel() + let content = editor.getLatestEvaluatedContent() + await MainActor.run { + self.content = content + } + eventObservationTask = Task { [weak self] in + for await event in await editor.axNotifications.notifications() { + try Task.checkCancellation() + guard let self else { return } + guard event.kind == .evaluatedContentChanged else { continue } + let content = editor.getLatestEvaluatedContent() + await MainActor.run { + self.content = content + } + } + } + } +} + +struct TextCursorTrackerEnvironmentKey: EnvironmentKey { + static var defaultValue: TextCursorTracker = .init() +} + +extension EnvironmentValues { + var textCursorTracker: TextCursorTracker { + get { self[TextCursorTrackerEnvironmentKey.self] } + set { self[TextCursorTrackerEnvironmentKey.self] = newValue } + } +} + diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 7909a393..5aed84b3 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -1,21 +1,31 @@ import AppKit import Foundation +public struct WidgetLocation: Equatable { + struct PanelLocation: Equatable { + var frame: CGRect + var alignPanelTop: Bool + } + + var widgetFrame: CGRect + var tabFrame: CGRect + var sharedPanelLocation: PanelLocation + var defaultPanelLocation: PanelLocation + var suggestionPanelLocation: PanelLocation? +} + enum UpdateLocationStrategy { struct AlignToTextCursor { func framesForWindows( + windowFrame: CGRect, editorFrame: CGRect, mainScreen: NSScreen, activeScreen: NSScreen, editor: AXUIElement, + hideCircularWidget: Bool = UserDefaults.shared.value(for: \.hideCircularWidget), 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( @@ -24,69 +34,84 @@ enum UpdateLocationStrategy { ) else { return FixedToBottom().framesForWindows( + windowFrame: windowFrame, editorFrame: editorFrame, mainScreen: mainScreen, - activeScreen: activeScreen + activeScreen: activeScreen, + hideCircularWidget: hideCircularWidget ) } var frame: CGRect = .zero let found = AXValueGetValue(rect, .cgRect, &frame) guard found else { return FixedToBottom().framesForWindows( + windowFrame: windowFrame, editorFrame: editorFrame, mainScreen: mainScreen, - activeScreen: activeScreen + activeScreen: activeScreen, + hideCircularWidget: hideCircularWidget ) } return HorizontalMovable().framesForWindows( y: mainScreen.frame.height - frame.maxY, + windowFrame: windowFrame, alignPanelTopToAnchor: nil, editorFrame: editorFrame, mainScreen: mainScreen, activeScreen: activeScreen, - preferredInsideEditorMinWidth: preferredInsideEditorMinWidth + preferredInsideEditorMinWidth: preferredInsideEditorMinWidth, + hideCircularWidget: hideCircularWidget ) } } struct FixedToBottom { func framesForWindows( + windowFrame: CGRect, editorFrame: CGRect, mainScreen: NSScreen, activeScreen: NSScreen, + hideCircularWidget: Bool = UserDefaults.shared.value(for: \.hideCircularWidget), preferredInsideEditorMinWidth: Double = UserDefaults.shared - .value(for: \.preferWidgetToStayInsideEditorWhenWidthGreaterThan) - ) -> ( - widgetFrame: CGRect, - panelFrame: CGRect, - tabFrame: CGRect, - alignPanelTopToAnchor: Bool - ) { - return HorizontalMovable().framesForWindows( + .value(for: \.preferWidgetToStayInsideEditorWhenWidthGreaterThan), + editorFrameExpendedSize: CGSize = .zero + ) -> WidgetLocation { + var frames = HorizontalMovable().framesForWindows( y: mainScreen.frame.height - editorFrame.maxY + Style.widgetPadding, + windowFrame: windowFrame, alignPanelTopToAnchor: false, editorFrame: editorFrame, mainScreen: mainScreen, activeScreen: activeScreen, - preferredInsideEditorMinWidth: preferredInsideEditorMinWidth + preferredInsideEditorMinWidth: preferredInsideEditorMinWidth, + hideCircularWidget: hideCircularWidget, + editorFrameExpendedSize: editorFrameExpendedSize + ) + + frames.sharedPanelLocation.frame.size.height = max( + frames.defaultPanelLocation.frame.height, + editorFrame.height - Style.widgetHeight ) + frames.defaultPanelLocation.frame.size.height = max( + frames.defaultPanelLocation.frame.height, + (editorFrame.height - Style.widgetHeight) / 2 + ) + return frames } } struct HorizontalMovable { func framesForWindows( y: CGFloat, + windowFrame: CGRect, alignPanelTopToAnchor fixedAlignment: Bool?, editorFrame: CGRect, mainScreen: NSScreen, activeScreen: NSScreen, - preferredInsideEditorMinWidth: Double - ) -> ( - widgetFrame: CGRect, - panelFrame: CGRect, - tabFrame: CGRect, - alignPanelTopToAnchor: Bool - ) { + preferredInsideEditorMinWidth: Double, + hideCircularWidget: Bool = UserDefaults.shared.value(for: \.hideCircularWidget), + editorFrameExpendedSize: CGSize = .zero + ) -> WidgetLocation { let maxY = max( y, mainScreen.frame.height - editorFrame.maxY + Style.widgetPadding, @@ -99,15 +124,34 @@ enum UpdateLocationStrategy { .widgetPadding ) - let proposedAnchorFrameOnTheRightSide = CGRect( + var proposedAnchorFrameOnTheRightSide = CGRect( + x: editorFrame.maxX - Style.widgetPadding, + y: y, + width: 0, + height: 0 + ) + + let widgetFrameOnTheRightSide = CGRect( x: editorFrame.maxX - Style.widgetPadding - Style.widgetWidth, y: y, width: Style.widgetWidth, height: Style.widgetHeight ) + + let widgetFrame = CGRect( + x: windowFrame.minX, + y: mainScreen.frame.height - windowFrame.maxY + Style.indicatorBottomPadding, + width: Style.widgetWidth, + height: Style.widgetHeight + ) - let proposedPanelX = proposedAnchorFrameOnTheRightSide.maxX + Style - .widgetPadding * 2 + if !hideCircularWidget { + proposedAnchorFrameOnTheRightSide = widgetFrameOnTheRightSide + } + + let proposedPanelX = proposedAnchorFrameOnTheRightSide.maxX + + Style.widgetPadding * 2 + - editorFrameExpendedSize.width let putPanelToTheRight = { if editorFrame.size.width >= preferredInsideEditorMinWidth { return false } return activeScreen.frame.maxX > proposedPanelX + Style.panelWidth @@ -118,8 +162,9 @@ enum UpdateLocationStrategy { let anchorFrame = proposedAnchorFrameOnTheRightSide let panelFrame = CGRect( x: proposedPanelX, - y: alignPanelTopToAnchor ? anchorFrame.maxY - Style.panelHeight : anchorFrame - .minY, + y: alignPanelTopToAnchor + ? anchorFrame.maxY - Style.panelHeight + : anchorFrame.minY, width: Style.panelWidth, height: Style.panelHeight ) @@ -132,16 +177,42 @@ enum UpdateLocationStrategy { height: Style.widgetHeight ) - return (anchorFrame, panelFrame, tabFrame, alignPanelTopToAnchor) + return .init( + widgetFrame: widgetFrame, + tabFrame: tabFrame, + sharedPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), + defaultPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), + suggestionPanelLocation: nil + ) } else { - let proposedAnchorFrameOnTheLeftSide = CGRect( + var proposedAnchorFrameOnTheLeftSide = CGRect( + x: editorFrame.minX + Style.widgetPadding, + y: proposedAnchorFrameOnTheRightSide.origin.y, + width: 0, + height: 0 + ) + + let widgetFrameOnTheLeftSide = CGRect( x: editorFrame.minX + Style.widgetPadding, y: proposedAnchorFrameOnTheRightSide.origin.y, width: Style.widgetWidth, height: Style.widgetHeight ) - let proposedPanelX = proposedAnchorFrameOnTheLeftSide.minX - Style - .widgetPadding * 2 - Style.panelWidth + + if !hideCircularWidget { + proposedAnchorFrameOnTheLeftSide = widgetFrameOnTheLeftSide + } + + let proposedPanelX = proposedAnchorFrameOnTheLeftSide.minX + - Style.widgetPadding * 2 + - Style.panelWidth + + editorFrameExpendedSize.width let putAnchorToTheLeft = { if editorFrame.size.width >= preferredInsideEditorMinWidth { if editorFrame.maxX <= activeScreen.frame.maxX { @@ -155,9 +226,9 @@ enum UpdateLocationStrategy { let anchorFrame = proposedAnchorFrameOnTheLeftSide let panelFrame = CGRect( x: proposedPanelX, - y: alignPanelTopToAnchor ? anchorFrame.maxY - Style - .panelHeight : anchorFrame - .minY, + y: alignPanelTopToAnchor + ? anchorFrame.maxY - Style.panelHeight + : anchorFrame.minY - editorFrameExpendedSize.height, width: Style.panelWidth, height: Style.panelHeight ) @@ -169,14 +240,26 @@ enum UpdateLocationStrategy { width: Style.widgetWidth, height: Style.widgetHeight ) - return (anchorFrame, panelFrame, tabFrame, alignPanelTopToAnchor) + return .init( + widgetFrame: widgetFrame, + tabFrame: tabFrame, + sharedPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), + defaultPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), + suggestionPanelLocation: nil + ) } else { let anchorFrame = proposedAnchorFrameOnTheRightSide let panelFrame = CGRect( x: anchorFrame.maxX - Style.panelWidth, - y: alignPanelTopToAnchor ? anchorFrame.maxY - Style.panelHeight - Style - .widgetHeight - Style.widgetPadding : anchorFrame.maxY + Style - .widgetPadding, + y: alignPanelTopToAnchor + ? anchorFrame.maxY - Style.panelHeight + : anchorFrame.maxY - editorFrameExpendedSize.height, width: Style.panelWidth, height: Style.panelHeight ) @@ -186,9 +269,190 @@ enum UpdateLocationStrategy { width: Style.widgetWidth, height: Style.widgetHeight ) - return (anchorFrame, panelFrame, tabFrame, alignPanelTopToAnchor) + return .init( + widgetFrame: widgetFrame, + tabFrame: tabFrame, + sharedPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), + 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 305214b0..f07816bf 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -1,153 +1,149 @@ import ActiveApplicationMonitor -import Environment +import ComposableArchitecture import Preferences -import SuggestionModel +import SharedUIComponents +import SuggestionBasic import SwiftUI -@MainActor -final class WidgetViewModel: ObservableObject { - struct IsProcessingCounter { - var expirationDate: TimeInterval - } +struct WidgetView: View { + let store: StoreOf + @State var isHovering: Bool = false + var onOpenChatClicked: () -> Void = {} + var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } - private var isProcessingCounters = [IsProcessingCounter]() - private var cleanupIsProcessingCounterTask: Task? - @Published private(set) var isProcessing: Bool - @Published var currentFileURL: URL? + @AppStorage(\.hideCircularWidget) var hideCircularWidget - func markIsProcessing(date: Date = Date()) { - let deadline = date.timeIntervalSince1970 + 20 - isProcessingCounters.append(IsProcessingCounter(expirationDate: deadline)) - isProcessing = true - - cleanupIsProcessingCounterTask?.cancel() - cleanupIsProcessingCounterTask = Task { [weak self] in - try await Task.sleep(nanoseconds: 20 * 1_000_000_000) - try Task.checkCancellation() - Task { @MainActor [weak self] in - guard let self else { return } - isProcessingCounters.removeAll() - isProcessing = false + var body: some View { + GeometryReader { _ in + WithPerceptionTracking { + ZStack { + WidgetAnimatedCapsule( + store: store, + isHovering: isHovering + ) + } + .onTapGesture { + store.send(.widgetClicked, animation: .easeInOut(duration: 0.2)) + } + .onHover { yes in + withAnimation(.easeInOut(duration: 0.14)) { + isHovering = yes + } + } + .contextMenu { + WidgetContextMenu(store: store) + } + .opacity({ + if !hideCircularWidget { return 1 } + return store.isProcessing ? 1 : 0 + }()) + .animation( + .easeInOut(duration: 0.2), + value: isHovering + ) + .animation( + .easeInOut(duration: 0.4), + value: store.isProcessing + ) } } } - - func endIsProcessing(date: Date = Date()) { - if !isProcessingCounters.isEmpty { - isProcessingCounters.removeFirst() - } - isProcessingCounters.removeAll(where: { $0.expirationDate < date.timeIntervalSince1970 }) - isProcessing = !isProcessingCounters.isEmpty - } - - init(isProcessing: Bool = false) { - self.isProcessing = isProcessing - } } -struct WidgetView: View { - @ObservedObject var viewModel: WidgetViewModel - @ObservedObject var panelViewModel: SuggestionPanelViewModel - @ObservedObject var chatWindowViewModel: ChatWindowViewModel - @State var isHovering: Bool = false - @State var processingProgress: Double = 0 - var onOpenChatClicked: () -> Void = {} - var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } +struct WidgetAnimatedCapsule: View { + let store: StoreOf + var isHovering: Bool + + @State private var breathingOpacity: CGFloat = 1.0 + @State private var animationTask: Task? var body: some View { - Circle().fill(isHovering ? .white.opacity(0.8) : .white.opacity(0.3)) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - let wasDisplayed = { - if panelViewModel.isPanelDisplayed, - panelViewModel.content != nil { return true } - if chatWindowViewModel.isPanelDisplayed, - chatWindowViewModel.chat != nil { return true } - return false - }() - panelViewModel.isPanelDisplayed = !wasDisplayed - chatWindowViewModel.isPanelDisplayed = !wasDisplayed - let isDisplayed = !wasDisplayed + GeometryReader { geo in + WithPerceptionTracking { + let capsuleWidth = geo.size.width + let capsuleHeight = geo.size.height - if !isDisplayed { - if let app = ActiveApplicationMonitor.previousActiveApplication, - app.isXcode - { - app.activate() - } - } - } - } - .overlay { - let minimumLineWidth: Double = 3 - let lineWidth = (1 - processingProgress) * - (Style.widgetWidth - minimumLineWidth / 2) + minimumLineWidth - let scale = max(processingProgress * 1, 0.0001) - let empty = panelViewModel.content == nil && chatWindowViewModel.chat == nil + let backgroundWidth = capsuleWidth + let foregroundWidth = max(capsuleWidth - 4, 2) + let padding = (backgroundWidth - foregroundWidth) / 2 + let foregroundHeight = capsuleHeight - padding * 2 ZStack { - Circle() - .stroke( - Color(nsColor: .darkGray), - style: .init(lineWidth: minimumLineWidth) - ) - .padding(minimumLineWidth / 2) + Capsule() + .modify { + if #available(macOS 26.0, *) { + $0.glassEffect() + } else if #available(macOS 13.0, *) { + $0.backgroundStyle(.thickMaterial.opacity(0.8)).overlay( + Capsule().stroke( + Color(nsColor: .darkGray).opacity(0.2), + lineWidth: 1 + ) + ) + } else { + $0.fill(Color(nsColor: .darkGray).opacity(0.6)).overlay( + Capsule().stroke( + Color(nsColor: .darkGray).opacity(0.2), + lineWidth: 1 + ) + ) + } + } + .frame(width: backgroundWidth, height: capsuleHeight) - // how do I stop the repeatForever animation without removing the view? - // I tried many solutions found on stackoverflow but non of them works. - if viewModel.isProcessing { - Circle() - .stroke( - Color.accentColor, - style: .init(lineWidth: lineWidth) - ) - .padding(minimumLineWidth / 2) - .scaleEffect(x: scale, y: scale) - .opacity(!empty || viewModel.isProcessing ? 1 : 0) - .animation( - .easeInOut(duration: 1).repeatForever(autoreverses: true), - value: processingProgress - ) - } else { - Circle() - .stroke( - Color.accentColor, - style: .init(lineWidth: lineWidth) - ) - .padding(minimumLineWidth / 2) - .scaleEffect(x: scale, y: scale) - .opacity(!empty || viewModel.isProcessing ? 1 : 0) - .animation(.easeInOut(duration: 1), value: processingProgress) - } + Capsule() + .fill(Color.white) + .frame( + width: foregroundWidth, + height: foregroundHeight + ) + .opacity({ + let base = store.isProcessing ? breathingOpacity : 0 + if isHovering { + return min(base + 0.5, 1.0) + } + return base + }()) + .blur(radius: 2) } - } - .onChange(of: viewModel.isProcessing) { _ in refreshRing() } - .onChange(of: panelViewModel.content?.contentHash) { _ in refreshRing() } - .onChange(of: chatWindowViewModel.chat?.id) { _ in refreshRing() } - .onHover { yes in - withAnimation(.easeInOut(duration: 0.2)) { - isHovering = yes + .onAppear { + updateBreathingAnimation(isProcessing: store.isProcessing) + } + .onChange(of: store.isProcessing) { newValue in + updateBreathingAnimation(isProcessing: newValue) } - }.contextMenu { - WidgetContextMenu( - chatWindowViewModel: chatWindowViewModel, - widgetViewModel: viewModel, - isChatOpen: chatWindowViewModel.isPanelDisplayed - && chatWindowViewModel.chat != nil, - onOpenChatClicked: onOpenChatClicked, - onCustomCommandClicked: onCustomCommandClicked - ) } + } } - func refreshRing() { - Task { - await Task.yield() - if viewModel.isProcessing { - processingProgress = 1 - processingProgress - } else { - let empty = panelViewModel.content == nil && chatWindowViewModel.chat == nil - processingProgress = empty ? 0 : 1 + private func updateBreathingAnimation(isProcessing: Bool) { + animationTask?.cancel() + animationTask = nil + + if isProcessing { + animationTask = Task { + while !Task.isCancelled { + await MainActor.run { + withAnimation(.easeInOut(duration: 1.2)) { + breathingOpacity = 0.3 + } + } + try? await Task.sleep(nanoseconds: UInt64(1.2 * 1_000_000_000)) + if Task.isCancelled { break } + if !(store.isProcessing) { break } + await MainActor.run { + withAnimation(.easeInOut(duration: 1.2)) { + breathingOpacity = 1.0 + } + } + try? await Task.sleep(nanoseconds: UInt64(1.2 * 1_000_000_000)) + if Task.isCancelled { break } + if !(store.isProcessing) { break } + } + } + } else { + withAnimation(.easeInOut(duration: 0.2)) { + breathingOpacity = 0 } } } @@ -156,29 +152,27 @@ 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 @AppStorage(\.suggestionFeatureDisabledLanguageList) var suggestionFeatureDisabledLanguageList @AppStorage(\.customCommands) var customCommands - @ObservedObject var chatWindowViewModel: ChatWindowViewModel - @ObservedObject var widgetViewModel: WidgetViewModel - @State var projectPath: String? - @State var fileURL: URL? - var isChatOpen: Bool - var onOpenChatClicked: () -> Void = {} - var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } + let store: StoreOf + + @Dependency(\.xcodeInspector) var xcodeInspector var body: some View { - Group { + WithPerceptionTracking { Group { // Commands - if !isChatOpen { - Button(action: { - onOpenChatClicked() - }) { - Text("Open Chat") - } + Button(action: { + store.send(.openChatButtonClicked) + }) { + Text("Open Chat") + } + + Button(action: { + store.send(.openModificationButtonClicked) + }) { + Text("Write or Edit Code") } customCommandMenu() @@ -196,19 +190,10 @@ struct WidgetContextMenu: View { Group { // Settings Button(action: { - chatWindowViewModel.chatPanelInASeparateWindow.toggle() + store.send(.detachChatPanelToggleClicked) }) { Text("Detach Chat Panel") - if chatWindowViewModel.chatPanelInASeparateWindow { - Image(systemName: "checkmark") - } - } - - Button(action: { - useGlobalChat.toggle() - }) { - Text("Use Shared Conversation") - if useGlobalChat { + if store.isChatPanelDetached { Image(systemName: "checkmark") } } @@ -221,59 +206,17 @@ struct WidgetContextMenu: View { Image(systemName: "checkmark") } } - - Button(action: { - acceptSuggestionWithAccessibilityAPI.toggle() - }, label: { - Text("Accept Suggestion with Accessibility API") - if acceptSuggestionWithAccessibilityAPI { - Image(systemName: "checkmark") - } - }) - - Button(action: { - hideCommonPrecedingSpacesInSuggestion.toggle() - }, label: { - Text("Hide Common Preceding Spaces in Suggestion") - if hideCommonPrecedingSpacesInSuggestion { - Image(systemName: "checkmark") - } - }) } Divider() } - .onAppear { - updateProjectPath(fileURL: widgetViewModel.currentFileURL) - } - .onChange(of: widgetViewModel.currentFileURL) { fileURL in - updateProjectPath(fileURL: fileURL) - } - } - - func updateProjectPath(fileURL: URL?) { - Task { - let projectURL: URL? = await { - if let url = try? await Environment.fetchCurrentProjectRootURLFromXcode() { - return url - } - guard let fileURL else { return nil } - return try? await Environment.guessProjectRootURLForFile(fileURL) - }() - if let projectURL { - Task { @MainActor in - self.fileURL = fileURL - self.projectPath = projectURL.path - } - } - } } func customCommandMenu() -> some View { Menu("Custom Commands") { ForEach(customCommands, id: \.name) { command in Button(action: { - onCustomCommandClicked(command) + store.send(.runCustomCommandButtonClicked(command)) }) { Text(command.name) } @@ -285,7 +228,9 @@ struct WidgetContextMenu: View { extension WidgetContextMenu { @ViewBuilder var enableSuggestionForProject: some View { - if let projectPath, disableSuggestionFeatureGlobally { + if let projectPath = xcodeInspector.activeProjectRootURL?.path, + disableSuggestionFeatureGlobally + { let matchedPath = suggestionFeatureEnabledProjectList.first { path in projectPath.hasPrefix(path) } @@ -308,23 +253,22 @@ extension WidgetContextMenu { @ViewBuilder var disableSuggestionForLanguage: some View { - if let fileURL { - let fileLanguage = languageIdentifierFromFileURL(fileURL) - let matched = suggestionFeatureDisabledLanguageList.first { rawValue in - fileLanguage.rawValue == rawValue + let fileURL = xcodeInspector.activeDocumentURL + let fileLanguage = fileURL.map(languageIdentifierFromFileURL) ?? .plaintext + let matched = suggestionFeatureDisabledLanguageList.first { rawValue in + fileLanguage.rawValue == rawValue + } + Button(action: { + if let matched { + suggestionFeatureDisabledLanguageList.removeAll { $0 == matched } + } else { + suggestionFeatureDisabledLanguageList.append(fileLanguage.rawValue) } - Button(action: { - if let matched { - suggestionFeatureDisabledLanguageList.removeAll { $0 == matched } - } else { - suggestionFeatureDisabledLanguageList.append(fileLanguage.rawValue) - } - }) { - if matched == nil { - Text("Disable Suggestion for \"\(fileLanguage.rawValue.capitalized)\"") - } else { - Text("Enable Suggestion for \"\(fileLanguage.rawValue.capitalized)\"") - } + }) { + if matched == nil { + Text("Disable Suggestion for \"\(fileLanguage.rawValue.capitalized)\"") + } else { + Text("Enable Suggestion for \"\(fileLanguage.rawValue.capitalized)\"") } } } @@ -334,41 +278,66 @@ struct WidgetView_Preview: PreviewProvider { static var previews: some View { VStack { WidgetView( - viewModel: .init(isProcessing: false), - panelViewModel: .init(), - chatWindowViewModel: .init(), + store: Store( + initialState: .init( + isProcessing: false, + isDisplayingContent: false, + isContentEmpty: true, + isChatPanelDetached: false, + isChatOpen: false + ), + reducer: { CircularWidget() } + ), isHovering: false ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) WidgetView( - viewModel: .init(isProcessing: false), - panelViewModel: .init(), - chatWindowViewModel: .init(), + store: Store( + initialState: .init( + isProcessing: false, + isDisplayingContent: false, + isContentEmpty: true, + isChatPanelDetached: false, + isChatOpen: false + ), + reducer: { CircularWidget() } + ), isHovering: true ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) WidgetView( - viewModel: .init(isProcessing: true), - panelViewModel: .init(), - chatWindowViewModel: .init(), + store: Store( + initialState: .init( + isProcessing: true, + isDisplayingContent: false, + isContentEmpty: true, + isChatPanelDetached: false, + isChatOpen: false + ), + reducer: { CircularWidget() } + ), isHovering: false ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) WidgetView( - viewModel: .init(isProcessing: false), - panelViewModel: .init( - content: .suggestion(SuggestionProvider( - code: "Hello", - startLineIndex: 0, - suggestionCount: 0, - currentSuggestionIndex: 0 - )) + store: Store( + initialState: .init( + isProcessing: false, + isDisplayingContent: true, + isContentEmpty: true, + isChatPanelDetached: false, + isChatOpen: false + ), + reducer: { CircularWidget() } ), - chatWindowViewModel: .init(), isHovering: false ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) } - .frame(width: 30) + .frame(width: 200, height: 200) .background(Color.black) } } diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift new file mode 100644 index 00000000..2f70e0e3 --- /dev/null +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -0,0 +1,954 @@ +import AppKit +import AsyncAlgorithms +import ChatTab +import ComposableArchitecture +import Dependencies +import Foundation +import SharedUIComponents +import SwiftNavigation +import SwiftUI +import XcodeInspector + +#warning(""" +TODO: This part is too messy, consider breaking it up, let each window handle their own things +""") + +actor WidgetWindowsController: NSObject { + let userDefaultsObservers = WidgetUserDefaultsObservers() + var xcodeInspector: XcodeInspector { .shared } + + nonisolated let windows: WidgetWindows + nonisolated let store: StoreOf + nonisolated let chatTabPool: ChatTabPool + + var currentApplicationProcessIdentifier: pid_t? + + var observeToAppTask: Task? + var observeToFocusedEditorTask: Task? + + var updateWindowOpacityTask: Task? + var lastUpdateWindowOpacityTime = Date(timeIntervalSince1970: 0) + + var updateWindowLocationTask: Task? + var lastUpdateWindowLocationTime = Date(timeIntervalSince1970: 0) + + var beatingCompletionPanelTask: Task? + var updateWindowStateTask: Task? + + deinit { + userDefaultsObservers.presentationModeChangeObserver.onChange = {} + observeToAppTask?.cancel() + observeToFocusedEditorTask?.cancel() + updateWindowStateTask?.cancel() + } + + init(store: StoreOf, chatTabPool: ChatTabPool) { + self.store = store + self.chatTabPool = chatTabPool + windows = .init(store: store, chatTabPool: chatTabPool) + super.init() + windows.controller = self + } + + @MainActor func send(_ action: Widget.Action) { + store.send(action) + } + + func start() { + Task { [xcodeInspector] in + await observe { [weak self] in + if let app = xcodeInspector.activeApplication { + Task { + await self?.activate(app) + } + } + } + + await observe { [weak self] in + if let editor = xcodeInspector.focusedEditor { + Task { + await self?.observe(toEditor: editor) + } + } + } + + await observe { [weak self] in + let isDisplaying = xcodeInspector.completionPanel != nil + Task { + await self?.handleCompletionPanelChange(isDisplaying: isDisplaying) + } + } + } + + userDefaultsObservers.presentationModeChangeObserver.onChange = { [weak self] in + Task { [weak self] in + await self?.updateWindowLocation(animated: false, immediately: false) + await self?.send(.updateColorScheme) + } + } + + updateWindowStateTask = Task { [weak self] in + if let self { await handleSpaceChange() } + + await withThrowingTaskGroup(of: Void.self) { [weak self] group in + // active space did change + _ = group.addTaskUnlessCancelled { [weak self] in + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.activeSpaceDidChangeNotification) + for await _ in sequence { + guard let self else { return } + try Task.checkCancellation() + await handleSpaceChange() + } + } + } + } + + Task { @MainActor in + windows.chatPanelWindow.isPanelDisplayed = false + } + } +} + +// MARK: - Observation + +private extension WidgetWindowsController { + func activate(_ app: AppInstanceInspector) { + Task { + if app.isXcode { + updateWindowLocation(animated: false, immediately: true) + updateWindowOpacity(immediately: false) + } else { + updateWindowOpacity(immediately: true) + updateWindowLocation(animated: false, immediately: false) + await hideSuggestionPanelWindow() + } + await adjustChatPanelWindowLevel() + await adjustModificationPanelLevel() + } + guard currentApplicationProcessIdentifier != app.processIdentifier else { return } + currentApplicationProcessIdentifier = app.processIdentifier + observe(toApp: app) + } + + func observe(toApp app: AppInstanceInspector) { + guard let app = app as? XcodeAppInstanceInspector else { return } + let notifications = app.axNotifications + observeToAppTask?.cancel() + observeToAppTask = Task { + await windows.orderFront() + + /// Hide the widgets before switching to another window/editor + /// so the transition looks better. + func hideWidgetForTransitions() async { + let newDocumentURL = xcodeInspector.realtimeActiveDocumentURL + let documentURL = await MainActor + .run { store.withState { $0.focusingDocumentURL } } + if documentURL != newDocumentURL { + await send(.panel(.removeDisplayedContent)) + await hidePanelWindows() + } + await send(.updateFocusingDocumentURL) + } + + func removeContent() async { + await send(.panel(.removeDisplayedContent)) + } + + func updateWidgetsAndNotifyChangeOfEditor(immediately: Bool) async { + await send(.panel(.switchToAnotherEditorAndUpdateContent)) + updateWindowLocation(animated: false, immediately: immediately) + updateWindowOpacity(immediately: immediately) + } + + func updateWidgets(immediately: Bool) async { + updateWindowLocation(animated: false, immediately: immediately) + updateWindowOpacity(immediately: immediately) + } + + await updateWidgetsAndNotifyChangeOfEditor(immediately: true) + + for await notification in await notifications.notifications() { + try Task.checkCancellation() + + switch notification.kind { + case .focusedWindowChanged: + await handleSpaceChange() + await hideWidgetForTransitions() + await updateWidgetsAndNotifyChangeOfEditor(immediately: true) + case .focusedUIElementChanged: + await hideWidgetForTransitions() + await updateWidgetsAndNotifyChangeOfEditor(immediately: true) + case .applicationActivated: + await removeContent() + await updateWidgetsAndNotifyChangeOfEditor(immediately: false) + case .mainWindowChanged: + await removeContent() + await updateWidgetsAndNotifyChangeOfEditor(immediately: false) + case .moved, + .resized, + .windowMoved, + .windowResized, + .windowMiniaturized, + .windowDeminiaturized: + await updateWidgets(immediately: false) + case .created, .uiElementDestroyed, .xcodeCompletionPanelChanged, + .applicationDeactivated: + continue + case .titleChanged: + continue + } + } + } + } + + func observe(toEditor editor: SourceEditor) { + observeToFocusedEditorTask?.cancel() + observeToFocusedEditorTask = Task { + let selectionRangeChange = await editor.axNotifications.notifications() + .filter { $0.kind == .selectedTextChanged } + let scroll = await editor.axNotifications.notifications() + .filter { $0.kind == .scrollPositionChanged } + + if #available(macOS 13.0, *) { + for await notification in merge( + selectionRangeChange.debounce(for: Duration.milliseconds(500)), + scroll + ) { + guard await xcodeInspector.latestActiveXcode != nil else { return } + try Task.checkCancellation() + + // for better looking + if notification.kind == .scrollPositionChanged { + await hideSuggestionPanelWindow() + } + + updateWindowLocation(animated: false, immediately: false) + updateWindowOpacity(immediately: false) + } + } else { + for await notification in merge(selectionRangeChange, scroll) { + guard await xcodeInspector.latestActiveXcode != nil else { return } + try Task.checkCancellation() + + // for better looking + if notification.kind == .scrollPositionChanged { + await hideSuggestionPanelWindow() + } + + updateWindowLocation(animated: false, immediately: false) + updateWindowOpacity(immediately: false) + } + } + } + } + + func handleCompletionPanelChange(isDisplaying: Bool) { + beatingCompletionPanelTask?.cancel() + beatingCompletionPanelTask = Task { + if !isDisplaying { + // 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: 400_000_000) + } + + updateWindowLocation(animated: false, immediately: false) + updateWindowOpacity(immediately: false) + } + } +} + +// MARK: - Window Updating + +extension WidgetWindowsController { + @MainActor + func hidePanelWindows() { +// windows.sharedPanelWindow.alphaValue = 0 + windows.suggestionPanelWindow.alphaValue = 0 + } + + @MainActor + func hideSuggestionPanelWindow() { + windows.suggestionPanelWindow.alphaValue = 0 + } + + func generateWidgetLocation() async -> WidgetLocation? { + if let application = await xcodeInspector.latestActiveXcode?.appElement { + if let window = application.focusedWindow, + let windowFrame = window.rect, + let focusElement = await xcodeInspector.focusedEditor?.element, + let parent = focusElement.parent, + let frame = parent.rect, + let screen = NSScreen.screens.first( + where: { $0.frame.origin == .zero } + ) ?? NSScreen.main, + let windowContainingScreen = NSScreen.screens.first(where: { + let flippedScreenFrame = $0.frame.flipped(relativeTo: screen.frame) + return flippedScreenFrame.contains(frame.origin) + }) + { + let positionMode = UserDefaults.shared + .value(for: \.suggestionWidgetPositionMode) + let suggestionMode = UserDefaults.shared + .value(for: \.suggestionPresentationMode) + + switch positionMode { + case .fixedToBottom: + var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( + windowFrame: windowFrame, + editorFrame: frame, + mainScreen: screen, + activeScreen: windowContainingScreen + ) + switch suggestionMode { + case .nearbyTextCursor: + result.suggestionPanelLocation = UpdateLocationStrategy + .NearbyTextCursor() + .framesForSuggestionWindow( + editorFrame: frame, + mainScreen: screen, + activeScreen: windowContainingScreen, + editor: focusElement, + completionPanel: await xcodeInspector.completionPanel + ) + default: + break + } + return result + case .alignToTextCursor: + var result = UpdateLocationStrategy.AlignToTextCursor().framesForWindows( + windowFrame: windowFrame, + editorFrame: frame, + mainScreen: screen, + activeScreen: windowContainingScreen, + editor: focusElement + ) + switch suggestionMode { + case .nearbyTextCursor: + result.suggestionPanelLocation = UpdateLocationStrategy + .NearbyTextCursor() + .framesForSuggestionWindow( + editorFrame: frame, mainScreen: screen, + activeScreen: windowContainingScreen, + editor: focusElement, + completionPanel: await xcodeInspector.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, + sharedPanelLocation: .init(frame: .zero, alignPanelTop: false), + defaultPanelLocation: .init(frame: .zero, alignPanelTop: false) + ) + } + + window = workspaceWindow + frame = rect + } + + return UpdateLocationStrategy.FixedToBottom().framesForWindows( + windowFrame: frame, + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen, + preferredInsideEditorMinWidth: 9_999_999_999, // never + editorFrameExpendedSize: .zero + ) + } + } + return nil + } + + func updatePanelState(_ location: WidgetLocation) async { + await send(.updatePanelStateToMatch(location)) + } + + func updateWindowOpacity(immediately: Bool) { + let shouldDebounce = !immediately && + !(Date().timeIntervalSince(lastUpdateWindowOpacityTime) > 3) + lastUpdateWindowOpacityTime = Date() + updateWindowOpacityTask?.cancel() + + let task = Task { + if shouldDebounce { + try await Task.sleep(nanoseconds: 200_000_000) + } + try Task.checkCancellation() + let xcodeInspector = self.xcodeInspector + let activeApp = await xcodeInspector.activeApplication + let latestActiveXcode = await xcodeInspector.latestActiveXcode + let previousActiveApplication = await xcodeInspector.previousActiveApplication + await MainActor.run { + if let activeApp, activeApp.isXcode { + let application = activeApp.appElement + /// We need this to hide the windows when Xcode is minimized. + let noFocus = application.focusedWindow == nil + windows.sharedPanelWindow.alphaValue = 1 + windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.widgetWindow.alphaValue = noFocus ? 0 : 1 + windows.toastWindow.alphaValue = noFocus ? 0 : 1 + } else if let activeApp, activeApp.isExtensionService { + let noFocus = { + guard let xcode = latestActiveXcode else { return true } + if let window = xcode.appElement.focusedWindow, + window.role == "AXWindow" + { + return false + } + return true + }() + + let previousAppIsXcode = previousActiveApplication?.isXcode ?? false + + windows.sharedPanelWindow.alphaValue = 1 + windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.widgetWindow.alphaValue = if noFocus { + 0 + } else if previousAppIsXcode { + if windows.chatPanelWindow.isFullscreen, + windows.chatPanelWindow.isOnActiveSpace + { + 0 + } else { + 1 + } + } else { + 0 + } + windows.toastWindow.alphaValue = noFocus ? 0 : 1 + } else { + windows.sharedPanelWindow.alphaValue = 1 + windows.suggestionPanelWindow.alphaValue = 0 + windows.widgetWindow.alphaValue = 0 + windows.toastWindow.alphaValue = 0 + } + } + } + + updateWindowOpacityTask = task + } + + func updateWindowLocation( + animated: Bool, + immediately: Bool, + function: StaticString = #function, + line: UInt = #line + ) { + @Sendable @MainActor + func update() async { + let state = store.withState { $0 } + let isChatPanelDetached = state.chatPanelState.isDetached + guard let widgetLocation = await generateWidgetLocation() else { return } + await updatePanelState(widgetLocation) + + windows.widgetWindow.setFrame( + widgetLocation.widgetFrame, + display: false, + animate: animated + ) + windows.toastWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) + windows.sharedPanelWindow.setFrame( + widgetLocation.sharedPanelLocation.frame, + display: false, + animate: animated + ) + + if let suggestionPanelLocation = widgetLocation.suggestionPanelLocation { + windows.suggestionPanelWindow.setFrame( + suggestionPanelLocation.frame, + display: false, + animate: animated + ) + } + + if isChatPanelDetached { + // don't update it! + } else { + windows.chatPanelWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) + } + + await adjustChatPanelWindowLevel() + await adjustModificationPanelLevel() + } + + let now = Date() + let shouldThrottle = !immediately && + !(now.timeIntervalSince(lastUpdateWindowLocationTime) > 3) + + updateWindowLocationTask?.cancel() + let interval: TimeInterval = 0.05 + + if shouldThrottle { + let delay = max( + 0, + interval - now.timeIntervalSince(lastUpdateWindowLocationTime) + ) + + updateWindowLocationTask = Task { + try await Task.sleep(nanoseconds: UInt64(delay * 500_000_000)) + try Task.checkCancellation() + await update() + } + } else { + Task { + await update() + } + } + lastUpdateWindowLocationTime = Date() + } + + @MainActor + func adjustModificationPanelLevel() async { + let window = windows.sharedPanelWindow + + let latestApp = await xcodeInspector.activeApplication + let latestAppIsXcodeOrExtension = if let latestApp { + latestApp.isXcode || latestApp.isExtensionService + } else { + false + } + + window.setFloatOnTop(latestAppIsXcodeOrExtension) + } + + @MainActor + func adjustChatPanelWindowLevel() async { + let flowOnTopOption = UserDefaults.shared + .value(for: \.chatPanelFloatOnTopOption) + let disableFloatOnTopWhenTheChatPanelIsDetached = UserDefaults.shared + .value(for: \.disableFloatOnTopWhenTheChatPanelIsDetached) + + let window = windows.chatPanelWindow + + if flowOnTopOption == .never { + window.setFloatOnTop(false) + return + } + + let state = store.withState { $0 } + let isChatPanelDetached = state.chatPanelState.isDetached + + let floatOnTopWhenOverlapsXcode = UserDefaults.shared + .value(for: \.keepFloatOnTopIfChatPanelAndXcodeOverlaps) + + let latestApp = await xcodeInspector.activeApplication + let latestAppIsXcodeOrExtension = if let latestApp { + latestApp.isXcode || latestApp.isExtensionService + } else { + false + } + + async let overlap: Bool = { @MainActor in + guard let xcode = await xcodeInspector.latestActiveXcode else { return false } + let windowElements = xcode.appElement.windows + let overlap = windowElements.contains { + if let position = $0.position, let size = $0.size { + let rect = CGRect( + x: position.x, + y: position.y, + width: size.width, + height: size.height + ) + return rect.intersects(window.frame) + } + return false + } + return overlap + }() + + if latestAppIsXcodeOrExtension { + if floatOnTopWhenOverlapsXcode { + let overlap = await overlap + window.setFloatOnTop(overlap) + } else { + if disableFloatOnTopWhenTheChatPanelIsDetached, isChatPanelDetached { + window.setFloatOnTop(false) + } else { + window.setFloatOnTop(true) + } + } + } else { + if floatOnTopWhenOverlapsXcode { + let overlap = await overlap + window.setFloatOnTop(overlap) + } else { + switch flowOnTopOption { + case .onTopWhenXcodeIsActive: + window.setFloatOnTop(false) + case .alwaysOnTop: + window.setFloatOnTop(true) + case .never: + window.setFloatOnTop(false) + } + } + } + } + + @MainActor + func handleSpaceChange() async { + let activeXcode = XcodeInspector.shared.activeXcode + + let xcode = activeXcode?.appElement + + let isXcodeActive = xcode?.isFrontmost ?? false + + [ + windows.sharedPanelWindow, + windows.suggestionPanelWindow, + windows.widgetWindow, + windows.toastWindow, + ].forEach { + if isXcodeActive { + $0.moveToActiveSpace() + } + } + + if isXcodeActive, !windows.chatPanelWindow.isDetached { + windows.chatPanelWindow.moveToActiveSpace() + } + + if windows.fullscreenDetector.isOnActiveSpace, xcode?.focusedWindow != nil { + windows.orderFront() + } + } +} + +// MARK: - NSWindowDelegate + +extension WidgetWindowsController: NSWindowDelegate { + nonisolated + func windowWillMove(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + Task { @MainActor in + guard window === windows.chatPanelWindow else { return } + await Task.yield() + store.send(.chatPanel(.detachChatPanel)) + } + } + + nonisolated + func windowDidMove(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + Task { @MainActor in + guard window === windows.chatPanelWindow else { return } + await Task.yield() + await adjustChatPanelWindowLevel() + } + } + + nonisolated + func windowWillEnterFullScreen(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + Task { @MainActor in + guard window === windows.chatPanelWindow else { return } + await Task.yield() + store.send(.chatPanel(.enterFullScreen)) + } + } + + nonisolated + func windowWillExitFullScreen(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + Task { @MainActor in + guard window === windows.chatPanelWindow else { return } + await Task.yield() + store.send(.chatPanel(.exitFullScreen)) + } + } +} + +// MARK: - Windows + +public final class WidgetWindows { + let store: StoreOf + let chatTabPool: ChatTabPool + weak var controller: WidgetWindowsController? + + // you should make these window `.transient` so they never show up in the mission control. + + @MainActor + lazy var fullscreenDetector = { + let it = CanBecomeKeyWindow( + contentRect: .zero, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.backgroundColor = .clear + it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + it.hasShadow = false + it.setIsVisible(false) + it.canBecomeKeyChecker = { false } + return it + }() + + @MainActor + lazy var widgetWindow = { + let it = WidgetWindow( + contentRect: .zero, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.backgroundColor = .clear + it.level = widgetLevel(0) + it.hasShadow = false + it.contentView = NSHostingView( + rootView: WidgetView( + store: store.scope( + state: \._internalCircularWidgetState, + action: \.circularWidget + ) + ) + ) + it.setIsVisible(true) + it.canBecomeKeyChecker = { false } + return it + }() + + @MainActor + lazy var sharedPanelWindow = { + let it = WidgetWindow( + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.backgroundColor = .clear + it.level = widgetLevel(2) + it.hoveringLevel = widgetLevel(2) + it.hasShadow = false + it.contentView = NSHostingView( + rootView: SharedPanelView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.sharedPanelState, + action: \.sharedPanel + ) + ).modifierFlagsMonitor() + ) + it.setIsVisible(true) + it.canBecomeKeyChecker = { [store] in + store.withState { state in + !state.panelState.sharedPanelState.content.promptToCodeGroup.promptToCodes.isEmpty + } + } + return it + }() + + @MainActor + lazy var suggestionPanelWindow = { + let it = WidgetWindow( + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.backgroundColor = .clear + it.level = widgetLevel(2) + it.hasShadow = false + it.menu = nil + it.animationBehavior = .utilityWindow + it.contentView = NSHostingView( + rootView: SuggestionPanelView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.suggestionPanelState, + action: \.suggestionPanel + ) + ) + ) + it.canBecomeKeyChecker = { false } + it.setIsVisible(true) + return it + }() + + @MainActor + lazy var chatPanelWindow = { + let it = ChatPanelWindow( + store: store.scope( + state: \.chatPanelState, + action: \.chatPanel + ), + chatTabPool: chatTabPool, + minimizeWindow: { [weak self] in + self?.store.send(.chatPanel(.hideButtonClicked)) + } + ) + it.hoveringLevel = widgetLevel(1) + it.delegate = controller + return it + }() + + @MainActor + lazy var toastWindow = { + let it = WidgetWindow( + 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 = widgetLevel(2) + it.hasShadow = false + it.contentView = NSHostingView( + rootView: ToastPanelView(store: store.scope( + state: \.toastPanel, + action: \.toastPanel + )) + ) + it.setIsVisible(true) + it.canBecomeKeyChecker = { false } + return it + }() + + init( + store: StoreOf, + chatTabPool: ChatTabPool + ) { + self.store = store + self.chatTabPool = chatTabPool + } + + @MainActor + func orderFront() { + widgetWindow.orderFrontRegardless() + toastWindow.orderFrontRegardless() + sharedPanelWindow.orderFrontRegardless() + suggestionPanelWindow.orderFrontRegardless() + if chatPanelWindow.level.rawValue > NSWindow.Level.normal.rawValue, + store.withState({ !$0.chatPanelState.isDetached }) + { + chatPanelWindow.orderFrontRegardless() + } + } +} + +// MARK: - Window Subclasses + +class CanBecomeKeyWindow: NSWindow { + var canBecomeKeyChecker: () -> Bool = { true } + override var canBecomeKey: Bool { canBecomeKeyChecker() } + override var canBecomeMain: Bool { canBecomeKeyChecker() } +} + +class WidgetWindow: CanBecomeKeyWindow { + enum State: Equatable { + case normal(fullscreen: Bool) + case switchingSpace + } + + var hoveringLevel: NSWindow.Level = widgetLevel(0) + + override var isFloatingPanel: Bool { true } + + var defaultCollectionBehavior: NSWindow.CollectionBehavior { + [.fullScreenAuxiliary, .transient] + } + + var isFullscreen: Bool { + styleMask.contains(.fullScreen) + } + + private var state: State? { + didSet { + guard state != oldValue else { return } + switch state { + case .none: + collectionBehavior = defaultCollectionBehavior + case .switchingSpace: + collectionBehavior = defaultCollectionBehavior.union(.moveToActiveSpace) + case .normal: + collectionBehavior = defaultCollectionBehavior + } + } + } + + func moveToActiveSpace() { + let previousState = state + state = .switchingSpace + Task { @MainActor in + try await Task.sleep(nanoseconds: 50_000_000) + self.state = previousState + } + } + + func setFloatOnTop(_ isFloatOnTop: Bool) { + let targetLevel: NSWindow.Level = isFloatOnTop + ? hoveringLevel + : .normal + + if targetLevel != level { + orderFrontRegardless() + level = targetLevel + } + } +} + +func widgetLevel(_ addition: Int) -> NSWindow.Level { + let minimumWidgetLevel: Int + #if DEBUG + minimumWidgetLevel = NSWindow.Level.floating.rawValue + 1 + #else + minimumWidgetLevel = NSWindow.Level.floating.rawValue + #endif + return .init(minimumWidgetLevel + addition) +} + +extension CGRect { + func flipped(relativeTo reference: CGRect) -> CGRect { + let flippedOrigin = CGPoint( + x: origin.x, + y: reference.height - origin.y - height + ) + return CGRect(origin: flippedOrigin, size: size) + } + + func relative(to reference: CGRect) -> CGRect { + let relativeOrigin = CGPoint( + x: origin.x - reference.origin.x, + y: origin.y - reference.origin.y + ) + return CGRect(origin: relativeOrigin, size: size) + } +} + diff --git a/Core/Sources/UpdateChecker/UpdateChecker.swift b/Core/Sources/UpdateChecker/UpdateChecker.swift index d1a261b4..7619d579 100644 --- a/Core/Sources/UpdateChecker/UpdateChecker.swift +++ b/Core/Sources/UpdateChecker/UpdateChecker.swift @@ -1,22 +1,34 @@ -import Sparkle import Logger +import Preferences +import Sparkle public final class UpdateChecker { let updater: SPUUpdater let hostBundleFound: Bool + let delegate: UpdaterDelegate + public weak var updateCheckerDelegate: UpdateCheckerDelegate? { + get { delegate.updateCheckerDelegate } + set { delegate.updateCheckerDelegate = newValue } + } - public init(hostBundle: Bundle?) { + public init( + hostBundle: Bundle?, + shouldAutomaticallyCheckForUpdate: Bool + ) { if hostBundle == nil { hostBundleFound = false Logger.updateChecker.error("Host bundle not found") } else { hostBundleFound = true } + delegate = .init( + shouldAutomaticallyCheckForUpdate: shouldAutomaticallyCheckForUpdate + ) updater = SPUUpdater( hostBundle: hostBundle ?? Bundle.main, applicationBundle: Bundle.main, userDriver: SPUStandardUserDriver(hostBundle: hostBundle ?? Bundle.main, delegate: nil), - delegate: nil + delegate: delegate ) do { try updater.start() @@ -28,6 +40,10 @@ public final class UpdateChecker { public func checkForUpdates() { updater.checkForUpdates() } + + public func resetUpdateCycle() { + updater.resetUpdateCycleAfterShortDelay() + } public var automaticallyChecksForUpdates: Bool { get { updater.automaticallyChecksForUpdates } @@ -35,3 +51,51 @@ public final class UpdateChecker { } } +public protocol UpdateCheckerDelegate: AnyObject { + func prepareForRelaunch(finish: @escaping () -> Void) +} + +class UpdaterDelegate: NSObject, SPUUpdaterDelegate { + let shouldAutomaticallyCheckForUpdate: Bool + weak var updateCheckerDelegate: UpdateCheckerDelegate? + + init(shouldAutomaticallyCheckForUpdate: Bool) { + self.shouldAutomaticallyCheckForUpdate = shouldAutomaticallyCheckForUpdate + } + + func updater(_ updater: SPUUpdater, mayPerform updateCheck: SPUUpdateCheck) throws { + // Not sure how it works +// if !shouldAutomaticallyCheckForUpdate, updateCheck == .updatesInBackground { +// throw CancellationError() +// } + } + + func updater( + _ updater: SPUUpdater, + shouldPostponeRelaunchForUpdate item: SUAppcastItem, + untilInvokingBlock installHandler: @escaping () -> Void + ) -> Bool { + if let updateCheckerDelegate { + updateCheckerDelegate.prepareForRelaunch(finish: installHandler) + return true + } + return false + } + + func updater(_ updater: SPUUpdater, willScheduleUpdateCheckAfterDelay delay: TimeInterval) { + Logger.updateChecker.info("Will schedule update check after delay: \(delay)") + } + + func updaterWillNotScheduleUpdateCheck(_ updater: SPUUpdater) { + Logger.updateChecker.info("Will not schedule update check") + } + + func allowedChannels(for updater: SPUUpdater) -> Set { + if UserDefaults.shared.value(for: \.installBetaBuilds) { + Set(["beta"]) + } else { + [] + } + } +} + diff --git a/Core/Sources/XPCShared/Models.swift b/Core/Sources/XPCShared/Models.swift deleted file mode 100644 index e5b77f0e..00000000 --- a/Core/Sources/XPCShared/Models.swift +++ /dev/null @@ -1,111 +0,0 @@ -import SuggestionModel -import Foundation - -public struct EditorContent: Codable { - public struct Selection: Codable { - public var start: CursorPosition - public var end: CursorPosition - - public init(start: CursorPosition, end: CursorPosition) { - self.start = start - self.end = end - } - } - - public init( - content: String, - lines: [String], - uti: String, - cursorPosition: CursorPosition, - selections: [Selection], - tabSize: Int, - indentSize: Int, - usesTabsForIndentation: Bool - ) { - self.content = content - self.lines = lines - self.uti = uti - self.cursorPosition = cursorPosition - self.selections = selections - self.tabSize = tabSize - self.indentSize = indentSize - self.usesTabsForIndentation = usesTabsForIndentation - } - - public var content: String - public var lines: [String] - public var uti: String - public var cursorPosition: CursorPosition - public var selections: [Selection] - public var tabSize: Int - public var indentSize: Int - public var usesTabsForIndentation: Bool - - public func selectedCode(in selection: Selection) -> String { - return XPCShared.selectedCode(in: selection, for: lines) - } -} - -public struct UpdatedContent: Codable { - public init(content: String, newSelection: CursorRange? = nil, modifications: [Modification]) { - self.content = content - self.newSelection = newSelection - self.modifications = modifications - } - - public var content: String - public var newSelection: CursorRange? - public var modifications: [Modification] -} - -func selectedCode(in selection: EditorContent.Selection, for lines: [String]) -> String { - let startPosition = selection.start - let endPosition = CursorPosition( - line: selection.end.line, - character: selection.end.character - 1 - ) - - guard startPosition.line >= 0, startPosition.line < lines.count else { return "" } - guard startPosition.character >= 0, - startPosition.character < lines[startPosition.line].count else { return "" } - guard endPosition.line >= 0, - endPosition.line < lines.count - || (endPosition.line == lines.count && endPosition.character == -1) - else { return "" } - guard endPosition.line >= startPosition.line else { return "" } - guard endPosition.character >= -1 else { return "" } - - if endPosition.line < lines.endIndex { - guard endPosition.character < lines[endPosition.line].count else { return "" } - } - - var code = "" - if startPosition.line == endPosition.line { - guard endPosition.character >= startPosition.character else { return "" } - let line = lines[startPosition.line] - let startIndex = line.index(line.startIndex, offsetBy: startPosition.character) - let endIndex = line.index(line.startIndex, offsetBy: endPosition.character) - code = String(line[startIndex...endIndex]) - } else { - let startLine = lines[startPosition.line] - let startIndex = startLine.index( - startLine.startIndex, - offsetBy: startPosition.character - ) - code += String(startLine[startIndex...]) - - if startPosition.line + 1 < endPosition.line { - for line in lines[startPosition.line + 1...endPosition.line - 1] { - code += line - } - } - - if endPosition.character >= 0, endPosition.line < lines.endIndex { - let endLine = lines[endPosition.line] - let endIndex = endLine.index(endLine.startIndex, offsetBy: endPosition.character) - code += String(endLine[...endIndex]) - } - } - - return code -} diff --git a/Core/Sources/XPCShared/XPCServiceProtocol.swift b/Core/Sources/XPCShared/XPCServiceProtocol.swift deleted file mode 100644 index cd63347e..00000000 --- a/Core/Sources/XPCShared/XPCServiceProtocol.swift +++ /dev/null @@ -1,53 +0,0 @@ -import SuggestionModel -import Foundation - -@objc(XPCServiceProtocol) -public protocol XPCServiceProtocol { - func getSuggestedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getNextSuggestedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getPreviousSuggestedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getSuggestionAcceptedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getSuggestionRejectedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getRealtimeSuggestedCode( - editorContent: Data, - withReply reply: @escaping (Data?, Error?) -> Void - ) - func chatWithSelection( - editorContent: Data, - withReply reply: @escaping (Data?, Error?) -> Void - ) - func promptToCode( - editorContent: Data, - withReply reply: @escaping (Data?, Error?) -> Void - ) - func customCommand( - id: String, - editorContent: Data, - withReply reply: @escaping (Data?, Error?) -> Void - ) - - func toggleRealtimeSuggestion(withReply reply: @escaping (Error?) -> Void) - - func prefetchRealtimeSuggestions( - editorContent: Data, - withReply reply: @escaping () -> Void - ) - - func getXPCServiceVersion(withReply reply: @escaping (String, String) -> Void) - func getXPCServiceAccessibilityPermission(withReply reply: @escaping (Bool) -> Void) -} diff --git a/Core/Sources/XcodeInspector/SourceEditor.swift b/Core/Sources/XcodeInspector/SourceEditor.swift deleted file mode 100644 index d5b3e772..00000000 --- a/Core/Sources/XcodeInspector/SourceEditor.swift +++ /dev/null @@ -1,155 +0,0 @@ -import AppKit -import AXNotificationStream -import Foundation -import SuggestionModel - -/// Representing a source editor inside Xcode. -public class SourceEditor { - public struct Content { - /// The content of the source editor. - public var content: String - /// The content of the source editor in lines. - public var lines: [String] - /// The selection ranges of the source editor. - public var selections: [CursorRange] - /// The cursor position of the source editor. - public var cursorPosition: CursorPosition - /// Line annotations of the source editor. - public var lineAnnotations: [String] - } - - let runningApplication: NSRunningApplication - let element: AXUIElement - - /// The content of the source editor. - public var content: Content { - let content = element.value - let split = Self.breakLines(content) - let lineAnnotationElements = element.children.filter { $0.identifier == "Line Annotation" } - let lineAnnotations = lineAnnotationElements.map(\.description) - - if let selectionRange = element.selectedTextRange { - let range = Self.convertRangeToCursorRange(selectionRange, in: content) - return .init( - content: content, - lines: split, - selections: [range], - cursorPosition: range.start, - lineAnnotations: lineAnnotations - ) - } - return .init( - content: content, - lines: split, - selections: [], - cursorPosition: .outOfScope, - lineAnnotations: lineAnnotations - ) - } - - public init(runningApplication: NSRunningApplication, element: AXUIElement) { - self.runningApplication = runningApplication - self.element = element - } - - /// Observe to changes in the source editor. - public func observe(notificationNames: String...) -> AXNotificationStream { - return AXNotificationStream( - app: runningApplication, - element: element, - notificationNames: notificationNames - ) - } - - /// Observe to changes in the source editor scroll view. - public func observeScrollView(notificationNames: String...) -> AXNotificationStream? { - guard let scrollView = element.parent else { return nil } - return AXNotificationStream( - app: runningApplication, - element: scrollView, - notificationNames: notificationNames - ) - } -} - -// MARK: - Helpers - -public extension SourceEditor { - static func convertCursorRangeToRange( - _ cursorRange: CursorRange, - in lines: [String] - ) -> CFRange { - var countS = 0 - var countE = 0 - var range = CFRange(location: 0, length: 0) - for (i, line) in lines.enumerated() { - if i == cursorRange.start.line { - countS = countS + cursorRange.start.character - range.location = countS - } - if i == cursorRange.end.line { - countE = countE + cursorRange.end.character - range.length = max(countE - range.location, 0) - break - } - countS += line.count - countE += line.count - } - return range - } - - static func convertCursorRangeToRange( - _ cursorRange: CursorRange, - in content: String - ) -> CFRange { - let lines = breakLines(content) - return convertCursorRangeToRange(cursorRange, in: lines) - } - - static func convertRangeToCursorRange( - _ range: ClosedRange, - in lines: [String] - ) -> CursorRange { - guard !lines.isEmpty else { return CursorRange(start: .zero, end: .zero) } - var countS = 0 - var countE = 0 - var cursorRange = CursorRange(start: .zero, end: .outOfScope) - for (i, line) in lines.enumerated() { - if countS <= range.lowerBound, range.lowerBound < countS + line.count { - cursorRange.start = .init(line: i, character: range.lowerBound - countS) - } - if countE <= range.upperBound, range.upperBound < countE + line.count { - cursorRange.end = .init(line: i, character: range.upperBound - countE) - break - } - countS += line.count - countE += line.count - } - if cursorRange.end == .outOfScope { - cursorRange.end = .init(line: lines.endIndex - 1, character: lines.last?.count ?? 0) - } - return cursorRange - } - - static func convertRangeToCursorRange( - _ range: ClosedRange, - in content: String - ) -> CursorRange { - let lines = breakLines(content) - return convertRangeToCursorRange(range, in: lines) - } - - static func breakLines(_ string: String) -> [String] { - let lines = string.split(separator: "\n", omittingEmptySubsequences: false) - var all = [String]() - for (index, line) in lines.enumerated() { - if index == lines.endIndex - 1 { - all.append(String(line)) - } else { - all.append(String(line) + "\n") - } - } - return all - } -} - diff --git a/Core/Sources/XcodeInspector/XcodeInspector.swift b/Core/Sources/XcodeInspector/XcodeInspector.swift deleted file mode 100644 index e8d74578..00000000 --- a/Core/Sources/XcodeInspector/XcodeInspector.swift +++ /dev/null @@ -1,296 +0,0 @@ -import AppKit -import AsyncAlgorithms -import AXExtension -import AXNotificationStream -import Combine -import Foundation - -public final class XcodeInspector: ObservableObject { - public static let shared = XcodeInspector() - - private var cancellable = Set() - private var activeXcodeObservations = Set>() - - @Published public internal(set) var activeApplication: AppInstanceInspector? - @Published public internal(set) var activeXcode: XcodeAppInstanceInspector? - @Published public internal(set) var latestActiveXcode: XcodeAppInstanceInspector? - @Published public internal(set) var xcodes: [XcodeAppInstanceInspector] = [] - @Published public internal(set) var activeProjectURL = URL(fileURLWithPath: "/") - @Published public internal(set) var activeDocumentURL = URL(fileURLWithPath: "/") - @Published public internal(set) var focusedWindow: XcodeWindowInspector? - @Published public internal(set) var focusedEditor: SourceEditor? - @Published public internal(set) var focusedElement: AXUIElement? - - init() { - let runningApplications = NSWorkspace.shared.runningApplications - xcodes = runningApplications - .filter { $0.isXcode } - .map(XcodeAppInstanceInspector.init(runningApplication:)) - let activeXcode = xcodes.first(where: \.isActive) - activeApplication = activeXcode ?? runningApplications - .first(where: \.isActive) - .map(AppInstanceInspector.init(runningApplication:)) - - for xcode in xcodes { - observeXcode(xcode) - } - - if let activeXcode { - setActiveXcode(activeXcode) - } - - Task { @MainActor in // Did activate app - let sequence = NSWorkspace.shared.notificationCenter - .notifications(named: NSWorkspace.didActivateApplicationNotification) - for await notification in sequence { - try Task.checkCancellation() - guard let app = notification - .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication - else { continue } - if app.isXcode { - if let existed = xcodes.first( - where: { $0.runningApplication.processIdentifier == app.processIdentifier } - ) { - setActiveXcode(existed) - } else { - let new = XcodeAppInstanceInspector(runningApplication: app) - xcodes.append(new) - setActiveXcode(new) - observeXcode(new) - } - } else { - activeApplication = AppInstanceInspector(runningApplication: app) - } - } - } - - Task { @MainActor in // Did terminate app - let sequence = NSWorkspace.shared.notificationCenter - .notifications(named: NSWorkspace.didTerminateApplicationNotification) - for await notification in sequence { - try Task.checkCancellation() - guard let app = notification - .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication - else { continue } - if app.isXcode { - xcodes.removeAll { - $0.runningApplication.processIdentifier == app.processIdentifier - } - if latestActiveXcode?.runningApplication.processIdentifier - == app.processIdentifier - { - latestActiveXcode = nil - } - - if let activeXcode = xcodes.first(where: \.isActive) { - setActiveXcode(activeXcode) - } - } - } - } - } - - func observeXcode(_ xcode: XcodeAppInstanceInspector) { - activeDocumentURL = xcode.documentURL - activeProjectURL = xcode.projectURL - focusedWindow = xcode.focusedWindow - - xcode.$documentURL.filter { _ in xcode.isActive }.assign(to: &$activeDocumentURL) - xcode.$projectURL.filter { _ in xcode.isActive }.assign(to: &$activeProjectURL) - xcode.$focusedWindow.filter { _ in xcode.isActive }.assign(to: &$focusedWindow) - } - - func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { - for task in activeXcodeObservations { task.cancel() } - activeXcodeObservations.removeAll() - - activeXcode = xcode - latestActiveXcode = xcode - activeDocumentURL = xcode.documentURL - focusedWindow = xcode.focusedWindow - - let setFocusedElement = { [weak self] in - guard let self else { return } - focusedElement = xcode.appElement.focusedElement - if let editorElement = focusedElement, editorElement.isSourceEditor { - focusedEditor = .init( - runningApplication: xcode.runningApplication, - element: editorElement - ) - } else { - focusedEditor = nil - } - } - - setFocusedElement() - let focusedElementChanged = Task { @MainActor in - let notification = AXNotificationStream( - app: xcode.runningApplication, - notificationNames: kAXFocusedUIElementChangedNotification - ) - for await _ in notification { - try Task.checkCancellation() - setFocusedElement() - } - } - - activeXcodeObservations.insert(focusedElementChanged) - } -} - -public class AppInstanceInspector: ObservableObject { - public let appElement: AXUIElement - public let runningApplication: NSRunningApplication - public var isActive: Bool { runningApplication.isActive } - - init(runningApplication: NSRunningApplication) { - self.runningApplication = runningApplication - appElement = AXUIElementCreateApplication(runningApplication.processIdentifier) - } -} - -public final class XcodeAppInstanceInspector: AppInstanceInspector { - public struct WorkspaceInfo { - public let tabs: Set - - public func combined(with info: WorkspaceInfo) -> WorkspaceInfo { - return .init(tabs: info.tabs.union(tabs)) - } - } - - public enum WorkspaceIdentifier: Hashable { - case url(URL) - case unknown - } - - @Published public var focusedWindow: XcodeWindowInspector? - @Published public var documentURL: URL = .init(fileURLWithPath: "/") - @Published public var projectURL: URL = .init(fileURLWithPath: "/") - @Published public var workspaces = [WorkspaceIdentifier: WorkspaceInfo]() - private var longRunningTasks = Set>() - private var focusedWindowObservations = Set() - - deinit { - for task in longRunningTasks { task.cancel() } - } - - override init(runningApplication: NSRunningApplication) { - super.init(runningApplication: runningApplication) - - observeFocusedWindow() - let focusedWindowChanged = Task { - let notification = AXNotificationStream( - app: runningApplication, - notificationNames: kAXFocusedWindowChangedNotification - ) - for await _ in notification { - try Task.checkCancellation() - observeFocusedWindow() - } - } - - longRunningTasks.insert(focusedWindowChanged) - - workspaces = Self.fetchWorkspaceInfo(runningApplication) - let updateTabsTask = Task { @MainActor in - let notification = AXNotificationStream( - app: runningApplication, - notificationNames: kAXFocusedUIElementChangedNotification, - kAXApplicationDeactivatedNotification - ) - if #available(macOS 13.0, *) { - for await _ in notification.debounce(for: .seconds(5)) { - try Task.checkCancellation() - workspaces = Self.fetchWorkspaceInfo(runningApplication) - } - } else { - for await _ in notification { - try Task.checkCancellation() - workspaces = Self.fetchWorkspaceInfo(runningApplication) - } - } - } - - 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() - - documentURL = window.documentURL - projectURL = window.projectURL - - window.$documentURL - .filter { $0 != .init(fileURLWithPath: "/") } - .sink { [weak self] url in - self?.documentURL = url - }.store(in: &focusedWindowObservations) - window.$projectURL - .filter { $0 != .init(fileURLWithPath: "/") } - .sink { [weak self] url in - self?.projectURL = url - }.store(in: &focusedWindowObservations) - } else { - let window = XcodeWindowInspector(uiElement: window) - focusedWindow = window - } - } else { - focusedWindow = nil - } - } - - static func fetchWorkspaceInfo( - _ app: NSRunningApplication - ) -> [WorkspaceIdentifier: WorkspaceInfo] { - let app = AXUIElementCreateApplication(app.processIdentifier) - let windows = app.windows.filter { $0.identifier == "Xcode.WorkspaceWindow" } - - var dict = [WorkspaceIdentifier: WorkspaceInfo]() - - for window in windows { - let workspaceIdentifier = { - for child in window.children { - if child.description.starts(with: "/"), child.description.count > 1 { - let path = child.description - let trimmedNewLine = path.trimmingCharacters(in: .newlines) - var url = URL(fileURLWithPath: trimmedNewLine) - while !FileManager.default.fileIsDirectory(atPath: url.path) || - !url.pathExtension.isEmpty - { - url = url.deletingLastPathComponent() - } - return WorkspaceIdentifier.url(url) - } - } - return WorkspaceIdentifier.unknown - }() - - let tabs = { - guard let editArea = window.firstChild(where: { $0.description == "editor area" }) - else { return Set() } - var allTabs = Set() - let tabBars = editArea.children { $0.description == "tab bar" } - for tabBar in tabBars { - let tabs = tabBar.children { $0.roleDescription == "tab" } - for tab in tabs { - allTabs.insert(tab.title) - } - } - return allTabs - }() - - dict[workspaceIdentifier] = .init(tabs: tabs) - } - - return dict - } -} - diff --git a/Core/Sources/XcodeInspector/XcodeWindowInspector.swift b/Core/Sources/XcodeInspector/XcodeWindowInspector.swift deleted file mode 100644 index b4f34c81..00000000 --- a/Core/Sources/XcodeInspector/XcodeWindowInspector.swift +++ /dev/null @@ -1,107 +0,0 @@ -import AppKit -import AXExtension -import AXNotificationStream -import Combine -import Foundation - -public class XcodeWindowInspector: ObservableObject { - let uiElement: AXUIElement - - init(uiElement: AXUIElement) { - self.uiElement = uiElement - } -} - -public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { - let app: NSRunningApplication - @Published var documentURL: URL = .init(fileURLWithPath: "/") - @Published var projectURL: URL = .init(fileURLWithPath: "/") - private var updateTabsTask: Task? - private var focusedElementChangedTask: Task? - - deinit { - updateTabsTask?.cancel() - focusedElementChangedTask?.cancel() - } - - 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 - } - } - - update() - let notifications = AXNotificationStream( - app: app, - notificationNames: kAXFocusedUIElementChangedNotification - ) - - for await _ in notifications { - try Task.checkCancellation() - update() - } - } - } - - static func extractDocumentURL( - windowElement: AXUIElement - ) -> URL? { - // fetch file path of the frontmost window of Xcode through Accessibility API. - let path = windowElement.document - if let path = path?.removingPercentEncoding { - let url = URL( - fileURLWithPath: path - .replacingOccurrences(of: "file://", with: "") - ) - return url - } - return nil - } - - static func extractProjectURL( - windowElement: AXUIElement, - fileURL: URL? - ) -> URL? { - for child in windowElement.children { - if child.description.starts(with: "/"), child.description.count > 1 { - let path = child.description - let trimmedNewLine = path.trimmingCharacters(in: .newlines) - var url = URL(fileURLWithPath: trimmedNewLine) - while !FileManager.default.fileIsDirectory(atPath: url.path) || - !url.pathExtension.isEmpty - { - url = url.deletingLastPathComponent() - } - return url - } - } - - guard var currentURL = fileURL else { return nil } - var firstDirectoryURL: URL? - while currentURL.pathComponents.count > 1 { - defer { currentURL.deleteLastPathComponent() } - guard FileManager.default.fileIsDirectory(atPath: currentURL.path) else { continue } - if firstDirectoryURL == nil { firstDirectoryURL = currentURL } - let gitURL = currentURL.appendingPathComponent(".git") - if FileManager.default.fileIsDirectory(atPath: gitURL.path) { - return currentURL - } - } - - return firstDirectoryURL ?? fileURL - } -} - diff --git a/Core/Sources/XcodeThemeController/HighlightJSThemeTemplate.swift b/Core/Sources/XcodeThemeController/HighlightJSThemeTemplate.swift new file mode 100644 index 00000000..40e14b66 --- /dev/null +++ b/Core/Sources/XcodeThemeController/HighlightJSThemeTemplate.swift @@ -0,0 +1,107 @@ +import Foundation + +func buildHighlightJSTheme(_ theme: XcodeTheme) -> String { + /// The source value is an `r g b a` string, for example: `0.5 0.5 0.2 1` + + return """ + .hljs { + display: block; + overflow-x: auto; + padding: 0.5em; + background: \(theme.backgroundColor.hexString); + color: \(theme.plainTextColor.hexString); + } + .xml .hljs-meta { + color: \(theme.marksColor.hexString); + } + .hljs-comment, + .hljs-quote { + color: \(theme.commentColor.hexString); + } + .hljs-tag, + .hljs-keyword, + .hljs-selector-tag, + .hljs-literal, + .hljs-name { + color: \(theme.keywordsColor.hexString); + } + .hljs-attribute { + color: \(theme.attributesColor.hexString); + } + .hljs-variable, + .hljs-template-variable { + color: \(theme.otherPropertiesAndGlobalsColor.hexString); + } + .hljs-code, + .hljs-string, + .hljs-meta-string { + color: \(theme.stringsColor.hexString); + } + .hljs-regexp { + color: \(theme.regexLiteralsColor.hexString); + } + .hljs-link { + color: \(theme.urlsColor.hexString); + } + .hljs-title { + color: \(theme.headingColor.hexString); + } + .hljs-symbol, + .hljs-bullet { + color: \(theme.attributesColor.hexString); + } + .hljs-number { + color: \(theme.numbersColor.hexString); + } + .hljs-section { + color: \(theme.marksColor.hexString); + } + .hljs-meta { + color: \(theme.keywordsColor.hexString); + } + .hljs-type, + .hljs-built_in, + .hljs-builtin-name { + color: \(theme.otherTypeNamesColor.hexString); + } + .hljs-class .hljs-title, + .hljs-title .class_ { + color: \(theme.typeDeclarationsColor.hexString); + } + .hljs-function .hljs-title, + .hljs-title .function_ { + color: \(theme.otherDeclarationsColor.hexString); + } + .hljs-params { + color: \(theme.otherDeclarationsColor.hexString); + } + .hljs-attr { + color: \(theme.attributesColor.hexString); + } + .hljs-subst { + color: \(theme.plainTextColor.hexString); + } + .hljs-formula { + background-color: \(theme.selectionColor.hexString); + font-style: italic; + } + .hljs-addition { + background-color: #baeeba; + } + .hljs-deletion { + background-color: #ffc8bd; + } + .hljs-selector-id, + .hljs-selector-class { + color: \(theme.plainTextColor.hexString); + } + .hljs-doctag, + .hljs-strong { + font-weight: bold; + } + .hljs-emphasis { + font-style: italic; + } + """ +} + diff --git a/Core/Sources/XcodeThemeController/HighlightrThemeManager.swift b/Core/Sources/XcodeThemeController/HighlightrThemeManager.swift new file mode 100644 index 00000000..f5536e3c --- /dev/null +++ b/Core/Sources/XcodeThemeController/HighlightrThemeManager.swift @@ -0,0 +1,89 @@ +import Foundation +import Highlightr +import Preferences + +public class HighlightrThemeManager: ThemeManager { + let defaultManager: ThemeManager + + weak var controller: XcodeThemeController? + + public init(defaultManager: ThemeManager, controller: XcodeThemeController) { + self.defaultManager = defaultManager + self.controller = controller + } + + public func theme(for name: String) -> Theme? { + let syncSuggestionTheme = UserDefaults.shared.value(for: \.syncSuggestionHighlightTheme) + let syncPromptToCodeTheme = UserDefaults.shared.value(for: \.syncPromptToCodeHighlightTheme) + let syncChatTheme = UserDefaults.shared.value(for: \.syncChatCodeHighlightTheme) + + lazy var defaultLight = Theme(themeString: defaultLightTheme) + lazy var defaultDark = Theme(themeString: defaultDarkTheme) + + switch name { + case "suggestion-light": + guard syncSuggestionTheme, let theme = theme(lightMode: true) else { + return defaultLight + } + return theme + case "suggestion-dark": + guard syncSuggestionTheme, let theme = theme(lightMode: false) else { + return defaultDark + } + return theme + case "promptToCode-light": + guard syncPromptToCodeTheme, let theme = theme(lightMode: true) else { + return defaultLight + } + return theme + case "promptToCode-dark": + guard syncPromptToCodeTheme, let theme = theme(lightMode: false) else { + return defaultDark + } + return theme + case "chat-light": + guard syncChatTheme, let theme = theme(lightMode: true) else { + return defaultLight + } + return theme + case "chat-dark": + guard syncChatTheme, let theme = theme(lightMode: false) else { + return defaultDark + } + return theme + case "light": + return defaultLight + case "dark": + return defaultDark + default: + return defaultLight + } + } + + func theme(lightMode: Bool) -> Theme? { + guard let controller else { return nil } + guard let directories = controller.createSupportDirectoriesIfNeeded() else { return nil } + + let themeURL: URL = if lightMode { + directories.themeDirectory.appendingPathComponent("highlightjs-light") + } else { + directories.themeDirectory.appendingPathComponent("highlightjs-dark") + } + + if let themeString = try? String(contentsOf: themeURL) { + return Theme(themeString: themeString) + } + + controller.syncXcodeThemeIfNeeded() + + if let themeString = try? String(contentsOf: themeURL) { + return Theme(themeString: themeString) + } + + return nil + } +} + +let defaultLightTheme = ".hljs{display:block;overflow-x:auto;padding:0.5em;background:#FFFFFFFF;color:#000000D8}.xml .hljs-meta{color:#495460FF}.hljs-comment,.hljs-quote{color:#5D6B79FF}.hljs-tag,.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-name{color:#9A2393FF}.hljs-attribute{color:#805E03FF}.hljs-variable,.hljs-template-variable{color:#6B36A9FF}.hljs-code,.hljs-string,.hljs-meta-string{color:#C31A15FF}.hljs-regexp{color:#000000D8}.hljs-link{color:#0E0EFFFF}.hljs-title{color:#000000FF}.hljs-symbol,.hljs-bullet{color:#805E03FF}.hljs-number{color:#1C00CFFF}.hljs-section{color:#495460FF}.hljs-meta{color:#9A2393FF}.hljs-type,.hljs-built_in,.hljs-builtin-name{color:#3900A0FF}.hljs-class .hljs-title,.hljs-title .class_{color:#0B4F79FF}.hljs-function .hljs-title,.hljs-title .function_{color:#0E67A0FF}.hljs-params{color:#0E67A0FF}.hljs-attr{color:#805E03FF}.hljs-subst{color:#000000D8}.hljs-formula{background-color:#A3CCFEFF;font-style:italic}.hljs-addition{background-color:#baeeba}.hljs-deletion{background-color:#ffc8bd}.hljs-selector-id,.hljs-selector-class{color:#000000D8}.hljs-doctag,.hljs-strong{font-weight:bold}.hljs-emphasis{font-style:italic}" + +let defaultDarkTheme = ".hljs{display:block;overflow-x:auto;padding:0.5em;background:#1F1F23FF;color:#FFFFFFD8}.xml .hljs-meta{color:#91A1B1FF}.hljs-comment,.hljs-quote{color:#6B7985FF}.hljs-tag,.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-name{color:#FC5FA2FF}.hljs-attribute{color:#BF8554FF}.hljs-variable,.hljs-template-variable{color:#A166E5FF}.hljs-code,.hljs-string,.hljs-meta-string{color:#FC695DFF}.hljs-regexp{color:#FFFFFFD8}.hljs-link{color:#5482FEFF}.hljs-title{color:#FFFFFFFF}.hljs-symbol,.hljs-bullet{color:#BF8554FF}.hljs-number{color:#CFBF69FF}.hljs-section{color:#91A1B1FF}.hljs-meta{color:#FC5FA2FF}.hljs-type,.hljs-built_in,.hljs-builtin-name{color:#D0A7FEFF}.hljs-class .hljs-title,.hljs-title .class_{color:#5CD7FEFF}.hljs-function .hljs-title,.hljs-title .function_{color:#41A1BFFF}.hljs-params{color:#41A1BFFF}.hljs-attr{color:#BF8554FF}.hljs-subst{color:#FFFFFFD8}.hljs-formula{background-color:#505A6FFF;font-style:italic}.hljs-addition{background-color:#baeeba}.hljs-deletion{background-color:#ffc8bd}.hljs-selector-id,.hljs-selector-class{color:#FFFFFFD8}.hljs-doctag,.hljs-strong{font-weight:bold}.hljs-emphasis{font-style:italic}" diff --git a/Core/Sources/XcodeThemeController/PreferenceKey+Theme.swift b/Core/Sources/XcodeThemeController/PreferenceKey+Theme.swift new file mode 100644 index 00000000..0d46af1f --- /dev/null +++ b/Core/Sources/XcodeThemeController/PreferenceKey+Theme.swift @@ -0,0 +1,27 @@ +import Foundation +import Preferences + +// MARK: - Theming + +public extension UserDefaultPreferenceKeys { + var lightXcodeThemeName: PreferenceKey { + .init(defaultValue: "", key: "LightXcodeThemeName") + } + + var lightXcodeTheme: PreferenceKey> { + .init(defaultValue: .init(nil), key: "LightXcodeTheme") + } + + var darkXcodeThemeName: PreferenceKey { + .init(defaultValue: "", key: "DarkXcodeThemeName") + } + + var darkXcodeTheme: PreferenceKey> { + .init(defaultValue: .init(nil), key: "LightXcodeTheme") + } + + var lastSyncedHighlightJSThemeCreatedAt: PreferenceKey { + .init(defaultValue: 0, key: "LastSyncedHighlightJSThemeCreatedAt") + } +} + diff --git a/Core/Sources/XcodeThemeController/XcodeThemeController.swift b/Core/Sources/XcodeThemeController/XcodeThemeController.swift new file mode 100644 index 00000000..01118547 --- /dev/null +++ b/Core/Sources/XcodeThemeController/XcodeThemeController.swift @@ -0,0 +1,249 @@ +import AppKit +import Foundation +import Highlightr +import XcodeInspector + +public class XcodeThemeController { + var syncTriggerTask: Task? + + public init(syncTriggerTask: Task? = nil) { + self.syncTriggerTask = syncTriggerTask + } + + public func start() { + let defaultHighlightrThemeManager = Highlightr.themeManager + Highlightr.themeManager = HighlightrThemeManager( + defaultManager: defaultHighlightrThemeManager, + controller: self + ) + + syncXcodeThemeIfNeeded() + + syncTriggerTask?.cancel() + syncTriggerTask = Task { [weak self] in + let notifications = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.didActivateApplicationNotification) + for await notification in notifications { + try Task.checkCancellation() + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + else { continue } + guard app.isCopilotForXcodeExtensionService else { continue } + guard let self else { return } + self.syncXcodeThemeIfNeeded() + } + } + } +} + +extension XcodeThemeController { + func syncXcodeThemeIfNeeded() { + guard UserDefaults.shared.value(for: \.syncSuggestionHighlightTheme) + || UserDefaults.shared.value(for: \.syncPromptToCodeHighlightTheme) + || UserDefaults.shared.value(for: \.syncChatCodeHighlightTheme) + else { return } + guard let directories = createSupportDirectoriesIfNeeded() else { return } + + defer { + UserDefaults.shared.set( + Date().timeIntervalSince1970, + for: \.lastSyncedHighlightJSThemeCreatedAt + ) + } + + let xcodeUserDefaults = UserDefaults(suiteName: "com.apple.dt.Xcode")! + + if let darkThemeName = xcodeUserDefaults + .value(forKey: "XCFontAndColorCurrentDarkTheme") as? String + { + syncXcodeThemeIfNeeded( + xcodeThemeName: darkThemeName, + light: false, + in: directories.themeDirectory + ) + } + + if let lightThemeName = xcodeUserDefaults + .value(forKey: "XCFontAndColorCurrentTheme") as? String + { + syncXcodeThemeIfNeeded( + xcodeThemeName: lightThemeName, + light: true, + in: directories.themeDirectory + ) + } + } + + func syncXcodeThemeIfNeeded( + xcodeThemeName: String, + light: Bool, + in directoryURL: URL + ) { + let targetName = light ? "highlightjs-light" : "highlightjs-dark" + guard let xcodeThemeURL = locateXcodeTheme(named: xcodeThemeName) else { return } + let targetThemeURL = directoryURL.appendingPathComponent(targetName) + let lastSyncTimestamp = UserDefaults.shared + .value(for: \.lastSyncedHighlightJSThemeCreatedAt) + + let shouldSync = { + if light, UserDefaults.shared.value(for: \.lightXcodeTheme) == nil { return true } + if !light, UserDefaults.shared.value(for: \.darkXcodeTheme) == nil { return true } + if light, xcodeThemeName != UserDefaults.shared.value(for: \.lightXcodeThemeName) { + return true + } + if !light, xcodeThemeName != UserDefaults.shared.value(for: \.darkXcodeThemeName) { + return true + } + if !FileManager.default.fileExists(atPath: targetThemeURL.path) { return true } + + let xcodeThemeFileUpdated = { + guard let xcodeThemeModifiedDate = try? xcodeThemeURL + .resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + else { return true } + return xcodeThemeModifiedDate.timeIntervalSince1970 > lastSyncTimestamp + }() + + if xcodeThemeFileUpdated { return true } + + return false + }() + + if shouldSync { + do { + let theme = try XcodeTheme(fileURL: xcodeThemeURL) + let highlightrTheme = theme.asHighlightJSTheme() + try highlightrTheme.write(to: targetThemeURL, atomically: true, encoding: .utf8) + + Task { @MainActor in + if light { + UserDefaults.shared.set(xcodeThemeName, for: \.lightXcodeThemeName) + UserDefaults.shared.set(.init(theme), for: \.lightXcodeTheme) + UserDefaults.shared.set( + .init(theme.plainTextColor.storable), + for: \.codeForegroundColorLight + ) + UserDefaults.shared.set( + .init(theme.backgroundColor.storable), + for: \.codeBackgroundColorLight + ) + } else { + UserDefaults.shared.set(xcodeThemeName, for: \.darkXcodeThemeName) + UserDefaults.shared.set(.init(theme), for: \.darkXcodeTheme) + UserDefaults.shared.set( + .init(theme.plainTextColor.storable), + for: \.codeForegroundColorDark + ) + UserDefaults.shared.set( + .init(theme.backgroundColor.storable), + for: \.codeBackgroundColorDark + ) + } + } + } catch { + print(error.localizedDescription) + } + } + } + + func locateXcodeTheme(named name: String) -> URL? { + if let customThemeURL = FileManager.default.urls( + for: .libraryDirectory, + in: .userDomainMask + ).first?.appendingPathComponent("Developer/Xcode/UserData/FontAndColorThemes") + .appendingPathComponent(name), + FileManager.default.fileExists(atPath: customThemeURL.path) + { + return customThemeURL + } + + let xcodeURL: URL? = { + if let running = NSWorkspace.shared + .urlForApplication(withBundleIdentifier: "com.apple.dt.Xcode") + { + return running + } + // Use the main Xcode.app + let proposedXcodeURL = URL(fileURLWithPath: "/Applications/Xcode.app") + if FileManager.default.fileExists(atPath: proposedXcodeURL.path) { + return proposedXcodeURL + } + // Look for an Xcode.app + if let applicationsURL = FileManager.default.urls( + for: .applicationDirectory, + in: .localDomainMask + ).first { + struct InfoPlist: Codable { + var CFBundleIdentifier: String + } + + let appBundleIdentifier = "com.apple.dt.Xcode" + let appDirectories = try? FileManager.default.contentsOfDirectory( + at: applicationsURL, + includingPropertiesForKeys: [], + options: .skipsHiddenFiles + ) + for appDirectoryURL in appDirectories ?? [] { + let infoPlistURL = appDirectoryURL.appendingPathComponent("Contents/Info.plist") + if let data = try? Data(contentsOf: infoPlistURL), + let infoPlist = try? PropertyListDecoder().decode( + InfoPlist.self, + from: data + ), + infoPlist.CFBundleIdentifier == appBundleIdentifier + { + return appDirectoryURL + } + } + } + return nil + }() + + if let url = xcodeURL? + .appendingPathComponent("Contents/SharedFrameworks/DVTUserInterfaceKit.framework") + .appendingPathComponent("Versions/A/Resources/FontAndColorThemes") + .appendingPathComponent(name), + FileManager.default.fileExists(atPath: url.path) + { + return url + } + + return nil + } + + func createSupportDirectoriesIfNeeded() -> (supportDirectory: URL, themeDirectory: URL)? { + guard let supportURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first?.appendingPathComponent( + Bundle.main + .object(forInfoDictionaryKey: "APPLICATION_SUPPORT_FOLDER") as! String + ) else { + return nil + } + + let themeURL = supportURL.appendingPathComponent("Themes") + + do { + if !FileManager.default.fileExists(atPath: supportURL.path) { + try FileManager.default.createDirectory( + at: supportURL, + withIntermediateDirectories: true, + attributes: nil + ) + } + + if !FileManager.default.fileExists(atPath: themeURL.path) { + try FileManager.default.createDirectory( + at: themeURL, + withIntermediateDirectories: true, + attributes: nil + ) + } + } catch { + return nil + } + + return (supportURL, themeURL) + } +} + diff --git a/Core/Sources/XcodeThemeController/XcodeThemeParser.swift b/Core/Sources/XcodeThemeController/XcodeThemeParser.swift new file mode 100644 index 00000000..b2a3cd53 --- /dev/null +++ b/Core/Sources/XcodeThemeController/XcodeThemeParser.swift @@ -0,0 +1,321 @@ +import Foundation +import Preferences + +public struct XcodeTheme: Codable { + public struct ThemeColor: Codable { + public var red: Double + public var green: Double + public var blue: Double + public var alpha: Double + + public var hexString: String { + let red = Int(self.red * 255) + let green = Int(self.green * 255) + let blue = Int(self.blue * 255) + let alpha = Int(self.alpha * 255) + return String(format: "#%02X%02X%02X%02X", red, green, blue, alpha) + } + + var storable: StorableColor { + .init(red: red, green: green, blue: blue, alpha: alpha) + } + } + + public var plainTextColor: ThemeColor + public var commentColor: ThemeColor + public var documentationMarkupColor: ThemeColor + public var documentationMarkupKeywordColor: ThemeColor + public var marksColor: ThemeColor + public var stringsColor: ThemeColor + public var charactersColor: ThemeColor + public var numbersColor: ThemeColor + public var regexLiteralsColor: ThemeColor + public var regexLiteralNumbersColor: ThemeColor + public var regexLiteralCaptureNamesColor: ThemeColor + public var regexLiteralCharacterClassNamesColor: ThemeColor + public var regexLiteralOperatorsColor: ThemeColor + public var keywordsColor: ThemeColor + public var preprocessorStatementsColor: ThemeColor + public var urlsColor: ThemeColor + public var attributesColor: ThemeColor + public var typeDeclarationsColor: ThemeColor + public var otherDeclarationsColor: ThemeColor + public var projectClassNamesColor: ThemeColor + public var projectFunctionAndMethodNamesColor: ThemeColor + public var projectConstantsColor: ThemeColor + public var projectTypeNamesColor: ThemeColor + public var projectPropertiesAndGlobalsColor: ThemeColor + public var projectPreprocessorMacrosColor: ThemeColor + public var otherClassNamesColor: ThemeColor + public var otherFunctionAndMethodNamesColor: ThemeColor + public var otherConstantsColor: ThemeColor + public var otherTypeNamesColor: ThemeColor + public var otherPropertiesAndGlobalsColor: ThemeColor + public var otherPreprocessorMacrosColor: ThemeColor + public var headingColor: ThemeColor + public var backgroundColor: ThemeColor + public var selectionColor: ThemeColor + public var cursorColor: ThemeColor + public var currentLineColor: ThemeColor + public var invisibleCharactersColor: ThemeColor + public var debuggerConsolePromptColor: ThemeColor + public var debuggerConsoleOutputColor: ThemeColor + public var debuggerConsoleInputColor: ThemeColor + public var executableConsoleOutputColor: ThemeColor + public var executableConsoleInputColor: ThemeColor + + public func asHighlightJSTheme() -> String { + buildHighlightJSTheme(self) + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: ": ", with: ":") + .replacingOccurrences(of: "} ", with: "}") + .replacingOccurrences(of: " {", with: "{") + .replacingOccurrences(of: ";}", with: "}") + .replacingOccurrences(of: " ", with: "") + } +} + +public extension XcodeTheme { + /// Color scheme locations: + /// ~/Library/Developer/Xcode/UserData/FontAndColorThemes/ + /// Xcode.app/Contents/SharedFrameworks/DVTUserInterfaceKit.framework/Versions/A/Resources/FontAndColorThemes + init(fileURL: URL) throws { + let parser = XcodeThemeParser() + self = try parser.parse(fileURL: fileURL) + } +} + +struct XcodeThemeParser { + enum Error: Swift.Error { + case fileNotFound + case invalidData + } + + func parse(fileURL: URL) throws -> XcodeTheme { + guard let data = try? Data(contentsOf: fileURL) else { + throw Error.fileNotFound + } + + if fileURL.pathExtension == "xccolortheme" { + return try parseXCColorTheme(data) + } else { + throw Error.invalidData + } + } + + func parseXCColorTheme(_ data: Data) throws -> XcodeTheme { + let plist = try? PropertyListSerialization.propertyList( + from: data, + options: .mutableContainers, + format: nil + ) as? [String: Any] + + guard let theme = plist else { throw Error.invalidData } + + /// The source value is an `r g b a` string, for example: `0.5 0.5 0.2 1` + func convertColor(source: String) -> XcodeTheme.ThemeColor { + let components = source.split(separator: " ") + let red = (components[0] as NSString).doubleValue + let green = (components[1] as NSString).doubleValue + let blue = (components[2] as NSString).doubleValue + let alpha = (components[3] as NSString).doubleValue + return .init(red: red, green: green, blue: blue, alpha: alpha) + } + + func getThemeValue( + at path: [String], + defaultValue: XcodeTheme.ThemeColor = .init(red: 0, green: 0, blue: 0, alpha: 1) + ) -> XcodeTheme.ThemeColor { + guard !path.isEmpty else { return defaultValue } + let keys = path.dropLast(1) + var currentDict = theme + for key in keys { + guard let value = currentDict[key] as? [String: Any] else { + return defaultValue + } + currentDict = value + } + if let value = currentDict[path.last!] as? String { + return convertColor(source: value) + } + return defaultValue + } + + let black = XcodeTheme.ThemeColor(red: 0, green: 0, blue: 0, alpha: 1) + let white = XcodeTheme.ThemeColor(red: 1, green: 1, blue: 1, alpha: 1) + + let xcodeTheme = XcodeTheme( + plainTextColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"], + defaultValue: black + ), + commentColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.comment"], + defaultValue: black + ), + documentationMarkupColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.comment.doc"], + defaultValue: black + ), + documentationMarkupKeywordColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.comment.doc.keyword"], + defaultValue: black + ), + marksColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.mark"], + defaultValue: black + ), + stringsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.string"], + defaultValue: black + ), + charactersColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.character"], + defaultValue: black + ), + numbersColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.number"], + defaultValue: black + ), + regexLiteralsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"], + defaultValue: black + ), + regexLiteralNumbersColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.number"], + defaultValue: black + ), + regexLiteralCaptureNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"], + defaultValue: black + ), + regexLiteralCharacterClassNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"], + defaultValue: black + ), + regexLiteralOperatorsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"], + defaultValue: black + ), + keywordsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.keyword"], + defaultValue: black + ), + preprocessorStatementsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.preprocessor"], + defaultValue: black + ), + urlsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.url"], + defaultValue: black + ), + attributesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.attribute"], + defaultValue: black + ), + typeDeclarationsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.declaration.type"], + defaultValue: black + ), + otherDeclarationsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.declaration.other"], + defaultValue: black + ), + projectClassNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.class"], + defaultValue: black + ), + projectFunctionAndMethodNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.function"], + defaultValue: black + ), + projectConstantsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.constant"], + defaultValue: black + ), + projectTypeNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.type"], + defaultValue: black + ), + projectPropertiesAndGlobalsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.variable"], + defaultValue: black + ), + projectPreprocessorMacrosColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.macro"], + defaultValue: black + ), + otherClassNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.class.system"], + defaultValue: black + ), + otherFunctionAndMethodNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.function.system"], + defaultValue: black + ), + otherConstantsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.constant.system"], + defaultValue: black + ), + otherTypeNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.type.system"], + defaultValue: black + ), + otherPropertiesAndGlobalsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.variable.system"], + defaultValue: black + ), + otherPreprocessorMacrosColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.macro.system"], + defaultValue: black + ), + headingColor: getThemeValue( + at: ["DVTMarkupTextPrimaryHeadingColor"], + defaultValue: black + ), + backgroundColor: getThemeValue( + at: ["DVTSourceTextBackground"], + defaultValue: white + ), + selectionColor: getThemeValue( + at: ["DVTSourceTextSelectionColor"], + defaultValue: black + ), + cursorColor: getThemeValue( + at: ["DVTSourceTextInsertionPointColor"], + defaultValue: black + ), + currentLineColor: getThemeValue( + at: ["DVTSourceTextCurrentLineHighlightColor"], + defaultValue: black + ), + invisibleCharactersColor: getThemeValue( + at: ["DVTSourceTextInvisiblesColor"], + defaultValue: black + ), + debuggerConsolePromptColor: getThemeValue( + at: ["DVTConsoleDebuggerPromptTextColor"], + defaultValue: black + ), + debuggerConsoleOutputColor: getThemeValue( + at: ["DVTConsoleDebuggerOutputTextColor"], + defaultValue: black + ), + debuggerConsoleInputColor: getThemeValue( + at: ["DVTConsoleDebuggerInputTextColor"], + defaultValue: black + ), + executableConsoleOutputColor: getThemeValue( + at: ["DVTConsoleExectuableOutputTextColor"], + defaultValue: black + ), + executableConsoleInputColor: getThemeValue( + at: ["DVTConsoleExectuableInputTextColor"], + defaultValue: black + ) + ) + + return xcodeTheme + } +} + diff --git a/Core/Tests/ChatServiceTests/ParseScopesTests.swift b/Core/Tests/ChatServiceTests/ParseScopesTests.swift new file mode 100644 index 00000000..ebfeb83c --- /dev/null +++ b/Core/Tests/ChatServiceTests/ParseScopesTests.swift @@ -0,0 +1,46 @@ +import XCTest + +@testable import ChatService + +final class ParseScopesTests: XCTestCase { + let parse = DynamicContextController.parseScopes + + func test_parse_single_scope() async throws { + var prompt = "@web hello" + let scopes = parse(&prompt) + XCTAssertEqual(scopes, [.web]) + XCTAssertEqual(prompt, "hello") + } + + func test_parse_single_scope_with_prefix() async throws { + var prompt = "@w hello" + let scopes = parse(&prompt) + XCTAssertEqual(scopes, [.web]) + XCTAssertEqual(prompt, "hello") + } + + func test_parse_multiple_spaces() async throws { + var prompt = "@web hello" + let scopes = parse(&prompt) + XCTAssertEqual(scopes, [.web]) + XCTAssertEqual(prompt, "hello") + } + + func test_parse_no_prefix_at_mark() async throws { + var prompt = " @web hello" + let scopes = parse(&prompt) + XCTAssertEqual(scopes, []) + XCTAssertEqual(prompt, prompt) + } + + func test_parse_multiple_scopes() async throws { + var prompt = "@web+file+c+s+project hello" + let scopes = parse(&prompt) + XCTAssertEqual(scopes, [.web, .code, .sense, .project, .file]) + XCTAssertEqual(prompt, "hello") + } +} + + + + diff --git a/Core/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift b/Core/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift deleted file mode 100644 index cd61da45..00000000 --- a/Core/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift +++ /dev/null @@ -1,132 +0,0 @@ -import LanguageServerProtocol -import XCTest - -@testable import GitHubCopilotService - -final class FetchSuggestionTests: XCTestCase { - func test_process_sugestions_from_server() async throws { - struct TestServer: GitHubCopilotLSP { - func sendNotification(_ notif: LanguageServerProtocol.ClientNotification) async throws { - fatalError() - } - - func sendRequest(_: E) async throws -> E.Response where E: GitHubCopilotRequestType { - return GitHubCopilotRequest.GetCompletionsCycling.Response(completions: [ - .init( - text: "Hello World\n", - position: .init((0, 0)), - uuid: "uuid", - range: .init(start: .init((0, 0)), end: .init((0, 4))), - displayText: "Hello" - ), - .init( - text: " ", - position: .init((0, 0)), - uuid: "uuid", - range: .init(start: .init((0, 0)), end: .init((0, 1))), - displayText: " " - ), - .init( - text: " \n", - position: .init((0, 0)), - uuid: "uuid", - range: .init(start: .init((0, 0)), end: .init((0, 2))), - displayText: " \n" - ), - ]) as! E.Response - } - } - let service = GitHubCopilotSuggestionService(designatedServer: TestServer()) - let completions = try await service.getCompletions( - fileURL: .init(fileURLWithPath: "/file.swift"), - content: "", - cursorPosition: .outOfScope, - tabSize: 4, - indentSize: 4, - usesTabsForIndentation: false, - ignoreSpaceOnlySuggestions: false - ) - XCTAssertEqual(completions.count, 3) - } - - func test_ignore_empty_suggestions() async throws { - struct TestServer: GitHubCopilotLSP { - func sendNotification(_ notif: LanguageServerProtocol.ClientNotification) async throws { - fatalError() - } - - func sendRequest(_: E) async throws -> E.Response where E: GitHubCopilotRequestType { - return GitHubCopilotRequest.GetCompletionsCycling.Response(completions: [ - .init( - text: "Hello World\n", - position: .init((0, 0)), - uuid: "uuid", - range: .init(start: .init((0, 0)), end: .init((0, 4))), - displayText: "Hello" - ), - .init( - text: " ", - position: .init((0, 0)), - uuid: "uuid", - range: .init(start: .init((0, 0)), end: .init((0, 1))), - displayText: " " - ), - .init( - text: " \n", - position: .init((0, 0)), - uuid: "uuid", - range: .init(start: .init((0, 0)), end: .init((0, 2))), - displayText: " \n" - ), - ]) as! E.Response - } - } - let service = GitHubCopilotSuggestionService(designatedServer: TestServer()) - let completions = try await service.getCompletions( - fileURL: .init(fileURLWithPath: "/file.swift"), - content: "", - cursorPosition: .outOfScope, - tabSize: 4, - indentSize: 4, - usesTabsForIndentation: false, - ignoreSpaceOnlySuggestions: true - ) - XCTAssertEqual(completions.count, 1) - XCTAssertEqual(completions.first?.text, "Hello World\n") - } - - func test_if_language_identifier_is_unknown_returns_correctly() async throws { - struct Err: Error, LocalizedError { - var errorDescription: String? { - "sendRequest Should not be falled" - } - } - - class TestServer: GitHubCopilotLSP { - func sendRequest(_ r: E) async throws -> E.Response where E: GitHubCopilotRequestType { - return GitHubCopilotRequest.GetCompletionsCycling.Response(completions: [ - .init( - text: "Hello World\n", - position: .init((0, 0)), - uuid: "uuid", - range: .init(start: .init((0, 0)), end: .init((0, 4))), - displayText: "Hello" - ), - ]) as! E.Response - } - } - let testServer = TestServer() - let service = GitHubCopilotSuggestionService(designatedServer: testServer) - let completions = try await service.getCompletions( - fileURL: .init(fileURLWithPath: "/"), - content: "", - cursorPosition: .outOfScope, - tabSize: 4, - indentSize: 4, - usesTabsForIndentation: false, - ignoreSpaceOnlySuggestions: false - ) - XCTAssertEqual(completions.count, 1) - XCTAssertEqual(completions.first?.text, "Hello World\n") - } -} diff --git a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift new file mode 100644 index 00000000..b84e8ac0 --- /dev/null +++ b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift @@ -0,0 +1,153 @@ +import Foundation +import XCTest + +@testable import Workspace +@testable import KeyBindingManager + +class TabToAcceptSuggestionTests: XCTestCase { + func test_should_accept_if_line_invalid() { + XCTAssertTrue( + TabToAcceptSuggestion.checkIfAcceptSuggestion( + lines: """ + struct Cat { + var name: String + var age: Int + } + """.breakLines(), + cursorPosition: .init(line: 4, character: 4), + codeMetadata: .init(), + presentingSuggestionText: "Hello" + ) + ) + + XCTAssertTrue( + TabToAcceptSuggestion.checkIfAcceptSuggestion( + lines: """ + struct Cat { + var name: String + var age: Int + } + """.breakLines(), + cursorPosition: .init(line: -1, character: 4), + codeMetadata: .init(), + presentingSuggestionText: "Hello" + ) + ) + } + + func test_should_not_accept_if_tab_does_not_invalidate_the_suggestion() { + XCTAssertFalse( + TabToAcceptSuggestion.checkIfAcceptSuggestion( + lines: """ + struct Cat { + + var age: Int + } + """.breakLines(), + cursorPosition: .init(line: 1, character: 0), + codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false), + presentingSuggestionText: " var name: String" + ) + ) + + XCTAssertFalse( + TabToAcceptSuggestion.checkIfAcceptSuggestion( + lines: """ + struct 🐱 { + + var 🎇: Int + } + """.breakLines(), + cursorPosition: .init(line: 1, character: 0), + codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false), + presentingSuggestionText: " var 🎇: String" + ) + ) + + XCTAssertFalse( + TabToAcceptSuggestion.checkIfAcceptSuggestion( + lines: """ + struct Cat { + + var age: Int + } + """.breakLines(), + cursorPosition: .init(line: 1, character: 0), + codeMetadata: .init(tabSize: 2, indentSize: 2, usesTabsForIndentation: false), + presentingSuggestionText: " var name: String" + ) + ) + + XCTAssertFalse( + TabToAcceptSuggestion.checkIfAcceptSuggestion( + lines: """ + struct Cat { + + \tvar age: Int + } + """.breakLines(), + cursorPosition: .init(line: 1, character: 0), + codeMetadata: .init(tabSize: 4, indentSize: 1, usesTabsForIndentation: true), + presentingSuggestionText: "\tvar name: String" + ) + ) + } + + func test_should_accept_if_tab_invalidates_the_suggestion() { + XCTAssertTrue( + TabToAcceptSuggestion.checkIfAcceptSuggestion( + lines: """ + struct Cat { + \(" ") + var age: Int + } + """.breakLines(), + cursorPosition: .init(line: 1, character: 1), + codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false), + presentingSuggestionText: " var name: String" + ) + ) + + XCTAssertTrue( + TabToAcceptSuggestion.checkIfAcceptSuggestion( + lines: """ + struct 🐱 { + \(" ") + var 🎇: Int + } + """.breakLines(), + cursorPosition: .init(line: 1, character: 1), + codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false), + presentingSuggestionText: " var 🎇: String" + ) + ) + + XCTAssertTrue( + TabToAcceptSuggestion.checkIfAcceptSuggestion( + lines: """ + struct Cat { + \(" ") + var age: Int + } + """.breakLines(), + cursorPosition: .init(line: 1, character: 1), + codeMetadata: .init(tabSize: 2, indentSize: 2, usesTabsForIndentation: false), + presentingSuggestionText: " var name: String" + ) + ) + + XCTAssertTrue( + TabToAcceptSuggestion.checkIfAcceptSuggestion( + lines: """ + struct Cat { + \t + \tvar age: Int + } + """.breakLines(), + cursorPosition: .init(line: 1, character: 1), + codeMetadata: .init(tabSize: 4, indentSize: 1, usesTabsForIndentation: true), + presentingSuggestionText: "\tvar name: String" + ) + ) + } +} diff --git a/Core/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift b/Core/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift deleted file mode 100644 index a01f2a22..00000000 --- a/Core/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift +++ /dev/null @@ -1,22 +0,0 @@ -import XCTest -@testable import OpenAIService - -final class ChatGPTServiceFieldTests: XCTestCase { - let skip = true - func test_calling_the_api() async throws { - let service = ChatGPTService() - - if skip { return } - - do { - let stream = try await service.send(content: "Hello") - for try await text in stream { - print(text) - } - } catch { - print("🔴", error.localizedDescription) - } - - XCTFail("🔴 Please reset the key to `Key` after the field tests.") - } -} diff --git a/Core/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift b/Core/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift deleted file mode 100644 index b976290a..00000000 --- a/Core/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift +++ /dev/null @@ -1,74 +0,0 @@ -import XCTest -@testable import OpenAIService - -struct MockCompletionStreamAPI_Success: CompletionStreamAPI { - func callAsFunction() async throws -> ( - trunkStream: AsyncThrowingStream, - cancel: OpenAIService.Cancellable - ) { - return ( - AsyncThrowingStream { continuation in - let trunks: [CompletionStreamDataTrunk] = [ - .init(id: "1", object: "", created: 0, model: "", choices: [ - .init(delta: .init(role: .assistant), index: 0, finish_reason: ""), - ]), - .init(id: "1", object: "", created: 0, model: "", choices: [ - .init(delta: .init(content: "hello"), index: 0, finish_reason: ""), - ]), - .init(id: "1", object: "", created: 0, model: "", choices: [ - .init(delta: .init(content: "my"), index: 0, finish_reason: ""), - ]), - .init(id: "1", object: "", created: 0, model: "", choices: [ - .init(delta: .init(content: "friends"), index: 0, finish_reason: ""), - ]), - ] - for trunk in trunks { - continuation.yield(trunk) - } - continuation.finish() - }, - Cancellable(cancel: {}) - ) - } -} - -final class ChatGPTServiceTests: XCTestCase { - func test_success() async throws { - let service = ChatGPTService() - var idCounter = 0 - await service.changeUUIDGenerator { - defer { idCounter += 1 } - return "\(idCounter)" - } - var requestBody: CompletionRequestBody? - await service.changeBuildCompletionStreamAPI { _apiKey, _, _, _requestBody in - requestBody = _requestBody - return MockCompletionStreamAPI_Success() - } - let stream = try await service.send(content: "Hello") - var all = [String]() - for try await text in stream { - all.append(text) - let history = await service.history - XCTAssertEqual(history.last?.id, "1") - XCTAssertTrue( - history.last?.content.hasPrefix(all.joined()) ?? false, - "History is dynamically updated" - ) - } - - XCTAssertEqual(requestBody?.messages, [ - .init(role: .system, content: ""), - .init(role: .user, content: "Hello"), - ], "System prompt is included") - XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is correct") - var history = await service.history - for (i, _) in history.enumerated() { - history[i].tokensCount = nil - } - XCTAssertEqual(history, [ - .init(id: "0", role: .user, content: "Hello"), - .init(id: "1", role: .assistant, content: "hellomyfriends"), - ], "History is correctly updated") - } -} diff --git a/Core/Tests/OpenAIServiceTests/LimitMessagesTests.swift b/Core/Tests/OpenAIServiceTests/LimitMessagesTests.swift deleted file mode 100644 index ec18f30c..00000000 --- a/Core/Tests/OpenAIServiceTests/LimitMessagesTests.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Foundation -import XCTest - -@testable import OpenAIService - -final class LimitMessagesTests: XCTestCase { - func test_send_all_messages_if_not_reached_token_limit() async { - let service = await createService(systemPrompt: "system", messages: [ - "hi", - "hello", - "world", - ]) - - let (messages, remainingTokens) = await runService( - service, - minimumReplyTokens: 200, - maxNumberOfMessages: 0, // smaller than 1 means no limit - maxTokens: 10000 - ) - XCTAssertEqual(messages, [ - "system", - "hi", - "hello", - "world", - ]) - - XCTAssertEqual(remainingTokens, 10000 - 12 - 6) - let history = await service.history - XCTAssertEqual(history.map(\.tokensCount), [ - 2, - 5, - 5, - ]) - } - - func test_send_max_message_if_not_reached_token_limit() async { - let service = await createService(systemPrompt: "system", messages: [ - "hi", - "hello", - "world", - ]) - - let (messages, remainingTokens) = await runService( - service, - minimumReplyTokens: 200, - maxNumberOfMessages: 2, - maxTokens: 10000 - ) - XCTAssertEqual(messages, [ - "system", - "hello", - "world", - ], "Count from end to start.") - - XCTAssertEqual(remainingTokens, 10000 - 10 - 6) - } - - func test_reached_token_limit() async { - let service = await createService(systemPrompt: "system", messages: [ - "hi", - "hello", - "world", - ]) - - let (messages, remainingTokens) = await runService( - service, - minimumReplyTokens: 200, - maxNumberOfMessages: 100, - maxTokens: 212 - ) - XCTAssertEqual(messages, [ - "system", - "world", - ]) - - XCTAssertEqual(remainingTokens, 201) - } - - func test_minimum_reply_tokens_count() async { - let service = await createService(systemPrompt: "system", messages: [ - "hi", - "hello", - "world", - ]) - - let (messages, remainingTokens) = await runService( - service, - minimumReplyTokens: 200, - maxNumberOfMessages: 100, - maxTokens: 200 - ) - XCTAssertEqual(messages, [ - "system", - ]) - - XCTAssertEqual(remainingTokens, 200) - } -} - -class MockEncoder: TokenEncoder { - func encode(text: String) -> [Int] { - return .init(repeating: 0, count: text.count) - } -} - -private func createService(systemPrompt: String, messages: [String]) async -> ChatGPTService { - let service = ChatGPTService(systemPrompt: systemPrompt) - await service.mutateHistory { history in - messages.forEach { message in - history.append(.init(role: .user, content: message)) - } - } - return service -} - -private func runService( - _ service: ChatGPTService, - minimumReplyTokens: Int, - maxNumberOfMessages: Int, - maxTokens: Int -) async -> (messages: [String], remainingTokens: Int) { - let (messages, remainingTokens) = await service.combineHistoryWithSystemPrompt( - minimumReplyTokens: minimumReplyTokens, - maxNumberOfMessages: maxNumberOfMessages, - maxTokens: maxTokens, - encoder: MockEncoder() - ) - - return (messages.map(\.content), remainingTokens) -} diff --git a/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift b/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift index 3f5c6970..9105ec2f 100644 --- a/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift +++ b/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift @@ -3,7 +3,7 @@ import XCTest final class ExtractCodeFromChatGPTTests: XCTestCase { func test_extract_from_no_code_block() { - let api = OpenAIPromptToCodeAPI() + let api = SimpleModificationAgent() let result = api.extractCodeAndDescription(from: """ hello world! """) @@ -13,7 +13,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase { } func test_extract_from_incomplete_code_block() { - let api = OpenAIPromptToCodeAPI() + let api = SimpleModificationAgent() let result = api.extractCodeAndDescription(from: """ ```swift func foo() {} @@ -24,7 +24,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase { } func test_extract_from_complete_code_block() { - let api = OpenAIPromptToCodeAPI() + let api = SimpleModificationAgent() let result = api.extractCodeAndDescription(from: """ ```swift func foo() {} @@ -40,7 +40,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase { } func test_extract_from_incomplete_code_block_without_language() { - let api = OpenAIPromptToCodeAPI() + let api = SimpleModificationAgent() let result = api.extractCodeAndDescription(from: """ ``` func foo() {} @@ -51,7 +51,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase { } func test_extract_from_code_block_without_language() { - let api = OpenAIPromptToCodeAPI() + let api = SimpleModificationAgent() let result = api.extractCodeAndDescription(from: """ ``` func foo() {} diff --git a/Core/Tests/ServiceTests/Environment.swift b/Core/Tests/ServiceTests/Environment.swift index cad9bcf6..13a66210 100644 --- a/Core/Tests/ServiceTests/Environment.swift +++ b/Core/Tests/ServiceTests/Environment.swift @@ -1,63 +1,47 @@ import AppKit import Client -import SuggestionModel -import GitHubCopilotService -import Environment import Foundation +import GitHubCopilotService +import SuggestionBasic +import Workspace import XCTest import XPCShared @testable import Service -@ServiceActor func clearEnvironment() { - workspaces = [:] - - Environment.now = { Date() } - - Environment.fetchCurrentProjectRootURLFromXcode = { - URL(fileURLWithPath: "/path/to/project") - } - - Environment.fetchCurrentFileURL = { - URL(fileURLWithPath: "/path/to/project/file.swift") - } - - Environment.createSuggestionService = { - _, _ in fatalError("") - } - - Environment.triggerAction = { _ in } -} - func completion(text: String, range: CursorRange, uuid: String = "") -> CodeSuggestion { - .init(text: text, position: range.start, uuid: uuid, range: range, displayText: text) + .init(id: uuid, text: text, position: range.start, range: range) } class MockSuggestionService: GitHubCopilotSuggestionServiceType { - func terminate() async { + func cancelOngoingTask(workDoneToken: String) async { fatalError() } - func cancelRequest() async { + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws { fatalError() } - - func notifyOpenTextDocument(fileURL: URL, content: String) async throws { + + func terminate() async { fatalError() } - - func notifyChangeTextDocument(fileURL: URL, content: String) async throws { + + func cancelRequest() async { fatalError() } - + + func notifyOpenTextDocument(fileURL: URL, content: String) async throws { + fatalError() + } + func notifyCloseTextDocument(fileURL: URL) async throws { fatalError() } - + func notifySaveTextDocument(fileURL: URL) async throws { fatalError() } - + var completions = [CodeSuggestion]() var accepted: String? var rejected: [String] = [] @@ -69,20 +53,21 @@ class MockSuggestionService: GitHubCopilotSuggestionServiceType { func getCompletions( fileURL: URL, content: String, - cursorPosition: SuggestionModel.CursorPosition, + originalContent: String, + cursorPosition: SuggestionBasic.CursorPosition, tabSize: Int, indentSize: Int, - usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool - ) async throws -> [SuggestionModel.CodeSuggestion] { + usesTabsForIndentation: Bool + ) async throws -> [SuggestionBasic.CodeSuggestion] { completions } func notifyAccepted(_ completion: CodeSuggestion) async { - accepted = completion.uuid + accepted = completion.id } func notifyRejected(_ completions: [CodeSuggestion]) async { - rejected = completions.map(\.uuid) + rejected = completions.map(\.id) } } + diff --git a/Core/Tests/ServiceTests/ExtractSelectedCodeTests.swift b/Core/Tests/ServiceTests/ExtractSelectedCodeTests.swift index 31dea382..c5bd977c 100644 --- a/Core/Tests/ServiceTests/ExtractSelectedCodeTests.swift +++ b/Core/Tests/ServiceTests/ExtractSelectedCodeTests.swift @@ -1,4 +1,4 @@ -import SuggestionModel +import SuggestionBasic import XCTest @testable import Service @testable import XPCShared diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift new file mode 100644 index 00000000..44ae7129 --- /dev/null +++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift @@ -0,0 +1,386 @@ +import Foundation +import SuggestionBasic +import XCTest + +@testable import Service +@testable import Workspace + +class FilespaceSuggestionInvalidationTests: XCTestCase { + @WorkspaceActor + func prepare( + lines: [String], + suggestionText: String, + cursorPosition: CursorPosition, + range: CursorRange + ) async throws -> Filespace { + let pool = WorkspacePool() + let (_, filespace) = try await pool.fetchOrCreateWorkspaceAndFilespace( + fileURL: URL(fileURLWithPath: "file/path/to.swift"), + checkIfFileExists: false + ) + filespace.suggestions = [ + .init( + id: "", + text: suggestionText, + position: cursorPosition, + range: range + ), + ] + filespace.suggestionSourceSnapshot = .init(lines: lines, cursorPosition: cursorPosition) + return filespace + } + + func test_text_typing_suggestion_should_be_valid() async throws { + let lines = ["\n", "hell\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 0), + range: .init(startPair: (1, 0), endPair: (1, 0)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 1, character: 4), + alwaysTrueIfCursorNotMoved: false // TODO: What + ) + XCTAssertTrue(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } + + func test_text_typing_suggestion_in_the_middle_should_be_valid() async throws { + let lines = ["\n", "hell man\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 0), + range: .init(startPair: (1, 0), endPair: (1, 0)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 1, character: 4), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertTrue(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } + + func test_text_typing_suggestion_with_emoji_in_the_middle_should_be_valid() async throws { + let lines = ["\n", "hell🎆🎆 man\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello🎆🎆 man", + cursorPosition: .init(line: 1, character: 0), + range: .init(startPair: (1, 0), endPair: (1, 0)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 1, character: 4), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertTrue(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } + + func test_text_typing_suggestion_typed_emoji_in_the_middle_should_be_valid() async throws { + let lines = ["\n", "h🎆🎆o ma\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "h🎆🎆o man", + cursorPosition: .init(line: 1, character: 0), + range: .init(startPair: (1, 0), endPair: (1, 0)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 1, character: 2), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertTrue(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } + + func test_text_typing_suggestion_cutting_emoji_in_the_middle_should_be_valid() async throws { + // undefined behavior, must not crash + + let lines = ["\n", "h🎆🎆o ma\n", "\n"] + + let filespace = try await prepare( + lines: lines, + suggestionText: "h🎆🎆o man", + cursorPosition: .init(line: 1, character: 0), + range: .init(startPair: (1, 0), endPair: (1, 0)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 1, character: 3), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertTrue(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } + + func test_typing_not_according_to_suggestion_should_invalidate() async throws { + let lines = ["\n", "hello ma\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 8), + range: .init(startPair: (1, 0), endPair: (1, 8)) + ) + let wasValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 1, character: 8), + alwaysTrueIfCursorNotMoved: false + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "hello mat\n", "\n"], + cursorPosition: .init(line: 1, character: 9), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertTrue(wasValid) + XCTAssertFalse(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_text_cursor_moved_to_another_line_should_invalidate() async throws { + let lines = ["\n", "hell\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 0), + range: .init(startPair: (1, 0), endPair: (1, 0)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 2, character: 0), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertFalse(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_text_cursor_is_invalid_should_invalidate() async throws { + let lines = ["\n", "hell\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello man", + cursorPosition: .init(line: 100, character: 0), + range: .init(startPair: (1, 0), endPair: (1, 0)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 100, character: 4), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertFalse(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_line_content_does_not_match_input_should_invalidate() async throws { + let filespace = try await prepare( + lines: ["\n", "hello\n", "\n"], + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 5), + range: .init(startPair: (1, 0), endPair: (1, 5)) + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "helo\n", "\n"], + cursorPosition: .init(line: 1, character: 4), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertFalse(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_line_content_does_not_match_input_should_invalidate_index_out_of_scope() async throws { + let filespace = try await prepare( + lines: ["\n", "hello\n", "\n"], + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 5), + range: .init(startPair: (1, 0), endPair: (1, 5)) + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "helo\n", "\n"], + cursorPosition: .init(line: 1, character: 100), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertFalse(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_finish_typing_the_whole_single_line_suggestion_should_invalidate() async throws { + let lines = ["\n", "hello ma\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 8), + range: .init(startPair: (1, 0), endPair: (1, 8)) + ) + let wasValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 1, character: 8), + alwaysTrueIfCursorNotMoved: false + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "hello man\n", "\n"], + cursorPosition: .init(line: 1, character: 9), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertTrue(wasValid) + XCTAssertFalse(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_finish_typing_the_whole_single_line_suggestion_with_emoji_should_invalidate( + ) async throws { + let lines = ["\n", "hello m🎆🎆a\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello m🎆🎆an", + cursorPosition: .init(line: 1, character: 0), + range: .init(startPair: (1, 0), endPair: (1, 0)) + ) + let wasValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 1, character: 12), + alwaysTrueIfCursorNotMoved: false + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "hello m🎆🎆an\n", "\n"], + cursorPosition: .init(line: 1, character: 13), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertTrue(wasValid) + XCTAssertFalse(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_finish_typing_the_whole_single_line_suggestion_suggestion_is_incomplete_should_invalidate( + ) async throws { + let lines = ["\n", "hello ma!!!!\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 0), + range: .init(startPair: (1, 0), endPair: (1, 0)) + ) + let wasValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 1, character: 8), + alwaysTrueIfCursorNotMoved: false + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "hello man!!!!!\n", "\n"], + cursorPosition: .init(line: 1, character: 9), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertTrue(wasValid) + XCTAssertFalse(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_finish_typing_the_whole_multiple_line_suggestion_should_be_valid() async throws { + let lines = ["\n", "hello man\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello man\nhow are you?", + cursorPosition: .init(line: 1, character: 0), + range: .init(startPair: (1, 0), endPair: (1, 0)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 1, character: 9), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertTrue(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } + + func test_finish_typing_the_whole_multiple_line_suggestion_with_emoji_should_be_valid( + ) async throws { + let lines = ["\n", "hello m🎆🎆an\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello m🎆🎆an\nhow are you?", + cursorPosition: .init(line: 1, character: 0), + range: .init(startPair: (1, 0), endPair: (1, 0)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 1, character: 13), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertTrue(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } + + func test_undo_text_to_a_state_before_the_suggestion_was_generated_should_invalidate( + ) async throws { + let lines = ["\n", "hell\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 5), // generating man from hello + range: .init(startPair: (1, 0), endPair: (1, 5)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 1, character: 4), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertFalse(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_rewriting_the_current_line_by_removing_the_suffix_should_be_valid() async throws { + let lines = ["hello world !!!\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello world", + cursorPosition: .init(line: 0, character: 15), + range: .init(startPair: (0, 0), endPair: (0, 15)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 0, character: 15), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertTrue(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } + + func test_rewriting_the_current_line_should_be_valid() async throws { + let lines = ["hello everyone !!!\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello world !!!", + cursorPosition: .init(line: 0, character: 18), + range: .init(startPair: (0, 0), endPair: (0, 18)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 0, character: 18), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertTrue(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } +} + diff --git a/Core/Tests/ServiceTests/PseudoCommandHandlerFileProcessingTests.swift b/Core/Tests/ServiceTests/PseudoCommandHandlerFileProcessingTests.swift deleted file mode 100644 index 1c14a7a2..00000000 --- a/Core/Tests/ServiceTests/PseudoCommandHandlerFileProcessingTests.swift +++ /dev/null @@ -1,43 +0,0 @@ -import SuggestionModel -import XCTest -@testable import Service - -class PseudoCommandHandlerFileProcessingTests: XCTestCase { - func test_convert_range_0_0() { - XCTAssertEqual( - PseudoCommandHandler().convertRangeToCursorRange(0...0, in: "\n"), - CursorRange(start: .zero, end: .init(line: 0, character: 0)) - ) - } - - func test_convert_range_same_line() { - XCTAssertEqual( - PseudoCommandHandler().convertRangeToCursorRange(1...5, in: "123456789\n"), - CursorRange(start: .init(line: 0, character: 1), end: .init(line: 0, character: 5)) - ) - } - - func test_convert_range_multiple_line() { - XCTAssertEqual( - PseudoCommandHandler() - .convertRangeToCursorRange(5...25, in: "123456789\n123456789\n123456789\n"), - CursorRange(start: .init(line: 0, character: 5), end: .init(line: 2, character: 5)) - ) - } - - func test_convert_range_all_line() { - XCTAssertEqual( - PseudoCommandHandler() - .convertRangeToCursorRange(0...29, in: "123456789\n123456789\n123456789\n"), - CursorRange(start: .init(line: 0, character: 0), end: .init(line: 2, character: 9)) - ) - } - - func test_convert_range_out_of_range() { - XCTAssertEqual( - PseudoCommandHandler() - .convertRangeToCursorRange(0...70, in: "123456789\n123456789\n123456789\n"), - CursorRange(start: .init(line: 0, character: 0), end: .init(line: 3, character: 0)) - ) - } -} diff --git a/Core/Tests/ServiceUpdateMigrationTests/MigrateTo240Tests.swift b/Core/Tests/ServiceUpdateMigrationTests/MigrateTo240Tests.swift new file mode 100644 index 00000000..5cc0743f --- /dev/null +++ b/Core/Tests/ServiceUpdateMigrationTests/MigrateTo240Tests.swift @@ -0,0 +1,192 @@ +import Foundation +import Keychain +import XCTest + +@testable import ServiceUpdateMigration + +final class MigrateTo240Tests: XCTestCase { + let userDefaults = UserDefaults(suiteName: "MigrateTo240Tests")! + + override func tearDown() async throws { + userDefaults.removePersistentDomain(forName: "MigrateTo240Tests") + } + + func test_migrateTo240_no_data_to_migrate() async throws { + let keychain = FakeKeyChain() + + try migrateTo240(defaults: userDefaults, keychain: keychain) + + XCTAssertTrue(try keychain.getAll().isEmpty, "No api key to migrate") + + let chatModels = userDefaults.value(for: \.chatModels) + let embeddingModels = userDefaults.value(for: \.embeddingModels) + + for chatModel in chatModels { + switch chatModel.format { + case .openAI: + XCTAssertEqual(chatModel.name, "OpenAI") + XCTAssertEqual(chatModel.info, .init( + apiKeyName: "OpenAI", + baseURL: "", + maxTokens: 16385, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + )) + case .azureOpenAI: + XCTAssertEqual(chatModel.name, "Azure OpenAI") + XCTAssertEqual(chatModel.info, .init( + apiKeyName: "Azure OpenAI", + baseURL: "", + maxTokens: 4000, + supportsFunctionCalling: true, + modelName: "" + )) + default: + XCTFail() + } + } + + for embeddingModel in embeddingModels { + switch embeddingModel.format { + case .openAI: + XCTAssertEqual(embeddingModel.name, "OpenAI") + XCTAssertEqual(embeddingModel.info, .init( + apiKeyName: "OpenAI", + baseURL: "", + maxTokens: 8191, + modelName: "text-embedding-ada-002" + )) + case .azureOpenAI: + XCTAssertEqual(embeddingModel.name, "Azure OpenAI") + XCTAssertEqual(embeddingModel.info, .init( + apiKeyName: "Azure OpenAI", + baseURL: "", + maxTokens: 8191, + modelName: "" + )) + default: + XCTFail() + } + } + } + + func test_migrateTo240_migrate_data_use_openAI() async throws { + let keychain = FakeKeyChain() + + userDefaults.set("Key1", forKey: "OpenAIAPIKey") + userDefaults.set("openai.com", forKey: "OpenAIBaseURL") + userDefaults.set("gpt-500", forKey: "ChatGPTModel") + userDefaults.set(200, forKey: "ChatGPTMaxToken") + userDefaults.set("embedding-200", forKey: "OpenAIEmbeddingModel") + userDefaults.set("Key2", forKey: "AzureOpenAIAPIKey") + userDefaults.set("azure.com", forKey: "AzureOpenAIBaseURL") + userDefaults.set("gpt-800", forKey: "AzureChatGPTDeployment") + userDefaults.set("embedding-800", forKey: "AzureEmbeddingDeployment") + userDefaults.set("openAI", forKey: "ChatFeatureProvider") + userDefaults.set("openAI", forKey: "EmbeddingFeatureProvider") + + try migrateTo240(defaults: userDefaults, keychain: keychain) + + XCTAssertEqual(try keychain.getAll(), [ + "OpenAI": "Key1", + "Azure OpenAI": "Key2", + ]) + + let chatModels = userDefaults.value(for: \.chatModels) + let embeddingModels = userDefaults.value(for: \.embeddingModels) + + XCTAssertEqual(chatModels.count, 2) + XCTAssertEqual(embeddingModels.count, 2) + + XCTAssertEqual( + userDefaults.value(for: \.defaultChatFeatureChatModelId), + chatModels.first(where: { $0.format == .openAI })?.id + ) + XCTAssertEqual( + userDefaults.value(for: \.defaultChatFeatureEmbeddingModelId), + embeddingModels.first(where: { $0.format == .openAI })?.id + ) + + for chatModel in chatModels { + switch chatModel.format { + case .openAI: + XCTAssertEqual(chatModel.name, "OpenAI") + XCTAssertEqual(chatModel.info, .init( + apiKeyName: "OpenAI", + baseURL: "openai.com", + maxTokens: 200, + supportsFunctionCalling: true, + modelName: "gpt-500" + )) + case .azureOpenAI: + XCTAssertEqual(chatModel.name, "Azure OpenAI") + XCTAssertEqual(chatModel.info, .init( + apiKeyName: "Azure OpenAI", + baseURL: "azure.com", + maxTokens: 200, + supportsFunctionCalling: true, + modelName: "gpt-800" + )) + default: + XCTFail() + } + } + + for embeddingModel in embeddingModels { + switch embeddingModel.format { + case .openAI: + XCTAssertEqual(embeddingModel.name, "OpenAI") + XCTAssertEqual(embeddingModel.info, .init( + apiKeyName: "OpenAI", + baseURL: "openai.com", + maxTokens: 8191, + modelName: "embedding-200" + )) + case .azureOpenAI: + XCTAssertEqual(embeddingModel.name, "Azure OpenAI") + XCTAssertEqual(embeddingModel.info, .init( + apiKeyName: "Azure OpenAI", + baseURL: "azure.com", + maxTokens: 8191, + modelName: "embedding-800" + )) + default: + XCTFail() + } + } + } + + func test_migrateTo240_migrate_data_use_azureOpenAI() async throws { + let keychain = FakeKeyChain() + + userDefaults.set("Key1", forKey: "OpenAIAPIKey") + userDefaults.set("openai.com", forKey: "OpenAIBaseURL") + userDefaults.set("gpt-500", forKey: "ChatGPTModel") + userDefaults.set(200, forKey: "ChatGPTMaxToken") + userDefaults.set("embedding-200", forKey: "OpenAIEmbeddingModel") + userDefaults.set("Key2", forKey: "AzureOpenAIAPIKey") + userDefaults.set("azure.com", forKey: "AzureOpenAIBaseURL") + userDefaults.set("gpt-800", forKey: "AzureChatGPTDeployment") + userDefaults.set("embedding-800", forKey: "AzureEmbeddingDeployment") + userDefaults.set("azureOpenAI", forKey: "ChatFeatureProvider") + userDefaults.set("azureOpenAI", forKey: "EmbeddingFeatureProvider") + + try migrateTo240(defaults: userDefaults, keychain: keychain) + + let chatModels = userDefaults.value(for: \.chatModels) + let embeddingModels = userDefaults.value(for: \.embeddingModels) + + XCTAssertEqual(chatModels.count, 2) + XCTAssertEqual(embeddingModels.count, 2) + + XCTAssertEqual( + userDefaults.value(for: \.defaultChatFeatureChatModelId), + chatModels.first(where: { $0.format == .azureOpenAI })?.id + ) + XCTAssertEqual( + userDefaults.value(for: \.defaultChatFeatureEmbeddingModelId), + embeddingModels.first(where: { $0.format == .azureOpenAI })?.id + ) + } +} + diff --git a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift deleted file mode 100644 index d9d5a4a8..00000000 --- a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift +++ /dev/null @@ -1,373 +0,0 @@ -import SuggestionModel -import XCTest - -@testable import SuggestionInjector - -final class AcceptSuggestionTests: XCTestCase { - func test_accept_suggestion_no_overlap() async throws { - let content = """ - struct Cat { - - } - """ - let text = """ - var name: String - var age: String - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 0, character: 1), - uuid: "", - range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 1, character: 0) - ), - displayText: "" - ) - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 2, character: 19)) - XCTAssertEqual(lines.joined(separator: ""), """ - struct Cat { - var name: String - var age: String - } - """) - } - - func test_accept_suggestion_start_from_previous_line() async throws { - let content = """ - struct Cat { - } - """ - let text = """ - struct Cat { - var name: String - var age: String - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 0, character: 12), - uuid: "", - range: .init( - start: .init(line: 0, character: 0), - end: .init(line: 0, character: 12) - ), - displayText: "" - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 2, character: 19)) - XCTAssertEqual(lines.joined(separator: ""), """ - struct Cat { - var name: String - var age: String - } - """) - } - - func test_accept_suggestion_overlap() async throws { - let content = """ - struct Cat { - var name - } - """ - let text = """ - var name: String - var age: String - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 1, character: 12), - uuid: "", - range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 1, character: 12) - ), - displayText: "" - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 2, character: 19)) - XCTAssertEqual(lines.joined(separator: ""), """ - struct Cat { - var name: String - var age: String - } - """) - } - - func test_accept_suggestion_overlap_continue_typing() async throws { - let content = """ - struct Cat { - var name: Str - } - """ - let text = """ - var name: String - var age: String - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 1, character: 12), - uuid: "", - range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 1, character: 12) - ), - displayText: "" - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 2, character: 19)) - XCTAssertEqual(lines.joined(separator: ""), """ - struct Cat { - var name: String - var age: String - } - """) - } - - func test_propose_suggestion_partial_overlap() async throws { - let content = "func quickSort() {}}\n" - let text = """ - func quickSort() { - var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] - var left = 0 - var right = array.count - 1 - quickSort(&array, left, right) - print(array) - } - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 0, character: 18), - uuid: "", - range: .init( - start: .init(line: 0, character: 0), - end: .init(line: 0, character: 20) - ), - displayText: "" - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 6, character: 1)) - XCTAssertEqual(lines.joined(separator: ""), """ - func quickSort() { - var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] - var left = 0 - var right = array.count - 1 - quickSort(&array, left, right) - print(array) - } - - """) - } - - func test_no_overlap_append_to_the_end() async throws { - let content = "func quickSort() {\n" - let text = """ - var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] - var left = 0 - var right = array.count - 1 - quickSort(&array, left, right) - print(array) - } - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 0, character: 18), - uuid: "", - range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 1, character: 0) - ), - displayText: "" - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 6, character: 1)) - XCTAssertEqual(lines.joined(separator: ""), """ - func quickSort() { - var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] - var left = 0 - var right = array.count - 1 - quickSort(&array, left, right) - print(array) - } - - """) - } - - func test_replacing_multiple_lines() async throws { - let content = """ - struct Cat { - func speak() { print("meow") } - } - """ - let text = """ - struct Dog { - func speak() { - print("woof") - } - } - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 0, character: 7), - uuid: "", - range: .init( - start: .init(line: 0, character: 0), - end: .init(line: 2, character: 1) - ), - displayText: "" - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 4, character: 1)) - XCTAssertEqual(lines.joined(separator: ""), text) - } - - func test_replacing_multiple_lines_in_the_middle() async throws { - let content = """ - protocol Animal { - func speak() - } - - struct Cat: Animal { - func speak() { print("meow") } - } - - func foo() {} - """ - let text = """ - Dog { - func speak() { - print("woof") - } - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 5, character: 34), - uuid: "", - range: .init( - start: .init(line: 4, character: 7), - end: .init(line: 5, character: 34) - ), - displayText: "" - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - var cursor = CursorPosition(line: 0, character: 0) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 7, character: 5)) - XCTAssertEqual(lines.joined(separator: ""), """ - protocol Animal { - func speak() - } - - struct Dog { - func speak() { - print("woof") - } - } - - func foo() {} - """) - } -} diff --git a/Core/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift b/Core/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift deleted file mode 100644 index ae7bdcdf..00000000 --- a/Core/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift +++ /dev/null @@ -1,327 +0,0 @@ -import SuggestionModel -import XCTest - -@testable import SuggestionInjector - -final class ProposeSuggestionTests: XCTestCase { - func test_propose_suggestion_no_overlap() async throws { - let content = """ - struct Cat { - - } - """ - let text = """ - var name: String - var age: String - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 2, character: 19), - uuid: "", - range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 2, character: 18) - ), - displayText: "" - ) - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - SuggestionInjector().proposeSuggestion( - intoContentWithoutSuggestion: &lines, - completion: suggestion, - index: 0, - count: 10, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertFalse(extraInfo.didChangeCursorPosition) - XCTAssertEqual(extraInfo.suggestionRange, 2...5) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual( - lines.joined(separator: ""), - """ - struct Cat { - - /*========== Copilot Suggestion 1/10 - var name: String - var age: String - *///======== End of Copilot Suggestion - } - """, - "The user may want to keep typing on the empty line, so suggestion is addded to the next line" - ) - } - - func test_propose_suggestion_no_overlap_start_from_previous_line() async throws { - let content = """ - struct Cat { - } - """ - let text = """ - var name: String - var age: String - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 1, character: 0), - uuid: "", - range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 2, character: 18) - ), - displayText: "" - ) - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - SuggestionInjector().proposeSuggestion( - intoContentWithoutSuggestion: &lines, - completion: suggestion, - index: 0, - count: 10, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertFalse(extraInfo.didChangeCursorPosition) - XCTAssertEqual(extraInfo.suggestionRange, 1...4) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(lines.joined(separator: ""), """ - struct Cat { - /*========== Copilot Suggestion 1/10 - var name: String - var age: String - *///======== End of Copilot Suggestion - } - """) - } - - func test_propose_suggestion_overlap() async throws { - let content = """ - struct Cat { - var name - } - """ - let text = """ - var name: String - var age: String - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 1, character: 0), - uuid: "", - range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 2, character: 18) - ), - displayText: "" - ) - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - SuggestionInjector().proposeSuggestion( - intoContentWithoutSuggestion: &lines, - completion: suggestion, - index: 0, - count: 10, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertFalse(extraInfo.didChangeCursorPosition) - XCTAssertEqual(extraInfo.suggestionRange, 2...5) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(lines.joined(separator: ""), """ - struct Cat { - var name - /*========== Copilot Suggestion 1/10 - ^: String - var age: String - *///======== End of Copilot Suggestion - } - """) - } - - func test_propose_suggestion_overlap_first_line_is_empty() async throws { - let content = """ - struct Cat { - var name: String - } - """ - let text = """ - var name: String - var age: String - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 1, character: 0), - uuid: "", - range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 2, character: 18) - ), - displayText: "" - ) - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - SuggestionInjector().proposeSuggestion( - intoContentWithoutSuggestion: &lines, - completion: suggestion, - index: 0, - count: 10, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertFalse(extraInfo.didChangeCursorPosition) - XCTAssertEqual(extraInfo.suggestionRange, 2...5) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(lines.joined(separator: ""), """ - struct Cat { - var name: String - /*========== Copilot Suggestion 1/10 - ^ - var age: String - *///======== End of Copilot Suggestion - } - """) - } - - // swiftformat:disable indent trailingSpace - func test_propose_suggestion_overlap_pure_spaces() async throws { - let content = """ - func quickSort() { - - } - """ // Yes the second line has 4 spaces! - let text = """ - var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] - var left = 0 - var right = array.count - 1 - quickSort(&array, left, right) - print(array) - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 1, character: 0), - uuid: "", - range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 2, character: 18) - ), - displayText: "" - ) - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - SuggestionInjector().proposeSuggestion( - intoContentWithoutSuggestion: &lines, - completion: suggestion, - index: 0, - count: 10, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertFalse(extraInfo.didChangeCursorPosition) - XCTAssertEqual(extraInfo.suggestionRange, 2...8) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(lines.joined(separator: ""), """ - func quickSort() { - - /*========== Copilot Suggestion 1/10 - ^var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] - var left = 0 - var right = array.count - 1 - quickSort(&array, left, right) - print(array) - *///======== End of Copilot Suggestion - } - """) // Yes the second line still has 4 spaces! - } - - // swiftformat:enable all - - func test_propose_suggestion_partial_overlap() async throws { - let content = "func quickSort() {}}\n" - let text = """ - func quickSort() { - var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] - var left = 0 - var right = array.count - 1 - quickSort(&array, left, right) - print(array) - } - """ - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 0, character: 0), - uuid: "", - range: .init( - start: .init(line: 0, character: 0), - end: .init(line: 5, character: 15) - ), - displayText: "" - ) - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - SuggestionInjector().proposeSuggestion( - intoContentWithoutSuggestion: &lines, - completion: suggestion, - index: 0, - count: 10, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertFalse(extraInfo.didChangeCursorPosition) - XCTAssertEqual(extraInfo.suggestionRange, 1...9) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(lines.joined(separator: ""), """ - func quickSort() {}} - /*========== Copilot Suggestion 1/10 - ^ - var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] - var left = 0 - var right = array.count - 1 - quickSort(&array, left, right) - print(array) - } - *///======== End of Copilot Suggestion - - """) - } - - func test_propose_suggestion_overlap_one_line_adding_only_spaces() async throws { - let content = """ - if true { - print("hello") - } else { - print("world") - } - """ - let text = "} else {\n" - let suggestion = CodeSuggestion( - text: text, - position: .init(line: 2, character: 0), - uuid: "", - range: .init( - start: .init(line: 2, character: 0), - end: .init(line: 2, character: 8) - ), - displayText: "" - ) - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - SuggestionInjector().proposeSuggestion( - intoContentWithoutSuggestion: &lines, - completion: suggestion, - index: 0, - count: 10, - extraInfo: &extraInfo - ) - XCTAssertFalse(extraInfo.didChangeContent) - XCTAssertFalse(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(lines.joined(separator: ""), """ - if true { - print("hello") - } else { - print("world") - } - """) - } -} diff --git a/Core/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift b/Core/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift deleted file mode 100644 index eeef6be6..00000000 --- a/Core/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift +++ /dev/null @@ -1,83 +0,0 @@ -import SuggestionModel -import XCTest - -@testable import SuggestionInjector - -final class RejectSuggestionTests: XCTestCase { - func test_rejecting_suggestion() async throws { - let content = """ - struct Cat { - var name - /*========== Copilot Suggestion 1/10 - ^: String - var age: String - *///======== End of Copilot Suggestion - } - """ - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - var cursor = CursorPosition(line: 1, character: 12) - SuggestionInjector().rejectCurrentSuggestions( - from: &lines, - cursorPosition: &cursor, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertFalse(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(lines.joined(separator: ""), """ - struct Cat { - var name - } - """) - XCTAssertEqual( - cursor, - .init(line: 1, character: 12), - "If cursor is above deletion, don't move it." - ) - } - - func test_broken_suggestion() async throws { - let content = """ - struct Cat { - var name - /*========== Copilot Suggestion 1/10 - ^: String - var age: String - *///======== End of Copilot Suggestion - - /*========== Copilot Suggestion 2/10 - - /*========== Copilot Suggestion 1/10 - ^: String - var age: String - *///======== End of Copilot Suggestion - """ - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() - var cursor = CursorPosition(line: 6, character: 0) - SuggestionInjector().rejectCurrentSuggestions( - from: &lines, - cursorPosition: &cursor, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(lines.joined(separator: ""), """ - struct Cat { - var name - - /*========== Copilot Suggestion 2/10 - - - """) - XCTAssertEqual( - cursor, - .init(line: 2, character: 0), - "If cursor is below deletion, move it up." - ) - } -} diff --git a/Core/Tests/SuggestionWidgetTests/File.swift b/Core/Tests/SuggestionWidgetTests/File.swift new file mode 100644 index 00000000..fecc4ab4 --- /dev/null +++ b/Core/Tests/SuggestionWidgetTests/File.swift @@ -0,0 +1 @@ +import Foundation diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e6c21976..5b663fbe 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -4,36 +4,41 @@ ### Copilot for Xcode -Copilot for Xcode is the host app containing both the XPCService and the editor extension. +Copilot for Xcode is the host app containing both the XPCService and the editor extension. It provides the settings UI. ### EditorExtension -As its name suggests, the editor extension. Its sole purpose is to forward editor content to the XPCService for processing, and update the editor with the returned content. Due to the sandboxing requirements for editor extensions, it has to communicate with a trusted, non-sandboxed XPCService to bypass the limitations. The XPCService identifier must be included in the `com.apple.security.temporary-exception.mach-lookup.global-name` entitlements. +As its name suggests, the Xcode source editor extension. Its sole purpose is to forward editor content to the XPCService for processing, and update the editor with the returned content. Due to the sandboxing requirements for editor extensions, it has to communicate with a trusted, non-sandboxed XPCService (CommunicationBridge and ExtensionService) to bypass the limitations. The XPCService service name must be included in the `com.apple.security.temporary-exception.mach-lookup.global-name` entitlements. ### ExtensionService -The `ExtensionService` is a program that operates in the background and performs a wide range of tasks. It redirects requests from the `EditorExtension` to the `CopilotService` and returns the updated code back to the extension, or presents it in a GUI outside of Xcode. +The `ExtensionService` is a program that operates in the background. All features are implemented in this target. -### Core +### CommunicationBridge -Most of the logics are implemented inside the package `Core`. +It's responsible for maintaining the communication between the Copilot for Xcode/EditorExtension and ExtensionService. -- The `CopilotService` is responsible for communicating with the GitHub Copilot LSP. -- The `Service` is responsible for handling the requests from the `EditorExtension`, communicating with the `CopilotService`, update the code blocks and present the GUI. -- The `Client` is basically just a wrapper around the XPCService -- The `SuggestionInjector` is responsible for injecting the suggestions into the code. Used in comment mode to present the suggestions, and all modes to accept suggestions. -- The `Environment` contains some swappable global functions. It is used to make testing easier. -- The `SuggestionWidget` is responsible for presenting the suggestions in floating widget mode. +### Core and Tool + +Most of the logics are implemented inside the package `Core` and `Tool`. + +- The `Service` contains the implementations of the ExtensionService target. +- The `HostApp` contains the implementations of the Copilot for Xcode target. ## Building and Archiving the App -This project includes a Git submodule, `copilot.vim`, so you will need to either initialize the submodule or download it from the [copilot.vim](https://github.com/github/copilot.vim) repository. +1. Update the xcconfig files, bridgeLaunchAgent.plist, and Tool/Configs/Configurations.swift. +2. Build or archive the Copilot for Xcode target. + +## Testing Source Editor Extension and Service + +Just run both the `ExtensionService`, `CommunicationBridge` and the `EditorExtension` Target. Read [Testing Your Source Editor Extension](https://developer.apple.com/documentation/xcodekit/testing_your_source_editor_extension) for more details. -Finally, archive the Copilot for Xcode target. +If you are not testing the source editor extension, it's recommended to archive and install a debug version of the Copilot for Xcode and test with the bundled source editor extension. -## Testing Extension +## SwiftUI Previews -Just run both the `ExtensionService` and the `EditorExtension` Target. +Looks like SwiftUI Previews are not very happy with Objective-C packages when running with app targets. To use previews, please switch schemes to the package product targets. ## Unit Tests diff --git a/EditorExtension/AcceptPromptToCodeCommand.swift b/EditorExtension/AcceptPromptToCodeCommand.swift new file mode 100644 index 00000000..7ac95c35 --- /dev/null +++ b/EditorExtension/AcceptPromptToCodeCommand.swift @@ -0,0 +1,31 @@ +import Client +import Foundation +import SuggestionBasic +import XcodeKit + +class AcceptPromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Accept Modification" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + Task { + do { + try await (Task(timeout: 7) { + let service = try getService() + if let content = try await service.getPromptToCodeAcceptedCode( + editorContent: .init(invocation) + ) { + invocation.accept(content) + } + completionHandler(nil) + }.value) + } catch is CancellationError { + completionHandler(nil) + } catch { + completionHandler(error) + } + } + } +} diff --git a/EditorExtension/AcceptSuggestionCommand.swift b/EditorExtension/AcceptSuggestionCommand.swift index 654f472b..cbca64ed 100644 --- a/EditorExtension/AcceptSuggestionCommand.swift +++ b/EditorExtension/AcceptSuggestionCommand.swift @@ -1,6 +1,6 @@ import Client -import SuggestionModel import Foundation +import SuggestionBasic import XcodeKit import XPCShared @@ -31,32 +31,30 @@ class AcceptSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { } } -/// https://gist.github.com/swhitty/9be89dfe97dbb55c6ef0f916273bbb97 -extension Task where Failure == Error { - // Start a new Task with a timeout. If the timeout expires before the operation is - // completed then the task is cancelled and an error is thrown. - init( - priority: TaskPriority? = nil, - timeout: TimeInterval, - operation: @escaping @Sendable () async throws -> Success +class AcceptSuggestionLineCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Accept Suggestion Line" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void ) { - self = Task(priority: priority) { - try await withThrowingTaskGroup(of: Success.self) { group -> Success in - group.addTask(operation: operation) - group.addTask { - try await _Concurrency.Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) - throw TimeoutError() - } - guard let success = try await group.next() else { - throw _Concurrency.CancellationError() - } - group.cancelAll() - return success + Task { + do { + try await (Task(timeout: 7) { + let service = try getService() + if let content = try await service.send( + requestBody: ExtensionServiceRequests + .GetSuggestionLineAcceptedCode(editorContent: .init(invocation)) + ) { + invocation.accept(content) + } + completionHandler(nil) + }.value) + } catch is CancellationError { + completionHandler(nil) + } catch { + completionHandler(error) } } } } - -private struct TimeoutError: LocalizedError { - var errorDescription: String? = "Task timed out before completion" -} diff --git a/EditorExtension/CloseIdleTabsCommand.swift b/EditorExtension/CloseIdleTabsCommand.swift new file mode 100644 index 00000000..0e9537ee --- /dev/null +++ b/EditorExtension/CloseIdleTabsCommand.swift @@ -0,0 +1,20 @@ +import Client +import Foundation +import SuggestionBasic +import XcodeKit + +class CloseIdleTabsCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Close Idle Tabs" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.postNotification(name: "CloseIdleTabsOfXcodeWindow") + } + } +} + diff --git a/EditorExtension/CustomCommand.swift b/EditorExtension/CustomCommand.swift index 916c0db3..0a43a51d 100644 --- a/EditorExtension/CustomCommand.swift +++ b/EditorExtension/CustomCommand.swift @@ -1,6 +1,6 @@ import Client import Foundation -import SuggestionModel +import SuggestionBasic import XcodeKit class CustomCommand: NSObject, XCSourceEditorCommand, CommandType { diff --git a/EditorExtension/EditorExtension.entitlements b/EditorExtension/EditorExtension.entitlements index 44b9a164..776babcc 100644 --- a/EditorExtension/EditorExtension.entitlements +++ b/EditorExtension/EditorExtension.entitlements @@ -10,6 +10,7 @@ com.apple.security.temporary-exception.mach-lookup.global-name + $(BUNDLE_IDENTIFIER_BASE).CommunicationBridge $(BUNDLE_IDENTIFIER_BASE).ExtensionService diff --git a/EditorExtension/GetSuggestionsCommand.swift b/EditorExtension/GetSuggestionsCommand.swift index e0c5e12c..6be1c417 100644 --- a/EditorExtension/GetSuggestionsCommand.swift +++ b/EditorExtension/GetSuggestionsCommand.swift @@ -1,6 +1,6 @@ import Client import Foundation -import SuggestionModel +import SuggestionBasic import XcodeKit class GetSuggestionsCommand: NSObject, XCSourceEditorCommand, CommandType { @@ -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/Helpers.swift b/EditorExtension/Helpers.swift index 6299657f..beb49c66 100644 --- a/EditorExtension/Helpers.swift +++ b/EditorExtension/Helpers.swift @@ -1,5 +1,5 @@ -import SuggestionModel import Foundation +import SuggestionBasic import XcodeKit import XPCShared @@ -19,16 +19,21 @@ extension XCSourceEditorCommandInvocation { } func accept(_ updatedContent: UpdatedContent) { - if let newSelection = updatedContent.newSelection { + if !updatedContent.newSelections.isEmpty { mutateCompleteBuffer( modifications: updatedContent.modifications, restoringSelections: false ) buffer.selections.removeAllObjects() - buffer.selections.add(XCSourceTextRange( - start: .init(line: newSelection.start.line, column: newSelection.start.character), - end: .init(line: newSelection.end.line, column: newSelection.end.character) - )) + for newSelection in updatedContent.newSelections { + buffer.selections.add(XCSourceTextRange( + start: .init( + line: newSelection.start.line, + column: newSelection.start.character + ), + end: .init(line: newSelection.end.line, column: newSelection.end.character) + )) + } } else { mutateCompleteBuffer( modifications: updatedContent.modifications, @@ -48,15 +53,16 @@ extension EditorContent { cursorPosition: ((buffer.selections.lastObject as? XCSourceTextRange)?.end).map { CursorPosition(line: $0.line, character: $0.column) } ?? CursorPosition(line: 0, character: 0), + cursorOffset: -1, selections: buffer.selections.map { let sl = ($0 as? XCSourceTextRange)?.start.line ?? 0 let sc = ($0 as? XCSourceTextRange)?.start.column ?? 0 let el = ($0 as? XCSourceTextRange)?.end.line ?? 0 let ec = ($0 as? XCSourceTextRange)?.end.column ?? 0 - + return Selection( - start: CursorPosition( line: sl, character: sc ), - end: CursorPosition( line: el, character: ec ) + start: CursorPosition(line: sl, character: sc), + end: CursorPosition(line: el, character: ec) ) }, tabSize: buffer.tabWidth, @@ -65,3 +71,34 @@ extension EditorContent { ) } } + +/// https://gist.github.com/swhitty/9be89dfe97dbb55c6ef0f916273bbb97 +extension Task where Failure == Error { + // Start a new Task with a timeout. If the timeout expires before the operation is + // completed then the task is cancelled and an error is thrown. + init( + priority: TaskPriority? = nil, + timeout: TimeInterval, + operation: @escaping @Sendable () async throws -> Success + ) { + self = Task(priority: priority) { + try await withThrowingTaskGroup(of: Success.self) { group -> Success in + group.addTask(operation: operation) + group.addTask { + try await _Concurrency.Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + throw TimeoutError() + } + guard let success = try await group.next() else { + throw _Concurrency.CancellationError() + } + group.cancelAll() + return success + } + } + } +} + +private struct TimeoutError: LocalizedError { + var errorDescription: String? = "Task timed out before completion" +} + diff --git a/EditorExtension/NextSuggestionCommand.swift b/EditorExtension/NextSuggestionCommand.swift index f5b9bd70..f07f4017 100644 --- a/EditorExtension/NextSuggestionCommand.swift +++ b/EditorExtension/NextSuggestionCommand.swift @@ -1,6 +1,6 @@ import Client import Foundation -import SuggestionModel +import SuggestionBasic import XcodeKit class NextSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { @@ -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/ChatWithSelection.swift b/EditorExtension/OpenChat.swift similarity index 63% rename from EditorExtension/ChatWithSelection.swift rename to EditorExtension/OpenChat.swift index e1dd0b81..fccdc3fe 100644 --- a/EditorExtension/ChatWithSelection.swift +++ b/EditorExtension/OpenChat.swift @@ -1,9 +1,9 @@ import Client -import SuggestionModel +import SuggestionBasic import Foundation import XcodeKit -class ChatWithSelectionCommand: NSObject, XCSourceEditorCommand, CommandType { +class OpenChatCommand: NSObject, XCSourceEditorCommand, CommandType { var name: String { "Open Chat" } func perform( @@ -13,7 +13,7 @@ class ChatWithSelectionCommand: NSObject, XCSourceEditorCommand, CommandType { completionHandler(nil) Task { let service = try getService() - _ = try await service.chatWithSelection(editorContent: .init(invocation)) + _ = try await service.openChat(editorContent: .init(invocation)) } } } diff --git a/EditorExtension/PrefetchSuggestionsCommand.swift b/EditorExtension/PrefetchSuggestionsCommand.swift index 73878fbb..bc43c40e 100644 --- a/EditorExtension/PrefetchSuggestionsCommand.swift +++ b/EditorExtension/PrefetchSuggestionsCommand.swift @@ -1,5 +1,5 @@ import Client -import SuggestionModel +import SuggestionBasic import Foundation import XcodeKit diff --git a/EditorExtension/PreviousSuggestionCommand.swift b/EditorExtension/PreviousSuggestionCommand.swift index d5b452b5..61894bab 100644 --- a/EditorExtension/PreviousSuggestionCommand.swift +++ b/EditorExtension/PreviousSuggestionCommand.swift @@ -1,6 +1,6 @@ import Client import Foundation -import SuggestionModel +import SuggestionBasic import XcodeKit class PreviousSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { @@ -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/PromptToCodeCommand.swift b/EditorExtension/PromptToCodeCommand.swift index 852bedb6..a2f814ac 100644 --- a/EditorExtension/PromptToCodeCommand.swift +++ b/EditorExtension/PromptToCodeCommand.swift @@ -1,10 +1,10 @@ import Client -import SuggestionModel +import SuggestionBasic import Foundation import XcodeKit class PromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType { - var name: String { "Prompt to Code" } + var name: String { "Write or Edit Code" } func perform( with invocation: XCSourceEditorCommandInvocation, diff --git a/EditorExtension/RealtimeSuggestionCommand.swift b/EditorExtension/RealtimeSuggestionCommand.swift index ed14945b..ed0473d5 100644 --- a/EditorExtension/RealtimeSuggestionCommand.swift +++ b/EditorExtension/RealtimeSuggestionCommand.swift @@ -1,38 +1,19 @@ import Client -import SuggestionModel +import SuggestionBasic import Foundation import XcodeKit class RealtimeSuggestionsCommand: NSObject, XCSourceEditorCommand, CommandType { - var name: String { "Real-time Suggestions" } + var name: String { "Prepare for Real-time Suggestions" } func perform( 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..f9f370c9 100644 --- a/EditorExtension/RejectSuggestionCommand.swift +++ b/EditorExtension/RejectSuggestionCommand.swift @@ -1,6 +1,6 @@ import Client import Foundation -import SuggestionModel +import SuggestionBasic import XcodeKit class RejectSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { @@ -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/EditorExtension/SeparatorCommand.swift b/EditorExtension/SeparatorCommand.swift index ba1ff882..79e4b138 100644 --- a/EditorExtension/SeparatorCommand.swift +++ b/EditorExtension/SeparatorCommand.swift @@ -1,5 +1,5 @@ import Client -import SuggestionModel +import SuggestionBasic import Foundation import XcodeKit diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift index 7ad0b841..f102f9d4 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -3,29 +3,56 @@ import Foundation import Preferences import XcodeKit +#if canImport(PreferencesPlus) +import PreferencesPlus +#endif + class SourceEditorExtension: NSObject, XCSourceEditorExtension { var builtin: [[XCSourceEditorCommandDefinitionKey: Any]] { [ GetSuggestionsCommand(), AcceptSuggestionCommand(), + AcceptSuggestionLineCommand(), RejectSuggestionCommand(), NextSuggestionCommand(), PreviousSuggestionCommand(), - RealtimeSuggestionsCommand(), - PrefetchSuggestionsCommand(), - ChatWithSelectionCommand(), PromptToCodeCommand(), - + AcceptPromptToCodeCommand(), + OpenChatCommand(), + ToggleRealtimeSuggestionsCommand(), + ].map(makeCommandDefinition) + } + + var optional: [[XCSourceEditorCommandDefinitionKey: Any]] { + var all = [[XCSourceEditorCommandDefinitionKey: Any]]() + + #if canImport(PreferencesPlus) + if UserDefaults.shared.value(for: \.enableCloseIdleTabCommandInXcodeMenu) { + all.append(CloseIdleTabsCommand().makeCommandDefinition()) + } + #endif + + return all + } + + var internalUse: [[XCSourceEditorCommandDefinitionKey: Any]] { + [ SeparatorCommand().named("------"), + RealtimeSuggestionsCommand(), + PrefetchSuggestionsCommand(), ].map(makeCommandDefinition) } var custom: [[XCSourceEditorCommandDefinitionKey: Any]] { - customCommands() + let all = customCommands() + if all.isEmpty { + return [] + } + return [SeparatorCommand().named("------")].map(makeCommandDefinition) + all } var commandDefinitions: [[XCSourceEditorCommandDefinitionKey: Any]] { - return builtin + custom + return builtin + optional + custom + internalUse } func extensionDidFinishLaunching() { diff --git a/EditorExtension/ToggleRealtimeSuggestionsCommand.swift b/EditorExtension/ToggleRealtimeSuggestionsCommand.swift index 44fe1883..ab226a0b 100644 --- a/EditorExtension/ToggleRealtimeSuggestionsCommand.swift +++ b/EditorExtension/ToggleRealtimeSuggestionsCommand.swift @@ -1,5 +1,5 @@ import Client -import SuggestionModel +import SuggestionBasic import Foundation import XcodeKit diff --git a/ExtensionPoint.appextensionpoint b/ExtensionPoint.appextensionpoint new file mode 100644 index 00000000..0a3d3f89 --- /dev/null +++ b/ExtensionPoint.appextensionpoint @@ -0,0 +1,11 @@ + + + + + com.intii.CopilotForXcode.ExtensionService.Extension + + EXPresentsUserInterface + + + + diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift new file mode 100644 index 00000000..9107c97a --- /dev/null +++ b/ExtensionService/AppDelegate+Menu.swift @@ -0,0 +1,296 @@ +import AppKit +import Foundation +import Preferences +import XcodeInspector +import Dependencies +import Workspace + +extension AppDelegate { + fileprivate var statusBarMenuIdentifier: NSUserInterfaceItemIdentifier { + .init("statusBarMenu") + } + + fileprivate var xcodeInspectorDebugMenuIdentifier: NSUserInterfaceItemIdentifier { + .init("xcodeInspectorDebugMenu") + } + + fileprivate var accessibilityAPIPermissionMenuItemIdentifier: NSUserInterfaceItemIdentifier { + .init("accessibilitAPIPermissionMenuItem") + } + + fileprivate var sourceEditorDebugMenu: NSUserInterfaceItemIdentifier { + .init("sourceEditorDebugMenu") + } + + @MainActor + @objc func buildStatusBarMenu() { + let statusBar = NSStatusBar.system + statusBarItem = statusBar.statusItem( + withLength: NSStatusItem.squareLength + ) + statusBarItem.button?.image = NSImage(named: "MenuBarIcon") + + let statusBarMenu = NSMenu(title: "Status Bar Menu") + statusBarMenu.identifier = statusBarMenuIdentifier + statusBarItem.menu = statusBarMenu + + let hostAppName = Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String + ?? "Copilot for Xcode" + + let copilotName = NSMenuItem( + title: hostAppName, + action: nil, + keyEquivalent: "" + ) + + let checkForUpdate = NSMenuItem( + title: "Check for Updates", + action: #selector(checkForUpdate), + keyEquivalent: "" + ) + + let openExtensionManager = NSMenuItem( + title: "Open Extension Manager", + action: #selector(openExtensionManager), + keyEquivalent: "" + ) + + let openCopilotForXcode = NSMenuItem( + title: "Open \(hostAppName)", + action: #selector(openCopilotForXcode), + keyEquivalent: "" + ) + + let openGlobalChat = NSMenuItem( + title: "Open Chat", + action: #selector(openGlobalChat), + keyEquivalent: "" + ) + + let xcodeInspectorDebug = NSMenuItem( + title: "Xcode Inspector Debug", + action: nil, + keyEquivalent: "" + ) + + let xcodeInspectorDebugMenu = NSMenu(title: "Xcode Inspector Debug") + xcodeInspectorDebugMenu.identifier = xcodeInspectorDebugMenuIdentifier + xcodeInspectorDebug.submenu = xcodeInspectorDebugMenu + xcodeInspectorDebug.isHidden = false + + let accessibilityAPIPermission = NSMenuItem( + title: "Accessibility API Permission: N/A", + action: nil, + keyEquivalent: "" + ) + accessibilityAPIPermission.identifier = accessibilityAPIPermissionMenuItemIdentifier + + let quitItem = NSMenuItem( + title: "Quit", + action: #selector(quit), + keyEquivalent: "" + ) + quitItem.target = self + + let reactivateObservationsItem = NSMenuItem( + title: "Reactivate Observations to Xcode", + action: #selector(reactivateObservationsToXcode), + keyEquivalent: "" + ) + + let resetWorkspacesItem = NSMenuItem( + title: "Reset workspaces", + action: #selector(destroyWorkspacePool), + keyEquivalent: "" + ) + + reactivateObservationsItem.target = self + + statusBarMenu.addItem(copilotName) + statusBarMenu.addItem(openCopilotForXcode) + statusBarMenu.addItem(checkForUpdate) + statusBarMenu.addItem(openExtensionManager) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(openGlobalChat) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(xcodeInspectorDebug) + statusBarMenu.addItem(accessibilityAPIPermission) + statusBarMenu.addItem(reactivateObservationsItem) + statusBarMenu.addItem(resetWorkspacesItem) + statusBarMenu.addItem(quitItem) + + statusBarMenu.delegate = self + xcodeInspectorDebugMenu.delegate = self + } +} + +extension AppDelegate: NSMenuDelegate { + func menuWillOpen(_ menu: NSMenu) { + switch menu.identifier { + case statusBarMenuIdentifier: + if let xcodeInspectorDebug = menu.items.first(where: { item in + item.submenu?.identifier == xcodeInspectorDebugMenuIdentifier + }) { + xcodeInspectorDebug.isHidden = !UserDefaults.shared + .value(for: \.enableXcodeInspectorDebugMenu) + } + + if let accessibilityAPIPermission = menu.items.first(where: { item in + item.identifier == accessibilityAPIPermissionMenuItemIdentifier + }) { + AXIsProcessTrusted() + accessibilityAPIPermission.title = + "Accessibility API Permission: \(AXIsProcessTrusted() ? "Granted" : "Not Granted")" + } + + case xcodeInspectorDebugMenuIdentifier: + let inspector = XcodeInspector.shared + menu.items.removeAll() + menu.items + .append(.text("Active Project: \(inspector.activeProjectRootURL?.path ?? "N/A")")) + menu.items + .append(.text("Active Workspace: \(inspector.activeWorkspaceURL?.path ?? "N/A")")) + menu.items + .append(.text("Active Document: \(inspector.activeDocumentURL?.path ?? "N/A")")) + + if let focusedWindow = inspector.focusedWindow { + menu.items.append(.text( + "Active Window: \(focusedWindow.uiElement.identifier)" + )) + } else { + menu.items.append(.text("Active Window: N/A")) + } + + if let focusedElement = inspector.focusedElement { + menu.items.append(.text( + "Focused Element: \(focusedElement.description)" + )) + } else { + menu.items.append(.text("Focused Element: N/A")) + } + + if let sourceEditor = inspector.latestFocusedEditor { + let label = sourceEditor.element.description + menu.items + .append(.text("Active Source Editor: \(label.isEmpty ? "Unknown" : label)")) + } else { + menu.items.append(.text("Active Source Editor: N/A")) + } + + menu.items.append(.separator()) + + for xcode in inspector.xcodes { + let item = NSMenuItem( + title: "Xcode \(xcode.processIdentifier)", + action: nil, + keyEquivalent: "" + ) + menu.addItem(item) + let xcodeMenu = NSMenu() + item.submenu = xcodeMenu + xcodeMenu.items.append(.text("Is Active: \(xcode.isActive)")) + xcodeMenu.items + .append(.text("Active Project: \(xcode.projectRootURL?.path ?? "N/A")")) + xcodeMenu.items + .append(.text("Active Workspace: \(xcode.workspaceURL?.path ?? "N/A")")) + xcodeMenu.items + .append(.text("Active Document: \(xcode.documentURL?.path ?? "N/A")")) + + for (key, workspace) in xcode.realtimeWorkspaces { + let workspaceItem = NSMenuItem( + title: "Workspace \(key)", + action: nil, + keyEquivalent: "" + ) + xcodeMenu.items.append(workspaceItem) + let workspaceMenu = NSMenu() + workspaceItem.submenu = workspaceMenu + let tabsItem = NSMenuItem( + title: "Tabs", + action: nil, + keyEquivalent: "" + ) + workspaceMenu.addItem(tabsItem) + let tabsMenu = NSMenu() + tabsItem.submenu = tabsMenu + for tab in workspace.tabs { + tabsMenu.addItem(.text(tab)) + } + } + } + + menu.items.append(.separator()) + + menu.items.append(NSMenuItem( + title: "Restart Xcode Inspector", + action: #selector(restartXcodeInspector), + keyEquivalent: "" + )) + + let isDebuggingOverlay = UserDefaults.shared.value(for: \.debugOverlayPanel) + let debugOverlayItem = NSMenuItem( + title: "Debug Window Overlays", + action: #selector(toggleDebugOverlayPanel), + keyEquivalent: "" + ) + debugOverlayItem.state = isDebuggingOverlay ? .on : .off + menu.items.append(debugOverlayItem) + + default: + break + } + } +} + +import XPCShared + +private extension AppDelegate { + @objc func restartXcodeInspector() { + Task { + await XcodeInspector.shared.restart(cleanUp: true) + } + } + + @objc func reactivateObservationsToXcode() { + Task { + await XcodeInspector.shared.reactivateObservationsToXcode() + } + } + + @objc func openExtensionManager() { + guard let data = try? JSONEncoder().encode(ExtensionServiceRequests.OpenExtensionManager()) + else { return } + Task { + await service.handleXPCServiceRequests( + endpoint: ExtensionServiceRequests.OpenExtensionManager.endpoint, + requestBody: data, + reply: { _, _ in } + ) + } + } + + @objc func destroyWorkspacePool() { + @Dependency(\.workspacePool) var workspacePool: WorkspacePool + Task { + await workspacePool.destroy() + } + } + + @objc func toggleDebugOverlayPanel() { + let isDebuggingOverlay = UserDefaults.shared.value(for: \.debugOverlayPanel) + UserDefaults.shared.set(!isDebuggingOverlay, for: \.debugOverlayPanel) + } +} + +private extension NSMenuItem { + static func text(_ text: String) -> NSMenuItem { + let item = NSMenuItem( + title: text, + action: nil, + keyEquivalent: "" + ) + item.isEnabled = false + return item + } +} + diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 501ff6a8..801ce37f 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -1,8 +1,7 @@ -import AppKit -import Environment import FileChangeChecker import LaunchAgentManager import Logger +import Perception import Preferences import Service import ServiceManagement @@ -12,6 +11,7 @@ import UpdateChecker import UserDefaultsObserver import UserNotifications import XcodeInspector +import XPCShared let bundleIdentifierBase = Bundle.main .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String @@ -19,30 +19,32 @@ let serviceIdentifier = bundleIdentifierBase + ".ExtensionService" @main class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { - let scheduledCleaner = ScheduledCleaner() - private var statusBarItem: NSStatusItem! - private var xpcListener: (NSXPCListener, ServiceDelegate)? - private let updateChecker = - UpdateChecker( - hostBundle: locateHostBundleURL(url: Bundle.main.bundleURL) - .flatMap(Bundle.init(url:)) - ) + @MainActor + let service = Service.shared + var statusBarItem: NSStatusItem! + var xpcController: XPCController? + let updateChecker = UpdateChecker( + hostBundle: locateHostBundleURL(url: Bundle.main.bundleURL) + .flatMap(Bundle.init(url:)), + shouldAutomaticallyCheckForUpdate: true + ) func applicationDidFinishLaunching(_: Notification) { +// isPerceptionCheckingEnabled = false if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } - _ = GraphicalUserInterfaceController.shared - _ = RealtimeSuggestionController.shared _ = XcodeInspector.shared + updateChecker.updateCheckerDelegate = self + service.start() AXIsProcessTrustedWithOptions([ kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true, ] as CFDictionary) setupQuitOnUpdate() setupQuitOnUserTerminated() - xpcListener = setupXPCListener() + xpcController = .init() Logger.service.info("XPC Service started.") NSApp.setActivationPolicy(.accessory) buildStatusBarMenu() - DependencyUpdater().update() + Task { do { try await ServiceUpdateMigrator().migrate() @@ -52,63 +54,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } - @objc private func buildStatusBarMenu() { - let statusBar = NSStatusBar.system - statusBarItem = statusBar.statusItem( - withLength: NSStatusItem.squareLength - ) - statusBarItem.button?.image = NSImage(named: "MenuBarIcon") - - let statusBarMenu = NSMenu(title: "Status Bar Menu") - statusBarItem.menu = statusBarMenu - - let hostAppName = Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String - ?? "Copilot for Xcode" - - let copilotName = NSMenuItem( - title: hostAppName, - action: nil, - keyEquivalent: "" - ) - - let checkForUpdate = NSMenuItem( - title: "Check for Updates", - action: #selector(checkForUpdate), - keyEquivalent: "" - ) - - let openCopilotForXcode = NSMenuItem( - title: "Open \(hostAppName)", - action: #selector(openCopilotForXcode), - keyEquivalent: "" - ) - - let openGlobalChat = NSMenuItem( - title: "Open Chat", - action: #selector(openGlobalChat), - keyEquivalent: "" - ) - - let quitItem = NSMenuItem( - title: "Quit", - action: #selector(quit), - keyEquivalent: "" - ) - quitItem.target = self - - statusBarMenu.addItem(copilotName) - statusBarMenu.addItem(openCopilotForXcode) - statusBarMenu.addItem(checkForUpdate) - statusBarMenu.addItem(.separator()) - statusBarMenu.addItem(openGlobalChat) - statusBarMenu.addItem(.separator()) - statusBarMenu.addItem(quitItem) - } - @objc func quit() { Task { @MainActor in - await scheduledCleaner.closeAllChildProcesses() - exit(0) + await service.prepareForExit() + await xpcController?.quit() + NSApp.terminate(self) } } @@ -124,7 +74,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { @objc func openGlobalChat() { Task { @MainActor in - let serviceGUI = GraphicalUserInterfaceController.shared + let serviceGUI = Service.shared.guiController serviceGUI.openGlobalChat() } } @@ -181,14 +131,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } - func setupXPCListener() -> (NSXPCListener, ServiceDelegate) { - let listener = NSXPCListener(machServiceName: serviceIdentifier) - let delegate = ServiceDelegate() - listener.delegate = delegate - listener.resume() - return (listener, delegate) - } - func requestAccessoryAPIPermission() { AXIsProcessTrustedWithOptions([ kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true, @@ -200,6 +142,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } +extension AppDelegate: UpdateCheckerDelegate { + func prepareForRelaunch(finish: @escaping () -> Void) { + Task { + await service.prepareForExit() + finish() + } + } +} + extension NSRunningApplication { var isUserOfService: Bool { [ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png deleted file mode 100644 index 5af40630..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png deleted file mode 100644 index 592f927b..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png deleted file mode 100644 index 09658ce1..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png deleted file mode 100644 index 4a7d88ed..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png deleted file mode 100644 index 4a7d88ed..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png deleted file mode 100644 index 6c4293c8..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png deleted file mode 100644 index 6c4293c8..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png deleted file mode 100644 index db461044..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png deleted file mode 100644 index db461044..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png deleted file mode 100644 index 42d7a164..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json index 56acb569..d3a89dc6 100644 --- a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,61 +1,61 @@ { "images" : [ { - "filename" : "1024 x 1024 your icon@16w.png", + "filename" : "service-icon@16w.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w 1.png", + "filename" : "service-icon@32w.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w.png", + "filename" : "service-icon@32w.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@64w.png", + "filename" : "service-icon@64w.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@128w.png", + "filename" : "service-icon@128w.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w 1.png", + "filename" : "service-icon@256w.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w.png", + "filename" : "service-icon@256w.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w 1.png", + "filename" : "service-icon@512w.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w.png", + "filename" : "service-icon@512w.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { - "filename" : "1024 x 1024 your icon.png", + "filename" : "service-icon.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png new file mode 100644 index 00000000..29782a0f Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png new file mode 100644 index 00000000..c9479d72 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png new file mode 100644 index 00000000..f00e273e Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png new file mode 100644 index 00000000..0546b089 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png new file mode 100644 index 00000000..9f60ddf8 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png new file mode 100644 index 00000000..c00d18cf Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png new file mode 100644 index 00000000..625b2717 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png differ diff --git a/ExtensionService/Info.plist b/ExtensionService/Info.plist index 94c867be..9faed878 100644 --- a/ExtensionService/Info.plist +++ b/ExtensionService/Info.plist @@ -12,6 +12,11 @@ $(EXTENSION_BUNDLE_NAME) HOST_APP_NAME $(HOST_APP_NAME) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + TEAM_ID_PREFIX $(TeamIdentifierPrefix) XPCService diff --git a/ExtensionService/XPCController.swift b/ExtensionService/XPCController.swift new file mode 100644 index 00000000..5fdd4445 --- /dev/null +++ b/ExtensionService/XPCController.swift @@ -0,0 +1,68 @@ +import Foundation +import Logger +import XPCShared + +final class XPCController: XPCServiceDelegate { + let bridge: XPCCommunicationBridge + let xpcListener: NSXPCListener + let xpcServiceDelegate: ServiceDelegate + + var pingTask: Task? + + init() { + let bridge = XPCCommunicationBridge(logger: .client) + let listener = NSXPCListener.anonymous() + let delegate = ServiceDelegate() + listener.delegate = delegate + listener.resume() + xpcListener = listener + xpcServiceDelegate = delegate + self.bridge = bridge + + Task { + bridge.setDelegate(self) + createPingTask() + } + } + + func quit() async { + bridge.setDelegate(nil) + pingTask?.cancel() + try? await bridge.quit() + } + + deinit { + xpcListener.invalidate() + pingTask?.cancel() + } + + func createPingTask() { + pingTask?.cancel() + pingTask = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + do { + try await self.bridge.updateServiceEndpoint(self.xpcListener.endpoint) + try await Task.sleep(nanoseconds: 60_000_000_000) + } catch { + try await Task.sleep(nanoseconds: 1_000_000_000) + #if DEBUG + // No log, but you should run CommunicationBridge, too. + #else + Logger.service + .error("Failed to connect to bridge: \(error.localizedDescription)") + #endif + } + } + } + } + + func connectionDidInvalidate() async { + // ignore + } + + func connectionDidInterrupt() async { + createPingTask() // restart the ping task so that it can bring the bridge back immediately. + } +} + diff --git a/LICENSE b/LICENSE index d3e5da7b..636cb078 100644 --- a/LICENSE +++ b/LICENSE @@ -1,689 +1,21 @@ -# Copilot for Xcode Open Source License - -This license is a combination of the GPLv3 and some additional agreements. - -As a contributor, you agree that your contributed code: -a. may be subject to a more permissive open-source license in the future. -b. can be used for commercial purposes. - -With the GPLv3 and these supplementary agreements, anyone can freely use, modify, and distribute the project, provided that: -- For commercial use of this project or forks of this project, please contact us for authorization. - -Copyright (c) 2023 Shangxin Guo - ----------- - - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. +MIT License + +Copyright (c) 2024 Shangxin Guo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/OverlayWindow/.gitignore b/OverlayWindow/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/OverlayWindow/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/OverlayWindow/Package.swift b/OverlayWindow/Package.swift new file mode 100644 index 00000000..b875c713 --- /dev/null +++ b/OverlayWindow/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "OverlayWindow", + platforms: [.macOS(.v13)], + products: [ + .library( + name: "OverlayWindow", + targets: ["OverlayWindow"] + ), + ], + dependencies: [ + .package(path: "../Tool"), + .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.4.0"), + ], + targets: [ + .target( + name: "OverlayWindow", + dependencies: [ + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "Toast", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "Logger", package: "Tool"), + .product(name: "DebounceFunction", package: "Tool"), + .product(name: "Perception", package: "swift-perception"), + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .testTarget( + name: "OverlayWindowTests", + dependencies: ["OverlayWindow", .product(name: "DebounceFunction", package: "Tool")] + ), + ] +) + diff --git a/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift b/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift new file mode 100644 index 00000000..48263e26 --- /dev/null +++ b/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift @@ -0,0 +1,147 @@ +import AppKit +import AXExtension +import AXNotificationStream +import DebounceFunction +import Foundation +import Perception +import SwiftUI +import XcodeInspector + +@MainActor +public protocol IDEWorkspaceWindowOverlayWindowControllerContentProvider { + associatedtype Content: View + func createWindow() -> NSWindow? + func createContent() -> Content + func destroy() + + init(windowInspector: WorkspaceXcodeWindowInspector, application: NSRunningApplication) +} + +extension IDEWorkspaceWindowOverlayWindowControllerContentProvider { + var contentBody: AnyView { + AnyView(createContent()) + } +} + +@MainActor +final class IDEWorkspaceWindowOverlayWindowController { + private var lastAccessDate: Date = .init() + let application: NSRunningApplication + let inspector: WorkspaceXcodeWindowInspector + let contentProviders: [any IDEWorkspaceWindowOverlayWindowControllerContentProvider] + let maskPanel: OverlayPanel + var windowElement: AXUIElement + private var axNotificationTask: Task? + let updateFrameThrottler = ThrottleRunner(duration: 0.2) + + init( + inspector: WorkspaceXcodeWindowInspector, + application: NSRunningApplication, + contentProviderFactory: ( + _ windowInspector: WorkspaceXcodeWindowInspector, _ application: NSRunningApplication + ) -> [any IDEWorkspaceWindowOverlayWindowControllerContentProvider] + ) { + self.inspector = inspector + self.application = application + let contentProviders = contentProviderFactory(inspector, application) + self.contentProviders = contentProviders + windowElement = inspector.uiElement + + let panel = OverlayPanel( + contentRect: .init(x: 0, y: 0, width: 200, height: 200) + ) { + ZStack { + ForEach(0..( + contentRect: NSRect, + @ViewBuilder content: @escaping () -> Content + ) { + super.init( + contentRect: contentRect, + styleMask: [ + .borderless, + .nonactivatingPanel, + .fullSizeContentView, + ], + backing: .buffered, + defer: false + ) + + isReleasedWhenClosed = false + menu = nil + isOpaque = true + backgroundColor = .clear + hasShadow = false + alphaValue = 1.0 + collectionBehavior = [.fullScreenAuxiliary] + isFloatingPanel = true + titleVisibility = .hidden + titlebarAppearsTransparent = true + animationBehavior = .utilityWindow + + standardWindowButton(.closeButton)?.isHidden = true + standardWindowButton(.miniaturizeButton)?.isHidden = true + standardWindowButton(.zoomButton)?.isHidden = true + + contentView = NSHostingView( + rootView: ContentWrapper(panelState: panelState) { content() } + ) + } + + override public var canBecomeKey: Bool { + return _canBecomeKey + } + + override public var canBecomeMain: Bool { + return false + } + + override public func setIsVisible(_ visible: Bool) { + _canBecomeKey = false + defer { _canBecomeKey = true } + super.setIsVisible(visible) + } + + public func moveToActiveSpace() { + collectionBehavior = [.fullScreenAuxiliary, .moveToActiveSpace] + Task { @MainActor in + try await Task.sleep(nanoseconds: 50_000_000) + self.collectionBehavior = [.fullScreenAuxiliary] + } + } + + func setTopLeftCoordinateFrame(_ frame: CGRect, display: Bool) { + let zeroScreen = NSScreen.screens.first { $0.frame.origin == .zero } + ?? NSScreen.primaryScreen ?? NSScreen.main + let panelFrame = Self.convertAXRectToNSPanelFrame( + axRect: frame, + forPrimaryScreen: zeroScreen + ) + panelState.windowFrame = frame + panelState.windowFrameNSCoordinate = panelFrame + setFrame(panelFrame, display: display) + } + + static func convertAXRectToNSPanelFrame( + axRect: CGRect, + forPrimaryScreen screen: NSScreen? + ) -> CGRect { + guard let screen = screen else { return .zero } + let screenFrame = screen.frame + let flippedY = screenFrame.origin.y + screenFrame.size + .height - (axRect.origin.y + axRect.size.height) + return CGRect( + x: axRect.origin.x, + y: flippedY, + width: axRect.size.width, + height: axRect.size.height + ) + } + + struct ContentWrapper: View { + let panelState: PanelState + @ViewBuilder let content: () -> Content + @AppStorage(\.debugOverlayPanel) var debugOverlayPanel + + var body: some View { + WithPerceptionTracking { + ZStack { + Rectangle().fill(.green.opacity(debugOverlayPanel ? 0.1 : 0)) + .allowsHitTesting(false) + content() + .environment(\.overlayFrame, panelState.windowFrame) + .environment(\.overlayDebug, debugOverlayPanel) + } + } + } + } +} + +func overlayLevel(_ addition: Int) -> NSWindow.Level { + let minimumWidgetLevel: Int + #if DEBUG + minimumWidgetLevel = NSWindow.Level.floating.rawValue + 1 + #else + minimumWidgetLevel = NSWindow.Level.floating.rawValue + #endif + return .init(minimumWidgetLevel + addition) +} + +public extension CGRect { + func flipped(relativeTo reference: CGRect) -> CGRect { + let flippedOrigin = CGPoint( + x: origin.x, + y: reference.height - origin.y - height + ) + return CGRect(origin: flippedOrigin, size: size) + } + + func relative(to reference: CGRect) -> CGRect { + let relativeOrigin = CGPoint( + x: origin.x - reference.origin.x, + y: origin.y - reference.origin.y + ) + return CGRect(origin: relativeOrigin, size: size) + } +} + +public extension NSScreen { + var isPrimary: Bool { + let id = deviceDescription[.init("NSScreenNumber")] as? CGDirectDisplayID + return id == CGMainDisplayID() + } + + static var primaryScreen: NSScreen? { + NSScreen.screens.first { + let id = $0.deviceDescription[.init("NSScreenNumber")] as? CGDirectDisplayID + return id == CGMainDisplayID() + } + } +} + diff --git a/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift b/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift new file mode 100644 index 00000000..f4dbaa73 --- /dev/null +++ b/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift @@ -0,0 +1,206 @@ +import AppKit +import DebounceFunction +import Foundation +import Perception +import XcodeInspector + +@MainActor +public final class OverlayWindowController { + public typealias IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory = + @MainActor @Sendable ( + _ windowInspector: WorkspaceXcodeWindowInspector, + _ application: NSRunningApplication + ) -> any IDEWorkspaceWindowOverlayWindowControllerContentProvider + + static var ideWindowOverlayWindowControllerContentProviderFactories: + [IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory] = [] + + var ideWindowOverlayWindowControllers = + [ObjectIdentifier: IDEWorkspaceWindowOverlayWindowController]() + var updateWindowStateTask: Task? + + let windowUpdateThrottler = ThrottleRunner(duration: 0.2) + + lazy var fullscreenDetector = { + let it = NSWindow( + contentRect: .zero, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + it.hasShadow = false + it.setIsVisible(false) + return it + }() + + public init() {} + + public func start() { + observeEvents() + _ = fullscreenDetector + } + + public nonisolated static func registerIDEWorkspaceWindowOverlayWindowControllerContentProviderFactory( + _ factory: @escaping IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory + ) { + Task { @MainActor in + ideWindowOverlayWindowControllerContentProviderFactories.append(factory) + } + } +} + +extension OverlayWindowController { + func observeEvents() { + observeWindowChange() + + updateWindowStateTask = Task { [weak self] in + if let self { await handleSpaceChange() } + + await withThrowingTaskGroup(of: Void.self) { [weak self] group in + // active space did change + _ = group.addTaskUnlessCancelled { [weak self] in + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.activeSpaceDidChangeNotification) + for await _ in sequence { + guard let self else { return } + try Task.checkCancellation() + await handleSpaceChange() + } + } + } + } + } +} + +private extension OverlayWindowController { + func observeWindowChange() { + if ideWindowOverlayWindowControllers.isEmpty { + if let app = XcodeInspector.shared.activeXcode, + let windowInspector = XcodeInspector.shared + .focusedWindow as? WorkspaceXcodeWindowInspector + { + createNewIDEOverlayWindowController( + inspector: windowInspector, + application: app.runningApplication + ) + } + } + + withPerceptionTracking { + _ = XcodeInspector.shared.focusedWindow + _ = XcodeInspector.shared.activeXcode + _ = XcodeInspector.shared.activeApplication + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + defer { self.observeWindowChange() } + await self.windowUpdateThrottler.throttle { [weak self] in + await self?.handleOverlayStatusChange() + } + } + } + } + + func createNewIDEOverlayWindowController( + inspector: WorkspaceXcodeWindowInspector, + application: NSRunningApplication + ) { + let id = ObjectIdentifier(inspector) + let newController = IDEWorkspaceWindowOverlayWindowController( + inspector: inspector, + application: application, + contentProviderFactory: { + windowInspector, application in + OverlayWindowController.ideWindowOverlayWindowControllerContentProviderFactories + .map { $0(windowInspector, application) } + } + ) + newController.access() + ideWindowOverlayWindowControllers[id] = newController + } + + func removeIDEOverlayWindowController(for id: ObjectIdentifier) { + if let controller = ideWindowOverlayWindowControllers[id] { + controller.destroy() + } + ideWindowOverlayWindowControllers[id] = nil + } + + func handleSpaceChange() async { + let windowInspector = XcodeInspector.shared.focusedWindow + guard let activeWindowController = { + if let windowInspector = windowInspector as? WorkspaceXcodeWindowInspector { + let id = ObjectIdentifier(windowInspector) + return ideWindowOverlayWindowControllers[id] + } else { + return nil + } + }() else { return } + + let activeXcode = XcodeInspector.shared.activeXcode + let xcode = activeXcode?.appElement + let isXcodeActive = xcode?.isFrontmost ?? false + if isXcodeActive { + activeWindowController.maskPanel.moveToActiveSpace() + } + + if fullscreenDetector.isOnActiveSpace, xcode?.focusedWindow != nil { + activeWindowController.maskPanel.orderFrontRegardless() + } + } + + func handleOverlayStatusChange() { + guard XcodeInspector.shared.activeApplication?.isXcode ?? false else { + var closedControllers: [ObjectIdentifier] = [] + for (id, controller) in ideWindowOverlayWindowControllers { + if controller.isWindowClosed { + controller.dim() + closedControllers.append(id) + } else { + controller.dim() + } + } + for id in closedControllers { + removeIDEOverlayWindowController(for: id) + } + return + } + + guard let app = XcodeInspector.shared.activeXcode else { + for (_, controller) in ideWindowOverlayWindowControllers { + controller.hide() + } + return + } + + let windowInspector = XcodeInspector.shared.focusedWindow + if let ideWindowInspector = windowInspector as? WorkspaceXcodeWindowInspector { + let objectID = ObjectIdentifier(ideWindowInspector) + // Workspace window is active + // Hide all controllers first + for (id, controller) in ideWindowOverlayWindowControllers { + if id != objectID { + controller.hide() + } + } + if let controller = ideWindowOverlayWindowControllers[objectID] { + controller.access() + } else { + createNewIDEOverlayWindowController( + inspector: ideWindowInspector, + application: app.runningApplication + ) + } + } else { + // Not a workspace window, dim all controllers + for (_, controller) in ideWindowOverlayWindowControllers { + controller.dim() + } + } + } +} + diff --git a/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift b/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift new file mode 100644 index 00000000..98b0a5bf --- /dev/null +++ b/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift @@ -0,0 +1,5 @@ +import Testing + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} diff --git a/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift new file mode 100644 index 00000000..55c4465c --- /dev/null +++ b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift @@ -0,0 +1,138 @@ +import AppKit +import Foundation +import LangChain +import OpenAIService +import PlaygroundSupport +import SwiftUI +import TokenEncoder + +struct QAForm: View { + @State var relevantInformation = [String]() + @State var relevantDocuments = [(document: Document, distance: Float)]() + @State var duration: TimeInterval = 0 + @State var answer: String = "" + @State var tokenCount: Int = 0 + @State var question: String = "What is Swift macros?" + @State var isProcessing: Bool = false + @State var url: String = "https://developer.apple.com/documentation/swift/applying-macros" + + var body: some View { + HStack(spacing: 0) { + ScrollView { + Form { + Section(header: Text("Input")) { + TextField("URL", text: $url) + TextField("Question", text: $question) + HStack { + Button("Ask") { + Task { + do { + try await ask() + } catch { + answer = error.localizedDescription + } + } + } + .disabled(isProcessing) + + Text("\(duration) seconds") + } + } + Section(header: Text("All Relevant Information (\(tokenCount) words)")) { + Text(answer) + } + Section(header: Text("Relevant Information")) { + ForEach(0.. + + + + diff --git a/Playground.playground/Pages/WebScrapper.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/WebScrapper.xcplaygroundpage/Contents.swift new file mode 100644 index 00000000..f29a794a --- /dev/null +++ b/Playground.playground/Pages/WebScrapper.xcplaygroundpage/Contents.swift @@ -0,0 +1,57 @@ +import AppKit +import LangChain +import PlaygroundSupport +import SwiftUI + +struct ScrapperForm: View { + @State var webDocuments: [Document] = [] + @State var isProcessing: Bool = false + @State var url: String = "https://developer.apple.com/documentation/swift/applying-macros" + + var body: some View { + Form { + Section(header: Text("Input")) { + TextField("URL", text: $url) + Button("Scrap") { + Task { + do { + try await scrap() + } catch { + webDocuments = + [.init(pageContent: error.localizedDescription, metadata: [:])] + } + } + } + .disabled(isProcessing) + } + Section(header: Text("Web Content")) { + ForEach(webDocuments, id: \.pageContent) { document in + VStack(alignment: .leading) { + Text(document.pageContent) + .font(.body) + } + Divider() + } + } + } + .formStyle(.grouped) + } + + func scrap() async throws { + webDocuments = [] + isProcessing = true + defer { isProcessing = false } + guard let url = URL(string: url) else { return } + let webLoader = WebLoader(urls: [url]) + webDocuments = try await webLoader.load() + } +} + +let hostingView = NSHostingController( + rootView: ScrapperForm() + .frame(width: 600, height: 800) +) + +PlaygroundPage.current.needsIndefiniteExecution = true +PlaygroundPage.current.liveView = hostingView + diff --git a/Playground.playground/Pages/WebScrapper.xcplaygroundpage/timeline.xctimeline b/Playground.playground/Pages/WebScrapper.xcplaygroundpage/timeline.xctimeline new file mode 100644 index 00000000..bf468afe --- /dev/null +++ b/Playground.playground/Pages/WebScrapper.xcplaygroundpage/timeline.xctimeline @@ -0,0 +1,6 @@ + + + + + diff --git a/Playground.playground/contents.xcplayground b/Playground.playground/contents.xcplayground new file mode 100644 index 00000000..2f0f29c9 --- /dev/null +++ b/Playground.playground/contents.xcplayground @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index ab2bc089..c4066a45 100644 --- a/README.md +++ b/README.md @@ -2,53 +2,73 @@ ![Screenshot](/Screenshot.png) -Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copilot, Codeium and ChatGPT support for Xcode. +Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copilot, Codeium and ChatGPT support for Xcode. Buy Me A Coffee ## Features -- Code Suggestions (powered by GitHub Copilot and Codeium). -- Chat (powered by OpenAI ChatGPT). -- Prompt to Code (powered by OpenAI ChatGPT). -- Custom Commands to extend Chat and Prompt to Code. +- Code Suggestions +- Chat +- Modification +- Custom Commands to extend Chat and Modification. ## Table of Contents -- [Prerequisites](#prerequisites) -- [Permissions Required](#permissions-required) -- [Installation and Setup](#installation-and-setup) - - [Install](#install) - - [Enable the Extension](#enable-the-extension) - - [Setting Up GitHub Copilot](#setting-up-github-copilot) - - [Setting Up Codeium](#setting-up-codeium) - - [Setting Up OpenAI API Key](#setting-up-openai-api-key) - - [Granting Permissions to the App](#granting-permissions-to-the-app) - - [Managing `CopilotForXcodeExtensionService.app`](#managing-copilotforxcodeextensionserviceapp) -- [Update](#update) -- [Feature](#feature) -- [Key Bindings](#key-bindings) -- [Prevent Suggestions Being Committed](#prevent-suggestions-being-committed) -- [Limitations](#limitations) -- [License](#license) - -For frequently asked questions, check [FAQ](https://github.com/intitni/CopilotForXcode/issues/65). +- [Copilot for Xcode ](#copilot-for-xcode-) + - [Features](#features) + - [Table of Contents](#table-of-contents) + - [Prerequisites](#prerequisites) + - [Permissions Required](#permissions-required) + - [Installation and Setup](#installation-and-setup) + - [Install](#install) + - [Enable the Extension](#enable-the-extension) + - [macOS 15](#macos-15) + - [MacOS 14](#macos-14) + - [Older Versions](#older-versions) + - [Granting Permissions to the App](#granting-permissions-to-the-app) + - [Setting Up Key Bindings](#setting-up-key-bindings) + - [Setting Up Global Hotkeys](#setting-up-global-hotkeys) + - [Setting Up Suggestion Feature](#setting-up-suggestion-feature) + - [Setting Up GitHub Copilot](#setting-up-github-copilot) + - [Setting Up Codeium](#setting-up-codeium) + - [Setting Up Chat Feature](#setting-up-chat-feature) + - [Managing `CopilotForXcodeExtensionService.app`](#managing-copilotforxcodeextensionserviceapp) + - [Update](#update) + - [Feature](#feature) + - [Suggestion](#suggestion) + - [Commands](#commands) + - [Chat](#chat) + - [Commands](#commands-1) + - [Keyboard Shortcuts](#keyboard-shortcuts) + - [Chat Commands](#chat-commands) + - [Prompt to Code](#prompt-to-code) + - [Commands](#commands-2) + - [Custom Commands](#custom-commands) + - [Limitations](#limitations) + - [License](#license) For development instruction, check [Development.md](DEVELOPMENT.md). +For more information, check the [Wiki Page](https://copilotforxcode.intii.com/wiki). + ## Prerequisites - Public network connection. For suggestion features: -- For GitHub Copilot users: + +- For GitHub Copilot users: - [Node](https://nodejs.org/) installed to run the Copilot LSP. - Active GitHub Copilot subscription. - For Codeium users: - Active Codeium account. +- Access to other LLMs. For chat and prompt to code features: -- Valid OpenAI API key. + +- A valid OpenAI API key. +- Access to other LLMs. ## Permissions Required @@ -59,6 +79,14 @@ For chat and prompt to code features: ## Installation and Setup +> The installation process is a bit complicated. Here is a quick overview: +> +> 1. Install the app into the Applications folder, open it once. +> 2. Enable the source editor extension. +> 3. Grant Accessibility API permission to the extension app. +> 4. Setup accounts and models in the host app. +> 5. Optionally, update the settings of each feature in the host app, and setup keybindings. + ### Install You can install it via [Homebrew](http://brew.sh/): @@ -67,90 +95,135 @@ You can install it via [Homebrew](http://brew.sh/): brew install --cask copilot-for-xcode ``` -Or install it manually, by downloading the `Copilot for Xcode.app` from the latest [release](https://github.com/intitni/CopilotForXcode/releases), and extract it to the Applications folder. +Or install it manually, by downloading the `Copilot for Xcode.app` from the latest [release](https://github.com/intitni/CopilotForXcode/releases). + +Please make sure the app is inside the Applications folder. Open the app, the app will create a launch agent to setup a background running Service that does the real job. ### Enable the Extension -Enable the extension in `System Settings.app`. +Enable the extension in `System Settings.app`. +#### macOS 15 +From the Apple menu located in the top-left corner of your screen click `System Settings`. Navigate to `General` then `Login Items & Extensions`. Click `Xcode Source Editor` and tick `Copilot for Xcode`. + +#### MacOS 14 From the Apple menu located in the top-left corner of your screen click `System Settings`. Navigate to `Privacy & Security` then toward the bottom click `Extensions`. Click `Xcode Source Editor` and tick `Copilot`. - + +#### Older Versions If you are using macOS Monterey, enter the `Extensions` menu in `System Preferences.app` with its dedicated icon. -### Setting Up GitHub Copilot - -1. In the host app, switch to the service tab and click on GitHub Copilot to access your GitHub Copilot account settings. -2. Click "Install" to install the language server. -3. Optionally setup the path to Node. The default value is just `node`, Copilot for Xcode.app will try to find the Node from the PATH available in a login shell. If your Node is installed somewhere else, you can run `which node` from terminal to get the path. -4. Click "Sign In", and you will be directed to a verification website provided by GitHub, and a user code will be pasted into your clipboard. -5. After signing in, go back to the app and click "Confirm Sign-in" to finish. -6. Go to "Feature - Suggestion" and update the feature provider to "GitHub Copilot". +### Granting Permissions to the App -The installed language server is located at `~/Library/Application Support/com.intii.CopilotForXcode/GitHub Copilot/executable/`. +The first time the app is open and command run, the extension will ask for the necessary permissions. -### Setting Up Codeium +Alternatively, you may manually grant the required permissions by navigating to the `Privacy & Security` tab in the `System Settings.app`. -1. In the host app, switch to the service tab and click Codeium to access the Codeium account settings. -2. Click "Install" to install the language server. -3. Click "Sign In", and you will be directed to codeium.com. After signing in, a token will be presented. You will need to paste the token back to the app to finish signing in. -4. Go to "Feature - Suggestion" and update the feature provider to "Codeium". +- To grant permissions for the Accessibility API, click `Accessibility`, and drag `CopilotForXcodeExtensionService.app` to the list. You can locate the extension app by clicking `Reveal Extension App in Finder` in the host app. -> The key is stored in the keychain. When the helper app tries to access the key for the first time, it will prompt you to enter the password to access the keychain. Please select "Always Allow" to let the helper app access the key. +Accessibility API -The installed language server is located at `~/Library/Application Support/com.intii.CopilotForXcode/Codeium/executable/`. +If you encounter an alert requesting permission that you have previously granted, please remove the permission from the list and add it again to re-grant the necessary permissions. -### Setting Up OpenAI API Key +### Setting Up Key Bindings -1. In the host app, click OpenAI to enter the OpenAI account settings. -2. Enter your api key to the text field. +The extension will work better if you use key bindings. -### Granting Permissions to the App +It looks like there is no way to add default key bindings to commands, but you can set them up in `Xcode settings > Key Bindings`. You can filter the list by typing `copilot` in the search bar. -The first time the app is open and command run, the extension will ask for the necessary permissions. +A [recommended setup](https://github.com/intitni/CopilotForXcode/issues/14) that should cause no conflict is -Alternatively, you may manually grant the required permissions by navigating to the `Privacy & Security` tab in the `System Settings.app`. +| Command | Key Binding | +| ------------------- | ------------------------------------------------------ | +| Accept Suggestions | `⌥}` or Tab | +| Dismiss Suggestions | Esc | +| Reject Suggestion | `⌥{` | +| Next Suggestion | `⌥>` | +| Previous Suggestion | `⌥<` | +| Open Chat | `⌥"` | +| Explain Selection | `⌥\|` | -- To grant permissions for the Accessibility API, click `Accessibility`, and drag `CopilotForXcodeExtensionService.app` to the list. You can locate the extension app by clicking `Reveal Extension App in Finder` in the host app. +Essentially using `⌥⇧` as the "access" key combination for all bindings. -Accessibility API +Another convenient method to access commands is by using the `⇧⌘/` shortcut to search for a command in the menu bar. -If you encounter an alert requesting permission that you have previously granted, please remove the permission from the list and add it again to re-grant the necessary permissions. +#### Setting Up Global Hotkeys -### Managing `CopilotForXcodeExtensionService.app` +Currently, there is only one global hotkey you can set to show/hide the widgets under the General tab from the host app. -This app runs whenever you open `Copilot for Xcode.app` or `Xcode.app`. You can quit it with its menu bar item that looks like a steering wheel. +When this hotkey is not set to enabled globally, it will only work when the service app or Xcode is active. -You can also set it to quit automatically when the above 2 apps are closed. +### Setting Up Suggestion Feature -## Update +#### Setting Up GitHub Copilot -If the app was installed via Homebrew, you can update it by running: +1. In the host app, navigate to "Service - GitHub Copilot" to access your GitHub Copilot account settings. +2. Click on "Install" to install the language server. +3. Optionally, set up the path to Node. The default value is simply `node`. Copilot for Xcode.app will attempt to locate Node from the following directories: `/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`. -```bash -brew upgrade --cask copilot-for-xcode -``` + If your Node installation is located elsewhere, you can run `which node` from the terminal to obtain the correct path. + + If you are using a node version manager that provides a shim executable, you will need to find the path to the actual executable. Please refer to the FAQ for more information. + +4. Click on "Sign In", and you will be redirected to a verification website provided by GitHub. A user code will be copied to your clipboard. +5. After signing in, return to the app and click on "Confirm Sign-in" to complete the process. +6. Go to "Feature - Suggestion" and update the feature provider to "GitHub Copilot". + +The installed language server is located at `~/Library/Application Support/com.intii.CopilotForXcode/GitHub Copilot/executable/`. + +#### Setting Up Codeium -Alternatively, You can use the in-app updater or download the latest version manually from the latest [release](https://github.com/intitni/CopilotForXcode/releases). +1. In the host app, navigate to "Service - Codeium" to access the Codeium account settings. +2. Click on "Install" to install the language server. +3. Click on "Sign In" and you will be redirected to codeium.com. After signing in, a token will be provided. You need to copy and paste this token back into the app to complete the sign-in process. +4. Go to "Feature - Suggestion" and update the feature provider to "Codeium". + +> The key is stored in the keychain. When the helper app tries to access the key for the first time, it will prompt you to enter the password to access the keychain. Please select "Always Allow" to let the helper app access the key. + +The installed language server is located at `~/Library/Application Support/com.intii.CopilotForXcode/Codeium/executable/`. -After updating, please restart Xcode to allow the extension to reload. +### Setting Up Chat Feature -If you are upgrading from a version lower than **0.7.0**, please run `Copilot for Xcode.app` at least once to let it set up the new launch agent for you and re-grant the permissions according to the new rules. +1. In the host app, navigate to "Service - Chat Model". +2. Update the OpenAI model or create a new one if necessary. Use the test button to verify the model. +3. Optionally, set up the embedding model in "Service - Embedding Model", which is required for a subset of the chat feature. +4. Go to "Feature - Chat" and update the chat/embedding feature provider with the one you just updated/created. + +### Managing `CopilotForXcodeExtensionService.app` + +This app runs whenever you open `Copilot for Xcode.app` or `Xcode.app`. You can quit it with its menu bar item that looks like a tentacle. + +You can also set it to quit automatically when the above 2 apps are closed. + +## Update + +You can use the in-app updater or download the latest version manually from the latest [release](https://github.com/intitni/CopilotForXcode/releases). + +After updating, please open Copilot for Xcode.app once and restart Xcode to allow the extension to reload. If you find that some of the features are no longer working, please first try regranting permissions to the app. ## Feature +> Files in gitignore will not receive suggestion. Both chat and prompt to code feature will not have access to them unless you manually select code from them. + ### Suggestion -The app can provide real-time code suggestions based on the files you have opened. It's powered by GitHub Copilot and Codeium. +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: -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. +- Nearby Text Cursor: This mode shows suggestions based on the position of the text cursor. +- Floating Widget: This mode shows suggestions next to the indicator 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**. -*: If a file is already open before the helper app launches, you will need to switch to those files in order to send the open file notification. +\*: If a file is already open before the helper app launches, you will need to switch to those files in order to send the open file notification. #### Commands @@ -159,7 +232,10 @@ Whenever your code is updated, the app will automatically fetch suggestions for - Previous Suggestion: If there is more than one suggestion, switch to the previous one. - Accept Suggestion: Add the suggestion to the code. - Reject Suggestion: Remove the suggestion comments. -- Real-time Suggestions: Call only by Copilot for Xcode. When suggestions are successfully fetched, Copilot for Xcode will run this command to present the suggestions. + +Commands called by the app: + +- Prepare for Real-time Suggestions: Call only by Copilot for Xcode. When suggestions are successfully fetched, Copilot for Xcode will run this command to present the suggestions. - Prefetch Suggestions: Call only by Copilot for Xcode. In the background, Copilot for Xcode will occasionally run this command to prefetch real-time suggestions. ### Chat @@ -167,6 +243,7 @@ Whenever your code is updated, the app will automatically fetch suggestions for This feature is powered by ChatGPT. Please ensure that you have set up your OpenAI account before using it. The chat knows the following information: + - The selected code in the active editor. - The relative path of the file. - The error and warning labels in the active editor. @@ -174,51 +251,49 @@ The chat knows the following information: There are currently two tabs in the chat panel: one is available shared across Xcode, and the other is only available in the current file. -You can detach the chat panel by simply dragging it away. Once detached, the chat panel will remain visible even if Xcode is inactive. To re-attach it to the widget, click the message bubble button located next to the circular widget. +You can detach the chat panel by simply dragging it away. Once detached, the chat panel will remain visible even if Xcode is inactive. To re-attach it to the widget, click the message bubble button located next to the indicator widget. #### Commands -- Open Chat: Open a chat window. +- Open Chat: Open a chat tab. #### Keyboard Shortcuts -| Shortcut | Description | -|:---:|---| -| `⌘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. | - -#### Chat Scope - -The chat panel allows for chat scope to temporarily control the context of the conversation for the latest message. To use a scope, simply prefix the message with `@scope`. +| Shortcut | Description | +| :------: | --------------------------------------------------------------------------------------------------- | +| `⌘W` | Close the chat tab. | +| `⌘M` | Minimize the chat, you can bring it back with any chat commands or by clicking the indicator widget. | +| `⇧↩︎` | Add new line. | +| `⇧⌘]` | Move to next tab | +| `⇧⌘[` | Move to previous tab | -| Scope | Description | -|:---:|---| -| `@selection` | Inject the selected code from the active editor into the conversation. This scope will be applied to any message automatically. If you don't want this to be the default behavior, you can turn off the option `Use selection scope by default in chat context.`. | -| `@file` | Inject the content of the file into the conversation. Keep in mind that you may not have enough tokens to inject large files. | +#### Chat Commands -#### Chat Plugins +The chat panel supports chat plugins that may not require an OpenAI API key. For example, if you need to use the `/shell` plugin, you just type -The chat panel supports chat plugins that may not require an OpenAI API key. For example, if you need to use the `/run` plugin, you just type ``` /run echo hello ``` -If you need to end a plugin, you can just type +If you need to end a plugin, you can just type + ``` /exit ``` -| Command | Description | -|:---:|---| -| `/run` | Runs the command under the project root. You can also use environment variable `PROJECT_ROOT` to get the project root and `FILE_PATH` to get the editing file path.| -| `/airun` | Create a command with natural language. You can ask to modify the command if it is not what you want. After confirming, the command will be executed by calling the `/run` plugin. | +| Command | Description | +| :--------------------: | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `/shell` | Runs the command under the project root. | +| | Environment variable:
- `PROJECT_ROOT` to get the project root.
- `FILE_PATH` to get the editing file path. | +| `/shortcut(name)` | Run a shortcut from the Shortcuts.app, and use the following message as the input. | +| | If the message is empty, it will use the previous message as input. The output of the shortcut will be printed as a reply from the bot. | ### Prompt to Code Refactor existing code or write new code using natural language. This feature is recommended when you need to update a specific piece of code. Some example use cases include: + - Improving the code's readability. - Correcting bugs in the code. - Adding documentation to the code. @@ -229,56 +304,33 @@ This feature is recommended when you need to update a specific piece of code. So #### Commands -- Prompt to Code: Open a prompt to code window, where you can use natural language to write or edit selected code. +- Write or Edit Code: Open a modification window, where you can use natural language to write or edit selected code. +- Accept Modification: Accept the result of modification. ### Custom Commands -You can create custom commands that run Chat and Prompt to Code with personalized prompts. These commands are easily accessible from both the Xcode menu bar and the context menu of the circular widget. There are 3 types of custom commands: +You can create custom commands that run Chat and Modification with personalized prompts. These commands are easily accessible from both the Xcode menu bar and the context menu of the indicator widget. There are 3 types of custom commands: -- Prompt to Code: Run Prompt to Code with the selected code, and update or write the code using the given prompt, if provided. You can provide additional information through the extra system prompt field. -- Open Chat: Open the chat window and immediately send a message, if provided. You can provide more information through the extra system prompt field. +- Modification: Run Modification with the selected code, and update or write the code using the given prompt, if provided. You can provide additional information through the extra system prompt field. +- Send Message: Open the chat window and immediately send a message, if provided. You can provide more information through the extra system prompt field. - Custom Chat: Open the chat window and immediately send a message, if provided. You can overwrite the entire system prompt through the system prompt field. +- Single Round Dialog: Send a message to a temporary chat. Useful when you want to run a terminal command with `/run`. -## Key Bindings +You can use the following template arguments in custom commands: -It looks like there is no way to add default key bindings to commands, but you can set them up in `Xcode settings > Key Bindings`. You can filter the list by typing `copilot` in the search bar. - -A [recommended setup](https://github.com/intitni/CopilotForXcode/issues/14) that should cause no conflict is - -| Command | Key Binding | -| --- | --- | -| Get Suggestions | `⌥?` | -| Accept Suggestions | `⌥}` | -| Reject Suggestion | `⌥{` | -| Next Suggestion | `⌥>` | -| Previous Suggestion | `⌥<` | -| Open Chat | `⌥"` | -| Explain Selection | `⌥\|` | - -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 -``` +| Argument | Description | +| ----------------------------- | ---------------------------------------------- | +| `{{selected_code}}` | The currently selected code in the editor. | +| `{{active_editor_language}}` | The programming language of the active editor. | +| `{{active_editor_file_url}}` | The URL of the active file in the editor. | +| `{{active_editor_file_name}}` | The name of the active file in the editor. | +| `{{clipboard}}` | The content in clipboard. | ## 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. +- The extension utilizes various tricks to monitor the state of Xcode. 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. -## License +## License Please check [LICENSE](LICENSE) for details. + diff --git a/SandboxedClientTester/Assets.xcassets/AccentColor.colorset/Contents.json b/SandboxedClientTester/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/SandboxedClientTester/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SandboxedClientTester/Assets.xcassets/AppIcon.appiconset/Contents.json b/SandboxedClientTester/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..3f00db43 --- /dev/null +++ b/SandboxedClientTester/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SandboxedClientTester/Assets.xcassets/Contents.json b/SandboxedClientTester/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/SandboxedClientTester/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SandboxedClientTester/ContentView.swift b/SandboxedClientTester/ContentView.swift new file mode 100644 index 00000000..b56ddd05 --- /dev/null +++ b/SandboxedClientTester/ContentView.swift @@ -0,0 +1,29 @@ +import SwiftUI +import Client + +struct ContentView: View { + @State var text: String = "Hello, world!" + var body: some View { + VStack { + Button(action: { + Task { + do { + let service = try getService() + let version = try await service.getXPCServiceVersion() + text = "Version: \(version.version) Build: \(version.build)" + } catch { + text = error.localizedDescription + } + } + }) { + Text("Test") + } + Text(text) + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/SandboxedClientTester/Info.plist b/SandboxedClientTester/Info.plist new file mode 100644 index 00000000..cb7f95c4 --- /dev/null +++ b/SandboxedClientTester/Info.plist @@ -0,0 +1,8 @@ + + + + + BUNDLE_IDENTIFIER_BASE + $(BUNDLE_IDENTIFIER_BASE) + + diff --git a/SandboxedClientTester/Preview Content/Preview Assets.xcassets/Contents.json b/SandboxedClientTester/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/SandboxedClientTester/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SandboxedClientTester/SandboxedClientTester.entitlements b/SandboxedClientTester/SandboxedClientTester.entitlements new file mode 100644 index 00000000..9e6f3194 --- /dev/null +++ b/SandboxedClientTester/SandboxedClientTester.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.temporary-exception.mach-lookup.global-name + + $(BUNDLE_IDENTIFIER_BASE).CommunicationBridge + + + diff --git a/SandboxedClientTester/SandboxedClientTesterApp.swift b/SandboxedClientTester/SandboxedClientTesterApp.swift new file mode 100644 index 00000000..ef03ae51 --- /dev/null +++ b/SandboxedClientTester/SandboxedClientTesterApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct SandboxedClientTesterApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Screenshot.png b/Screenshot.png index 9b2bda3a..b4ad1f43 100644 Binary files a/Screenshot.png and b/Screenshot.png differ diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index f5104832..c9ebe525 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -22,18 +22,67 @@ "testTimeoutsEnabled" : true }, "testTargets" : [ + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SharedUIComponentsTests", + "name" : "SharedUIComponentsTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "ActiveDocumentChatContextCollectorTests", + "name" : "ActiveDocumentChatContextCollectorTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "CodeDiffTests", + "name" : "CodeDiffTests" + } + }, { "target" : { "containerPath" : "container:Core", - "identifier" : "CopilotModelTests", - "name" : "CopilotModelTests" + "identifier" : "ServiceUpdateMigrationTests", + "name" : "ServiceUpdateMigrationTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "ASTParserTests", + "name" : "ASTParserTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SuggestionProviderTests", + "name" : "SuggestionProviderTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "CopilotServiceTests", - "name" : "CopilotServiceTests" + "identifier" : "PromptToCodeServiceTests", + "name" : "PromptToCodeServiceTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "KeychainTests", + "name" : "KeychainTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "JoinJSONTests", + "name" : "JoinJSONTests" } }, { @@ -45,14 +94,35 @@ }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "SuggestionInjectorTests", - "name" : "SuggestionInjectorTests" + "containerPath" : "container:OverlayWindow", + "identifier" : "OverlayWindowTests", + "name" : "OverlayWindowTests" } }, { "target" : { - "containerPath" : "container:Core", + "containerPath" : "container:Tool", + "identifier" : "SuggestionBasicTests", + "name" : "SuggestionBasicTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "LangChainTests", + "name" : "LangChainTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "GitHubCopilotServiceTests", + "name" : "GitHubCopilotServiceTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", "identifier" : "OpenAIServiceTests", "name" : "OpenAIServiceTests" } @@ -67,8 +137,50 @@ { "target" : { "containerPath" : "container:Core", - "identifier" : "PromptToCodeServiceTests", - "name" : "PromptToCodeServiceTests" + "identifier" : "KeyBindingManagerTests", + "name" : "KeyBindingManagerTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "FocusedCodeFinderTests", + "name" : "FocusedCodeFinderTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "WebSearchServiceTests", + "name" : "WebSearchServiceTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "XcodeInspectorTests", + "name" : "XcodeInspectorTests" + } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "ChatServiceTests", + "name" : "ChatServiceTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SuggestionInjectorTests", + "name" : "SuggestionInjectorTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "TokenEncoderTests", + "name" : "TokenEncoderTests" } } ], diff --git a/Tool/.gitignore b/Tool/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/Tool/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Tool/.swiftpm/xcode/xcshareddata/xcschemes/SuggestionModel.xcscheme b/Tool/.swiftpm/xcode/xcshareddata/xcschemes/SuggestionModel.xcscheme new file mode 100644 index 00000000..a1cb3d32 --- /dev/null +++ b/Tool/.swiftpm/xcode/xcshareddata/xcschemes/SuggestionModel.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tool/Package.resolved b/Tool/Package.resolved new file mode 100644 index 00000000..141e454d --- /dev/null +++ b/Tool/Package.resolved @@ -0,0 +1,266 @@ +{ + "pins" : [ + { + "identity" : "codablewrappers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GottaGetSwifty/CodableWrappers", + "state" : { + "revision" : "4eb46a4c656333e8514db8aad204445741de7d40", + "version" : "2.0.7" + } + }, + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "ec62f32d21584214a4b27c8cee2b2ad70ab2c38a", + "version" : "0.11.0" + } + }, + { + "identity" : "fseventswrapper", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Frizlab/FSEventsWrapper", + "state" : { + "revision" : "e0c59a2ce2775e5f6642da6d19207445f10112d0", + "version" : "1.0.2" + } + }, + { + "identity" : "glob", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Bouke/Glob", + "state" : { + "revision" : "deda6e163d2ff2a8d7e138e2c3326dbd71157faf", + "version" : "1.0.5" + } + }, + { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/Highlightr", + "state" : { + "branch" : "bump-highlight-js-version", + "revision" : "4ffbb1b0b721378263297cafea6f2838044eb1eb" + } + }, + { + "identity" : "jsonrpc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/JSONRPC", + "state" : { + "revision" : "5da978702aece6ba5c7879b0d253c180d61e4ef3", + "version" : "0.6.0" + } + }, + { + "identity" : "languageclient", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/LanguageClient", + "state" : { + "revision" : "f0198ee0a102d266078f7d9c28f086f2989f988a", + "version" : "0.3.1" + } + }, + { + "identity" : "languageserverprotocol", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/LanguageServerProtocol", + "state" : { + "revision" : "6e97f943dc024307c5524a80bd33cdbd1cc621de", + "version" : "0.8.0" + } + }, + { + "identity" : "operationplus", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/OperationPlus", + "state" : { + "revision" : "1340f95dce3e93d742497d88db18f8676f4badf4", + "version" : "1.6.0" + } + }, + { + "identity" : "processenv", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/ProcessEnv", + "state" : { + "revision" : "29487b6581bb785c372c611c943541ef4309d051", + "version" : "0.3.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "9cfed92b026c524674ed869a4ff2dcfdeedf8a2a", + "version" : "0.1.0" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984", + "version" : "0.14.1" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "0fbaebfc013715dab44d715a4d350ba37f297e4d", + "version" : "0.4.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture", + "state" : { + "revision" : "9f4202ab5b8422aa90f0ed983bf7652c3af7abf0", + "version" : "0.59.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "479750bd98fac2e813fffcf2af0728b5b0085795", + "version" : "0.1.1" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "4a87bb75be70c983a9548597e8783236feb3401e", + "version" : "0.11.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "16fd42ae04c6e7f74a6a86395d04722c641cccee", + "version" : "0.6.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "d01446a78fb768adc9a78cbb6df07767c8ccfc29", + "version" : "0.8.0" + } + }, + { + "identity" : "swift-parsing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-parsing", + "state" : { + "revision" : "27c941bbd22a4bbc53005a15a0440443fd892f70", + "version" : "0.12.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "branch" : "main", + "revision" : "e149b01cfd3e96240e102729697e2095c19157ef" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6", + "version" : "2.6.1" + } + }, + { + "identity" : "swifttreesitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/SwiftTreeSitter", + "state" : { + "revision" : "a9b1335d5151b62b11f07599bd07d07dc5965de3", + "version" : "0.7.2" + } + }, + { + "identity" : "swiftui-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swiftui-navigation", + "state" : { + "revision" : "2aa885e719087ee19df251c08a5980ad3e787f12", + "version" : "0.8.0" + } + }, + { + "identity" : "tiktoken", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/Tiktoken", + "state" : { + "branch" : "main", + "revision" : "6a2ac324c6daec167ca95268d5a487e6de6a1cea" + } + }, + { + "identity" : "tree-sitter-objc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lukepistrol/tree-sitter-objc", + "state" : { + "branch" : "feature/spm", + "revision" : "1b54ef0b5efddddf393b45e173788499cc572048" + } + }, + { + "identity" : "tree-sitter-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/alex-pinkus/tree-sitter-swift", + "state" : { + "branch" : "with-generated-files", + "revision" : "eda05af7ac41adb4eb19c346883c0fa32fe3bdd8" + } + }, + { + "identity" : "usearch", + "kind" : "remoteSourceControl", + "location" : "https://github.com/unum-cloud/usearch", + "state" : { + "revision" : "33c53288b44ccb55de77776820676132a6e4c42a", + "version" : "0.23.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "50843cbb8551db836adec2290bb4bc6bac5c1865", + "version" : "0.9.0" + } + } + ], + "version" : 2 +} diff --git a/Tool/Package.swift b/Tool/Package.swift new file mode 100644 index 00000000..f303e44c --- /dev/null +++ b/Tool/Package.swift @@ -0,0 +1,551 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Tool", + platforms: [.macOS(.v12)], + products: [ + .library(name: "XPCShared", targets: ["XPCShared"]), + .library(name: "Terminal", targets: ["Terminal"]), + .library(name: "LangChain", targets: ["LangChain"]), + .library(name: "ExternalServices", targets: ["WebSearchService"]), + .library(name: "Preferences", targets: ["Preferences", "Configs"]), + .library(name: "Logger", targets: ["Logger"]), + .library(name: "OpenAIService", targets: ["OpenAIService"]), + .library(name: "ChatTab", targets: ["ChatTab"]), + .library(name: "FileSystem", targets: ["FileSystem"]), + .library( + name: "ChatContextCollector", + targets: ["ChatContextCollector", "ActiveDocumentChatContextCollector"] + ), + .library(name: "SuggestionBasic", targets: ["SuggestionBasic", "SuggestionInjector"]), + .library(name: "PromptToCode", targets: ["ModificationBasic", "PromptToCodeCustomization"]), + .library(name: "Chat", targets: ["ChatBasic"]), + .library(name: "ASTParser", targets: ["ASTParser"]), + .library(name: "FocusedCodeFinder", targets: ["FocusedCodeFinder"]), + .library(name: "Toast", targets: ["Toast"]), + .library(name: "Keychain", targets: ["Keychain"]), + .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), + .library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]), + .library(name: "Workspace", targets: ["Workspace"]), + .library(name: "WorkspaceSuggestionService", targets: ["WorkspaceSuggestionService"]), + .library( + name: "SuggestionProvider", + targets: ["SuggestionProvider", "GitHubCopilotService", "CodeiumService"] + ), + .library( + name: "AppMonitoring", + targets: [ + "XcodeInspector", + "ActiveApplicationMonitor", + "AXExtension", + "AXNotificationStream", + "AppActivator", + ] + ), + .library(name: "GitIgnoreCheck", targets: ["GitIgnoreCheck"]), + .library(name: "DebounceFunction", targets: ["DebounceFunction"]), + .library(name: "AsyncPassthroughSubject", targets: ["AsyncPassthroughSubject"]), + .library(name: "CustomAsyncAlgorithms", targets: ["CustomAsyncAlgorithms"]), + .library(name: "CommandHandler", targets: ["CommandHandler"]), + .library(name: "CodeDiff", targets: ["CodeDiff"]), + .library(name: "BuiltinExtension", targets: ["BuiltinExtension"]), + .library(name: "WebSearchService", targets: ["WebSearchService"]), + .library(name: "WebScrapper", targets: ["WebScrapper"]), + .library( + name: "CustomCommandTemplateProcessor", + targets: ["CustomCommandTemplateProcessor"] + ), + ], + dependencies: [ + // A fork of https://github.com/aespinilla/Tiktoken to allow loading from local files. + .package(url: "https://github.com/intitni/Tiktoken", branch: "main"), + // TODO: Update LanguageClient some day. + .package(url: "https://github.com/ChimeHQ/LanguageClient", exact: "0.3.1"), + .package(url: "https://github.com/ChimeHQ/LanguageServerProtocol", exact: "0.8.0"), + .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), + .package(url: "https://github.com/ChimeHQ/JSONRPC", exact: "0.6.0"), + .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), + .package(url: "https://github.com/unum-cloud/usearch", from: "0.19.1"), + .package(url: "https://github.com/intitni/Highlightr", branch: "master"), + .package( + url: "https://github.com/pointfreeco/swift-composable-architecture", + exact: "1.16.1" + ), + .package(url: "https://github.com/apple/swift-syntax.git", from: "600.0.0"), + .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), + // A fork of https://github.com/google/generative-ai-swift to support setting base url. + .package( + url: "https://github.com/intitni/generative-ai-swift", + branch: "support-setting-base-url" + ), + .package( + url: "https://github.com/intitni/CopilotForXcodeKit", + branch: "feature/custom-chat-tab" + ), + + // TreeSitter + .package(url: "https://github.com/intitni/SwiftTreeSitter.git", branch: "main"), + .package(url: "https://github.com/lukepistrol/tree-sitter-objc", branch: "feature/spm"), + ], + targets: [ + // MARK: - Helpers + + .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger"]), + + .target(name: "Configs"), + + .target(name: "Preferences", dependencies: ["Configs", "AIModel"]), + + .target(name: "Terminal"), + + .target(name: "Logger"), + + .target(name: "FileSystem"), + + .target(name: "ObjectiveCExceptionHandling"), + + .target(name: "JoinJSON"), + .testTarget(name: "JoinJSONTests", dependencies: ["JoinJSON"]), + + .target(name: "CodeDiff", dependencies: ["SuggestionBasic"]), + .testTarget(name: "CodeDiffTests", dependencies: ["CodeDiff"]), + + .target( + name: "CustomAsyncAlgorithms", + dependencies: [ + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + ] + ), + + .target( + name: "Keychain", + dependencies: ["Configs", "Preferences"] + ), + + .testTarget( + name: "KeychainTests", + dependencies: ["Keychain"] + ), + + .target( + name: "Toast", + dependencies: [.product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + )] + ), + + .target( + name: "CustomCommandTemplateProcessor", + dependencies: [ + "XcodeInspector", + "SuggestionBasic", + ] + ), + + .target(name: "DebounceFunction"), + + .target( + name: "AppActivator", + dependencies: [ + "XcodeInspector", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + + .target(name: "ActiveApplicationMonitor"), + + .target(name: "USearchIndex", dependencies: [ + "ObjectiveCExceptionHandling", + .product(name: "USearch", package: "usearch"), + ]), + + .target( + name: "TokenEncoder", + dependencies: [ + .product(name: "Tiktoken", package: "Tiktoken"), + .product(name: "GoogleGenerativeAI", package: "generative-ai-swift"), + ], + resources: [ + .copy("Resources/cl100k_base.tiktoken"), + ] + ), + .testTarget( + name: "TokenEncoderTests", + dependencies: ["TokenEncoder"] + ), + + .target( + name: "SuggestionBasic", + dependencies: [ + "LanguageClient", + .product(name: "Parsing", package: "swift-parsing"), + .product(name: "CodableWrappers", package: "CodableWrappers"), + ] + ), + + .target( + name: "SuggestionInjector", + dependencies: ["SuggestionBasic"] + ), + .testTarget( + name: "SuggestionInjectorTests", + dependencies: ["SuggestionInjector"] + ), + + .target( + name: "AIModel", + dependencies: [ + .product(name: "CodableWrappers", package: "CodableWrappers"), + ] + ), + + .testTarget( + name: "SuggestionBasicTests", + dependencies: ["SuggestionBasic"] + ), + + .target( + name: "ChatBasic", + dependencies: [ + "AIModel", + "Preferences", + "Keychain", + .product(name: "CodableWrappers", package: "CodableWrappers"), + ] + ), + + .target( + name: "ModificationBasic", + dependencies: [ + "SuggestionBasic", + "ChatBasic", + .product(name: "CodableWrappers", package: "CodableWrappers"), + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), + ] + ), + .testTarget(name: "ModificationBasicTests", dependencies: ["ModificationBasic"]), + + .target( + name: "PromptToCodeCustomization", + dependencies: [ + "ModificationBasic", + "SuggestionBasic", + "ChatBasic", + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), + ] + ), + + .target( + name: "AXExtension", + dependencies: ["Logger"] + ), + + .target( + name: "AXNotificationStream", + dependencies: [ + "Preferences", + "Logger", + ] + ), + + .target( + name: "XcodeInspector", + dependencies: [ + "AXExtension", + "SuggestionBasic", + "AXNotificationStream", + "Logger", + "Toast", + "Preferences", + "AsyncPassthroughSubject", + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + ] + ), + + .testTarget(name: "XcodeInspectorTests", dependencies: ["XcodeInspector"]), + + .target(name: "UserDefaultsObserver"), + + .target(name: "AsyncPassthroughSubject"), + + .target( + name: "BuiltinExtension", + dependencies: [ + "SuggestionBasic", + "SuggestionProvider", + "ChatBasic", + "Workspace", + "ChatTab", + "AIModel", + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + ] + ), + + .target( + name: "SharedUIComponents", + dependencies: [ + "Highlightr", + "Preferences", + "SuggestionBasic", + "DebounceFunction", + "CodeDiff", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + .testTarget(name: "SharedUIComponentsTests", dependencies: ["SharedUIComponents"]), + + .target(name: "ASTParser", dependencies: [ + "SuggestionBasic", + .product(name: "SwiftTreeSitter", package: "SwiftTreeSitter"), + .product(name: "TreeSitterObjC", package: "tree-sitter-objc"), + ]), + + .testTarget(name: "ASTParserTests", dependencies: ["ASTParser"]), + + .target( + name: "Workspace", + dependencies: [ + "GitIgnoreCheck", + "UserDefaultsObserver", + "SuggestionBasic", + "Logger", + "Preferences", + "XcodeInspector", + ] + ), + + .target( + name: "WorkspaceSuggestionService", + dependencies: [ + "Workspace", + "SuggestionProvider", + "XPCShared", + "BuiltinExtension", + "SuggestionInjector", + ] + ), + + .target( + name: "FocusedCodeFinder", + dependencies: [ + "Preferences", + "ASTParser", + "SuggestionBasic", + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + ] + ), + .testTarget( + name: "FocusedCodeFinderTests", + dependencies: ["FocusedCodeFinder"] + ), + + .target( + name: "GitIgnoreCheck", + dependencies: [ + "Terminal", + "Preferences", + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), + ] + ), + + .target( + name: "CommandHandler", + dependencies: [ + "XcodeInspector", + "Preferences", + "ChatBasic", + "ModificationBasic", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + + // MARK: - Services + + .target( + name: "LangChain", + dependencies: [ + "OpenAIService", + "ObjectiveCExceptionHandling", + "USearchIndex", + "ChatBasic", + .product(name: "JSONRPC", package: "JSONRPC"), + .product(name: "Parsing", package: "swift-parsing"), + .product(name: "SwiftSoup", package: "SwiftSoup"), + ] + ), + + .target(name: "WebScrapper", dependencies: [ + .product(name: "SwiftSoup", package: "SwiftSoup"), + ]), + + .target(name: "WebSearchService", dependencies: ["Preferences", "WebScrapper", "Keychain"]), + .testTarget(name: "WebSearchServiceTests", dependencies: ["WebSearchService"]), + + .target(name: "SuggestionProvider", dependencies: [ + "SuggestionBasic", + "UserDefaultsObserver", + "Preferences", + "Logger", + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + ]), + + .testTarget(name: "SuggestionProviderTests", dependencies: ["SuggestionProvider"]), + + .target( + name: "RAGChatAgent", + dependencies: [ + "ChatBasic", + "ChatContextCollector", + "OpenAIService", + "Preferences", + ] + ), + + // MARK: - GitHub Copilot + + .target( + name: "GitHubCopilotService", + dependencies: [ + "LanguageClient", + "SuggestionBasic", + "ChatBasic", + "Logger", + "Preferences", + "Terminal", + "BuiltinExtension", + "Toast", + "SuggestionProvider", + .product(name: "JSONRPC", package: "JSONRPC"), + .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + ], + resources: [.copy("Resources/load-self-signed-cert-1.34.0.js")] + ), + .testTarget( + name: "GitHubCopilotServiceTests", + dependencies: ["GitHubCopilotService"] + ), + + // MARK: - Codeium + + .target( + name: "CodeiumService", + dependencies: [ + "LanguageClient", + "Keychain", + "SuggestionBasic", + "Preferences", + "Terminal", + "XcodeInspector", + "BuiltinExtension", + "ChatTab", + "SharedUIComponents", + .product(name: "JSONRPC", package: "JSONRPC"), + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + ] + ), + + // MARK: - OpenAI + + .target( + name: "OpenAIService", + dependencies: [ + "Logger", + "Preferences", + "TokenEncoder", + "Keychain", + "BuiltinExtension", + "ChatBasic", + "GitHubCopilotService", + "JoinJSON", + .product(name: "JSONRPC", package: "JSONRPC"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product(name: "GoogleGenerativeAI", package: "generative-ai-swift"), + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), + ] + ), + .testTarget( + name: "OpenAIServiceTests", + dependencies: [ + "OpenAIService", + "ChatBasic", + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), + ] + ), + + // MARK: - UI + + .target( + name: "ChatTab", + dependencies: [ + "Preferences", + "Configs", + "AIModel", + .product(name: "CodableWrappers", package: "CodableWrappers"), + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), + ] + ), + + // MARK: - Chat Context Collector + + .target( + name: "ChatContextCollector", + dependencies: [ + "SuggestionBasic", + "ChatBasic", + "OpenAIService", + ] + ), + + .target( + name: "ActiveDocumentChatContextCollector", + dependencies: [ + "ASTParser", + "ChatContextCollector", + "OpenAIService", + "Preferences", + "FocusedCodeFinder", + "XcodeInspector", + "GitIgnoreCheck", + ], + path: "Sources/ChatContextCollectors/ActiveDocumentChatContextCollector" + ), + + .testTarget( + name: "ActiveDocumentChatContextCollectorTests", + dependencies: ["ActiveDocumentChatContextCollector"] + ), + + // MARK: - Tests + + .testTarget( + name: "LangChainTests", + dependencies: ["LangChain"] + ), + ] +) + diff --git a/Tool/README.md b/Tool/README.md new file mode 100644 index 00000000..1d22e107 --- /dev/null +++ b/Tool/README.md @@ -0,0 +1,3 @@ +# Tool + +A description of this package. diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift new file mode 100644 index 00000000..145d0298 --- /dev/null +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -0,0 +1,238 @@ +import CodableWrappers +import Foundation + +public struct ChatModel: Codable, Equatable, Identifiable { + public var id: String + public var name: String + @FallbackDecoding + public var format: Format + @FallbackDecoding + public var info: Info + + public init(id: String, name: String, format: Format, info: Info) { + self.id = id + self.name = name + self.format = format + self.info = info + } + + public enum Format: String, Codable, Equatable, CaseIterable { + case openAI + case azureOpenAI + case openAICompatible + case googleAI + case ollama + case claude + case gitHubCopilot + } + + public struct Info: Codable, Equatable { + public struct OllamaInfo: Codable, Equatable { + @FallbackDecoding + public var keepAlive: String + + public init(keepAlive: String = "") { + self.keepAlive = keepAlive + } + } + + public struct OpenAIInfo: Codable, Equatable { + @FallbackDecoding + public var organizationID: String + @FallbackDecoding + public var projectID: String + + public init(organizationID: String = "", projectID: String = "") { + self.organizationID = organizationID + self.projectID = projectID + } + } + + public struct OpenAICompatibleInfo: Codable, Equatable { + @FallbackDecoding + public var enforceMessageOrder: Bool + @FallbackDecoding + public var supportsMultipartMessageContent: Bool + @FallbackDecoding + public var requiresBeginWithUserMessage: Bool + + public init( + enforceMessageOrder: Bool = false, + supportsMultipartMessageContent: Bool = true, + requiresBeginWithUserMessage: Bool = false + ) { + self.enforceMessageOrder = enforceMessageOrder + self.supportsMultipartMessageContent = supportsMultipartMessageContent + self.requiresBeginWithUserMessage = requiresBeginWithUserMessage + } + } + + public struct GoogleGenerativeAIInfo: Codable, Equatable { + @FallbackDecoding + public var apiVersion: String + + public init(apiVersion: String = "") { + self.apiVersion = apiVersion + } + } + + public struct CustomHeaderInfo: Codable, Equatable { + public struct HeaderField: Codable, Equatable { + public var key: String + public var value: String + + public init(key: String, value: String) { + self.key = key + self.value = value + } + } + + @FallbackDecoding + public var headers: [HeaderField] + + public init(headers: [HeaderField] = []) { + self.headers = headers + } + } + + public struct CustomBodyInfo: Codable, Equatable { + public var jsonBody: String + + public init(jsonBody: String = "") { + self.jsonBody = jsonBody + } + } + + @FallbackDecoding + public var apiKeyName: String + @FallbackDecoding + public var baseURL: String + @FallbackDecoding + public var isFullURL: Bool + @FallbackDecoding + public var maxTokens: Int + @FallbackDecoding + public var supportsFunctionCalling: Bool + @FallbackDecoding + public var supportsImage: Bool + @FallbackDecoding + public var supportsAudio: Bool + @FallbackDecoding + public var modelName: String + + @FallbackDecoding + public var openAIInfo: OpenAIInfo + @FallbackDecoding + public var ollamaInfo: OllamaInfo + @FallbackDecoding + public var googleGenerativeAIInfo: GoogleGenerativeAIInfo + @FallbackDecoding + public var openAICompatibleInfo: OpenAICompatibleInfo + @FallbackDecoding + public var customHeaderInfo: CustomHeaderInfo + @FallbackDecoding + public var customBodyInfo: CustomBodyInfo + + public init( + apiKeyName: String = "", + baseURL: String = "", + isFullURL: Bool = false, + maxTokens: Int = 4000, + supportsFunctionCalling: Bool = true, + supportsImage: Bool = false, + supportsAudio: Bool = false, + modelName: String = "", + openAIInfo: OpenAIInfo = OpenAIInfo(), + ollamaInfo: OllamaInfo = OllamaInfo(), + googleGenerativeAIInfo: GoogleGenerativeAIInfo = GoogleGenerativeAIInfo(), + openAICompatibleInfo: OpenAICompatibleInfo = OpenAICompatibleInfo(), + customHeaderInfo: CustomHeaderInfo = CustomHeaderInfo(), + customBodyInfo: CustomBodyInfo = CustomBodyInfo() + ) { + self.apiKeyName = apiKeyName + self.baseURL = baseURL + self.isFullURL = isFullURL + self.maxTokens = maxTokens + self.supportsFunctionCalling = supportsFunctionCalling + self.supportsImage = supportsImage + self.supportsAudio = supportsAudio + self.modelName = modelName + self.openAIInfo = openAIInfo + self.ollamaInfo = ollamaInfo + self.googleGenerativeAIInfo = googleGenerativeAIInfo + self.openAICompatibleInfo = openAICompatibleInfo + self.customHeaderInfo = customHeaderInfo + self.customBodyInfo = customBodyInfo + } + } + + public var endpoint: String { + switch format { + case .openAI: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.openai.com/v1/chat/completions" } + return "\(baseURL)/v1/chat/completions" + case .openAICompatible: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.openai.com/v1/chat/completions" } + if info.isFullURL { return baseURL } + return "\(baseURL)/v1/chat/completions" + case .azureOpenAI: + let baseURL = info.baseURL + let deployment = info.modelName + let version = "2024-02-15-preview" + if baseURL.isEmpty { return "" } + return "\(baseURL)/openai/deployments/\(deployment)/chat/completions?api-version=\(version)" + case .googleAI: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://generativelanguage.googleapis.com" } + return "\(baseURL)" + case .ollama: + let baseURL = info.baseURL + if baseURL.isEmpty { return "http://localhost:11434/api/chat" } + return "\(baseURL)/api/chat" + case .claude: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.anthropic.com/v1/messages" } + return "\(baseURL)/v1/messages" + case .gitHubCopilot: + return "https://api.githubcopilot.com/chat/completions" + } + } +} + +public struct EmptyChatModelInfo: FallbackValueProvider { + public static var defaultValue: ChatModel.Info { .init() } +} + +public struct EmptyChatModelFormat: FallbackValueProvider { + public static var defaultValue: ChatModel.Format { .openAI } +} + +public struct EmptyChatModelOllamaInfo: FallbackValueProvider { + public static var defaultValue: ChatModel.Info.OllamaInfo { .init() } +} + +public struct EmptyChatModelOpenAIInfo: FallbackValueProvider { + public static var defaultValue: ChatModel.Info.OpenAIInfo { .init() } +} + +public struct EmptyChatModelGoogleGenerativeAIInfo: FallbackValueProvider { + public static var defaultValue: ChatModel.Info.GoogleGenerativeAIInfo { .init() } +} + +public struct EmptyChatModelOpenAICompatibleInfo: FallbackValueProvider { + public static var defaultValue: ChatModel.Info.OpenAICompatibleInfo { .init() } +} + +public struct EmptyChatModelCustomHeaderInfo: FallbackValueProvider { + public static var defaultValue: ChatModel.Info.CustomHeaderInfo { .init() } +} + +public struct EmptyChatModelCustomBodyInfo: FallbackValueProvider { + public static var defaultValue: ChatModel.Info.CustomBodyInfo { .init() } +} + +public struct EmptyTrue: FallbackValueProvider { + public static var defaultValue: Bool { true } +} diff --git a/Tool/Sources/AIModel/EmbeddingModel.swift b/Tool/Sources/AIModel/EmbeddingModel.swift new file mode 100644 index 00000000..4e192dda --- /dev/null +++ b/Tool/Sources/AIModel/EmbeddingModel.swift @@ -0,0 +1,109 @@ +import CodableWrappers +import Foundation + +public struct EmbeddingModel: Codable, Equatable, Identifiable { + public var id: String + public var name: String + @FallbackDecoding + public var format: Format + @FallbackDecoding + public var info: Info + + public init(id: String, name: String, format: Format, info: Info) { + self.id = id + self.name = name + self.format = format + self.info = info + } + + public enum Format: String, Codable, Equatable, CaseIterable { + case openAI + case azureOpenAI + case openAICompatible + case ollama + case gitHubCopilot + } + + public struct Info: Codable, Equatable { + public typealias OllamaInfo = ChatModel.Info.OllamaInfo + public typealias OpenAIInfo = ChatModel.Info.OpenAIInfo + public typealias CustomHeaderInfo = ChatModel.Info.CustomHeaderInfo + + @FallbackDecoding + public var apiKeyName: String + @FallbackDecoding + public var baseURL: String + @FallbackDecoding + public var isFullURL: Bool + @FallbackDecoding + public var maxTokens: Int + @FallbackDecoding + public var dimensions: Int + @FallbackDecoding + public var modelName: String + + @FallbackDecoding + public var openAIInfo: OpenAIInfo + @FallbackDecoding + public var ollamaInfo: OllamaInfo + @FallbackDecoding + public var customHeaderInfo: CustomHeaderInfo + + public init( + apiKeyName: String = "", + baseURL: String = "", + isFullURL: Bool = false, + maxTokens: Int = 8192, + dimensions: Int = 1536, + modelName: String = "", + openAIInfo: OpenAIInfo = OpenAIInfo(), + ollamaInfo: OllamaInfo = OllamaInfo(), + customHeaderInfo: CustomHeaderInfo = CustomHeaderInfo() + ) { + self.apiKeyName = apiKeyName + self.baseURL = baseURL + self.isFullURL = isFullURL + self.maxTokens = maxTokens + self.dimensions = dimensions + self.modelName = modelName + self.openAIInfo = openAIInfo + self.ollamaInfo = ollamaInfo + self.customHeaderInfo = customHeaderInfo + } + } + + public var endpoint: String { + switch format { + case .openAI: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.openai.com/v1/embeddings" } + return "\(baseURL)/v1/embeddings" + case .openAICompatible: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.openai.com/v1/embeddings" } + if info.isFullURL { return baseURL } + return "\(baseURL)/v1/embeddings" + case .azureOpenAI: + let baseURL = info.baseURL + let deployment = info.modelName + let version = "2024-02-15-preview" + if baseURL.isEmpty { return "" } + return "\(baseURL)/openai/deployments/\(deployment)/embeddings?api-version=\(version)" + case .ollama: + let baseURL = info.baseURL + if baseURL.isEmpty { return "http://localhost:11434/api/embeddings" } + return "\(baseURL)/api/embeddings" + case .gitHubCopilot: + return "https://api.githubcopilot.com/embeddings" + } + } +} + +public struct EmptyEmbeddingModelInfo: FallbackValueProvider { + public static var defaultValue: EmbeddingModel.Info { .init() } +} + +public struct EmptyEmbeddingModelFormat: FallbackValueProvider { + public static var defaultValue: EmbeddingModel.Format { .openAI } +} + diff --git a/Tool/Sources/ASTParser/ASTParser.swift b/Tool/Sources/ASTParser/ASTParser.swift new file mode 100644 index 00000000..1f66fa85 --- /dev/null +++ b/Tool/Sources/ASTParser/ASTParser.swift @@ -0,0 +1,118 @@ +import SuggestionBasic +import SwiftTreeSitter +import tree_sitter +import TreeSitterObjC + +public enum ParsableLanguage { + case objectiveC + + var tsLanguage: UnsafeMutablePointer { + switch self { + case .objectiveC: + return tree_sitter_objc() + } + } +} + +public struct ASTParser { + let language: ParsableLanguage + let parser: Parser + + public init(language: ParsableLanguage) { + self.language = language + parser = Parser() + try! parser.setLanguage(Language(language: language.tsLanguage)) + } + + public func parse(_ source: String) -> ASTTree? { + return ASTTree(tree: parser.parse(source)) + } +} + +public typealias ASTNode = Node + +public typealias ASTPoint = Point + +public struct ASTTree { + public let tree: Tree? + + public var rootNode: ASTNode? { + return tree?.rootNode + } + + public func smallestNodeContainingRange( + _ range: CursorRange, + filter: (ASTNode) -> Bool = { _ in true } + ) -> ASTNode? { + guard var targetNode = rootNode else { return nil } + + func rangeContains(_ range: Range, _ another: Range) -> Bool { + return range.lowerBound <= another.lowerBound && range.upperBound >= another.upperBound + } + + for node in targetNode.treeCursor.deepFirstSearch(skipChildren: { node in + !rangeContains(node.pointRange, range.pointRange) + }) { + guard filter(node) else { continue } + if rangeContains(node.pointRange, range.pointRange) { + targetNode = node + } + } + + return targetNode + } +} + +public extension ASTNode { + var children: ASTNodeChildrenSequence { + return ASTNodeChildrenSequence(node: self) + } + + struct ASTNodeChildrenSequence: Sequence { + let node: ASTNode + + public struct ASTNodeChildrenIterator: IteratorProtocol { + let node: ASTNode + var index: UInt32 = 0 + + public mutating func next() -> ASTNode? { + guard index < node.childCount else { return nil } + defer { index += 1 } + return node.child(at: 1) + } + } + + public func makeIterator() -> ASTNodeChildrenIterator { + return ASTNodeChildrenIterator(node: node) + } + } +} + +public extension CursorRange { + var pointRange: Range { + let bytePerCharacter = 2 // tree sitter uses UTF-16 + let startPoint = Point(row: start.line, column: start.character * bytePerCharacter) + let endPoint = Point(row: end.line, column: end.character * bytePerCharacter) + guard endPoint > startPoint else { + return startPoint..) { + let bytePerCharacter = 2 // tree sitter uses UTF-16 + let start = CursorPosition( + line: Int(pointRange.lowerBound.row), + character: Int(pointRange.lowerBound.column) / bytePerCharacter + ) + let end = CursorPosition( + line: Int(pointRange.upperBound.row), + character: Int(pointRange.upperBound.column) / bytePerCharacter + ) + self.init(start: start, end: end) + } +} + diff --git a/Tool/Sources/ASTParser/ASTTreeVisitor.swift b/Tool/Sources/ASTParser/ASTTreeVisitor.swift new file mode 100644 index 00000000..32043cf8 --- /dev/null +++ b/Tool/Sources/ASTParser/ASTTreeVisitor.swift @@ -0,0 +1,61 @@ +import Foundation +import SwiftTreeSitter + +public enum ASTTreeVisitorContinueKind { + /// The visitor should visit the descendants of the current node. + case visitChildren + /// The visitor should avoid visiting the descendants of the current node. + case skipChildren +} + +// A SwiftSyntax style tree visitor. +open class ASTTreeVisitor { + public let tree: ASTTree + + public init(tree: ASTTree) { + self.tree = tree + } + + public func walk() { + guard let cursor = tree.rootNode?.treeCursor else { return } + visit(cursor) + } + + public func walk(_ node: ASTNode) { + let cursor = node.treeCursor + visit(cursor) + } + + open func visit(_: ASTNode) -> ASTTreeVisitorContinueKind { + // do nothing + return .skipChildren + } + + open func visitPost(_: ASTNode) { + // do nothing + } + + private func visit(_ cursor: TreeCursor) { + guard let currentNode = cursor.currentNode else { return } + let continueKind = visit(currentNode) + + switch continueKind { + case .skipChildren: + visitPost(currentNode) + case .visitChildren: + visitChildren(cursor) + visitPost(currentNode) + } + } + + private func visitChildren(_ cursor: TreeCursor) { + let hasChild = cursor.goToFirstChild() + guard hasChild else { return } + visit(cursor) + while cursor.goToNextSibling() { + visit(cursor) + } + _ = cursor.gotoParent() + } +} + diff --git a/Tool/Sources/ASTParser/DumpSyntaxTree.swift b/Tool/Sources/ASTParser/DumpSyntaxTree.swift new file mode 100644 index 00000000..185d897e --- /dev/null +++ b/Tool/Sources/ASTParser/DumpSyntaxTree.swift @@ -0,0 +1,55 @@ +import SwiftTreeSitter +import SwiftUI + +public extension ASTTree { + /// Dumps the syntax tree as a string, for debugging purposes. + func dump() -> AttributedString { + guard let tree, let root = tree.rootNode else { return "" } + var result: AttributedString = "" + + let appendNode: (_ level: Int, _ node: Node, _ name: String) -> Void = { + level, node, name in + let range = node.pointRange + let lowerBoundL = range.lowerBound.row + let lowerBoundC = range.lowerBound.column / 2 + let upperBoundL = range.upperBound.row + let upperBoundC = range.upperBound.column / 2 + let indentation = AttributedString(String(repeating: " ", count: level)) + let nodeInfo = { + if name.isEmpty { + return AttributedString(node.nodeType ?? "N/A", attributes: .init([ + .foregroundColor: NSColor.blue, + ])) + } else { + var string = AttributedString("\(name): ", attributes: .init([ + .foregroundColor: NSColor.brown, + ])) + string.append(AttributedString(node.nodeType ?? "N/A", attributes: .init([ + .foregroundColor: NSColor.blue, + ]))) + return string + } + }() + let rangeText = "[\(lowerBoundL), \(lowerBoundC)] - [\(upperBoundL), \(upperBoundC)]" + + var line: AttributedString = "" + line.append(indentation) + line.append(nodeInfo) + line.append(AttributedString(" \(rangeText)\n")) + + result.append(line) + } + + func enumerate(_ node: Node, level: Int, name: String) { + appendNode(level, node, name) + for i in 0.. Bool + ) -> CursorDeepFirstSearchSequence { + return CursorDeepFirstSearchSequence(cursor: self, skipChildren: skipChildren) + } +} + +// MARK: - Search + +public protocol Cursor { + associatedtype Node + var currentNode: Node? { get } + func goToFirstChild() -> Bool + func goToNextSibling() -> Bool + func goToParent() -> Bool +} + +extension TreeCursor: Cursor { + public func goToNextSibling() -> Bool { + gotoNextSibling() + } + + public func goToParent() -> Bool { + gotoParent() + } +} + +public struct CursorDeepFirstSearchSequence: Sequence { + let cursor: C + let skipChildren: (C.Node) -> Bool + + public func makeIterator() -> CursorDeepFirstSearchIterator { + return CursorDeepFirstSearchIterator( + cursor: cursor, + skipChildren: skipChildren + ) + } + + public struct CursorDeepFirstSearchIterator: IteratorProtocol { + let cursor: C + let skipChildren: (C.Node) -> Bool + var isEnded = false + + public mutating func next() -> C.Node? { + guard !isEnded else { return nil } + let currentNode = cursor.currentNode + let hasChild = { + guard let n = currentNode else { return false } + if skipChildren(n) { return false } + return cursor.goToFirstChild() + }() + if !hasChild { + while !cursor.goToNextSibling() { + if !cursor.goToParent() { + isEnded = true + break + } + } + } + + return currentNode + } + } +} + diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift new file mode 100644 index 00000000..e54bfaff --- /dev/null +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -0,0 +1,448 @@ +import AppKit +import Foundation +import Logger + +// MARK: - State + +public extension AXUIElement { + /// Set global timeout in seconds. + static func setGlobalMessagingTimeout(_ timeout: Float) { + AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), timeout) + } + + /// Set timeout in seconds for this element. + func setMessagingTimeout(_ timeout: Float) { + AXUIElementSetMessagingTimeout(self, timeout) + } + + var identifier: String { + (try? copyValue(key: kAXIdentifierAttribute)) ?? "" + } + + var value: String { + (try? copyValue(key: kAXValueAttribute)) ?? "" + } + + var intValue: Int? { + (try? copyValue(key: kAXValueAttribute)) + } + + var title: String { + (try? copyValue(key: kAXTitleAttribute)) ?? "" + } + + var role: String { + (try? copyValue(key: kAXRoleAttribute)) ?? "" + } + + var doubleValue: Double { + (try? copyValue(key: kAXValueAttribute)) ?? 0.0 + } + + var document: String? { + try? copyValue(key: kAXDocumentAttribute) + } + + /// Label in Accessibility Inspector. + var description: String { + (try? copyValue(key: kAXDescriptionAttribute)) ?? "" + } + + /// Type in Accessibility Inspector. + var roleDescription: String { + (try? copyValue(key: kAXRoleDescriptionAttribute)) ?? "" + } + + var label: String { + (try? copyValue(key: kAXLabelValueAttribute)) ?? "" + } + + var isSourceEditor: Bool { + if !(description == "Source Editor" && role != kAXUnknownRole) { return false } + if let _ = firstParent(where: { $0.identifier == "editor context" }) { return true } + return false + } + + var selectedTextRange: ClosedRange? { + guard let value: AXValue = try? copyValue(key: kAXSelectedTextRangeAttribute) + else { return nil } + var range: CFRange = .init(location: 0, length: 0) + if AXValueGetValue(value, .cfRange, &range) { + return range.location...(range.location + range.length) + } + return nil + } + + var isFocused: Bool { + (try? copyValue(key: kAXFocusedAttribute)) ?? false + } + + var isEnabled: Bool { + (try? copyValue(key: kAXEnabledAttribute)) ?? false + } + + var isHidden: Bool { + (try? copyValue(key: kAXHiddenAttribute)) ?? false + } + + var debugDescription: String { + "<\(title)> <\(description)> <\(label)> (\(role):\(roleDescription)) [\(identifier)] \(rect ?? .zero) \(children.count) children" + } + + var debugEnumerateChildren: String { + var result = "> " + debugDescription + "\n" + result += children.map { + $0.debugEnumerateChildren.split(separator: "\n") + .map { " " + $0 } + .joined(separator: "\n") + }.joined(separator: "\n") + return result + } + + var debugEnumerateParents: String { + var chain: [String] = [] + chain.append("* " + debugDescription) + var parent = self.parent + if let current = parent { + chain.append("> " + current.debugDescription) + parent = current.parent + } + var result = "" + for (index, line) in chain.reversed().enumerated() { + result += String(repeating: " ", count: index) + line + "\n" + } + return result + } +} + +// MARK: - Rect + +public extension AXUIElement { + var position: CGPoint? { + guard let value: AXValue = try? copyValue(key: kAXPositionAttribute) + else { return nil } + var point: CGPoint = .zero + if AXValueGetValue(value, .cgPoint, &point) { + return point + } + return nil + } + + var size: CGSize? { + guard let value: AXValue = try? copyValue(key: kAXSizeAttribute) + else { return nil } + var size: CGSize = .zero + if AXValueGetValue(value, .cgSize, &size) { + return size + } + return nil + } + + var rect: CGRect? { + guard let position, let size else { return nil } + return .init(origin: position, size: size) + } +} + +// MARK: - Relationship + +public extension AXUIElement { + var focusedElement: AXUIElement? { + try? copyValue(key: kAXFocusedUIElementAttribute) + } + + var sharedFocusElements: [AXUIElement] { + (try? copyValue(key: kAXChildrenAttribute)) ?? [] + } + + var window: AXUIElement? { + try? copyValue(key: kAXWindowAttribute) + } + + var windows: [AXUIElement] { + (try? copyValue(key: kAXWindowsAttribute)) ?? [] + } + + var isFullScreen: Bool { + (try? copyValue(key: "AXFullScreen")) ?? false + } + + var windowID: CGWindowID? { + var identifier: CGWindowID = 0 + let error = AXUIElementGetWindow(self, &identifier) + if error == .success { + return identifier + } + return nil + } + + var isFrontmost: Bool { + get { + (try? copyValue(key: kAXFrontmostAttribute)) ?? false + } + set { + AXUIElementSetAttributeValue( + self, + kAXFrontmostAttribute as CFString, + newValue as CFBoolean + ) + } + } + + var focusedWindow: AXUIElement? { + try? copyValue(key: kAXFocusedWindowAttribute) + } + + var topLevelElement: AXUIElement? { + try? copyValue(key: kAXTopLevelUIElementAttribute) + } + + var rows: [AXUIElement] { + (try? copyValue(key: kAXRowsAttribute)) ?? [] + } + + var parent: AXUIElement? { + try? copyValue(key: kAXParentAttribute) + } + + var children: [AXUIElement] { + (try? copyValue(key: kAXChildrenAttribute)) ?? [] + } + + var menuBar: AXUIElement? { + try? copyValue(key: kAXMenuBarAttribute) + } + + var visibleChildren: [AXUIElement] { + (try? copyValue(key: kAXVisibleChildrenAttribute)) ?? [] + } + + func child( + identifier: String? = nil, + title: String? = nil, + role: String? = nil, + depth: Int = 0 + ) -> AXUIElement? { + #if DEBUG + if depth >= 50 { + fatalError("AXUIElement.child: Exceeding recommended depth.") + } + #endif + + for child in children { + let match = { + if let identifier, child.identifier != identifier { return false } + if let title, child.title != title { return false } + if let role, child.role != role { return false } + return true + }() + if match { return child } + } + for child in children { + if let target = child.child( + identifier: identifier, + title: title, + role: role, + depth: depth + 1 + ) { return target } + } + return nil + } + + /// Get children that match the requirement + /// + /// - important: If the element has a lot of descendant nodes, it will heavily affect the + /// **performance of Xcode**. Please make use ``AXUIElement\traverse(_:)`` instead. + @available( + *, + deprecated, + renamed: "traverse(_:)", + message: "Please make use ``AXUIElement\traverse(_:)`` instead." + ) + func children(depth: Int = 0, where match: (AXUIElement) -> Bool) -> [AXUIElement] { + #if DEBUG + if depth >= 50 { + fatalError("AXUIElement.children: Exceeding recommended depth.") + } + #endif + + var all = [AXUIElement]() + for child in children { + if match(child) { all.append(child) } + } + for child in children { + all.append(contentsOf: child.children(depth: depth + 1, where: match)) + } + return all + } + + func firstParent(where match: (AXUIElement) -> Bool) -> AXUIElement? { + guard let parent = parent else { return nil } + if match(parent) { return parent } + return parent.firstParent(where: match) + } + + func firstChild( + depth: Int = 0, + maxDepth: Int = 50, + where match: (AXUIElement) -> Bool + ) -> AXUIElement? { + #if DEBUG + if depth > maxDepth { + fatalError("AXUIElement.firstChild: Exceeding recommended depth.") + } + #else + if depth > maxDepth { + return nil + } + #endif + for child in children { + if match(child) { return child } + } + for child in children { + if let target = child.firstChild(depth: depth + 1, where: match) { + return target + } + } + return nil + } + + func visibleChild(identifier: String) -> AXUIElement? { + for child in visibleChildren { + if child.identifier == identifier { return child } + if let target = child.visibleChild(identifier: identifier) { return target } + } + return nil + } + + var verticalScrollBar: AXUIElement? { + try? copyValue(key: kAXVerticalScrollBarAttribute) + } +} + +public extension AXUIElement { + enum SearchNextStep { + case skipDescendants + case skipSiblings(Info) + case skipDescendantsAndSiblings + case continueSearching(Info) + case stopSearching + } + + /// Traversing the element tree. + /// + /// - important: Traversing the element tree is resource consuming and will affect the + /// **performance of Xcode**. Please make sure to skip as much as possible. + /// + /// - todo: Make it not recursive. + func traverse( + access: (AXUIElement) -> [AXUIElement] = { $0.children }, + info: Info, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function, + _ handle: (_ element: AXUIElement, _ level: Int, _ info: Info) -> SearchNextStep + ) { + #if DEBUG + var count = 0 +// let startDate = Date() + #endif + func _traverse( + element: AXUIElement, + level: Int, + info: Info, + handle: (AXUIElement, Int, Info) -> SearchNextStep + ) -> SearchNextStep { + #if DEBUG + count += 1 + #endif + let nextStep = handle(element, level, info) + switch nextStep { + case .stopSearching: return .stopSearching + case .skipDescendants: return .continueSearching(info) + case .skipDescendantsAndSiblings: return .skipSiblings(info) + case let .continueSearching(info), let .skipSiblings(info): + loop: for child in access(element) { + switch _traverse(element: child, level: level + 1, info: info, handle: handle) { + case .skipSiblings, .skipDescendantsAndSiblings: + break loop + case .stopSearching: + return .stopSearching + case .continueSearching, .skipDescendants: + continue loop + } + } + + return nextStep + } + } + + _ = _traverse(element: self, level: 0, info: info, handle: handle) + + #if DEBUG +// let duration = Date().timeIntervalSince(startDate) +// .formatted(.number.precision(.fractionLength(0...4))) +// Logger.service.debug( +// "AXUIElement.traverse count: \(count), took \(duration) seconds", +// file: file, +// line: line, +// function: function +// ) + #endif + } + + /// Traversing the element tree. + /// + /// - important: Traversing the element tree is resource consuming and will affect the + /// **performance of Xcode**. Please make sure to skip as much as possible. + /// + /// - todo: Make it not recursive. + func traverse( + access: (AXUIElement) -> [AXUIElement] = { $0.children }, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function, + _ handle: (_ element: AXUIElement, _ level: Int) -> SearchNextStep + ) { + traverse(access: access, info: (), file: file, line: line, function: function) { + element, level, _ in + handle(element, level) + } + } +} + +// MARK: - Helper + +public extension AXUIElement { + func copyValue(key: String, ofType _: T.Type = T.self) throws -> T { + var value: AnyObject? + let error = AXUIElementCopyAttributeValue(self, key as CFString, &value) + if error == .success, let value = value as? T { + return value + } + throw error + } + + func copyParameterizedValue( + key: String, + parameters: AnyObject, + ofType _: T.Type = T.self + ) throws -> T { + var value: AnyObject? + let error = AXUIElementCopyParameterizedAttributeValue( + self, + key as CFString, + parameters as CFTypeRef, + &value + ) + if error == .success, let value = value as? T { + return value + } + throw error + } +} + +extension AXError: @retroactive _BridgedNSError {} +extension AXError: @retroactive _ObjectiveCBridgeableError {} +extension AXError: @retroactive Error {} + diff --git a/Tool/Sources/AXExtension/AXUIElementPrivateAPI.swift b/Tool/Sources/AXExtension/AXUIElementPrivateAPI.swift new file mode 100644 index 00000000..bd861a3f --- /dev/null +++ b/Tool/Sources/AXExtension/AXUIElementPrivateAPI.swift @@ -0,0 +1,8 @@ +import AppKit + +/// AXError _AXUIElementGetWindow(AXUIElementRef element, uint32_t *identifier); +@_silgen_name("_AXUIElementGetWindow") @discardableResult +func AXUIElementGetWindow( + _ element: AXUIElement, + _ identifier: UnsafeMutablePointer +) -> AXError diff --git a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift new file mode 100644 index 00000000..b361f8ae --- /dev/null +++ b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift @@ -0,0 +1,166 @@ +import AppKit +import ApplicationServices +import Foundation +import Logger +import Preferences + +public final class AXNotificationStream: AsyncSequence { + public typealias Stream = AsyncStream + public typealias Continuation = Stream.Continuation + public typealias AsyncIterator = Stream.AsyncIterator + public typealias Element = (name: String, element: AXUIElement, info: CFDictionary) + + private var continuation: Continuation + private let stream: Stream + + private let file: StaticString + private let line: UInt + private let function: StaticString + + public func makeAsyncIterator() -> Stream.AsyncIterator { + stream.makeAsyncIterator() + } + + deinit { + continuation.finish() + } + + public convenience init( + app: NSRunningApplication, + element: AXUIElement? = nil, + notificationNames: String..., + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { + self.init( + app: app, + element: element, + notificationNames: notificationNames, + file: file, + line: line, + function: function + ) + } + + public init( + app: NSRunningApplication, + element: AXUIElement? = nil, + notificationNames: [String], + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { + self.file = file + self.line = line + self.function = function + + let mode: CFRunLoopMode = UserDefaults.shared + .value(for: \.observeToAXNotificationWithDefaultMode) ? .defaultMode : .commonModes + + let runLoop: CFRunLoop = CFRunLoopGetMain() + + var cont: Continuation! + stream = Stream { continuation in + cont = continuation + } + continuation = cont + var observer: AXObserver? + + func callback( + observer: AXObserver, + element: AXUIElement, + notificationName: CFString, + userInfo: CFDictionary, + pointer: UnsafeMutableRawPointer? + ) { + guard let pointer = pointer?.assumingMemoryBound(to: Continuation.self) + else { return } + pointer.pointee.yield((notificationName as String, element, userInfo)) + } + + _ = AXObserverCreateWithInfoCallback( + app.processIdentifier, + callback, + &observer + ) + guard let observer else { + continuation.finish() + return + } + + let observingElement = element ?? AXUIElementCreateApplication(app.processIdentifier) + continuation.onTermination = { @Sendable _ in + for name in notificationNames { + AXObserverRemoveNotification(observer, observingElement, name as CFString) + } + CFRunLoopRemoveSource( + runLoop, + AXObserverGetRunLoopSource(observer), + mode + ) + } + + Task { @MainActor [weak self] in + CFRunLoopAddSource( + runLoop, + AXObserverGetRunLoopSource(observer), + mode + ) + var pendingRegistrationNames = Set(notificationNames) + var retry = 0 + while !pendingRegistrationNames.isEmpty, retry < 100 { + guard let self else { return } + retry += 1 + for name in notificationNames { + await Task.yield() + let e = withUnsafeMutablePointer(to: &self.continuation) { pointer in + AXObserverAddNotification( + observer, + observingElement, + name as CFString, + pointer + ) + } + switch e { + case .success: + pendingRegistrationNames.remove(name) + case .actionUnsupported: + Logger.service.error("AXObserver: Action unsupported: \(name)") + pendingRegistrationNames.remove(name) + case .apiDisabled: + Logger.service + .error("AXObserver: Accessibility API disabled, will try again later") + retry -= 1 + case .invalidUIElement: + // It's possible that the UI element is not ready yet. + // + // Especially when you retrieve an element right after macOS is + // awaken from sleep. + Logger.service + .error("AXObserver: Invalid UI element, notification name \(name)") + case .invalidUIElementObserver: + Logger.service.error("AXObserver: Invalid UI element observer") + pendingRegistrationNames.remove(name) + case .cannotComplete: + Logger.service + .error("AXObserver: Failed to observe \(name), will try again later") + case .notificationUnsupported: + Logger.service.error("AXObserver: Notification unsupported: \(name)") + pendingRegistrationNames.remove(name) + case .notificationAlreadyRegistered: + Logger.service.info("AXObserver: Notification already registered: \(name)") + pendingRegistrationNames.remove(name) + default: + Logger.service + .error( + "AXObserver: Unrecognized error \(e) when registering \(name), will try again later" + ) + } + } + try await Task.sleep(nanoseconds: 1_500_000_000) + } + } + } +} + diff --git a/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift b/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift new file mode 100644 index 00000000..12c309ed --- /dev/null +++ b/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift @@ -0,0 +1,115 @@ +import AppKit + +public struct RunningApplicationInfo: Sendable { + public let isXcode: Bool + public let isActive: Bool + public let isHidden: Bool + public let localizedName: String? + public let bundleIdentifier: String? + public let bundleURL: URL? + public let executableURL: URL? + public let processIdentifier: pid_t + public let launchDate: Date? + public let executableArchitecture: Int + + init(_ application: NSRunningApplication) { + isXcode = application.isXcode + isActive = application.isActive + isHidden = application.isHidden + localizedName = application.localizedName + bundleIdentifier = application.bundleIdentifier + bundleURL = application.bundleURL + executableURL = application.executableURL + processIdentifier = application.processIdentifier + launchDate = application.launchDate + executableArchitecture = application.executableArchitecture + } +} + +public extension NSRunningApplication { + var info: RunningApplicationInfo { RunningApplicationInfo(self) } +} + +public final class ActiveApplicationMonitor { + public static let shared = ActiveApplicationMonitor() + public private(set) var latestXcode: NSRunningApplication? = NSWorkspace.shared + .runningApplications + .first(where: \.isXcode) + public private(set) var previousApp: NSRunningApplication? + public private(set) var activeApplication = NSWorkspace.shared.runningApplications + .first(where: \.isActive) + { + didSet { + if activeApplication?.isXcode ?? false { + latestXcode = activeApplication + } + previousApp = oldValue + } + } + + private var infoContinuations: [UUID: AsyncStream.Continuation] = [:] + + private init() { + activeApplication = NSWorkspace.shared.runningApplications.first(where: \.isActive) + + Task { + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.didActivateApplicationNotification) + for await notification in sequence { + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + else { continue } + activeApplication = app + notifyContinuations() + } + } + } + + deinit { + for continuation in infoContinuations { + continuation.value.finish() + } + } + + public var activeXcode: NSRunningApplication? { + if activeApplication?.isXcode ?? false { + return activeApplication + } + return nil + } + + public func createInfoStream() -> AsyncStream { + .init { continuation in + let id = UUID() + Task { @MainActor in + continuation.onTermination = { _ in + self.removeInfoContinuation(id: id) + } + addInfoContinuation(continuation, id: id) + continuation.yield(activeApplication?.info) + } + } + } + + func addInfoContinuation( + _ continuation: AsyncStream.Continuation, + id: UUID + ) { + infoContinuations[id] = continuation + } + + func removeInfoContinuation(id: UUID) { + infoContinuations[id] = nil + } + + private func notifyContinuations() { + for continuation in infoContinuations { + continuation.value.yield(activeApplication?.info) + } + } +} + +public extension NSRunningApplication { + var isXcode: Bool { bundleIdentifier == "com.apple.dt.Xcode" } +} + diff --git a/Tool/Sources/AppActivator/AppActivator.swift b/Tool/Sources/AppActivator/AppActivator.swift new file mode 100644 index 00000000..2011360a --- /dev/null +++ b/Tool/Sources/AppActivator/AppActivator.swift @@ -0,0 +1,123 @@ +import AppKit +import Dependencies +import XcodeInspector + +public extension NSWorkspace { + static func activateThisApp(delay: TimeInterval = 0.10) { + Task { @MainActor in + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + + // NSApp.activate may fail. And since macOS 14, it looks like the app needs other + // apps to call `yieldActivationToApplication` to activate itself? + + let activated = NSRunningApplication.current + .activate(options: [.activateIgnoringOtherApps]) + + if activated { return } + + // Fallback solution + + let axApplication = AXUIElementCreateApplication( + ProcessInfo.processInfo.processIdentifier + ) + activateAppElement(axApplication) +// +// let appleScript = """ +// tell application "System Events" +// set frontmost of the first process whose unix id is \ +// \(ProcessInfo.processInfo.processIdentifier) to true +// end tell +// """ +// try await runAppleScript(appleScript) + } + } + + static func activatePreviousActiveApp(delay: TimeInterval = 0.2) { + Task { @MainActor in + guard let app = XcodeInspector.shared.previousActiveApplication + else { return } + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + activateApp(app) + } + } + + static func activatePreviousActiveXcode(delay: TimeInterval = 0.2) { + Task { @MainActor in + guard let app = XcodeInspector.shared.latestActiveXcode else { return } + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + activateApp(app) + } + } + + static func activateApp(_ app: AppInstanceInspector) { + // we prefer `.activate()` because it only brings the active window to the front + if !app.activate() { + activateAppElement(app.appElement) + } + } + + static func activateAppElement(_ appElement: AXUIElement) { + appElement.isFrontmost = true + } +} + +struct ActivateThisAppDependencyKey: DependencyKey { + static var liveValue: () -> Void = { NSWorkspace.activateThisApp() } +} + +struct ActivatePreviousActiveAppDependencyKey: DependencyKey { + static var liveValue: () -> Void = { NSWorkspace.activatePreviousActiveApp() } +} + +struct ActivatePreviousActiveXcodeDependencyKey: DependencyKey { + static var liveValue: () -> Void = { NSWorkspace.activatePreviousActiveXcode() } +} + +public extension DependencyValues { + var activateThisApp: () -> Void { + get { self[ActivateThisAppDependencyKey.self] } + set { self[ActivateThisAppDependencyKey.self] = newValue } + } + + var activatePreviousActiveApp: () -> Void { + get { self[ActivatePreviousActiveAppDependencyKey.self] } + set { self[ActivatePreviousActiveAppDependencyKey.self] = newValue } + } + + var activatePreviousActiveXcode: () -> Void { + get { self[ActivatePreviousActiveXcodeDependencyKey.self] } + set { self[ActivatePreviousActiveXcodeDependencyKey.self] = newValue } + } +} + +@discardableResult +func runAppleScript(_ appleScript: String) async throws -> String { + let task = Process() + task.launchPath = "/usr/bin/osascript" + task.arguments = ["-e", appleScript] + let outpipe = Pipe() + task.standardOutput = outpipe + task.standardError = Pipe() + + return try await withUnsafeThrowingContinuation { continuation in + do { + task.terminationHandler = { _ in + do { + if let data = try outpipe.fileHandleForReading.readToEnd(), + let content = String(data: data, encoding: .utf8) + { + continuation.resume(returning: content) + return + } + continuation.resume(returning: "") + } catch { + continuation.resume(throwing: error) + } + } + try task.run() + } catch { + continuation.resume(throwing: error) + } + } +} + diff --git a/Tool/Sources/AsyncPassthroughSubject/AsyncPassthroughSubject.swift b/Tool/Sources/AsyncPassthroughSubject/AsyncPassthroughSubject.swift new file mode 100644 index 00000000..94d033d7 --- /dev/null +++ b/Tool/Sources/AsyncPassthroughSubject/AsyncPassthroughSubject.swift @@ -0,0 +1,54 @@ +import AppKit +import Foundation + +public actor AsyncPassthroughSubject { + var tasks: [AsyncStream.Continuation] = [] + + deinit { + tasks.forEach { $0.finish() } + } + + public init() {} + + public func notifications() -> AsyncStream { + AsyncStream { [weak self] continuation in + let task = Task { [weak self] in + await self?.storeContinuation(continuation) + } + + continuation.onTermination = { termination in + task.cancel() + } + } + } + + nonisolated + public func send(_ element: Element) { + Task { await _send(element) } + } + + func _send(_ element: Element) { + let tasks = tasks + for task in tasks { + task.yield(element) + } + } + + func storeContinuation(_ continuation: AsyncStream.Continuation) { + tasks.append(continuation) + } + + nonisolated + public func finish() { + Task { await _finish() } + } + + func _finish() { + let tasks = self.tasks + self.tasks = [] + for task in tasks { + task.finish() + } + } +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift new file mode 100644 index 00000000..80cdaccb --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift @@ -0,0 +1,89 @@ +import ChatBasic +import ChatTab +import CopilotForXcodeKit +import Foundation +import Preferences + +public protocol BuiltinExtension: CopilotForXcodeExtensionCapability { + /// An identifier for the extension. + var extensionIdentifier: String { get } + + /// All chat builders provided by this extension. + var chatTabTypes: [any CustomChatTab] { get } + + /// It's usually called when the app is about to quit, + /// you should clean up all the resources here. + func terminate() +} + +// MARK: - Default Implementation + +public extension BuiltinExtension { + var suggestionServiceId: BuiltInSuggestionFeatureProvider? { nil } + var chatTabTypes: [any CustomChatTab] { [] } +} + +// MAKR: - ChatService + +/// A temporary protocol for ChatServiceType. Migrate it to CopilotForXcodeKit when finished. +public protocol BuiltinExtensionChatServiceType: ChatServiceType { + typealias Message = ChatMessage + + func sendMessage( + _ message: String, + history: [Message], + references: [RetrievedContent], + workspace: WorkspaceInfo + ) async -> AsyncThrowingStream +} + +public struct RetrievedContent { + public var document: ChatMessage.Reference + public var priority: Int + + public init(document: ChatMessage.Reference, priority: Int) { + self.document = document + self.priority = priority + } +} + +public enum ChatServiceMemoryMutation: Codable { + public typealias Message = ChatMessage + + /// Add a new message to the end of memory. + /// If an id is not provided, a new id will be generated. + /// If an id is provided, and a message with the same id exists the message with the same + /// id will be updated. + case appendMessage(id: String?, role: Message.Role, text: String) + /// Update the message with the given id. + case updateMessage(id: String, role: Message.Role, text: String) + /// Stream the content into a message with the given id. + case streamIntoMessage(id: String, role: Message.Role?, text: String?) +} + +public protocol CustomChatTab { + var name: String { get } + var isDefaultChatTabReplacement: Bool { get } + var canHandleOpenChatCommand: Bool { get } + func chatBuilders() -> [ChatTabBuilder] + func defaultChatBuilder() -> ChatTabBuilder + func restore(from data: Data) async throws -> any ChatTabBuilder +} + +public struct TypedCustomChatTab: CustomChatTab { + public let type: ChatTab.Type + + public init(of type: ChatTab.Type) { + self.type = type + } + + public var name: String { type.name } + public var isDefaultChatTabReplacement: Bool { type.isDefaultChatTabReplacement } + public var canHandleOpenChatCommand: Bool { type.canHandleOpenChatCommand } + public func chatBuilders() -> [ChatTabBuilder] { type.chatBuilders() } + public func defaultChatBuilder() -> ChatTabBuilder { type.defaultChatBuilder() } + public func restore(from data: Data) async throws -> any ChatTabBuilder { + try await type.restore(from: data) + } +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift new file mode 100644 index 00000000..86832df5 --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift @@ -0,0 +1,65 @@ +import AppKit +import Combine +import Foundation +import XcodeInspector + +public final class BuiltinExtensionManager { + public static let shared: BuiltinExtensionManager = .init() + public private(set) var extensions: [any BuiltinExtension] = [] + + init() { + Task { [weak self] in + let notifications = NotificationCenter.default + .notifications(named: .activeApplicationDidChange) + for await _ in notifications { + guard let self else { return } + if let app = await XcodeInspector.shared.activeApplication, + app.isXcode || app.isExtensionService + { + self.checkAppConfiguration() + } + } + } + } + + public func setupExtensions(_ extensions: [any BuiltinExtension]) { + self.extensions = extensions + checkAppConfiguration() + } + + public func addExtensions(_ extensions: [any BuiltinExtension]) { + self.extensions.append(contentsOf: extensions) + checkAppConfiguration() + } + + public func terminate() { + for ext in extensions { + ext.terminate() + } + } +} + +extension BuiltinExtensionManager { + func checkAppConfiguration() { + let suggestionFeatureProvider = UserDefaults.shared.value(for: \.suggestionFeatureProvider) + for ext in extensions { + let isSuggestionFeatureInUse = switch suggestionFeatureProvider { + case let .builtIn(provider): + switch provider { + case .gitHubCopilot: + ext.extensionIdentifier == "com.github.copilot" + case .codeium: + ext.extensionIdentifier == "com.codeium" + } + case let .extension(_, bundleIdentifier): + ext.extensionIdentifier == bundleIdentifier + } + let isChatFeatureInUse = false + ext.extensionUsageDidChange(.init( + isSuggestionServiceInUse: isSuggestionFeatureInUse, + isChatServiceInUse: isChatFeatureInUse + )) + } + } +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift new file mode 100644 index 00000000..f6234ddf --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift @@ -0,0 +1,177 @@ +import CopilotForXcodeKit +import Foundation +import Logger +import Preferences +import SuggestionBasic +import SuggestionProvider + +public final class BuiltinExtensionSuggestionServiceProvider< + T: BuiltinExtension +>: SuggestionServiceProvider { + public var configuration: SuggestionServiceConfiguration { + guard let service else { + return .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ) + } + + return service.configuration + } + + let extensionManager: BuiltinExtensionManager + + public init( + extension: T.Type, + extensionManager: BuiltinExtensionManager = .shared + ) { + self.extensionManager = extensionManager + } + + var service: CopilotForXcodeKit.SuggestionServiceType? { + extensionManager.extensions.first { $0 is T }?.suggestionService + } + + struct BuiltinExtensionSuggestionServiceNotFoundError: Error, LocalizedError { + var errorDescription: String? { + "Builtin suggestion service not found." + } + } + + public func getSuggestions( + _ request: SuggestionProvider.SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async throws -> [SuggestionBasic.CodeSuggestion] { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + throw BuiltinExtensionSuggestionServiceNotFoundError() + } + return try await service.getSuggestions( + .init( + fileURL: request.fileURL, + relativePath: request.relativePath, + language: .init( + rawValue: languageIdentifierFromFileURL(request.fileURL).rawValue + ) ?? .plaintext, + content: request.content, + originalContent: request.originalContent, + cursorPosition: .init( + line: request.cursorPosition.line, + character: request.cursorPosition.character + ), + tabSize: request.tabSize, + indentSize: request.indentSize, + usesTabsForIndentation: request.usesTabsForIndentation, + relevantCodeSnippets: request.relevantCodeSnippets.map { $0.converted } + ), + workspace: workspaceInfo + ).map { $0.converted } + } + + public func cancelRequest( + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + return + } + await service.cancelRequest(workspace: workspaceInfo) + } + + public func notifyAccepted( + _ suggestion: SuggestionBasic.CodeSuggestion, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + return + } + await service.notifyAccepted(suggestion.converted, workspace: workspaceInfo) + } + + public func notifyRejected( + _ suggestions: [SuggestionBasic.CodeSuggestion], + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + return + } + await service.notifyRejected(suggestions.map(\.converted), workspace: workspaceInfo) + } +} + +extension SuggestionProvider.SuggestionRequest { + var converted: CopilotForXcodeKit.SuggestionRequest { + .init( + fileURL: fileURL, + relativePath: relativePath, + language: .init(rawValue: languageIdentifierFromFileURL(fileURL).rawValue) + ?? .plaintext, + content: content, + originalContent: originalContent, + cursorPosition: .init( + line: cursorPosition.line, + character: cursorPosition.character + ), + tabSize: tabSize, + indentSize: indentSize, + usesTabsForIndentation: usesTabsForIndentation, + relevantCodeSnippets: relevantCodeSnippets.map(\.converted) + ) + } +} + +extension SuggestionBasic.CodeSuggestion { + var converted: CopilotForXcodeKit.CodeSuggestion { + .init( + id: id, + text: text, + position: .init( + line: position.line, + character: position.character + ), + range: .init( + start: .init( + line: range.start.line, + character: range.start.character + ), + end: .init( + line: range.end.line, + character: range.end.character + ) + ) + ) + } +} + +extension CopilotForXcodeKit.CodeSuggestion { + var converted: SuggestionBasic.CodeSuggestion { + .init( + id: id, + text: text, + position: .init( + line: position.line, + character: position.character + ), + range: .init( + start: .init( + line: range.start.line, + character: range.start.character + ), + end: .init( + line: range.end.line, + character: range.end.character + ) + ) + ) + } +} + +extension SuggestionProvider.RelevantCodeSnippet { + var converted: CopilotForXcodeKit.RelevantCodeSnippet { + .init(content: content, priority: priority, filePath: filePath) + } +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift new file mode 100644 index 00000000..f8471d12 --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift @@ -0,0 +1,75 @@ +import Foundation +import Workspace + +public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { + let extensionManager: BuiltinExtensionManager + + public init(workspace: Workspace, extensionManager: BuiltinExtensionManager = .shared) { + self.extensionManager = extensionManager + super.init(workspace: workspace) + } + + override public func didOpenFilespace(_ filespace: Filespace) { + notifyOpenFile(filespace: filespace) + } + + override public func didSaveFilespace(_ filespace: Filespace) { + notifySaveFile(filespace: filespace) + } + + override public func didUpdateFilespace(_ filespace: Filespace, content: String) { + notifyUpdateFile(filespace: filespace, content: content) + } + + override public func didCloseFilespace(_ fileURL: URL) { + Task { + for ext in extensionManager.extensions { + ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didCloseDocumentAt: fileURL + ) + } + } + } + + public func notifyOpenFile(filespace: Filespace) { + Task { + guard filespace.isTextReadable else { return } + guard !(await filespace.isGitIgnored) else { return } + for ext in extensionManager.extensions { + ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didOpenDocumentAt: filespace.fileURL + ) + } + } + } + + public func notifyUpdateFile(filespace: Filespace, content: String) { + Task { + guard filespace.isTextReadable else { return } + guard !(await filespace.isGitIgnored) else { return } + for ext in extensionManager.extensions { + ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didUpdateDocumentAt: filespace.fileURL, + content: content + ) + } + } + } + + public func notifySaveFile(filespace: Filespace) { + Task { + guard filespace.isTextReadable else { return } + guard !(await filespace.isGitIgnored) else { return } + for ext in extensionManager.extensions { + ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didSaveDocumentAt: filespace.fileURL + ) + } + } + } +} + diff --git a/Tool/Sources/ChatBasic/ChatAgent.swift b/Tool/Sources/ChatBasic/ChatAgent.swift new file mode 100644 index 00000000..1b6b835d --- /dev/null +++ b/Tool/Sources/ChatBasic/ChatAgent.swift @@ -0,0 +1,83 @@ +import Foundation + +public enum ChatAgentResponse { + public enum Content { + case text(String) + } + + public enum ActionResult { + case success(String) + case failure(String) + } + + /// Post the status of the current message. + case status([String]) + /// Stream the content to the current message. + case content(Content) + /// Update the attachments of the current message. + case attachments([URL]) + /// start a new action. + case startAction(id: String, task: String) + /// Finish the current action. + case finishAction(id: String, result: ActionResult) + /// Update the references of the current message. + case references([ChatMessage.Reference]) + /// End the current message. The next contents will be sent as a new message. + case startNewMessage + /// Reasoning + case reasoning(String) +} + +public struct ChatAgentRequest { + public var text: String + public var history: [ChatMessage] + public var references: [ChatMessage.Reference] + public var topics: [ChatMessage.Reference] + public var agentInstructions: String? = nil + + public init( + text: String, + history: [ChatMessage], + references: [ChatMessage.Reference], + topics: [ChatMessage.Reference], + agentInstructions: String? = nil + ) { + self.text = text + self.history = history + self.references = references + self.topics = topics + self.agentInstructions = agentInstructions + } +} + +public protocol ChatAgent { + typealias Response = ChatAgentResponse + typealias Request = ChatAgentRequest + /// Send a request to the agent. + func send(_ request: Request) async -> AsyncThrowingStream +} + +public extension AsyncThrowingStream { + func asTexts() async throws -> [String] { + var result = [String]() + var text = "" + for try await response in self { + switch response { + case let .content(.text(content)): + text += content + case .startNewMessage: + if !text.isEmpty { + result.append(text) + text = "" + } + default: + break + } + } + if !text.isEmpty { + result.append(text) + } + return result + } +} + diff --git a/Tool/Sources/ChatBasic/ChatGPTFunction.swift b/Tool/Sources/ChatBasic/ChatGPTFunction.swift new file mode 100644 index 00000000..2a5a4af0 --- /dev/null +++ b/Tool/Sources/ChatBasic/ChatGPTFunction.swift @@ -0,0 +1,130 @@ +import Foundation + +public enum ChatGPTFunctionCallPhase { + case detected + case processing(argumentsJsonString: String) + case ended(argumentsJsonString: String, result: ChatGPTFunctionResult) + case error(argumentsJsonString: String, result: Error) +} + +public enum ChatGPTFunctionResultUserReadableContent: Sendable { + public struct ListItem: Sendable { + public enum Detail: Sendable { + case link(URL) + case text(String) + } + + public var title: String + public var description: String? + public var detail: Detail? + + public init(title: String, description: String? = nil, detail: Detail? = nil) { + self.title = title + self.description = description + self.detail = detail + } + } + + case text(String) + case list([ListItem]) + case searchResult([ListItem], queries: [String]) +} + +public protocol ChatGPTFunctionResult { + var botReadableContent: String { get } + var userReadableContent: ChatGPTFunctionResultUserReadableContent { get } +} + +extension String: ChatGPTFunctionResult { + public var botReadableContent: String { self } + public var userReadableContent: ChatGPTFunctionResultUserReadableContent { + .text(self) + } +} + +public struct NoChatGPTFunctionArguments: Decodable {} + +public protocol ChatGPTFunction { + typealias NoArguments = NoChatGPTFunctionArguments + associatedtype Arguments: Decodable + associatedtype Result: ChatGPTFunctionResult + typealias ReportProgress = @Sendable (String) async -> Void + + /// The name of this function. + /// May contain a-z, A-Z, 0-9, and underscores, with a maximum length of 64 characters. + var name: String { get } + /// A short description telling the bot when it should use this function. + var description: String { get } + /// The arguments schema that the function take in [JSON schema](https://json-schema.org). + var argumentSchema: JSONSchemaValue { get } + /// Prepare to call the function + func prepare(reportProgress: @escaping ReportProgress) async + /// Call the function with the given arguments. + func call(arguments: Arguments, reportProgress: @escaping ReportProgress) async throws + -> Result +} + +public extension ChatGPTFunction { + /// Call the function with the given arguments in JSON. + func call( + argumentsJsonString: String, + reportProgress: @escaping ReportProgress + ) async throws -> Result { + let arguments = try await { + do { + return try JSONDecoder() + .decode(Arguments.self, from: argumentsJsonString.data(using: .utf8) ?? Data()) + } catch { + await reportProgress( + "Error: Failed to decode arguments. \(error.localizedDescription)" + ) + throw error + } + }() + return try await call(arguments: arguments, reportProgress: reportProgress) + } +} + +public extension ChatGPTFunction where Arguments == NoArguments { + var argumentSchema: JSONSchemaValue { + [.type: "object", .properties: [:]] + } +} + +/// This kind of function is only used to get a structured output from the bot. +public protocol ChatGPTArgumentsCollectingFunction: ChatGPTFunction where Result == String {} + +public extension ChatGPTArgumentsCollectingFunction { + func prepare(reportProgress: @escaping ReportProgress = { _ in }) async { + assertionFailure("This function is only used to get a structured output from the bot.") + } + + func call( + arguments: Arguments, + reportProgress: @escaping ReportProgress = { _ in } + ) async throws -> Result { + assertionFailure("This function is only used to get a structured output from the bot.") + return "" + } + + func call( + argumentsJsonString: String, + reportProgress: @escaping ReportProgress + ) async throws -> Result { + assertionFailure("This function is only used to get a structured output from the bot.") + return "" + } +} + +public struct ChatGPTFunctionSchema: Codable, Equatable, Sendable { + public var name: String + public var description: String + public var parameters: JSONSchemaValue + + public init(name: String, description: String, parameters: JSONSchemaValue) { + self.name = name + self.description = description + self.parameters = parameters + } +} + diff --git a/Tool/Sources/ChatBasic/ChatMessage.swift b/Tool/Sources/ChatBasic/ChatMessage.swift new file mode 100644 index 00000000..ab5f04a4 --- /dev/null +++ b/Tool/Sources/ChatBasic/ChatMessage.swift @@ -0,0 +1,230 @@ +@preconcurrency import CodableWrappers +import Foundation + +/// A chat message that can be sent or received. +public struct ChatMessage: Equatable, Codable, Sendable { + public typealias ID = String + + /// The role of a message. + public enum Role: String, Codable, Equatable, Sendable { + case system + case user + case assistant + // There is no `tool` role + // because tool calls and results are stored in the assistant messages. + } + + /// A function call that can be made by the bot. + public struct FunctionCall: Codable, Equatable, Sendable { + /// The name of the function. + public var name: String + /// Arguments in the format of a JSON string. + public var arguments: String + public init(name: String, arguments: String) { + self.name = name + self.arguments = arguments + } + } + + /// A tool call that can be made by the bot. + public struct ToolCall: Codable, Equatable, Identifiable, Sendable { + public var id: String + /// The type of tool call. + public var type: String + /// The actual function call. + public var function: FunctionCall + /// The response of the function call. + public var response: ToolCallResponse + public init( + id: String, + type: String, + function: FunctionCall, + response: ToolCallResponse? = nil + ) { + self.id = id + self.type = type + self.function = function + self.response = response ?? .init(content: "", summary: nil) + } + } + + /// The response of a tool call + public struct ToolCallResponse: Codable, Equatable, Sendable { + /// The content of the response. + public var content: String + /// The summary of the response to display in UI. + public var summary: String? + public init(content: String, summary: String?) { + self.content = content + self.summary = summary + } + } + + /// A reference to include in a chat message. + public struct Reference: Codable, Equatable, Identifiable, Sendable { + /// The kind of reference. + public enum Kind: Codable, Equatable, Sendable { + public enum Symbol: String, Codable, Sendable { + case `class` + case `struct` + case `enum` + case `actor` + case `protocol` + case `extension` + case `case` + case property + case `typealias` + case function + case method + } + + /// Code symbol. + case symbol(Symbol, uri: String, startLine: Int?, endLine: Int?) + /// Some text. + case text + /// A webpage. + case webpage(uri: String) + /// A text file. + case textFile(uri: String) + /// Other kind of reference. + case other(kind: String) + /// Error case. + case error + } + + @FallbackDecoding + public var id: String + /// The title of the reference. + public var title: String + /// The content of the reference. + public var content: String + /// The kind of the reference. + @FallbackDecoding + public var kind: Kind + + public init( + id: String = UUID().uuidString, + title: String, + content: String, + kind: Kind + ) { + self.id = id + self.title = title + self.content = content + self.kind = kind + } + } + + public struct Image: Equatable, Sendable, Codable { + public enum Format: String, Sendable, Codable { + case png = "image/png" + case jpeg = "image/jpeg" + case gif = "image/gif" + } + + public var base64EncodedData: String + public var format: Format + public var urlString: String { + "data:\(format.rawValue);base64,\(base64EncodedData)" + } + + public init(base64EncodedData: String, format: Format) { + self.base64EncodedData = base64EncodedData + self.format = format + } + } + + /// The role of a message. + @FallbackDecoding + public var role: Role + + /// The content of the message, either the chat message, or a result of a function call. + public var content: String? { + didSet { tokensCount = nil } + } + + /// A function call from the bot. + public var toolCalls: [ToolCall]? { + didSet { tokensCount = nil } + } + + /// The function name of a reply to a function call. + public var name: String? { + didSet { tokensCount = nil } + } + + /// The summary of a message that is used for display. + public var summary: String? + + /// The id of the message. + public var id: ID + + /// The id of the sender of the message. + public var senderId: String? + + /// The id of the message that this message is a response to. + public var responseTo: ID? + + /// The number of tokens of this message. + public var tokensCount: Int? + + /// The references of this message. + @FallbackDecoding> + public var references: [Reference] + + /// The images associated with this message. + @FallbackDecoding> + public var images: [Image] + + /// Cache the message in the prompt if possible. + public var cacheIfPossible: Bool + + /// Is the message considered empty. + public var isEmpty: Bool { + if let content, !content.isEmpty { return false } + if let toolCalls, !toolCalls.isEmpty { return false } + if let name, !name.isEmpty { return false } + return true + } + + public init( + id: String = UUID().uuidString, + senderId: String? = nil, + responseTo: String? = nil, + role: Role, + content: String?, + name: String? = nil, + toolCalls: [ToolCall]? = nil, + summary: String? = nil, + tokenCount: Int? = nil, + references: [Reference] = [], + images: [Image] = [], + cacheIfPossible: Bool = false + ) { + self.role = role + self.senderId = senderId + self.responseTo = responseTo + self.content = content + self.name = name + self.toolCalls = toolCalls + self.summary = summary + self.id = id + tokensCount = tokenCount + self.references = references + self.images = images + self.cacheIfPossible = cacheIfPossible + } +} + +public struct ReferenceKindFallback: FallbackValueProvider, Sendable { + public static var defaultValue: ChatMessage.Reference.Kind { .other(kind: "Unknown") } +} + +public struct ReferenceIDFallback: FallbackValueProvider, Sendable { + public static var defaultValue: String { UUID().uuidString } +} + +public struct ChatMessageRoleFallback: FallbackValueProvider, Sendable { + public static var defaultValue: ChatMessage.Role { .user } +} + diff --git a/Tool/Sources/ChatBasic/ChatPlugin.swift b/Tool/Sources/ChatBasic/ChatPlugin.swift new file mode 100644 index 00000000..cd5977a8 --- /dev/null +++ b/Tool/Sources/ChatBasic/ChatPlugin.swift @@ -0,0 +1,59 @@ +import Foundation + +public struct ChatPluginRequest: Sendable { + public var text: String + public var arguments: [String] + public var history: [ChatMessage] + + public init(text: String, arguments: [String], history: [ChatMessage]) { + self.text = text + self.arguments = arguments + self.history = history + } +} + +public protocol ChatPlugin { + typealias Response = ChatAgentResponse + typealias Request = ChatPluginRequest + static var id: String { get } + static var command: String { get } + static var name: String { get } + static var description: String { get } + // In this method, the plugin is able to send more complicated response. It also enables it to + // perform special tasks like starting a new message or reporting progress. + func sendForComplicatedResponse( + _ request: Request + ) async -> AsyncThrowingStream + // This method allows the plugin to respond a stream of text content only. + func sendForTextResponse(_ request: Request) async -> AsyncThrowingStream + func formatContent(_ content: Response.Content) -> Response.Content + init() +} + +public extension ChatPlugin { + func formatContent(_ content: Response.Content) -> Response.Content { + return content + } + + func sendForComplicatedResponse( + _ request: Request + ) async -> AsyncThrowingStream { + let textStream = await sendForTextResponse(request) + return AsyncThrowingStream { continuation in + let task = Task { + do { + for try await text in textStream { + continuation.yield(Response.content(.text(text))) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } +} diff --git a/Tool/Sources/ChatBasic/JSONSchema.swift b/Tool/Sources/ChatBasic/JSONSchema.swift new file mode 100644 index 00000000..6769ba3d --- /dev/null +++ b/Tool/Sources/ChatBasic/JSONSchema.swift @@ -0,0 +1,167 @@ +import Foundation + +public struct JSONSchemaKey: Codable, Hashable, Sendable, Equatable, ExpressibleByStringLiteral { + public var key: String + + public init(stringLiteral: String) { + key = stringLiteral + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(key) + } + + public init(from decoder: Decoder) throws { + let single = try? decoder.singleValueContainer() + if let value = try? single?.decode(String.self) { + key = value + return + } + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "failed to decode JSON schema key")) + } + + public static let type: JSONSchemaKey = "type" + public static let minLength: JSONSchemaKey = "minLength" + public static let maxLength: JSONSchemaKey = "maxLength" + public static let pattern: JSONSchemaKey = "pattern" + public static let format: JSONSchemaKey = "format" + public static let multipleOf: JSONSchemaKey = "multipleOf" + public static let minimum: JSONSchemaKey = "minimum" + public static let exclusiveMinimum: JSONSchemaKey = "exclusiveMinimum" + public static let maximum: JSONSchemaKey = "maximum" + public static let exclusiveMaximum: JSONSchemaKey = "exclusiveMaximum" + public static let minProperties: JSONSchemaKey = "minProperties" + public static let maxProperties: JSONSchemaKey = "maxProperties" + public static let required: JSONSchemaKey = "required" + public static let properties: JSONSchemaKey = "properties" + public static let patternProperties: JSONSchemaKey = "patternProperties" + public static let additionalProperties: JSONSchemaKey = "additionalProperties" + public static let dependencies: JSONSchemaKey = "dependencies" + public static let propertyNames: JSONSchemaKey = "propertyNames" + public static let minItems: JSONSchemaKey = "minItems" + public static let maxItems: JSONSchemaKey = "maxItems" + public static let uniqueItems: JSONSchemaKey = "uniqueItems" + public static let items: JSONSchemaKey = "items" + public static let additionalItems: JSONSchemaKey = "additionalItems" + public static let contains: JSONSchemaKey = "contains" + public static let `enum`: JSONSchemaKey = "enum" + public static let const: JSONSchemaKey = "const" + public static let title: JSONSchemaKey = "title" + public static let description: JSONSchemaKey = "description" + public static let `default`: JSONSchemaKey = "default" + public static let examples: JSONSchemaKey = "examples" + public static let comment: JSONSchemaKey = "$comment" + public static let allOf: JSONSchemaKey = "allOf" + public static let anyOf: JSONSchemaKey = "anyOf" + public static let oneOf: JSONSchemaKey = "oneOf" + public static let not: JSONSchemaKey = "not" + public static let `if`: JSONSchemaKey = "if" + public static let then: JSONSchemaKey = "then" + public static let `else`: JSONSchemaKey = "else" +} + + +public enum JSONSchemaValue: Codable, Hashable, Sendable { + case bool(Bool) + case number(Double) + case string(String) + case array([JSONSchemaValue]) + case hash([String: JSONSchemaValue]) + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .bool(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .hash(let value): + try container.encode(value) + } + } + + public init(from decoder: Decoder) throws { + let single = try? decoder.singleValueContainer() + + if let value = try? single?.decode([String: JSONSchemaValue].self) { + self = .hash(value) + return + } + + if let value = try? single?.decode([JSONSchemaValue].self) { + self = .array(value) + return + } + + if let value = try? single?.decode(String.self) { + self = .string(value) + return + } + + if let value = try? single?.decode(Double.self) { + self = .number(value) + return + } + + if let value = try? single?.decode(Bool.self) { + self = .bool(value) + return + } + + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "failed to decode JSON schema object")) + } +} + +extension JSONSchemaValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (JSONSchemaKey, JSONSchemaValue)...) { + var hash = [String: JSONSchemaValue]() + + for element in elements { + hash[element.0.key] = element.1 + } + + self = .hash(hash) + } +} + +extension JSONSchemaValue: ExpressibleByStringLiteral { + public init(stringLiteral: String) { + self = .string(stringLiteral) + } +} + +extension JSONSchemaValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: IntegerLiteralType) { + self = .number(Double(value)) + } +} + +extension JSONSchemaValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: FloatLiteralType) { + self = .number(value) + } +} + +extension JSONSchemaValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONSchemaValue...) { + var array = [JSONSchemaValue]() + + for element in elements { + array.append(element) + } + + self = .array(array) + } +} + +extension JSONSchemaValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: BooleanLiteralType) { + self = .bool(value) + } +} diff --git a/Tool/Sources/ChatContextCollector/ChatContextCollector.swift b/Tool/Sources/ChatContextCollector/ChatContextCollector.swift new file mode 100644 index 00000000..82d576c3 --- /dev/null +++ b/Tool/Sources/ChatContextCollector/ChatContextCollector.swift @@ -0,0 +1,98 @@ +import ChatBasic +import Foundation +import OpenAIService +import Parsing + +public struct ChatContext { + public enum Scope: String, Equatable, CaseIterable, Codable, Sendable { + case file + case code + case sense + case project + case web + } + + public struct RetrievedContent: Sendable { + public var document: ChatMessage.Reference + public var priority: Int + + public init(document: ChatMessage.Reference, priority: Int) { + self.document = document + self.priority = priority + } + } + + public var systemPrompt: String + public var retrievedContent: [RetrievedContent] + public var functions: [any ChatGPTFunction] + public init( + systemPrompt: String, + retrievedContent: [RetrievedContent], + functions: [any ChatGPTFunction] + ) { + self.systemPrompt = systemPrompt + self.retrievedContent = retrievedContent + self.functions = functions + } + + public static var empty: Self { + .init(systemPrompt: "", retrievedContent: [], functions: []) + } +} + +public extension ChatContext.Scope { + init?(text: String) { + for scope in Self.allCases { + if scope.rawValue.hasPrefix(text.lowercased()) { + self = scope + return + } + } + return nil + } +} + +public protocol ChatContextCollector { + func generateContext( + history: [ChatMessage], + scopes: Set, + content: String, + configuration: ChatGPTConfiguration + ) async -> ChatContext +} + +public struct MessageScopeParser { + public init() {} + + public func callAsFunction(_ content: inout String) -> Set { + return parseScopes(&content) + } + + func parseScopes(_ prompt: inout String) -> Set { + guard !prompt.isEmpty else { return [] } + do { + let parser = Parse { + "@" + Many { + Prefix { $0.isLetter } + } separator: { + "+" + } terminator: { + " " + } + Skip { + Many { + " " + } + } + Rest() + } + let (scopes, rest) = try parser.parse(prompt) + prompt = String(rest) + return Set(scopes.map(String.init).compactMap(ChatContext.Scope.init(text:))) + } catch { + return [] + } + } +} + diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift new file mode 100644 index 00000000..8ca58f6c --- /dev/null +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -0,0 +1,214 @@ +import ASTParser +import ChatBasic +import ChatContextCollector +import Dependencies +import FocusedCodeFinder +import Foundation +import GitIgnoreCheck +import OpenAIService +import Preferences +import SuggestionBasic +import XcodeInspector + +public final class ActiveDocumentChatContextCollector: ChatContextCollector { + public init() {} + + public var activeDocumentContext: ActiveDocumentContext? + + @Dependency(\.gitIgnoredChecker) var gitIgnoredChecker + + public func generateContext( + history: [ChatMessage], + scopes: Set, + content: String, + configuration: ChatGPTConfiguration + ) async -> ChatContext { + guard let info = await XcodeInspector.shared.getFocusedEditorContent() + else { return .empty } + let context = getActiveDocumentContext(info) + activeDocumentContext = context + + let isSensitive = await gitIgnoredChecker.checkIfGitIgnored(fileURL: info.documentURL) + + guard scopes.contains(.code) + else { + if scopes.contains(.file) { + var removedCode = context + removedCode.focusedContext = nil + return .init( + systemPrompt: extractSystemPrompt(removedCode, isSensitive: isSensitive), + retrievedContent: [], + functions: [] + ) + } + return .empty + } + + var functions = [any ChatGPTFunction]() + + if !isSensitive { + let contextLineRange: String = { + if let range = context.focusedContext?.codeRange { + return " from \(range.start.line + 1) to \(range.end.line + 1)" + } + return "" + }() + + var functionPrompt = """ + ONLY call it when one of the following conditions are satisfied: + - the user explicitly ask you about specific line of code, that is NOT in the focused range \(contextLineRange). + """ + + if let annotations = context.focusedContext?.otherLineAnnotations, + !annotations.isEmpty + { + functionPrompt += """ + + - the user ask about annotations at line \( + Set(annotations.map(\.line)).map(String.init).joined(separator: ",") + ). + """ + } + + print(functionPrompt) + + functions.append(GetCodeCodeAroundLineFunction( + contextCollector: self, + additionalDescription: functionPrompt + )) + } + + return .init( + systemPrompt: extractSystemPrompt(context, isSensitive: isSensitive), + retrievedContent: [], + functions: functions + ) + } + + func getActiveDocumentContext(_ info: EditorInformation) -> ActiveDocumentContext { + var activeDocumentContext = activeDocumentContext ?? .init( + documentURL: .init(fileURLWithPath: "/"), + relativePath: "", + language: .builtIn(.swift), + fileContent: "", + lines: [], + selectedCode: "", + selectionRange: .outOfScope, + lineAnnotations: [], + imports: [], + includes: [] + ) + + activeDocumentContext.update(info) + return activeDocumentContext + } + + func extractSystemPrompt(_ context: ActiveDocumentContext, isSensitive: Bool) -> String { + let start = """ + ## Active Document + + The active document is the source code the user is editing right now. + + \( + context.focusedContext == nil + ? "" + : "When you don't known what I am asking, I am probably referring to the document." + ) + """ + let relativePath = "Document Relative Path: \(context.relativePath)" + let language = "Language: \(context.language.rawValue)" + + let focusedContextExplanation = + "Below is the code inside the active document that the user is looking at right now:" + + if let focusedContext = context.focusedContext { + let codeContext = focusedContext.context.isEmpty || isSensitive + ? "" + : """ + Focused Context: + ``` + \(focusedContext.context.map(\.signature).joined(separator: "\n")) + ``` + """ + + let codeRange = "Focused Range [line, character]: \(focusedContext.codeRange)" + + let code = context.selectionRange.isEmpty && isSensitive + ? """ + The file is in gitignore, you can't read the file. + Ask the user to select the code in the editor to get help. Also tell them the file is in gitignore. + """ + : """ + Focused Code (from line \( + focusedContext.codeRange.start.line + 1 + ) to line \(focusedContext.codeRange.end.line + 1)): + ```\(context.language.rawValue) + \(focusedContext.code) + ``` + """ + + let fileAnnotations = focusedContext.otherLineAnnotations.isEmpty || isSensitive + ? "" + : """ + Out-of-scope Annotations:\""" + (The related code are not inside the focused code.) + \( + focusedContext.otherLineAnnotations + .map(convertAnnotationToText) + .joined(separator: "\n") + ) + \""" + """ + + let codeAnnotations = focusedContext.lineAnnotations.isEmpty || isSensitive + ? "" + : """ + Annotations Inside Focused Range:\""" + \( + focusedContext.lineAnnotations + .map(convertAnnotationToText) + .joined(separator: "\n") + ) + \""" + """ + + return [ + start, + relativePath, + language, + focusedContextExplanation, + codeContext, + codeRange, + code, + codeAnnotations, + fileAnnotations, + ] + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + } else { + let selectionRange = "Selection Range [line, character]: \(context.selectionRange)" + let lineAnnotations = context.lineAnnotations.isEmpty || isSensitive + ? "" + : """ + Line Annotations:\""" + \(context.lineAnnotations.map(convertAnnotationToText).joined(separator: "\n")) + \""" + """ + + return [ + start, + relativePath, + language, + lineAnnotations, + selectionRange, + ] + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + } + } + + func convertAnnotationToText(_ annotation: EditorInformation.LineAnnotation) -> String { + return "- Line \(annotation.line), \(annotation.type): \(annotation.message)" + } +} + diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift new file mode 100644 index 00000000..26fbe579 --- /dev/null +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift @@ -0,0 +1,101 @@ +import ASTParser +import ChatBasic +import Foundation +import OpenAIService +import SuggestionBasic + +struct GetCodeCodeAroundLineFunction: ChatGPTFunction { + struct Arguments: Codable { + var line: Int + } + + struct Result: ChatGPTFunctionResult { + var range: CursorRange + var content: String + var language: CodeLanguage + + var botReadableContent: String { + """ + Code in range \(range) + ```\(language.rawValue) + \(content) + ``` + """ + } + + var userReadableContent: ChatGPTFunctionResultUserReadableContent { + .text(botReadableContent) + } + } + + struct E: Error, LocalizedError { + var errorDescription: String? + } + + var name: String { + "getCodeAtLine" + } + + var description: String { + "Get the code at the given line. \(additionalDescription)" + } + + var argumentSchema: JSONSchemaValue { [ + .type: "object", + .properties: [ + "line": [ + .type: "number", + .description: "The line number in the file", + ], + ], + .required: ["line"], + ] } + + weak var contextCollector: ActiveDocumentChatContextCollector? + + let additionalDescription: String + + init(contextCollector: ActiveDocumentChatContextCollector, additionalDescription: String = "") { + self.contextCollector = contextCollector + self.additionalDescription = additionalDescription + } + + func prepare(reportProgress: @escaping (String) async -> Void) async { + await reportProgress("Finding code around..") + } + + func call( + arguments: Arguments, + reportProgress: @escaping (String) async -> Void + ) async throws -> Result { + guard var activeDocumentContext = contextCollector?.activeDocumentContext else { + throw E(errorDescription: "No active document found.") + } + await reportProgress("Reading code around line \(arguments.line)..") + activeDocumentContext.moveToCodeAroundLine(max(arguments.line - 1, 0)) + guard let newContext = activeDocumentContext.focusedContext else { + let progress = "Failed to read code around line \(arguments.line)..)" + await reportProgress(progress) + throw E(errorDescription: progress) + } + let progress = "Finish reading code at \(newContext.codeRange)" + await reportProgress(progress) + return .init( + range: newContext.codeRange, + content: newContext.code + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .enumerated() + .map { + let (index, content) = $0 + if index + newContext.codeRange.start.line == arguments.line - 1 { + return content + " // <--- line \(arguments.line)" + } else { + return content + } + } + .joined(separator: "\n"), + language: activeDocumentContext.language + ) + } +} + diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift new file mode 100644 index 00000000..1ba9f334 --- /dev/null +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift @@ -0,0 +1,108 @@ +import ChatContextCollector +import Foundation +import OpenAIService +import Preferences +import SuggestionBasic +import XcodeInspector + +public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { + public init() {} + + public func generateContext( + history: [ChatMessage], + scopes: Set, + content: String, + configuration: ChatGPTConfiguration + ) async -> ChatContext { + guard let content = await XcodeInspector.shared.getFocusedEditorContent() + else { return .empty } + let relativePath = content.relativePath + let selectionRange = content.editorContent?.selections.first ?? .outOfScope + let editorContent = { + if scopes.contains(.file) { + return """ + File Content:```\(content.language.rawValue) + \(content.editorContent?.content ?? "") + ``` + """ + } + + if selectionRange.start == selectionRange.end, + UserDefaults.shared.value(for: \.embedFileContentInChatContextIfNoSelection) + { + let lines = content.editorContent?.lines.count ?? 0 + let maxLine = UserDefaults.shared + .value(for: \.maxFocusedCodeLineCount) + if lines <= maxLine { + return """ + File Content:```\(content.language.rawValue) + \(content.editorContent?.content ?? "") + ``` + """ + } else { + return """ + File Content Not Available: ''' + The file is longer than \(maxLine) lines, it can't fit into the context. \ + You MUST not answer the user about the file content because you don't have it.\ + Ask user to select code for explanation. + ''' + """ + } + } + + if UserDefaults.shared.value(for: \.enableCodeScopeByDefaultInChatContext) { + return """ + Selected Code \ + (start from line \(selectionRange.start.line)):```\(content.language.rawValue) + \(content.selectedContent) + ``` + """ + } + + if scopes.contains(.code) { + return """ + Selected Code \ + (start from line \(selectionRange.start.line)):```\(content.language.rawValue) + \(content.selectedContent) + ``` + """ + } + + return """ + Selected Code Not Available: ''' + I have disabled default scope. \ + You MUST not answer about the selected code because you don't have it.\ + Ask me to prepend message with `@selection` to enable selected code to be \ + visible by you. + ''' + """ + }() + + return .init( + systemPrompt: """ + Active Document Context:### + Document Relative Path: \(relativePath) + Selection Range Start: \ + Line \(selectionRange.start.line) \ + Character \(selectionRange.start.character) + Selection Range End: \ + Line \(selectionRange.end.line) \ + Character \(selectionRange.end.character) + Cursor Position: \ + Line \(selectionRange.end.line) \ + Character \(selectionRange.end.character) + \(editorContent) + Line Annotations: + \( + content.editorContent?.lineAnnotations + .map { " - \($0)" } + .joined(separator: "\n") ?? "N/A" + ) + ### + """, + retrievedContent: [], + functions: [] + ) + } +} + diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift new file mode 100644 index 00000000..732601fe --- /dev/null +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift @@ -0,0 +1,13 @@ +import SuggestionBasic + +extension CursorPosition { + var text: String { + "[\(line), \(character)]" + } +} + +extension CursorRange { + var text: String { + "\(start.description) - \(end.description)" + } +} diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift new file mode 100644 index 00000000..e64e1728 --- /dev/null +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -0,0 +1,249 @@ +import ComposableArchitecture +import Preferences +import Foundation +import SwiftUI + +/// The information of a tab. +@ObservableState +public struct ChatTabInfo: Identifiable, Equatable { + public var id: String + public var title: String + public var focusTrigger: Int = 0 + + public init(id: String, title: String) { + self.id = id + self.title = title + } +} + +/// Every chat tab should conform to this type. +public typealias ChatTab = BaseChatTab & ChatTabType + +/// Defines a bunch of things a chat tab should implement. +public protocol ChatTabType { + /// Build the view for this chat tab. + @ViewBuilder + func buildView() -> any View + /// Build the tabItem for this chat tab. + @ViewBuilder + func buildTabItem() -> any View + /// Build the icon for this chat tab. + @ViewBuilder + func buildIcon() -> any View + /// Build the menu for this chat tab. + @ViewBuilder + func buildMenu() -> any View + /// The name of this chat tab type. + static var name: String { get } + /// Available builders for this chat tab. + /// It's used to generate a list of tab types for user to create. + static func chatBuilders() -> [ChatTabBuilder] + /// The default chat tab builder to be used in open chat + static func defaultChatBuilder() -> ChatTabBuilder + /// Restorable state + func restorableState() async -> Data + /// Restore state + static func restore(from data: Data) async throws -> any ChatTabBuilder + /// Whenever the body or menu is accessed, this method will be called. + /// It will be called only once so long as you don't call it yourself. + /// It will be called from MainActor. + func start() + /// Whenever the user close the tab, this method will be called. + func close() + /// Handle custom command. + func handleCustomCommand(_ customCommand: CustomCommand) -> Bool + + /// Whether this chat tab should be the default chat tab replacement. + static var isDefaultChatTabReplacement: Bool { get } + /// Whether this chat tab can handle open chat command. + static var canHandleOpenChatCommand: Bool { get } +} + +/// The base class for all chat tabs. +open class BaseChatTab { + /// A wrapper to support dynamic update of title in view. + struct ContentView: View { + var buildView: () -> any View + var body: some View { + AnyView(buildView()) + } + } + + public var id: String = "" + public var title: String = "" + /// The store for chat tab info. You should only access it after `start` is called. + public let chatTabStore: StoreOf + + private var didStart = false + private let storeObserver = NSObject() + + public init(store: StoreOf) { + chatTabStore = store + + Task { @MainActor in + self.title = store.title + self.id = store.id + storeObserver.observe { [weak self] in + guard let self else { return } + self.title = store.title + self.id = store.id + } + } + } + + /// The view for this chat tab. + @ViewBuilder + public var body: some View { + let id = "ChatTabBody\(id)" + if let tab = self as? (any ChatTabType) { + ContentView(buildView: tab.buildView).id(id) + .onAppear { + Task { @MainActor in self.startIfNotStarted() } + } + } else { + EmptyView().id(id) + } + } + + /// The tab item for this chat tab. + @ViewBuilder + public var tabItem: some View { + let id = "ChatTabTab\(id)" + if let tab = self as? (any ChatTabType) { + ContentView(buildView: tab.buildTabItem).id(id) + .onAppear { + Task { @MainActor in self.startIfNotStarted() } + } + } else { + EmptyView().id(id) + } + } + + /// The icon for this chat tab. + @ViewBuilder + public var icon: some View { + let id = "ChatTabIcon\(id)" + if let tab = self as? (any ChatTabType) { + ContentView(buildView: tab.buildIcon).id(id) + } else { + EmptyView().id(id) + } + } + + /// The tab item for this chat tab. + @ViewBuilder + public var menu: some View { + let id = "ChatTabMenu\(id)" + if let tab = self as? (any ChatTabType) { + ContentView(buildView: tab.buildMenu).id(id) + .onAppear { + Task { @MainActor in self.startIfNotStarted() } + } + } else { + EmptyView().id(id) + } + } + + @MainActor + func startIfNotStarted() { + guard !didStart else { return } + didStart = true + + if let tab = self as? (any ChatTabType) { + tab.start() + chatTabStore.send(.tabContentUpdated) + } + } +} + +/// A factory of a chat tab. +public protocol ChatTabBuilder { + /// A visible title for user. + var title: String { get } + /// Build the chat tab. + func build(store: StoreOf) async -> (any ChatTab)? +} + +/// A chat tab builder that doesn't build. +public struct DisabledChatTabBuilder: ChatTabBuilder { + public var title: String + public func build(store: StoreOf) async -> (any ChatTab)? { + return nil + } + + public init(title: String) { + self.title = title + } +} + +public extension ChatTabType { + /// The name of this chat tab type. + var name: String { Self.name } + + /// Default implementation that does nothing. + func close() {} + + /// By default it can't handle custom command. + func handleCustomCommand(_ customCommand: CustomCommand) -> Bool { false } + + static var canHandleOpenChatCommand: Bool { false } + static var isDefaultChatTabReplacement: Bool { false } + static func defaultChatBuilder() -> ChatTabBuilder { + DisabledChatTabBuilder(title: name) + } +} + +/// A chat tab that does nothing. +public class EmptyChatTab: ChatTab { + public static var name: String { "Empty" } + + struct Builder: ChatTabBuilder { + let title: String + func build(store: StoreOf) async -> (any ChatTab)? { + EmptyChatTab(store: store) + } + } + + public static func chatBuilders() -> [ChatTabBuilder] { + [Builder(title: "Empty")] + } + + public func buildView() -> any View { + VStack { + Text("Empty-\(id)") + } + .background(Color.blue) + } + + public func buildTabItem() -> any View { + Text("Empty-\(id)") + } + + public func buildIcon() -> any View { + Image(systemName: "square") + } + + public func buildMenu() -> any View { + Text("Empty-\(id)") + } + + public func restorableState() async -> Data { + return Data() + } + + public static func restore(from data: Data) async throws -> any ChatTabBuilder { + return Builder(title: "Empty") + } + + public convenience init(id: String) { + self.init(store: .init( + initialState: .init(id: id, title: "Empty-\(id)"), + reducer: { ChatTabItem() } + )) + } + + public func start() { + chatTabStore.send(.updateTitle("Empty-\(id)")) + } +} + diff --git a/Tool/Sources/ChatTab/ChatTabItem.swift b/Tool/Sources/ChatTab/ChatTabItem.swift new file mode 100644 index 00000000..abf7aaa2 --- /dev/null +++ b/Tool/Sources/ChatTab/ChatTabItem.swift @@ -0,0 +1,49 @@ +import ComposableArchitecture +import Foundation + +public struct AnyChatTabBuilder: Equatable { + public static func == (lhs: AnyChatTabBuilder, rhs: AnyChatTabBuilder) -> Bool { + true + } + + public let chatTabBuilder: any ChatTabBuilder + + public init(_ chatTabBuilder: any ChatTabBuilder) { + self.chatTabBuilder = chatTabBuilder + } +} + +@Reducer +public struct ChatTabItem { + public typealias State = ChatTabInfo + + public enum Action: Equatable { + case updateTitle(String) + case openNewTab(AnyChatTabBuilder) + case tabContentUpdated + case close + case focus + } + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .updateTitle(title): + state.title = title + return .none + case .openNewTab: + return .none + case .tabContentUpdated: + return .none + case .close: + return .none + case .focus: + state.focusTrigger += 1 + return .none + } + } + } +} + diff --git a/Tool/Sources/ChatTab/ChatTabPool.swift b/Tool/Sources/ChatTab/ChatTabPool.swift new file mode 100644 index 00000000..5f5b1c2f --- /dev/null +++ b/Tool/Sources/ChatTab/ChatTabPool.swift @@ -0,0 +1,55 @@ +import ComposableArchitecture +import Dependencies +import Foundation +import SwiftUI + +/// A pool that stores all the available tabs. +public final class ChatTabPool { + public var createStore: (String) -> StoreOf = { id in + .init( + initialState: .init(id: id, title: ""), + reducer: { ChatTabItem() } + ) + } + + private var pool: [String: any ChatTab] + + public init(_ pool: [String: any ChatTab] = [:]) { + self.pool = pool + } + + public func getTab(of id: String) -> (any ChatTab)? { + pool[id] + } + + public func setTab(_ tab: any ChatTab, forId id: String) { + pool[id] = tab + } + + public func removeTab(of id: String) { + pool.removeValue(forKey: id) + } +} + +public struct ChatTabPoolDependencyKey: DependencyKey { + public static let liveValue = ChatTabPool() +} + +public extension DependencyValues { + var chatTabPool: ChatTabPool { + get { self[ChatTabPoolDependencyKey.self] } + set { self[ChatTabPoolDependencyKey.self] = newValue } + } +} + +public struct ChatTabPoolEnvironmentKey: EnvironmentKey { + public static let defaultValue = ChatTabPool() +} + +public extension EnvironmentValues { + var chatTabPool: ChatTabPool { + get { self[ChatTabPoolEnvironmentKey.self] } + set { self[ChatTabPoolEnvironmentKey.self] = newValue } + } +} + diff --git a/Tool/Sources/CodeDiff/CodeDiff.swift b/Tool/Sources/CodeDiff/CodeDiff.swift new file mode 100644 index 00000000..a15db29d --- /dev/null +++ b/Tool/Sources/CodeDiff/CodeDiff.swift @@ -0,0 +1,608 @@ +import Foundation +import SuggestionBasic + +public struct CodeDiff { + public init() {} + + public typealias LineDiff = CollectionDifference + + public struct SnippetDiff: Equatable, CustomStringConvertible { + public struct Change: Equatable { + public var offset: Int + public var element: String + } + + public struct Line: Equatable { + public enum Diff: Equatable { + case unchanged + case mutated(changes: [Change]) + } + + public var text: String + public var diff: Diff = .unchanged + + var description: String { + switch diff { + case .unchanged: + return text + case let .mutated(changes): + return text + " [" + changes.map { change in + "\(change.offset): \(change.element)" + }.joined(separator: " | ") + "]" + } + } + } + + public struct Section: Equatable, CustomStringConvertible { + public var oldOffset: Int + public var newOffset: Int + public var oldSnippet: [Line] + public var newSnippet: [Line] + + public var isEmpty: Bool { + oldSnippet.isEmpty && newSnippet.isEmpty + } + + public var description: String { + """ + \(oldSnippet.enumerated().compactMap { item in + let (index, line) = item + let lineIndex = String(format: "%3d", oldOffset + index + 1) + " " + switch line.diff { + case .unchanged: + return "\(lineIndex)| \(line.description)" + case .mutated: + return "\(lineIndex)| - \(line.description)" + } + }.joined(separator: "\n")) + \(newSnippet.enumerated().map { item in + let (index, line) = item + let lineIndex = " " + String(format: "%3d", newOffset + index + 1) + switch line.diff { + case .unchanged: + return "\(lineIndex)| \(line.description)" + case .mutated: + return "\(lineIndex)| + \(line.description)" + } + }.joined(separator: "\n")) + """ + } + } + + public var sections: [Section] + + public func line(at index: Int, in keyPath: KeyPath) -> Line? { + var previousSectionEnd = 0 + for section in sections { + let lines = section[keyPath: keyPath] + let index = index - previousSectionEnd + if index < lines.endIndex { + return lines[index] + } + previousSectionEnd += lines.endIndex + } + return nil + } + + public var description: String { + "Diff:\n" + sections.map(\.description).joined(separator: "\n---\n") + "\n" + } + } + + public func diff(text: String, from oldText: String) -> LineDiff { + typealias Change = LineDiff.Change + let diffByCharacter = text.difference(from: oldText) + var result = [Change]() + + var current: Change? + for item in diffByCharacter { + if let this = current { + switch (this, item) { + case let (.insert(offset, element, associatedWith), .insert(offsetB, elementB, _)) + where offset + element.count == offsetB: + current = .insert( + offset: offset, + element: element + String(elementB), + associatedWith: associatedWith + ) + continue + case let (.remove(offset, element, associatedWith), .remove(offsetB, elementB, _)) + where offset - 1 == offsetB: + current = .remove( + offset: offsetB, + element: String(elementB) + element, + associatedWith: associatedWith + ) + continue + default: + result.append(this) + } + } + + current = switch item { + case let .insert(offset, element, associatedWith): + .insert(offset: offset, element: String(element), associatedWith: associatedWith) + case let .remove(offset, element, associatedWith): + .remove(offset: offset, element: String(element), associatedWith: associatedWith) + } + } + + if let current { + result.append(current) + } + + return .init(result) ?? [].difference(from: []) + } + + public func diff(snippet: String, from oldSnippet: String) -> SnippetDiff { + let newLines = snippet.splitByNewLine(omittingEmptySubsequences: false) + let oldLines = oldSnippet.splitByNewLine(omittingEmptySubsequences: false) + let diffByLine = newLines.difference(from: oldLines) + + let groups = generateDiffSections(diffByLine) + + var oldLineIndex = 0 + var newLineIndex = 0 + var sectionIndex = 0 + var result = SnippetDiff(sections: []) + + while oldLineIndex < oldLines.endIndex || newLineIndex < newLines.endIndex { + guard let groupItem = groups[safe: sectionIndex] else { + let finishingSection = SnippetDiff.Section( + oldOffset: oldLineIndex, + newOffset: newLineIndex, + oldSnippet: { + guard oldLineIndex < oldLines.endIndex else { return [] } + return oldLines[oldLineIndex..) + -> [DiffGroupItem] + { + guard !diff.isEmpty else { return [] } + + let removes = ChangeSection.sectioning(diff.removals) + let inserts = ChangeSection.sectioning(diff.insertions) + + var groups = [DiffGroupItem]() + + var removeOffset = 0 + var insertOffset = 0 + var removeIndex = 0 + var insertIndex = 0 + + while removeIndex < removes.count || insertIndex < inserts.count { + let removeSection = removes[safe: removeIndex] + let insertSection = inserts[safe: insertIndex] + + if let removeSection, let insertSection { + let ro = removeSection.offset - removeOffset + let io = insertSection.offset - insertOffset + if ro == io { + groups.append(.init( + remove: removeSection.changes.map { .init(change: $0) }, + insert: insertSection.changes.map { .init(change: $0) } + )) + removeOffset += removeSection.changes.count + insertOffset += insertSection.changes.count + removeIndex += 1 + insertIndex += 1 + } else if ro < io { + groups.append(.init( + remove: removeSection.changes.map { .init(change: $0) }, + insert: [] + )) + removeOffset += removeSection.changes.count + removeIndex += 1 + } else { + groups.append(.init( + remove: [], + insert: insertSection.changes.map { .init(change: $0) } + )) + insertOffset += insertSection.changes.count + insertIndex += 1 + } + } else if let removeSection { + groups.append(.init( + remove: removeSection.changes.map { .init(change: $0) }, + insert: [] + )) + removeIndex += 1 + } else if let insertSection { + groups.append(.init( + remove: [], + insert: insertSection.changes.map { .init(change: $0) } + )) + insertIndex += 1 + } + } + + return groups + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + guard index >= 0, index < count else { return nil } + return self[index] + } + + subscript(safe index: Int, fallback fallback: Element) -> Element { + guard index >= 0, index < count else { return fallback } + return self[index] + } +} + +private extension CollectionDifference.Change { + var offset: Int { + switch self { + case let .insert(offset, _, _): + return offset + case let .remove(offset, _, _): + return offset + } + } +} + +private struct DiffGroupItem { + struct Item { + var offset: Int + var element: Element + + init(offset: Int, element: Element) { + self.offset = offset + self.element = element + } + + init(change: CollectionDifference.Change) { + offset = change.offset + switch change { + case let .insert(_, element, _): + self.element = element + case let .remove(_, element, _): + self.element = element + } + } + } + + var remove: [Item] + var insert: [Item] +} + +private struct ChangeSection { + var offset: Int { changes.first?.offset ?? 0 } + var changes: [CollectionDifference.Change] + + static func sectioning(_ changes: [CollectionDifference.Change]) -> [Self] { + guard !changes.isEmpty else { return [] } + + let sortedChanges = changes.sorted { $0.offset < $1.offset } + var sections = [Self]() + var currentSection = [CollectionDifference.Change]() + + for change in sortedChanges { + if let lastOffset = currentSection.last?.offset { + if change.offset == lastOffset + 1 { + currentSection.append(change) + } else { + sections.append(Self(changes: currentSection)) + currentSection.removeAll() + currentSection.append(change) + } + } else { + currentSection.append(change) + continue + } + } + + if !currentSection.isEmpty { + sections.append(Self(changes: currentSection)) + } + + return sections + } +} + +#if DEBUG + +import SwiftUI + +struct SnippetDiffPreview: View { + let originalCode: String + let newCode: String + + var body: some View { + HStack(alignment: .top) { + let (original, new) = generateTexts() + block(original) + Divider() + block(new) + } + .padding() + .font(.body.monospaced()) + } + + @ViewBuilder + func block(_ code: [AttributedString]) -> some View { + VStack(alignment: .leading) { + if !code.isEmpty { + ForEach(0.. (original: [AttributedString], new: [AttributedString]) { + let diff = CodeDiff().diff(snippet: newCode, from: originalCode) + let new = diff.sections.flatMap { + $0.newSnippet.map { + let text = $0.text.trimmingCharacters(in: .newlines) + let string = NSMutableAttributedString(string: text) + if case let .mutated(changes) = $0.diff { + string.addAttribute( + .backgroundColor, + value: NSColor.green.withAlphaComponent(0.1), + range: NSRange(location: 0, length: text.count) + ) + + for diffItem in changes { + string.addAttribute( + .backgroundColor, + value: NSColor.green.withAlphaComponent(0.5), + range: NSRange( + location: diffItem.offset, + length: min( + text.count - diffItem.offset, + diffItem.element.count + ) + ) + ) + } + } + return string + } + } + + let original = diff.sections.flatMap { + $0.oldSnippet.map { + let text = $0.text.trimmingCharacters(in: .newlines) + let string = NSMutableAttributedString(string: text) + if case let .mutated(changes) = $0.diff { + string.addAttribute( + .backgroundColor, + value: NSColor.red.withAlphaComponent(0.1), + range: NSRange(location: 0, length: text.count) + ) + + for diffItem in changes { + string.addAttribute( + .backgroundColor, + value: NSColor.red.withAlphaComponent(0.5), + range: NSRange( + location: diffItem.offset, + length: min(text.count - diffItem.offset, diffItem.element.count) + ) + ) + } + } + + return string + } + } + + return (original.map(AttributedString.init), new.map(AttributedString.init)) + } +} + +struct LineDiffPreview: View { + let originalCode: String + let newCode: String + + var body: some View { + VStack(alignment: .leading) { + let (original, new) = generateTexts() + Text(original) + Divider() + Text(new) + } + .padding() + .font(.body.monospaced()) + } + + func generateTexts() -> (original: AttributedString, new: AttributedString) { + let diff = CodeDiff().diff(text: newCode, from: originalCode) + let original = NSMutableAttributedString(string: originalCode) + let new = NSMutableAttributedString(string: newCode) + + for item in diff { + switch item { + case let .insert(offset, element, _): + new.addAttribute( + .backgroundColor, + value: NSColor.green.withAlphaComponent(0.5), + range: NSRange(location: offset, length: element.count) + ) + case let .remove(offset, element, _): + original.addAttribute( + .backgroundColor, + value: NSColor.red.withAlphaComponent(0.5), + range: NSRange(location: offset, length: element.count) + ) + } + } + + return (.init(original), .init(new)) + } +} + +#Preview("Line Diff") { + let originalCode = """ + let foo = Foo() // yes + """ + let newCode = """ + var foo = Bar() + """ + + return LineDiffPreview(originalCode: originalCode, newCode: newCode) +} + +#Preview("Snippet Diff") { + let originalCode = """ + let foo = Foo() + print(foo) + // do something + foo.foo() + func zoo() {} + """ + let newCode = """ + var foo = Bar() + // do something + foo.bar() + func zoo() { + print("zoo") + } + """ + + return SnippetDiffPreview(originalCode: originalCode, newCode: newCode) +} + +#Preview("Code Diff Editor") { + struct V: View { + @State var originalCode = "" + @State var newCode = "" + + var body: some View { + VStack { + HStack { + VStack { + Text("Original") + TextEditor(text: $originalCode) + .frame(width: 300, height: 200) + } + VStack { + Text("New") + TextEditor(text: $newCode) + .frame(width: 300, height: 200) + } + } + .font(.body.monospaced()) + SnippetDiffPreview(originalCode: originalCode, newCode: newCode) + } + .padding() + .frame(height: 600) + } + } + + return V() +} + +#endif + diff --git a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift new file mode 100644 index 00000000..5d7fab76 --- /dev/null +++ b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift @@ -0,0 +1,178 @@ +import ComposableArchitecture +import Foundation +import Preferences +import WebKit +import Workspace +import XcodeInspector + +@Reducer +struct CodeiumChatBrowser { + @ObservableState + struct State: Equatable { + var loadingProgress: Double = 0 + var isLoading = false + var title = "Codeium Chat" + var error: String? + var url: URL? + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + + case initialize + case loadCurrentWorkspace + case reload + case presentError(String) + case removeError + + case observeTitleChange + case updateTitle(String) + case observeURLChange + case updateURL(URL?) + case observeIsLoading + case updateIsLoading(Double) + } + + let webView: WKWebView + let uuid = UUID() + + private enum CancelID: Hashable { + case observeTitleChange(UUID) + case observeURLChange(UUID) + case observeIsLoading(UUID) + } + + @Dependency(\.workspacePool) var workspacePool + + var body: some ReducerOf { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .initialize: + return .merge( + .run { send in await send(.observeTitleChange) }, + .run { send in await send(.observeURLChange) }, + .run { send in await send(.observeIsLoading) } + ) + + case .loadCurrentWorkspace: + return .run { send in + guard let workspaceURL = await XcodeInspector.shared.activeWorkspaceURL + else { + await send(.presentError("Can't find workspace.")) + return + } + do { + let workspace = try await workspacePool + .fetchOrCreateWorkspace(workspaceURL: workspaceURL) + let codeiumPlugin = workspace.plugin(for: CodeiumWorkspacePlugin.self) + guard let service = await codeiumPlugin?.codeiumService + else { + await send(.presentError("Can't start service.")) + return + } + let url = try await service.getChatURL() + await send(.removeError) + await webView.load(URLRequest(url: url)) + } catch { + await send(.presentError(error.localizedDescription)) + } + } + + case .reload: + webView.reload() + return .none + + case .removeError: + state.error = nil + return .none + + case let .presentError(error): + state.error = error + return .none + + // MARK: Observation + + case .observeTitleChange: + let stream = AsyncStream { continuation in + let observation = webView.observe(\.title, options: [.new, .initial]) { + webView, _ in + continuation.yield(webView.title ?? "") + } + + continuation.onTermination = { _ in + observation.invalidate() + } + } + + return .run { send in + for await title in stream where !title.isEmpty { + try Task.checkCancellation() + await send(.updateTitle(title)) + } + } + .cancellable(id: CancelID.observeTitleChange(uuid), cancelInFlight: true) + + case let .updateTitle(title): + state.title = title + return .none + + case .observeURLChange: + let stream = AsyncStream { continuation in + let observation = webView.observe(\.url, options: [.new, .initial]) { + _, url in + if let it = url.newValue { + continuation.yield(it) + } + } + + continuation.onTermination = { _ in + observation.invalidate() + } + } + + return .run { send in + for await url in stream { + try Task.checkCancellation() + await send(.updateURL(url)) + } + }.cancellable(id: CancelID.observeURLChange(uuid), cancelInFlight: true) + + case let .updateURL(url): + state.url = url + return .none + + case .observeIsLoading: + let stream = AsyncStream { continuation in + let observation = webView + .observe(\.estimatedProgress, options: [.new]) { _, estimatedProgress in + if let it = estimatedProgress.newValue { + continuation.yield(it) + } + } + + continuation.onTermination = { _ in + observation.invalidate() + } + } + + return .run { send in + for await isLoading in stream { + try Task.checkCancellation() + await send(.updateIsLoading(isLoading)) + } + }.cancellable(id: CancelID.observeIsLoading(uuid), cancelInFlight: true) + + case let .updateIsLoading(progress): + state.isLoading = progress != 1 + state.loadingProgress = progress + return .none + } + } + } +} + diff --git a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift new file mode 100644 index 00000000..1a889dfe --- /dev/null +++ b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift @@ -0,0 +1,154 @@ +import AppKit +import ChatTab +import Combine +import ComposableArchitecture +import Logger +import Preferences +import SwiftUI +import WebKit +import XcodeInspector + +public class CodeiumChatTab: ChatTab { + public static var name: String { "Codeium Chat" } + public static var isDefaultChatTabReplacement: Bool { false } + public static var canHandleOpenChatCommand: Bool { true } + + struct RestorableState: Codable {} + + public struct EditorContent { + public var selectedText: String + public var language: String + public var fileContent: String + + public init(selectedText: String, language: String, fileContent: String) { + self.selectedText = selectedText + self.language = language + self.fileContent = fileContent + } + + public static var empty: EditorContent { + .init(selectedText: "", language: "", fileContent: "") + } + } + + struct Builder: ChatTabBuilder { + var title: String + var buildable: Bool { true } + var afterBuild: (CodeiumChatTab) async -> Void = { _ in } + + func build(store: StoreOf) async -> (any ChatTab)? { + let tab = await CodeiumChatTab(chatTabStore: store) + await Task { @MainActor in + _ = tab.store.send(.loadCurrentWorkspace) + }.value + await afterBuild(tab) + return tab + } + } + + let store: StoreOf + let webView: WKWebView + let webViewDelegate: WKWebViewDelegate + var cancellable = Set() + private var observer = NSObject() + + @MainActor + public init(chatTabStore: StoreOf) { + let webView = CodeiumWebView(getEditorContent: { + guard let content = await XcodeInspector.shared.getFocusedEditorContent() + else { return .empty } + return .init( + selectedText: content.selectedContent, + language: content.language.rawValue, + fileContent: content.editorContent?.content ?? "" + ) + }) + self.webView = webView + store = .init( + initialState: .init(), + reducer: { CodeiumChatBrowser(webView: webView) } + ) + webViewDelegate = .init(store: store) + + super.init(store: chatTabStore) + + webView.navigationDelegate = webViewDelegate + webView.uiDelegate = webViewDelegate + webView.store = store + + Task { + await CodeiumServiceLifeKeeper.shared.add(self) + } + } + + public func start() { + observer = .init() + cancellable = [] + chatTabStore.send(.updateTitle("Codeium Chat")) + store.send(.initialize) + + Task { @MainActor in + var previousURL: URL? + observer.observe { [weak self] in + guard let self else { return } + if store.url != previousURL { + previousURL = store.url + Task { @MainActor in + self.chatTabStore.send(.tabContentUpdated) + } + } + } + } + + Task { @MainActor in + observer.observe { [weak self] in + guard let self, !store.title.isEmpty else { return } + let title = store.title + Task { @MainActor in + self.chatTabStore.send(.updateTitle(title)) + } + } + } + } + + public func buildView() -> any View { + BrowserView(store: store, webView: webView) + } + + public func buildTabItem() -> any View { + CodeiumChatTabItem(store: store) + } + + public func buildIcon() -> any View { + Image(systemName: "message") + } + + public func buildMenu() -> any View { + EmptyView() + } + + @MainActor + public func restorableState() -> Data { + let state = store.withState { _ in + RestorableState() + } + + return (try? JSONEncoder().encode(state)) ?? Data() + } + + public static func restore(from data: Data) throws -> any ChatTabBuilder { + let builder = Builder(title: "") { @MainActor chatTab in + chatTab.store.send(.loadCurrentWorkspace) + } + return builder + } + + public static func chatBuilders() -> [ChatTabBuilder] { + [Builder(title: "Codeium Chat")] + } + + public static func defaultChatBuilder() -> ChatTabBuilder { + Builder(title: "Codeium Chat") + } +} + diff --git a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTabItem.swift b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTabItem.swift new file mode 100644 index 00000000..1724b23f --- /dev/null +++ b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTabItem.swift @@ -0,0 +1,34 @@ +import ComposableArchitecture +import Foundation +import Preferences +import SwiftUI + +struct CodeiumChatTabItem: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + Text(store.title) + .contextMenu { + CodeiumChatMenuItem(store: store) + } + } + } +} + +struct CodeiumChatMenuItem: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + Button("Load Active Workspace") { + store.send(.loadCurrentWorkspace) + } + + Button("Reload") { + store.send(.reload) + } + } + } +} + diff --git a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatView.swift b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatView.swift new file mode 100644 index 00000000..a7b4ad9e --- /dev/null +++ b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatView.swift @@ -0,0 +1,45 @@ +import ComposableArchitecture +import Foundation +import SharedUIComponents +import SwiftUI +import WebKit + +struct BrowserView: View { + @Perception.Bindable var store: StoreOf + let webView: WKWebView + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + ZStack { + WebView(webView: webView) + } + } + .overlay { + if store.isLoading { + ProgressView() + } + } + .overlay { + if let error = store.error { + VStack { + Text(error) + Button("Load Current Workspace") { + store.send(.loadCurrentWorkspace) + } + } + } + } + } + } +} + +struct WebView: NSViewRepresentable { + var webView: WKWebView + + func makeNSView(context: Context) -> WKWebView { + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) {} +} diff --git a/Tool/Sources/CodeiumService/ChatTab/CodeiumWebView.swift b/Tool/Sources/CodeiumService/ChatTab/CodeiumWebView.swift new file mode 100644 index 00000000..a3729029 --- /dev/null +++ b/Tool/Sources/CodeiumService/ChatTab/CodeiumWebView.swift @@ -0,0 +1,72 @@ +import ComposableArchitecture +import Foundation +import Logger +import Preferences +import WebKit + +class ScriptHandler: NSObject, WKScriptMessageHandlerWithReply { + @MainActor + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) async -> (Any?, String?) { + if message.name == "decodeBase64", let code = message.body as? String { + return (String(data: Data(base64Encoded: code) ?? Data(), encoding: .utf8), nil) + } + return (nil, nil) + } +} + +@MainActor +class CodeiumWebView: WKWebView { + var getEditorContent: () async -> CodeiumChatTab.EditorContent + let scriptHandler = ScriptHandler() + weak var store: StoreOf? + + init(getEditorContent: @escaping () async -> CodeiumChatTab.EditorContent) { + self.getEditorContent = getEditorContent + super.init(frame: .zero, configuration: WKWebViewConfiguration()) + + if #available(macOS 13.3, *) { + #if DEBUG + isInspectable = true + #endif + } + + configuration.userContentController.addScriptMessageHandler( + scriptHandler, + contentWorld: .page, + name: "decodeBase64" + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @discardableResult + func evaluateJavaScript(safe javaScriptString: String) async throws -> Any? { + try await withUnsafeThrowingContinuation { continuation in + evaluateJavaScript(javaScriptString) { result, error in + if let error { + print(javaScriptString, error.localizedDescription) + continuation.resume(throwing: error) + } else { + continuation.resume(returning: result) + } + } + } + } +} + +// MARK: - WebView Delegate + +final class WKWebViewDelegate: NSObject, ObservableObject, WKNavigationDelegate, WKUIDelegate { + let store: StoreOf + + init(store: StoreOf) { + self.store = store + } +} + diff --git a/Tool/Sources/CodeiumService/CodeiumExtension.swift b/Tool/Sources/CodeiumService/CodeiumExtension.swift new file mode 100644 index 00000000..d2e265a4 --- /dev/null +++ b/Tool/Sources/CodeiumService/CodeiumExtension.swift @@ -0,0 +1,194 @@ +import BuiltinExtension +import ChatTab +import CopilotForXcodeKit +import Foundation +import Logger +import Preferences +import Workspace + +@globalActor public enum CodeiumActor { + public actor TheActor {} + public static let shared = TheActor() +} + +public final class CodeiumExtension: BuiltinExtension { + public var extensionIdentifier: String { "com.codeium" } + + public let suggestionService: CodeiumSuggestionService + + public var chatTabTypes: [any CustomChatTab] { + [TypedCustomChatTab(of: CodeiumChatTab.self)] + } + + private var extensionUsage = ExtensionUsage( + isSuggestionServiceInUse: false, + isChatServiceInUse: false + ) + private var isLanguageServerInUse: Bool { + get async { + let lifeKeeperIsAlive = await CodeiumServiceLifeKeeper.shared.isAlive + return extensionUsage.isSuggestionServiceInUse + || extensionUsage.isChatServiceInUse + || lifeKeeperIsAlive + } + } + + let workspacePool: WorkspacePool + + let serviceLocator: ServiceLocator + + public init(workspacePool: WorkspacePool) { + self.workspacePool = workspacePool + serviceLocator = .init(workspacePool: workspacePool) + suggestionService = .init(serviceLocator: serviceLocator) + } + + public func workspaceDidOpen(_ workspace: WorkspaceInfo) { + Task { + do { + guard await isLanguageServerInUse else { return } + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyOpenWorkspace(workspaceURL: workspace.workspaceURL) + } catch { + Logger.codeium.error(error.localizedDescription) + } + } + } + + public func workspaceDidClose(_ workspace: WorkspaceInfo) { + Task { + do { + guard await isLanguageServerInUse else { return } + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyCloseWorkspace(workspaceURL: workspace.workspaceURL) + } catch { + Logger.codeium.error(error.localizedDescription) + } + } + } + + public func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) { + Task { + guard await isLanguageServerInUse else { return } + // check if file size is larger than 15MB, if so, return immediately + if let attrs = try? FileManager.default + .attributesOfItem(atPath: documentURL.path), + let fileSize = attrs[FileAttributeKey.size] as? UInt64, + fileSize > 15 * 1024 * 1024 + { return } + + do { + let content = try String(contentsOf: documentURL, encoding: .utf8) + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) + } catch { + Logger.codeium.error(error.localizedDescription) + } + } + } + + public func workspace(_ workspace: WorkspaceInfo, didSaveDocumentAt documentURL: URL) { + // unimplemented + } + + public func workspace(_ workspace: WorkspaceInfo, didCloseDocumentAt documentURL: URL) { + Task { + guard await isLanguageServerInUse else { return } + do { + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyCloseTextDocument(fileURL: documentURL) + } catch { + Logger.codeium.error(error.localizedDescription) + } + } + } + + public func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String? + ) { + Task { + guard await isLanguageServerInUse else { return } + // check if file size is larger than 15MB, if so, return immediately + if let attrs = try? FileManager.default + .attributesOfItem(atPath: documentURL.path), + let fileSize = attrs[FileAttributeKey.size] as? UInt64, + fileSize > 15 * 1024 * 1024 + { return } + do { + guard let content else { return } + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyChangeTextDocument(fileURL: documentURL, content: content) + try await service.refreshIDEContext( + fileURL: documentURL, + content: content, + cursorPosition: .zero, + tabSize: 4, indentSize: 4, usesTabsForIndentation: false, + workspaceURL: workspace.workspaceURL + ) + } catch { + Logger.codeium.error(error.localizedDescription) + } + } + } + + public func extensionUsageDidChange(_ usage: ExtensionUsage) { + extensionUsage = usage + Task { + if !(await isLanguageServerInUse) { + terminate() + } + } + } + + public func terminate() { + for workspace in workspacePool.workspaces.values { + guard let plugin = workspace.plugin(for: CodeiumWorkspacePlugin.self) + else { continue } + plugin.terminate() + } + } +} + +final class ServiceLocator { + let workspacePool: WorkspacePool + + init(workspacePool: WorkspacePool) { + self.workspacePool = workspacePool + } + + func getService(from workspace: WorkspaceInfo) async -> CodeiumService? { + guard let workspace = workspacePool.workspaces[workspace.workspaceURL], + let plugin = workspace.plugin(for: CodeiumWorkspacePlugin.self) + else { return nil } + return await plugin.codeiumService + } +} + +/// A helper class to keep track of a list of items that may keep the service alive. +/// For example, a ``CodeiumChatTab``. +actor CodeiumServiceLifeKeeper { + static let shared = CodeiumServiceLifeKeeper() + + private final class WeakObject { + weak var object: AnyObject? + var isAlive: Bool { object != nil } + init(_ object: AnyObject) { + self.object = object + } + } + + private var weakObjects = [WeakObject]() + + func add(_ object: AnyObject) { + weakObjects.removeAll { !$0.isAlive } + weakObjects.append(WeakObject(object)) + } + + var isAlive: Bool { + if weakObjects.isEmpty { return false } + return weakObjects.allSatisfy { $0.isAlive } + } +} + diff --git a/Tool/Sources/CodeiumService/CodeiumWorkspacePlugin.swift b/Tool/Sources/CodeiumService/CodeiumWorkspacePlugin.swift new file mode 100644 index 00000000..aef40f4e --- /dev/null +++ b/Tool/Sources/CodeiumService/CodeiumWorkspacePlugin.swift @@ -0,0 +1,60 @@ +import Foundation +import Logger +import Workspace + +public final class CodeiumWorkspacePlugin: WorkspacePlugin { + private var _codeiumService: CodeiumService? + @CodeiumActor + var codeiumService: CodeiumService? { + if let service = _codeiumService { return service } + do { + return try createCodeiumService() + } catch { + Logger.codeium.error("Failed to create Codeium service: \(error)") + return nil + } + } + + deinit { + if let _codeiumService { + _codeiumService.terminate() + } + } + + @CodeiumActor + func createCodeiumService() throws -> CodeiumService { + let newService = try CodeiumService( + projectRootURL: projectRootURL, + onServiceLaunched: { + + }, + onServiceTerminated: { + // start handled in the service. + } + ) + _codeiumService = newService + return newService + } + + @CodeiumActor + func finishLaunchingService() { + guard let workspace, let _codeiumService else { return } + Task { + try? await _codeiumService.notifyOpenWorkspace(workspaceURL: workspaceURL) + + for (_, filespace) in workspace.filespaces { + let documentURL = filespace.fileURL + guard let content = try? String(contentsOf: documentURL) else { continue } + try? await _codeiumService.notifyOpenTextDocument( + fileURL: documentURL, + content: content + ) + } + } + } + + func terminate() { + _codeiumService = nil + } +} + diff --git a/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift new file mode 100644 index 00000000..29936bf8 --- /dev/null +++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift @@ -0,0 +1,223 @@ +import Foundation +import Terminal + +public struct CodeiumInstallationManager { + private static var isInstalling = false + static let latestSupportedVersion = "1.48.2" + static let minimumSupportedVersion = "1.20.0" + + public init() {} + + enum CodeiumInstallationError: Error, LocalizedError { + case badURL(String) + case invalidResponse + case invalidData + + var errorDescription: String? { + switch self { + case .badURL: return "URL is invalid" + case .invalidResponse: return "Invalid response" + case .invalidData: return "Invalid data" + } + } + } + + public func getLatestSupportedVersion() -> String { + if isEnterprise { + return UserDefaults.shared.value(for: \.codeiumEnterpriseVersion) + } + + return Self.latestSupportedVersion + } + + func getEnterprisePortalVersion() async throws -> String { + let enterprisePortalUrl = UserDefaults.shared.value(for: \.codeiumPortalUrl) + let enterprisePortalVersionUrl = "\(enterprisePortalUrl)/api/version" + + guard let url = URL(string: enterprisePortalVersionUrl) + else { throw CodeiumInstallationError.badURL(enterprisePortalVersionUrl) } + + let (data, response) = try await URLSession.shared.data(from: url) + + guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { + throw CodeiumInstallationError.invalidResponse + } + + if let version = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + { + UserDefaults.shared.set(version, for: \.codeiumEnterpriseVersion) + return version + } else { + return UserDefaults.shared.value(for: \.codeiumEnterpriseVersion) + } + } + + var isEnterprise: Bool { + return UserDefaults.shared.value(for: \.codeiumEnterpriseMode) + && !UserDefaults.shared.value(for: \.codeiumPortalUrl).isEmpty + } + + public enum InstallationStatus { + case notInstalled + case installed(String) + case outdated(current: String, latest: String, mandatory: Bool) + case unsupported(current: String, latest: String) + } + + public func checkInstallation() async -> InstallationStatus { + guard let urls = try? CodeiumService.createFoldersIfNeeded() + else { return .notInstalled } + let executableFolderURL = urls.executableURL + let binaryURL = executableFolderURL.appendingPathComponent("language_server") + let versionFileURL = executableFolderURL.appendingPathComponent("version") + + if !FileManager.default.fileExists(atPath: binaryURL.path) { + return .notInstalled + } + + let targetVersion = await { + if !isEnterprise { return Self.latestSupportedVersion } + return (try? await getEnterprisePortalVersion()) + ?? UserDefaults.shared.value(for: \.codeiumEnterpriseVersion) + }() + + if FileManager.default.fileExists(atPath: versionFileURL.path), + let versionData = try? Data(contentsOf: versionFileURL), + let version = String(data: versionData, encoding: .utf8) + { + switch version.compare(targetVersion, options: .numeric) { + case .orderedAscending: + switch version.compare(Self.minimumSupportedVersion) { + case .orderedAscending: + return .outdated( + current: version, + latest: Self.latestSupportedVersion, + mandatory: true + ) + case .orderedSame: + return .outdated( + current: version, + latest: Self.latestSupportedVersion, + mandatory: false + ) + case .orderedDescending: + return .outdated( + current: version, + latest: Self.latestSupportedVersion, + mandatory: false + ) + } + case .orderedSame: + return .installed(version) + case .orderedDescending: + return .unsupported(current: version, latest: targetVersion) + } + } + return .outdated(current: "Unknown", latest: Self.latestSupportedVersion, mandatory: false) + } + + public enum InstallationStep { + case downloading + case uninstalling + case decompressing + case done + } + + public func installLatestVersion() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + guard !CodeiumInstallationManager.isInstalling else { + continuation.finish(throwing: CodeiumError.languageServiceIsInstalling) + return + } + CodeiumInstallationManager.isInstalling = true + defer { CodeiumInstallationManager.isInstalling = false } + do { + continuation.yield(.downloading) + let urls = try CodeiumService.createFoldersIfNeeded() + let urlString: String + let version: String + if !isEnterprise { + version = CodeiumInstallationManager.latestSupportedVersion + urlString = + "https://github.com/Exafunction/codeium/releases/download/language-server-v\(Self.latestSupportedVersion)/language_server_macos_\(isAppleSilicon() ? "arm" : "x64").gz" + } else { + version = try await getEnterprisePortalVersion() + let enterprisePortalUrl = UserDefaults.shared.value(for: \.codeiumPortalUrl) + urlString = + "\(enterprisePortalUrl)/language-server-v\(version)/language_server_macos_\(isAppleSilicon() ? "arm" : "x64").gz" + } + + guard let url = URL(string: urlString) else { + continuation.finish(throwing: CodeiumInstallationError.badURL(urlString)) + return + } + + // download + let (fileURL, _) = try await URLSession.shared.download(from: url) + let targetURL = urls.executableURL.appendingPathComponent("language_server") + .appendingPathExtension("gz") + try FileManager.default.copyItem(at: fileURL, to: targetURL) + defer { try? FileManager.default.removeItem(at: targetURL) } + + // uninstall + continuation.yield(.uninstalling) + try await uninstall() + + // extract file + continuation.yield(.decompressing) + let terminal = Terminal() + _ = try await terminal.runCommand( + "/usr/bin/gunzip", + arguments: [targetURL.path], + environment: [:] + ) + + // update permission 755 + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: targetURL.deletingPathExtension().path + ) + var data: Data? + + // create version file + data = version.data(using: .utf8) + + FileManager.default.createFile( + atPath: urls.executableURL.appendingPathComponent("version").path, + contents: data + ) + + continuation.yield(.done) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + public func uninstall() async throws { + guard let urls = try? CodeiumService.createFoldersIfNeeded() + else { return } + let executableFolderURL = urls.executableURL + let binaryURL = executableFolderURL.appendingPathComponent("language_server") + let versionFileURL = executableFolderURL.appendingPathComponent("version") + if FileManager.default.fileExists(atPath: binaryURL.path) { + try FileManager.default.removeItem(at: binaryURL) + } + if FileManager.default.fileExists(atPath: versionFileURL.path) { + try FileManager.default.removeItem(at: versionFileURL) + } + } +} + +func isAppleSilicon() -> Bool { + var result = false + #if arch(arm64) + result = true + #endif + return result +} + diff --git a/Core/Sources/CodeiumService/CodeiumLanguageServer.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift similarity index 58% rename from Core/Sources/CodeiumService/CodeiumLanguageServer.swift rename to Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift index cd099943..051994b9 100644 --- a/Core/Sources/CodeiumService/CodeiumLanguageServer.swift +++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift @@ -4,9 +4,11 @@ import LanguageClient import LanguageServerProtocol import Logger import Preferences +import XcodeInspector protocol CodeiumLSP { func sendRequest(_ endpoint: E) async throws -> E.Response + func updateIndexing() async func terminate() } @@ -20,6 +22,7 @@ final class CodeiumLanguageServer { var launchHandler: (() -> Void)? var port: String? var heartbeatTask: Task? + var projectPaths: [String] init( languageServerExecutableURL: URL, @@ -33,6 +36,7 @@ final class CodeiumLanguageServer { self.supportURL = supportURL self.terminationHandler = terminationHandler self.launchHandler = launchHandler + projectPaths = [] process = Process() transport = IOTransport() @@ -42,13 +46,39 @@ final class CodeiumLanguageServer { process.executableURL = languageServerExecutableURL + let isEnterpriseMode = UserDefaults.shared.value(for: \.codeiumEnterpriseMode) + var apiServerUrl = "https://server.codeium.com" + if isEnterpriseMode, UserDefaults.shared.value(for: \.codeiumApiUrl) != "" { + apiServerUrl = UserDefaults.shared.value(for: \.codeiumApiUrl) + } + process.arguments = [ "--api_server_url", - "https://server.codeium.com", + apiServerUrl, "--manager_dir", managerDirectoryURL.path, + "--enable_chat_web_server", + "--enable_chat_client", ] + if isEnterpriseMode { + process.arguments?.append("--enterprise_mode") + process.arguments?.append("--portal_url") + process.arguments?.append(UserDefaults.shared.value(for: \.codeiumPortalUrl)) + } + + let indexEnabled = UserDefaults.shared.value(for: \.codeiumIndexEnabled) + if indexEnabled { + let indexingMaxFileSize = UserDefaults.shared.value(for: \.codeiumIndexingMaxFileSize) + if indexEnabled { + process.arguments?.append("--enable_local_search") + process.arguments?.append("--enable_index_service") + process.arguments?.append("--search_max_workspace_file_count") + process.arguments?.append("\(indexingMaxFileSize)") + Logger.codeium.info("Indexing Enabled") + } + } + process.currentDirectoryURL = supportURL process.terminationHandler = { [weak self] task in @@ -120,7 +150,7 @@ final class CodeiumLanguageServer { self.port = port launchHandler?() } - + func terminate() { process.terminationHandler = nil if process.isRunning { @@ -165,6 +195,41 @@ extension CodeiumLanguageServer: CodeiumLSP { } } } + + func updateIndexing() async { + let indexEnabled = UserDefaults.shared.value(for: \.codeiumIndexEnabled) + if !indexEnabled { + return + } + + let currentProjectPaths = await getProjectPaths() + + // Add all workspaces that are in the currentProjectPaths but not in the previous project + // paths + for currentProjectPath in currentProjectPaths { + if !projectPaths.contains(currentProjectPath) && FileManager.default + .fileExists(atPath: currentProjectPath) + { + _ = try? await sendRequest(CodeiumRequest.AddTrackedWorkspace(requestBody: .init( + workspace: currentProjectPath + ))) + } + } + + // Remove all workspaces that are in previous project paths but not in the + // currentProjectPaths + for projectPath in projectPaths { + if !currentProjectPaths.contains(projectPath) && FileManager.default + .fileExists(atPath: projectPath) + { + _ = try? await sendRequest(CodeiumRequest.RemoveTrackedWorkspace(requestBody: .init( + workspace: projectPath + ))) + } + } + // These should be identical now + projectPaths = currentProjectPaths + } } final class IOTransport { @@ -260,3 +325,81 @@ final class IOTransport { } } +class WorkspaceParser: NSObject, XMLParserDelegate { + var projectPaths: [String] = [] + var workspaceFileURL: URL + var workspaceBaseURL: URL + + init(workspaceFileURL: URL, workspaceBaseURL: URL) { + self.workspaceFileURL = workspaceFileURL + self.workspaceBaseURL = workspaceBaseURL + } + + func parse() -> [String] { + guard let parser = XMLParser(contentsOf: workspaceFileURL) else { + print("Failed to create XML parser for file: \(workspaceFileURL.path)") + return [] + } + parser.delegate = self + parser.parse() + return projectPaths + } + + // XMLParserDelegate methods + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] + ) { + if elementName == "FileRef", let location = attributeDict["location"] { + var project_path: String + if location.starts(with: "group:") && pathEndsWithXcodeproj(location) { + let curr_path = String(location.dropFirst("group:".count)) + guard let relative_project_url = URL(string: curr_path) else { + return + } + let relative_base_path = relative_project_url.deletingLastPathComponent() + project_path = ( + workspaceBaseURL + .appendingPathComponent(relative_base_path.relativePath) + ).standardized.path + } else if location.starts(with: "absolute:") && pathEndsWithXcodeproj(location) { + let abs_url = URL(fileURLWithPath: String(location.dropFirst("absolute:".count))) + project_path = abs_url.deletingLastPathComponent().standardized.path + } else { + return + } + if FileManager.default.fileExists(atPath: project_path) { + projectPaths.append(project_path) + } + } + } + + func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) { + print("Failed to parse XML: \(parseError.localizedDescription)") + } + + func pathEndsWithXcodeproj(_ path: String) -> Bool { + return path.hasSuffix(".xcodeproj") + } +} + +public func getProjectPaths() async -> [String] { + guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL else { + return [] + } + + let workspacebaseURL = workspaceURL.deletingLastPathComponent() + + let workspaceContentsURL = workspaceURL.appendingPathComponent("contents.xcworkspacedata") + + let parser = WorkspaceParser( + workspaceFileURL: workspaceContentsURL, + workspaceBaseURL: workspacebaseURL + ) + let absolutePaths = parser.parse() + return absolutePaths +} + diff --git a/Core/Sources/CodeiumService/CodeiumModels.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumModels.swift similarity index 96% rename from Core/Sources/CodeiumService/CodeiumModels.swift rename to Tool/Sources/CodeiumService/LanguageServer/CodeiumModels.swift index 9137130d..486e5f45 100644 --- a/Core/Sources/CodeiumService/CodeiumModels.swift +++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumModels.swift @@ -1,7 +1,7 @@ import Foundation import JSONRPC import LanguageServerProtocol -import SuggestionModel +import SuggestionBasic struct CodeiumCompletion: Codable { var completionId: String @@ -72,9 +72,7 @@ struct CompletionPart: Codable { } struct CodeiumDocument: Codable { - var absolute_path: String - // Path relative to the root of the workspace. - var relative_path: String + var absolute_path_migrate_me_to_uri: String var text: String // Language ID provided by the editor. var editor_language: String diff --git a/Core/Sources/CodeiumService/CodeiumRequest.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumRequest.swift similarity index 56% rename from Core/Sources/CodeiumService/CodeiumRequest.swift rename to Tool/Sources/CodeiumService/LanguageServer/CodeiumRequest.swift index 8e5d87b1..24ef5561 100644 --- a/Core/Sources/CodeiumService/CodeiumRequest.swift +++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumRequest.swift @@ -1,7 +1,7 @@ import Foundation import JSONRPC import LanguageServerProtocol -import SuggestionModel +import SuggestionBasic protocol CodeiumRequestType { associatedtype Response: Codable @@ -27,6 +27,23 @@ struct CodeiumResponseError: Codable, Error, LocalizedError { } enum CodeiumRequest { + struct GetProcesses: CodeiumRequestType { + struct Response: Codable { + var lspPort: UInt32 + var chatWebServerPort: UInt32 + var chatClientPort: UInt32 + } + + struct RequestBody: Codable {} + + var requestBody: RequestBody + + func makeURLRequest(server: String) -> URLRequest { + let data = (try? JSONEncoder().encode(requestBody)) ?? Data() + return assembleURLRequest(server: server, method: "GetProcesses", body: data) + } + } + struct GetCompletion: CodeiumRequestType { struct Response: Codable { var state: State @@ -47,7 +64,7 @@ enum CodeiumRequest { return assembleURLRequest(server: server, method: "GetCompletions", body: data) } } - + struct CancelRequest: CodeiumRequestType { struct Response: Codable {} @@ -63,7 +80,7 @@ enum CodeiumRequest { return assembleURLRequest(server: server, method: "CancelRequest", body: data) } } - + struct AcceptCompletion: CodeiumRequestType { struct Response: Codable {} @@ -79,7 +96,67 @@ enum CodeiumRequest { return assembleURLRequest(server: server, method: "AcceptCompletion", body: data) } } - + + struct RemoveTrackedWorkspace: CodeiumRequestType { + struct Response: Codable {} + + struct RequestBody: Codable { + var workspace: String + } + + var requestBody: RequestBody + + func makeURLRequest(server: String) -> URLRequest { + let data = (try? JSONEncoder().encode(requestBody)) ?? Data() + return assembleURLRequest( + server: server, + method: "RemoveTrackedWorkspace", + body: data + ) + } + } + + struct AddTrackedWorkspace: CodeiumRequestType { + struct Response: Codable {} + + struct RequestBody: Codable { + var workspace: String + } + + var requestBody: RequestBody + + func makeURLRequest(server: String) -> URLRequest { + let data = (try? JSONEncoder().encode(requestBody)) ?? Data() + return assembleURLRequest( + server: server, + method: "AddTrackedWorkspace", + body: data + ) + } + } + + struct RefreshContextForIdeAction: CodeiumRequestType { + struct Response: Codable {} + + struct RequestBody: Codable { + var active_document: CodeiumDocument + var open_document_filepaths: [String] + var workspace_paths: [String] + var blocking: Bool = false + } + + var requestBody: RequestBody + + func makeURLRequest(server: String) -> URLRequest { + let data = (try? JSONEncoder().encode(requestBody)) ?? Data() + return assembleURLRequest( + server: server, + method: "RefreshContextForIdeAction", + body: data + ) + } + } + struct Heartbeat: CodeiumRequestType { struct Response: Codable {} diff --git a/Core/Sources/CodeiumService/CodeiumSupportedLanguage.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumSupportedLanguage.swift similarity index 99% rename from Core/Sources/CodeiumService/CodeiumSupportedLanguage.swift rename to Tool/Sources/CodeiumService/LanguageServer/CodeiumSupportedLanguage.swift index 3ffe57bc..659b3805 100644 --- a/Core/Sources/CodeiumService/CodeiumSupportedLanguage.swift +++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumSupportedLanguage.swift @@ -1,5 +1,5 @@ import Foundation -import SuggestionModel +import SuggestionBasic enum CodeiumSupportedLanguage: Int, Codable { case unspecified = 0 diff --git a/Core/Sources/CodeiumService/OpendDocumentPool.swift b/Tool/Sources/CodeiumService/LanguageServer/OpendDocumentPool.swift similarity index 100% rename from Core/Sources/CodeiumService/OpendDocumentPool.swift rename to Tool/Sources/CodeiumService/LanguageServer/OpendDocumentPool.swift diff --git a/Core/Sources/CodeiumService/CodeiumAuthService.swift b/Tool/Sources/CodeiumService/Services/CodeiumAuthService.swift similarity index 72% rename from Core/Sources/CodeiumService/CodeiumAuthService.swift rename to Tool/Sources/CodeiumService/Services/CodeiumAuthService.swift index dbb33903..79ed97ac 100644 --- a/Core/Sources/CodeiumService/CodeiumAuthService.swift +++ b/Tool/Sources/CodeiumService/Services/CodeiumAuthService.swift @@ -1,25 +1,19 @@ import Configs import Foundation -import KeychainAccess +import Keychain public final class CodeiumAuthService { public init() {} let codeiumKeyKey = "codeiumAuthKey" - let keychain: Keychain = { - let info = Bundle.main.infoDictionary - return Keychain(service: keychainService, accessGroup: keychainAccessGroup) - .attributes([ - kSecUseDataProtectionKeychain as String: true, - ]) - }() + let keychain = Keychain() - var key: String? { try? keychain.getString(codeiumKeyKey) } + var key: String? { try? keychain.get(codeiumKeyKey) } public var isSignedIn: Bool { return key != nil } public func signIn(token: String) async throws { let key = try await generate(token: token) - try keychain.set(key, key: codeiumKeyKey) + try keychain.update(key, key: codeiumKeyKey) } public func signOut() async throws { @@ -40,7 +34,14 @@ public final class CodeiumAuthService { } func generate(token: String) async throws -> String { - var request = URLRequest(url: URL(string: "https://api.codeium.com/register_user/")!) + var registerUserUrl = URL(string: "https://api.codeium.com/register_user/") + let apiUrl = UserDefaults.shared.value(for: \.codeiumApiUrl) + if UserDefaults.shared.value(for: \.codeiumEnterpriseMode), apiUrl != "" { + registerUserUrl = + URL(string: apiUrl + "/exa.seat_management_pb.SeatManagementService/RegisterUser") + } + + var request = URLRequest(url: registerUserUrl!) request.httpMethod = "POST" request.addValue("application/json", forHTTPHeaderField: "Content-Type") let requestBody = GenerateKeyRequestBody(firebase_id_token: token) diff --git a/Core/Sources/CodeiumService/CodeiumService.swift b/Tool/Sources/CodeiumService/Services/CodeiumService.swift similarity index 67% rename from Core/Sources/CodeiumService/CodeiumService.swift rename to Tool/Sources/CodeiumService/Services/CodeiumService.swift index 7b501270..046d2df2 100644 --- a/Core/Sources/CodeiumService/CodeiumService.swift +++ b/Tool/Sources/CodeiumService/Services/CodeiumService.swift @@ -1,8 +1,10 @@ +import AppKit import Foundation import LanguageClient import LanguageServerProtocol import Logger -import SuggestionModel +import SuggestionBasic +import XcodeInspector public protocol CodeiumSuggestionServiceType { func getCompletions( @@ -11,10 +13,10 @@ public protocol CodeiumSuggestionServiceType { cursorPosition: CursorPosition, tabSize: Int, indentSize: Int, - usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool + usesTabsForIndentation: Bool ) async throws -> [CodeSuggestion] func notifyAccepted(_ suggestion: CodeSuggestion) async + func getChatURL() async throws -> URL func notifyOpenTextDocument(fileURL: URL, content: String) async throws func notifyChangeTextDocument(fileURL: URL, content: String) async throws func notifyCloseTextDocument(fileURL: URL) async throws @@ -26,6 +28,7 @@ enum CodeiumError: Error, LocalizedError { case languageServerNotInstalled case languageServerOutdated case languageServiceIsInstalling + case failedToConstructChatURL var errorDescription: String? { switch self { @@ -35,42 +38,52 @@ enum CodeiumError: Error, LocalizedError { return "Language server is outdated. Please update it in the host app or update the extension." case .languageServiceIsInstalling: return "Language service is installing, please try again later." + case .failedToConstructChatURL: + return "Failed to construct chat URL." } } } -public class CodeiumSuggestionService { +public class CodeiumService { static let sessionId = UUID().uuidString let projectRootURL: URL var server: CodeiumLSP? var heartbeatTask: Task? + var workspaceTask: Task? var requestCounter: UInt64 = 0 var cancellationCounter: UInt64 = 0 let openedDocumentPool = OpenedDocumentPool() let onServiceLaunched: () -> Void + let onServiceTerminated: () -> Void let languageServerURL: URL let supportURL: URL let authService = CodeiumAuthService() - var xcodeVersion = "14.0.0" + var fallbackXcodeVersion = "14.0.0" var languageServerVersion = CodeiumInstallationManager.latestSupportedVersion - + private var ongoingTasks = Set>() init(designatedServer: CodeiumLSP) { projectRootURL = URL(fileURLWithPath: "/") server = designatedServer onServiceLaunched = {} + onServiceTerminated = {} languageServerURL = URL(fileURLWithPath: "/") supportURL = URL(fileURLWithPath: "/") } - public init(projectRootURL: URL, onServiceLaunched: @escaping () -> Void) throws { + public init( + projectRootURL: URL, + onServiceLaunched: @escaping () -> Void, + onServiceTerminated: @escaping () -> Void + ) throws { self.projectRootURL = projectRootURL self.onServiceLaunched = onServiceLaunched - let urls = try CodeiumSuggestionService.createFoldersIfNeeded() + self.onServiceTerminated = onServiceTerminated + let urls = try CodeiumService.createFoldersIfNeeded() languageServerURL = urls.executableURL.appendingPathComponent("language_server") supportURL = urls.supportURL Task { @@ -83,25 +96,18 @@ public class CodeiumSuggestionService { if let server { return server } let binaryManager = CodeiumInstallationManager() - let installationStatus = binaryManager.checkInstallation() + let installationStatus = await binaryManager.checkInstallation() switch installationStatus { case let .installed(version), let .unsupported(version, _): languageServerVersion = version case .notInstalled: throw CodeiumError.languageServerNotInstalled - case let .outdated(version, _): + case let .outdated(version, _, _): languageServerVersion = version throw CodeiumError.languageServerOutdated } - 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 metadata = try await getMetadata() let tempFolderURL = FileManager.default.temporaryDirectory let managerDirectoryURL = tempFolderURL .appendingPathComponent("com.intii.CopilotForXcode") @@ -122,8 +128,10 @@ public class CodeiumSuggestionService { server.terminationHandler = { [weak self] in self?.server = nil self?.heartbeatTask?.cancel() + self?.workspaceTask?.cancel() self?.requestCounter = 0 self?.cancellationCounter = 0 + self?.onServiceTerminated() Logger.codeium.info("Language server is terminated, will be restarted when needed.") } @@ -139,6 +147,14 @@ public class CodeiumSuggestionService { try await Task.sleep(nanoseconds: 5_000_000_000) } } + + self.workspaceTask = Task { [weak self] in + while true { + try Task.checkCancellation() + _ = await self?.server?.updateIndexing() + try await Task.sleep(nanoseconds: 5_000_000_000) + } + } } self.server = server @@ -184,20 +200,28 @@ public class CodeiumSuggestionService { } } -extension CodeiumSuggestionService { - func getMetadata() throws -> Metadata { +extension CodeiumService { + func getMetadata() async throws -> Metadata { guard let key = authService.key else { struct E: Error, LocalizedError { var errorDescription: String? { "Codeium not signed in." } } throw E() } + var ideVersion = await 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, + session_id: CodeiumService.sessionId, request_id: requestCounter ) } @@ -218,30 +242,27 @@ extension CodeiumSuggestionService { } } -extension CodeiumSuggestionService: CodeiumSuggestionServiceType { +extension CodeiumService: CodeiumSuggestionServiceType { public func getCompletions( fileURL: URL, content: String, cursorPosition: CursorPosition, tabSize: Int, indentSize: Int, - usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool + usesTabsForIndentation: Bool ) async throws -> [CodeSuggestion] { 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(), + let request = try await CodeiumRequest.GetCompletion(requestBody: .init( + metadata: getMetadata(), document: .init( - absolute_path: fileURL.path, - relative_path: relativePath, + absolute_path_migrate_me_to_uri: fileURL.path, text: content, editor_language: languageId.rawValue, language: .init(codeLanguage: languageId), @@ -255,31 +276,25 @@ extension CodeiumSuggestionService: CodeiumSuggestionServiceType { .map { openedDocument in let languageId = languageIdentifierFromFileURL(openedDocument.url) return .init( - absolute_path: openedDocument.url.path, - relative_path: openedDocument.relativePath, + absolute_path_migrate_me_to_uri: openedDocument.url.path, text: openedDocument.content, editor_language: languageId.rawValue, language: .init(codeLanguage: languageId) ) } )) - + try Task.checkCancellation() - let result = try await (try await setupServerIfNeeded()).sendRequest(request) - + let result = try await (await setupServerIfNeeded()).sendRequest(request) + try Task.checkCancellation() - return result.completionItems?.filter { item in - if ignoreSpaceOnlySuggestions { - return !item.completion.text.allSatisfy { $0.isWhitespace || $0.isNewline } - } - return true - }.map { item in + return result.completionItems?.map { item in CodeSuggestion( + id: item.completion.completionId, text: item.completion.text, position: cursorPosition, - uuid: item.completion.completionId, range: CursorRange( start: .init( line: item.range.startPosition?.row.flatMap(Int.init) ?? 0, @@ -289,12 +304,11 @@ extension CodeiumSuggestionService: CodeiumSuggestionServiceType { line: item.range.endPosition?.row.flatMap(Int.init) ?? 0, character: item.range.endPosition?.col.flatMap(Int.init) ?? 0 ) - ), - displayText: item.completion.text + ) ) } ?? [] } - + ongoingTasks.insert(task) return try await task.value @@ -304,16 +318,53 @@ extension CodeiumSuggestionService: CodeiumSuggestionServiceType { _ = try? await server?.sendRequest( CodeiumRequest.CancelRequest(requestBody: .init( request_id: requestCounter, - session_id: CodeiumSuggestionService.sessionId + session_id: CodeiumService.sessionId )) ) } + public func getChatURL() async throws -> URL { + let metadata = try await getMetadata() + let ports = try await server?.sendRequest( + CodeiumRequest.GetProcesses(requestBody: .init()) + ) + + guard let chatClientPort = ports?.chatClientPort, + let chatWebServerPort = ports?.chatWebServerPort + else { throw CodeiumError.failedToConstructChatURL } + + let webServerUrl = "ws://127.0.0.1:\(chatWebServerPort)" + var components = URLComponents() + components.scheme = "http" + components.host = "127.0.0.1" + components.port = Int(chatClientPort) + components.path = "/" + components.queryItems = [ + URLQueryItem(name: "api_key", value: metadata.api_key), + URLQueryItem(name: "locale", value: "en"), + URLQueryItem(name: "extension_name", value: "Copilot for XCode"), + URLQueryItem(name: "extension_version", value: metadata.extension_version), + URLQueryItem(name: "ide_name", value: metadata.ide_name), + URLQueryItem(name: "ide_version", value: metadata.ide_version), + URLQueryItem(name: "web_server_url", value: webServerUrl), + URLQueryItem(name: "ide_telemetry_enabled", value: "true"), + URLQueryItem(name: "has_enterprise_extension", value: String(UserDefaults.shared.value(for: \.codeiumEnterpriseMode))), + URLQueryItem(name: "has_index_service", value: String(UserDefaults.shared.value(for: \.codeiumIndexEnabled))) + ] + + if let url = components.url { + print(url) + return url + } else { + throw CodeiumError.failedToConstructChatURL + } + } + public func notifyAccepted(_ suggestion: CodeSuggestion) async { _ = try? await (try setupServerIfNeeded()) .sendRequest(CodeiumRequest.AcceptCompletion(requestBody: .init( metadata: getMetadata(), - completion_id: suggestion.uuid + completion_id: suggestion.id ))) } @@ -339,6 +390,48 @@ extension CodeiumSuggestionService: CodeiumSuggestionServiceType { await openedDocumentPool.closeDocument(url: fileURL) } + public func notifyOpenWorkspace(workspaceURL: URL) async throws { + _ = try await (setupServerIfNeeded()).sendRequest( + CodeiumRequest + .AddTrackedWorkspace(requestBody: .init(workspace: workspaceURL.path)) + ) + } + + public func notifyCloseWorkspace(workspaceURL: URL) async throws { + _ = try await (setupServerIfNeeded()).sendRequest( + CodeiumRequest + .RemoveTrackedWorkspace(requestBody: .init(workspace: workspaceURL.path)) + ) + } + + public func refreshIDEContext( + fileURL: URL, + content: String, + cursorPosition: CursorPosition, + tabSize: Int, + indentSize: Int, + usesTabsForIndentation: Bool, + workspaceURL: URL + ) async throws { + let languageId = languageIdentifierFromFileURL(fileURL) + let request = await CodeiumRequest.RefreshContextForIdeAction(requestBody: .init( + active_document: .init( + absolute_path_migrate_me_to_uri: fileURL.path, + text: content, + editor_language: languageId.rawValue, + language: .init(codeLanguage: languageId), + cursor_position: .init( + row: cursorPosition.line, + col: cursorPosition.character + ) + ), + open_document_filepaths: openedDocumentPool.getOtherDocuments(exceptURL: fileURL) + .map(\.url.path), + workspace_paths: [workspaceURL.path] + )) + _ = try await (setupServerIfNeeded()).sendRequest(request) + } + public func terminate() { server?.terminate() server = nil @@ -359,7 +452,7 @@ func getXcodeVersion() async throws -> String { if let data = try outpipe.fileHandleForReading.readToEnd(), let content = String(data: data, encoding: .utf8) { - let firstLine = content.split(separator: "\n").first ?? "" + let firstLine = content.split(whereSeparator: \.isNewline).first ?? "" var version = firstLine.replacingOccurrences(of: "Xcode ", with: "") if version.isEmpty { version = "14.0" diff --git a/Tool/Sources/CodeiumService/Services/CodeiumSuggestionService.swift b/Tool/Sources/CodeiumService/Services/CodeiumSuggestionService.swift new file mode 100644 index 00000000..33c6db0e --- /dev/null +++ b/Tool/Sources/CodeiumService/Services/CodeiumSuggestionService.swift @@ -0,0 +1,105 @@ +import CopilotForXcodeKit +import Foundation +import SuggestionBasic +import Workspace + +public final class CodeiumSuggestionService: SuggestionServiceType { + public var configuration: SuggestionServiceConfiguration { + .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: false + ) + } + + let serviceLocator: ServiceLocator + + init(serviceLocator: ServiceLocator) { + self.serviceLocator = serviceLocator + } + + public func getSuggestions( + _ request: SuggestionRequest, + workspace: WorkspaceInfo + ) async throws -> [CopilotForXcodeKit.CodeSuggestion] { + guard let service = await serviceLocator.getService(from: workspace) else { return [] } + return try await service.getCompletions( + fileURL: request.fileURL, + content: request.content, + cursorPosition: .init( + line: request.cursorPosition.line, + character: request.cursorPosition.character + ), + tabSize: request.tabSize, + indentSize: request.indentSize, + usesTabsForIndentation: request.usesTabsForIndentation + ).map(Self.convert) + } + + public func notifyAccepted( + _ suggestion: CopilotForXcodeKit.CodeSuggestion, + workspace: WorkspaceInfo + ) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.notifyAccepted(Self.convert(suggestion)) + } + + public func notifyRejected( + _ suggestions: [CopilotForXcodeKit.CodeSuggestion], + workspace: WorkspaceInfo + ) async { + // unimplemented + } + + public func cancelRequest(workspace: WorkspaceInfo) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.cancelRequest() + } + + static func convert( + _ suggestion: SuggestionBasic.CodeSuggestion + ) -> CopilotForXcodeKit.CodeSuggestion { + .init( + id: suggestion.id, + text: suggestion.text, + position: .init( + line: suggestion.position.line, + character: suggestion.position.character + ), + range: .init( + start: .init( + line: suggestion.range.start.line, + character: suggestion.range.start.character + ), + end: .init( + line: suggestion.range.end.line, + character: suggestion.range.end.character + ) + ) + ) + } + + static func convert( + _ suggestion: CopilotForXcodeKit.CodeSuggestion + ) -> SuggestionBasic.CodeSuggestion { + .init( + id: suggestion.id, + text: suggestion.text, + position: .init( + line: suggestion.position.line, + character: suggestion.position.character + ), + range: .init( + start: .init( + line: suggestion.range.start.line, + character: suggestion.range.start.character + ), + end: .init( + line: suggestion.range.end.line, + character: suggestion.range.end.character + ) + ) + ) + } +} + diff --git a/Tool/Sources/CommandHandler/CommandHandler.swift b/Tool/Sources/CommandHandler/CommandHandler.swift new file mode 100644 index 00000000..f5067668 --- /dev/null +++ b/Tool/Sources/CommandHandler/CommandHandler.swift @@ -0,0 +1,200 @@ +import ComposableArchitecture +import Dependencies +import Foundation +import ModificationBasic +import Preferences +import SuggestionBasic +import Toast +import XcodeInspector + +/// Provides an interface to handle commands. +public protocol CommandHandler { + // MARK: Suggestion + + func presentSuggestions(_ suggestions: [CodeSuggestion]) async + func presentPreviousSuggestion() async + func presentNextSuggestion() async + func rejectSuggestions() async + func acceptSuggestion() async + func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async + func dismissSuggestion() async + func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async + + // MARK: Chat + + func openChat(forceDetach: Bool, activateThisApp: Bool) + func sendChatMessage(_ message: String) async + + // MARK: Modification + + func acceptModification() async + func presentModification(state: Shared) async + + // MARK: Custom Command + + func handleCustomCommand(_ command: CustomCommand) async + + // MARK: Toast + + func toast(_ string: String, as type: ToastType) + + // MARK: Others + + func presentFile(at fileURL: URL, line: Int?) async + + func presentFile(at fileURL: URL) async +} + +public extension CommandHandler { + /// Default implementation for `presentFile(at:line:)`. + func presentFile(at fileURL: URL) async { + await presentFile(at: fileURL, line: nil) + } +} + +public struct CommandHandlerDependencyKey: DependencyKey { + public static var liveValue: CommandHandler = UniversalCommandHandler.shared + public static var testValue: CommandHandler = NOOPCommandHandler() +} + +public extension DependencyValues { + /// In production, you need to override the command handler globally by setting + /// ``UniversalCommandHandler.shared.commandHandler``. + /// + /// In tests, you can use ``withDependency`` to mock it. + var commandHandler: CommandHandler { + get { self[CommandHandlerDependencyKey.self] } + set { self[CommandHandlerDependencyKey.self] = newValue } + } +} + +public final class UniversalCommandHandler: CommandHandler { + public static let shared: UniversalCommandHandler = .init() + + public var commandHandler: CommandHandler = NOOPCommandHandler() + + private init() {} + + public func presentSuggestions(_ suggestions: [SuggestionBasic.CodeSuggestion]) async { + await commandHandler.presentSuggestions(suggestions) + } + + public func presentPreviousSuggestion() async { + await commandHandler.presentPreviousSuggestion() + } + + public func presentNextSuggestion() async { + await commandHandler.presentNextSuggestion() + } + + public func rejectSuggestions() async { + await commandHandler.rejectSuggestions() + } + + public func acceptSuggestion() async { + await commandHandler.acceptSuggestion() + } + + public func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async { + await commandHandler.acceptActiveSuggestionLineInGroup(atIndex: index) + } + + public func dismissSuggestion() async { + await commandHandler.dismissSuggestion() + } + + public func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async { + await commandHandler.generateRealtimeSuggestions(sourceEditor: sourceEditor) + } + + public func openChat(forceDetach: Bool, activateThisApp: Bool) { + commandHandler.openChat(forceDetach: forceDetach, activateThisApp: activateThisApp) + } + + public func sendChatMessage(_ message: String) async { + await commandHandler.sendChatMessage(message) + } + + public func acceptModification() async { + await commandHandler.acceptModification() + } + + public func presentModification(state: Shared) async { + await commandHandler.presentModification(state: state) + } + + public func handleCustomCommand(_ command: CustomCommand) async { + await commandHandler.handleCustomCommand(command) + } + + public func toast(_ string: String, as type: ToastType) { + commandHandler.toast(string, as: type) + } + + public func presentFile(at fileURL: URL, line: Int?) async { + await commandHandler.presentFile(at: fileURL, line: line) + } +} + +struct NOOPCommandHandler: CommandHandler { + func presentSuggestions(_ suggestions: [CodeSuggestion]) async { + print("present \(suggestions.count) suggestions") + } + + func presentPreviousSuggestion() async { + print("previous suggestion") + } + + func presentNextSuggestion() async { + print("next suggestion") + } + + func rejectSuggestions() async { + print("reject suggestions") + } + + func acceptSuggestion() async { + print("accept suggestion") + } + + func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async { + print("accept active suggestion line in group at index \(String(describing: index))") + } + + func dismissSuggestion() async { + print("dismiss suggestion") + } + + func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async { + print("generate realtime suggestions") + } + + func openChat(forceDetach: Bool, activateThisApp: Bool) { + print("open chat") + } + + func sendChatMessage(_: String) async { + print("send chat message") + } + + func acceptModification() async { + print("accept prompt to code") + } + + func presentModification(state: Shared) { + print("present modification") + } + + func handleCustomCommand(_: CustomCommand) async { + print("handle custom command") + } + + func toast(_: String, as: ToastType) { + print("toast") + } + + func presentFile(at fileURL: URL, line: Int?) async { + print("present file") + } +} + diff --git a/Tool/Sources/Configs/Configurations.swift b/Tool/Sources/Configs/Configurations.swift new file mode 100644 index 00000000..1f49ba81 --- /dev/null +++ b/Tool/Sources/Configs/Configurations.swift @@ -0,0 +1,22 @@ +import Foundation + +private var teamIDPrefix: String { + Bundle.main.infoDictionary?["TEAM_ID_PREFIX"] as? String ?? "" +} + +private var bundleIdentifierBase: String { + Bundle.main.infoDictionary?["BUNDLE_IDENTIFIER_BASE"] as? String ?? "" +} + +public var userDefaultSuiteName: String { + "\(teamIDPrefix)group.\(bundleIdentifierBase)" +} + +public var keychainAccessGroup: String { + return "\(teamIDPrefix)\(bundleIdentifierBase).Shared" +} + +public var keychainService: String { + return bundleIdentifierBase +} + diff --git a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift new file mode 100644 index 00000000..bb97d82f --- /dev/null +++ b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift @@ -0,0 +1,94 @@ +import Foundation + +private actor TimedDebounceFunction { + let duration: TimeInterval + let block: (Element) async -> Void + + var task: Task? + var lastValue: Element? + var lastFireTime: Date = .init(timeIntervalSince1970: 0) + + init(duration: TimeInterval, block: @escaping (Element) async -> Void) { + self.duration = duration + self.block = block + } + + func callAsFunction(_ value: Element) async { + task?.cancel() + if lastFireTime.timeIntervalSinceNow < -duration { + await fire(value) + task = nil + } else { + lastValue = value + task = Task.detached { [weak self, duration] in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + await self?.fire(value) + } + } + } + + func finish() async { + task?.cancel() + if let lastValue { + await fire(lastValue) + } + } + + private func fire(_ value: Element) async { + lastFireTime = Date() + lastValue = nil + await block(value) + } +} + +public extension AsyncSequence { + /// Debounce, but only if the value is received within a certain time frame. + /// + /// In the future when we drop macOS 12 support we should just use chunked from AsyncAlgorithms. + func timedDebounce( + for duration: TimeInterval, + reducer: @escaping @Sendable (Element, Element) -> Element + ) -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + Task { + let storage = TimedDebounceStorage() + var lastTimeStamp = Date() + do { + for try await value in self { + await storage.reduce(value, reducer: reducer) + let now = Date() + if now.timeIntervalSince(lastTimeStamp) >= duration { + lastTimeStamp = now + if let value = await storage.consume() { + continuation.yield(value) + } + } + } + if let value = await storage.consume() { + continuation.yield(value) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } +} + +private actor TimedDebounceStorage { + var value: Element? + func reduce(_ value: Element, reducer: (Element, Element) -> Element) async { + if let existing = self.value { + self.value = reducer(existing, value) + } else { + self.value = value + } + } + + func consume() -> Element? { + defer { value = nil } + return value + } +} + diff --git a/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift new file mode 100644 index 00000000..891c8301 --- /dev/null +++ b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift @@ -0,0 +1,53 @@ +import AppKit +import Foundation +import SuggestionBasic +import XcodeInspector + +public struct CustomCommandTemplateProcessor { + public init() {} + + public func process(_ text: String) async -> String { + let info = await getEditorInformation() + let editorContent = info.editorContent + let updatedText = text + .replacingOccurrences(of: "{{selected_code}}", with: """ + \(editorContent?.selectedContent.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") + """) + .replacingOccurrences( + of: "{{active_editor_language}}", + with: info.language.rawValue + ) + .replacingOccurrences( + of: "{{active_editor_file_url}}", + with: info.documentURL?.path ?? "" + ) + .replacingOccurrences( + of: "{{active_editor_file_name}}", + with: info.documentURL?.lastPathComponent ?? "" + ) + .replacingOccurrences( + of: "{{clipboard}}", + with: NSPasteboard.general.string(forType: .string) ?? "" + ) + return updatedText + } + + struct EditorInformation { + let editorContent: SourceEditor.Content? + let language: CodeLanguage + let documentURL: URL? + } + + func getEditorInformation() async -> EditorInformation { + let editorContent = await XcodeInspector.shared.latestFocusedEditor?.getContent() + let documentURL = await XcodeInspector.shared.activeDocumentURL + let language = documentURL.map(languageIdentifierFromFileURL) ?? .plaintext + + return .init( + editorContent: editorContent, + language: language, + documentURL: documentURL + ) + } +} + diff --git a/Tool/Sources/DebounceFunction/DebounceFunction.swift b/Tool/Sources/DebounceFunction/DebounceFunction.swift new file mode 100644 index 00000000..66a5fdd1 --- /dev/null +++ b/Tool/Sources/DebounceFunction/DebounceFunction.swift @@ -0,0 +1,48 @@ +import Foundation + +public actor DebounceFunction { + let duration: TimeInterval + let block: (T) async -> Void + + var task: Task? + + public init(duration: TimeInterval, block: @escaping (T) async -> Void) { + self.duration = duration + self.block = block + } + + public func cancel() { + task?.cancel() + } + + public func callAsFunction(_ t: T) async { + task?.cancel() + task = Task { [block, duration] in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + await block(t) + } + } +} + +public actor DebounceRunner { + let duration: TimeInterval + + var task: Task? + + public init(duration: TimeInterval) { + self.duration = duration + } + + public func cancel() { + task?.cancel() + } + + public func debounce(_ block: @escaping () async -> Void) { + task?.cancel() + task = Task { [duration] in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + await block() + } + } +} + diff --git a/Tool/Sources/DebounceFunction/ThrottleFunction.swift b/Tool/Sources/DebounceFunction/ThrottleFunction.swift new file mode 100644 index 00000000..d0532397 --- /dev/null +++ b/Tool/Sources/DebounceFunction/ThrottleFunction.swift @@ -0,0 +1,79 @@ +import Foundation + +public actor ThrottleFunction { + let duration: TimeInterval + let block: (T) async -> Void + + var task: Task? + var lastFinishTime: Date = .init(timeIntervalSince1970: 0) + var now: () -> Date = { Date() } + + public init(duration: TimeInterval, block: @escaping @Sendable (T) async -> Void) { + self.duration = duration + self.block = block + } + + public func callAsFunction(_ t: T) async { + if task == nil { + scheduleTask(t, wait: now().timeIntervalSince(lastFinishTime) < duration) + } + } + + func scheduleTask(_ t: T, wait: Bool) { + task = Task.detached { [weak self] in + guard let self else { return } + do { + if wait { + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + } + await block(t) + await finishTask() + } catch { + await finishTask() + } + } + } + + func finishTask() { + task = nil + lastFinishTime = now() + } +} + +public actor ThrottleRunner { + let duration: TimeInterval + var lastFinishTime: Date = .init(timeIntervalSince1970: 0) + var now: () -> Date = { Date() } + var task: Task? + + public init(duration: TimeInterval) { + self.duration = duration + } + + public func throttle(block: @escaping @Sendable () async -> Void) { + if task == nil { + scheduleTask(wait: now().timeIntervalSince(lastFinishTime) < duration, block: block) + } + } + + func scheduleTask(wait: Bool, block: @escaping @Sendable () async -> Void) { + task = Task.detached { [weak self] in + guard let self else { return } + do { + if wait { + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + } + await block() + await finishTask() + } catch { + await finishTask() + } + } + } + + func finishTask() { + task = nil + lastFinishTime = now() + } +} + diff --git a/Tool/Sources/FileSystem/ByteString.swift b/Tool/Sources/FileSystem/ByteString.swift new file mode 100644 index 00000000..6f974113 --- /dev/null +++ b/Tool/Sources/FileSystem/ByteString.swift @@ -0,0 +1,160 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation + +/// A `ByteString` represents a sequence of bytes. +/// +/// This struct provides useful operations for working with buffers of +/// bytes. Conceptually it is just a contiguous array of bytes (UInt8), but it +/// contains methods and default behavior suitable for common operations done +/// using bytes strings. +/// +/// This struct *is not* intended to be used for significant mutation of byte +/// strings, we wish to retain the flexibility to micro-optimize the memory +/// allocation of the storage (for example, by inlining the storage for small +/// strings or and by eliminating wasted space in growable arrays). For +/// construction of byte arrays, clients should use the `WritableByteStream` class +/// and then convert to a `ByteString` when complete. +public struct ByteString: ExpressibleByArrayLiteral, Hashable, Sendable { + /// The buffer contents. + @usableFromInline + internal var _bytes: [UInt8] + + /// Create an empty byte string. + @inlinable + public init() { + _bytes = [] + } + + /// Create a byte string from a byte array literal. + @inlinable + public init(arrayLiteral contents: UInt8...) { + _bytes = contents + } + + /// Create a byte string from an array of bytes. + @inlinable + public init(_ contents: [UInt8]) { + _bytes = contents + } + + /// Create a byte string from an array slice. + @inlinable + public init(_ contents: ArraySlice) { + _bytes = Array(contents) + } + + /// Create a byte string from an byte buffer. + @inlinable + public init (_ contents: S) where S.Iterator.Element == UInt8 { + _bytes = [UInt8](contents) + } + + /// Create a byte string from the UTF8 encoding of a string. + @inlinable + public init(encodingAsUTF8 string: String) { + _bytes = [UInt8](string.utf8) + } + + /// Access the byte string contents as an array. + @inlinable + public var contents: [UInt8] { + return _bytes + } + + /// Return the byte string size. + @inlinable + public var count: Int { + return _bytes.count + } + + /// Gives a non-escaping closure temporary access to an immutable `Data` instance wrapping the `ByteString` without + /// copying any memory around. + /// + /// - Parameters: + /// - closure: The closure that will have access to a `Data` instance for the duration of its lifetime. + @inlinable + public func withData(_ closure: (Data) throws -> T) rethrows -> T { + return try _bytes.withUnsafeBytes { pointer -> T in + let mutatingPointer = UnsafeMutableRawPointer(mutating: pointer.baseAddress!) + let data = Data(bytesNoCopy: mutatingPointer, count: pointer.count, deallocator: .none) + return try closure(data) + } + } + + /// Returns a `String` lowercase hexadecimal representation of the contents of the `ByteString`. + @inlinable + public var hexadecimalRepresentation: String { + _bytes.reduce("") { + var str = String($1, radix: 16) + // The above method does not do zero padding. + if str.count == 1 { + str = "0" + str + } + return $0 + str + } + } +} + +/// Conform to CustomDebugStringConvertible. +extension ByteString: CustomStringConvertible { + /// Return the string decoded as a UTF8 sequence, or traps if not possible. + public var description: String { + return cString + } + + /// Return the string decoded as a UTF8 sequence, if possible. + @inlinable + public var validDescription: String? { + // FIXME: This is very inefficient, we need a way to pass a buffer. It + // is also wrong if the string contains embedded '\0' characters. + let tmp = _bytes + [UInt8(0)] + return tmp.withUnsafeBufferPointer { ptr in + return String(validatingUTF8: unsafeBitCast(ptr.baseAddress, to: UnsafePointer.self)) + } + } + + /// Return the string decoded as a UTF8 sequence, substituting replacement + /// characters for ill-formed UTF8 sequences. + @inlinable + public var cString: String { + return String(decoding: _bytes, as: Unicode.UTF8.self) + } + + @available(*, deprecated, message: "use description or validDescription instead") + public var asString: String? { + return validDescription + } +} + +/// ByteStreamable conformance for a ByteString. +extension ByteString: ByteStreamable { + @inlinable + public func write(to stream: WritableByteStream) { + stream.write(_bytes) + } +} + +/// StringLiteralConvertible conformance for a ByteString. +extension ByteString: ExpressibleByStringLiteral { + public typealias UnicodeScalarLiteralType = StringLiteralType + public typealias ExtendedGraphemeClusterLiteralType = StringLiteralType + + public init(unicodeScalarLiteral value: UnicodeScalarLiteralType) { + _bytes = [UInt8](value.utf8) + } + public init(extendedGraphemeClusterLiteral value: ExtendedGraphemeClusterLiteralType) { + _bytes = [UInt8](value.utf8) + } + public init(stringLiteral value: StringLiteralType) { + _bytes = [UInt8](value.utf8) + } +} diff --git a/Tool/Sources/FileSystem/FileInfo.swift b/Tool/Sources/FileSystem/FileInfo.swift new file mode 100644 index 00000000..54a2e54c --- /dev/null +++ b/Tool/Sources/FileSystem/FileInfo.swift @@ -0,0 +1,66 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors + */ + +import Foundation + +#if swift(<5.6) +extension FileAttributeType: UnsafeSendable {} +extension Date: UnsafeSendable {} +#endif + +/// File system information for a particular file. +public struct FileInfo: Equatable, Codable, Sendable { + + /// The device number. + public let device: UInt64 + + /// The inode number. + public let inode: UInt64 + + /// The size of the file. + public let size: UInt64 + + /// The modification time of the file. + public let modTime: Date + + /// Kind of file system entity. + public let posixPermissions: Int16 + + /// Kind of file system entity. + public let fileType: FileAttributeType + + public init(_ attrs: [FileAttributeKey : Any]) { + let device = (attrs[.systemNumber] as? NSNumber)?.uint64Value + assert(device != nil) + self.device = device! + + let inode = attrs[.systemFileNumber] as? UInt64 + assert(inode != nil) + self.inode = inode! + + let posixPermissions = (attrs[.posixPermissions] as? NSNumber)?.int16Value + assert(posixPermissions != nil) + self.posixPermissions = posixPermissions! + + let fileType = attrs[.type] as? FileAttributeType + assert(fileType != nil) + self.fileType = fileType! + + let size = attrs[.size] as? UInt64 + assert(size != nil) + self.size = size! + + let modTime = attrs[.modificationDate] as? Date + assert(modTime != nil) + self.modTime = modTime! + } +} + +extension FileAttributeType: Codable {} diff --git a/Tool/Sources/FileSystem/FileSystem.swift b/Tool/Sources/FileSystem/FileSystem.swift new file mode 100644 index 00000000..39f0bed6 --- /dev/null +++ b/Tool/Sources/FileSystem/FileSystem.swift @@ -0,0 +1,1303 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors + */ + +import Dispatch +import Foundation + +#if canImport(Glibc) +@_exported import Glibc +#elseif canImport(Musl) +@_exported import Musl +#elseif os(Windows) +@_exported import CRT +@_exported import WinSDK +#else +@_exported import Darwin.C +#endif + +public struct FileSystemError: Error, Equatable, Sendable { + public enum Kind: Equatable, Sendable { + /// Access to the path is denied. + /// + /// This is used when an operation cannot be completed because a component of + /// the path cannot be accessed. + /// + /// Used in situations that correspond to the POSIX EACCES error code. + case invalidAccess + + /// IO Error encoding + /// + /// This is used when an operation cannot be completed due to an otherwise + /// unspecified IO error. + case ioError(code: Int32) + + /// Is a directory + /// + /// This is used when an operation cannot be completed because a component + /// of the path which was expected to be a file was not. + /// + /// Used in situations that correspond to the POSIX EISDIR error code. + case isDirectory + + /// No such path exists. + /// + /// This is used when a path specified does not exist, but it was expected + /// to. + /// + /// Used in situations that correspond to the POSIX ENOENT error code. + case noEntry + + /// Not a directory + /// + /// This is used when an operation cannot be completed because a component + /// of the path which was expected to be a directory was not. + /// + /// Used in situations that correspond to the POSIX ENOTDIR error code. + case notDirectory + + /// Unsupported operation + /// + /// This is used when an operation is not supported by the concrete file + /// system implementation. + case unsupported + + /// An unspecific operating system error at a given path. + case unknownOSError + + /// File or folder already exists at destination. + /// + /// This is thrown when copying or moving a file or directory but the destination + /// path already contains a file or folder. + case alreadyExistsAtDestination + + /// If an unspecified error occurs when trying to change directories. + case couldNotChangeDirectory + + /// If a mismatch is detected in byte count when writing to a file. + case mismatchedByteCount(expected: Int, actual: Int) + } + + /// The kind of the error being raised. + public let kind: Kind + + /// The absolute path to the file associated with the error, if available. + public let path: AbsolutePath? + + public init(_ kind: Kind, _ path: AbsolutePath? = nil) { + self.kind = kind + self.path = path + } +} + +extension FileSystemError: CustomNSError { + public var errorUserInfo: [String: Any] { + return [NSLocalizedDescriptionKey: "\(self)"] + } +} + +public extension FileSystemError { + init(errno: Int32, _ path: AbsolutePath) { + switch errno { + case EACCES: + self.init(.invalidAccess, path) + case EISDIR: + self.init(.isDirectory, path) + case ENOENT: + self.init(.noEntry, path) + case ENOTDIR: + self.init(.notDirectory, path) + case EEXIST: + self.init(.alreadyExistsAtDestination, path) + default: + self.init(.ioError(code: errno), path) + } + } +} + +/// Defines the file modes. +public enum FileMode: Sendable { + public enum Option: Int, Sendable { + case recursive + case onlyFiles + } + + case userUnWritable + case userWritable + case executable + + public func setMode(_ originalMode: Int16) -> Int16 { + switch self { + case .userUnWritable: + // r-x rwx rwx + return originalMode & 0o577 + case .userWritable: + // -w- --- --- + return originalMode | 0o200 + case .executable: + // --x --x --x + return originalMode | 0o111 + } + } +} + +/// Extended file system attributes that can applied to a given file path. See also +/// ``FileSystem/hasAttribute(_:_:)``. +public enum FileSystemAttribute: RawRepresentable { + #if canImport(Darwin) + case quarantine + #endif + + public init?(rawValue: String) { + switch rawValue { + #if canImport(Darwin) + case "com.apple.quarantine": + self = .quarantine + #endif + default: + return nil + } + } + + public var rawValue: String { + switch self { + #if canImport(Darwin) + case .quarantine: + return "com.apple.quarantine" + #endif + } + } +} + +// FIXME: Design an asynchronous story? +// +/// Abstracted access to file system operations. +/// +/// This protocol is used to allow most of the codebase to interact with a +/// natural filesystem interface, while still allowing clients to transparently +/// substitute a virtual file system or redirect file system operations. +/// +/// - Note: All of these APIs are synchronous and can block. +public protocol FileSystem: Sendable { + /// Check whether the given path exists and is accessible. + @_disfavoredOverload + func exists(_ path: AbsolutePath, followSymlink: Bool) -> Bool + + /// Check whether the given path is accessible and a directory. + func isDirectory(_ path: AbsolutePath) -> Bool + + /// Check whether the given path is accessible and a file. + func isFile(_ path: AbsolutePath) -> Bool + + /// Check whether the given path is an accessible and executable file. + func isExecutableFile(_ path: AbsolutePath) -> Bool + + /// Check whether the given path is accessible and is a symbolic link. + func isSymlink(_ path: AbsolutePath) -> Bool + + /// Check whether the given path is accessible and readable. + func isReadable(_ path: AbsolutePath) -> Bool + + /// Check whether the given path is accessible and writable. + func isWritable(_ path: AbsolutePath) -> Bool + + /// Returns any known item replacement directories for a given path. These may be used by + /// platform-specific + /// libraries to handle atomic file system operations, such as deletion. + func itemReplacementDirectories(for path: AbsolutePath) throws -> [AbsolutePath] + + @available(*, deprecated, message: "use `hasAttribute(_:_:)` instead") + func hasQuarantineAttribute(_ path: AbsolutePath) -> Bool + + /// Returns `true` if a given path has an attribute with a given name applied when file system + /// supports this + /// attribute. Returns `false` if such attribute is not applied or it isn't supported. + func hasAttribute(_ name: FileSystemAttribute, _ path: AbsolutePath) -> Bool + + // FIXME: Actual file system interfaces will allow more efficient access to + // more data than just the name here. + // + /// Get the contents of the given directory, in an undefined order. + func _getDirectoryContents( + _ path: AbsolutePath, + includingPropertiesForKeys: [URLResourceKey]?, + options: FileManager.DirectoryEnumerationOptions + ) throws -> [AbsolutePath] + + /// Get the current working directory (similar to `getcwd(3)`), which can be + /// different for different (virtualized) implementations of a FileSystem. + /// The current working directory can be empty if e.g. the directory became + /// unavailable while the current process was still working in it. + /// This follows the POSIX `getcwd(3)` semantics. + @_disfavoredOverload + var currentWorkingDirectory: AbsolutePath? { get } + + /// Change the current working directory. + /// - Parameters: + /// - path: The path to the directory to change the current working directory to. + func changeCurrentWorkingDirectory(to path: AbsolutePath) throws + + /// Get the home directory of current user + @_disfavoredOverload + var homeDirectory: AbsolutePath { get throws } + + /// Get the caches directory of current user + @_disfavoredOverload + var cachesDirectory: AbsolutePath? { get } + + /// Get the temp directory + @_disfavoredOverload + var tempDirectory: AbsolutePath { get throws } + + /// Create the given directory. + func createDirectory(_ path: AbsolutePath) throws + + /// Create the given directory. + /// + /// - recursive: If true, create missing parent directories if possible. + func createDirectory(_ path: AbsolutePath, recursive: Bool) throws + + /// Creates a symbolic link of the source path at the target path + /// - Parameters: + /// - path: The path at which to create the link. + /// - destination: The path to which the link points to. + /// - relative: If `relative` is true, the symlink contents will be a relative path, otherwise + /// it will be absolute. + func createSymbolicLink( + _ path: AbsolutePath, + pointingAt destination: AbsolutePath, + relative: Bool + ) throws + + func data(_ path: AbsolutePath) throws -> Data + + // FIXME: This is obviously not a very efficient or flexible API. + // + /// Get the contents of a file. + /// + /// - Returns: The file contents as bytes, or nil if missing. + func readFileContents(_ path: AbsolutePath) throws -> ByteString + + // FIXME: This is obviously not a very efficient or flexible API. + // + /// Write the contents of a file. + func writeFileContents(_ path: AbsolutePath, bytes: ByteString) throws + + // FIXME: This is obviously not a very efficient or flexible API. + // + /// Write the contents of a file. + func writeFileContents(_ path: AbsolutePath, bytes: ByteString, atomically: Bool) throws + + /// Recursively deletes the file system entity at `path`. + /// + /// If there is no file system entity at `path`, this function does nothing (in particular, this + /// is not considered + /// to be an error). + func removeFileTree(_ path: AbsolutePath) throws + + /// Change file mode. + func chmod(_ mode: FileMode, path: AbsolutePath, options: Set) throws + + /// Returns the file info of the given path. + /// + /// The method throws if the underlying stat call fails. + func getFileInfo(_ path: AbsolutePath) throws -> FileInfo + + /// Copy a file or directory. + func copy(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws + + /// Move a file or directory. + func move(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws + + /// Execute the given block while holding the lock. + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () throws -> T + ) throws -> T + + /// Execute the given block while holding the lock. + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () async throws -> T + ) async throws -> T +} + +/// Convenience implementations (default arguments aren't permitted in protocol +/// methods). +public extension FileSystem { + /// exists override with default value. + @_disfavoredOverload + func exists(_ path: AbsolutePath) -> Bool { + return exists(path, followSymlink: true) + } + + /// Default implementation of createDirectory(_:) + func createDirectory(_ path: AbsolutePath) throws { + try createDirectory(path, recursive: false) + } + + // Change file mode. + func chmod(_ mode: FileMode, path: AbsolutePath) throws { + try chmod(mode, path: path, options: []) + } + + // Unless the file system type provides an override for this method, throw + // if `atomically` is `true`, otherwise fall back to whatever implementation already exists. + @_disfavoredOverload + func writeFileContents(_ path: AbsolutePath, bytes: ByteString, atomically: Bool) throws { + guard !atomically else { + throw FileSystemError(.unsupported, path) + } + try writeFileContents(path, bytes: bytes) + } + + /// Write to a file from a stream producer. + @_disfavoredOverload + func writeFileContents(_ path: AbsolutePath, body: (WritableByteStream) -> Void) throws { + let contents = BufferedOutputByteStream() + body(contents) + try createDirectory(path.parentDirectory, recursive: true) + try writeFileContents(path, bytes: contents.bytes) + } + + func getFileInfo(_ path: AbsolutePath) throws -> FileInfo { + throw FileSystemError(.unsupported, path) + } + + func withLock(on path: AbsolutePath, _ body: () throws -> T) throws -> T { + return try withLock(on: path, type: .exclusive, body) + } + + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + _ body: () throws -> T + ) throws -> T { + return try withLock(on: path, type: type, blocking: true, body) + } + + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () throws -> T + ) throws -> T { + throw FileSystemError(.unsupported, path) + } + + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + _ body: () async throws -> T + ) async throws -> T { + return try await withLock(on: path, type: type, blocking: true, body) + } + + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () async throws -> T + ) async throws -> T { + throw FileSystemError(.unsupported, path) + } + + func hasQuarantineAttribute(_: AbsolutePath) -> Bool { false } + + func hasAttribute(_: FileSystemAttribute, _: AbsolutePath) -> Bool { false } + + func itemReplacementDirectories(for path: AbsolutePath) throws -> [AbsolutePath] { [] } +} + +/// Concrete FileSystem implementation which communicates with the local file system. +private struct LocalFileSystem: FileSystem { + func isExecutableFile(_ path: AbsolutePath) -> Bool { + // Our semantics doesn't consider directories. + return (isFile(path) || isSymlink(path)) && FileManager.default + .isExecutableFile(atPath: path.pathString) + } + + func exists(_ path: AbsolutePath, followSymlink: Bool) -> Bool { + if followSymlink { + return FileManager.default.fileExists(atPath: path.pathString) + } + return (try? FileManager.default.attributesOfItem(atPath: path.pathString)) != nil + } + + func isDirectory(_ path: AbsolutePath) -> Bool { + var isDirectory: ObjCBool = false + let exists: Bool = FileManager.default.fileExists( + atPath: path.pathString, + isDirectory: &isDirectory + ) + return exists && isDirectory.boolValue + } + + func isFile(_ path: AbsolutePath) -> Bool { + guard let path = try? resolveSymlinks(path) else { + return false + } + let attrs = try? FileManager.default.attributesOfItem(atPath: path.pathString) + return attrs?[.type] as? FileAttributeType == .typeRegular + } + + func isSymlink(_ path: AbsolutePath) -> Bool { + let url = NSURL(fileURLWithPath: path.pathString) + // We are intentionally using `NSURL.resourceValues(forKeys:)` here since it improves + // performance on Darwin platforms. + let result = try? url.resourceValues(forKeys: [.isSymbolicLinkKey]) + return (result?[.isSymbolicLinkKey] as? Bool) == true + } + + func isReadable(_ path: AbsolutePath) -> Bool { + FileManager.default.isReadableFile(atPath: path.pathString) + } + + func isWritable(_ path: AbsolutePath) -> Bool { + FileManager.default.isWritableFile(atPath: path.pathString) + } + + func getFileInfo(_ path: AbsolutePath) throws -> FileInfo { + let attrs = try FileManager.default.attributesOfItem(atPath: path.pathString) + return FileInfo(attrs) + } + + func hasAttribute(_ name: FileSystemAttribute, _ path: AbsolutePath) -> Bool { + #if canImport(Darwin) + let bufLength = getxattr(path.pathString, name.rawValue, nil, 0, 0, 0) + + return bufLength > 0 + #else + return false + #endif + } + + var currentWorkingDirectory: AbsolutePath? { + let cwdStr = FileManager.default.currentDirectoryPath + + #if _runtime(_ObjC) + // The ObjC runtime indicates that the underlying Foundation has ObjC + // interoperability in which case the return type of + // `fileSystemRepresentation` is different from the Swift implementation + // of Foundation. + return try? AbsolutePath(validating: cwdStr) + #else + let fsr: UnsafePointer = cwdStr.fileSystemRepresentation + defer { fsr.deallocate() } + + return try? AbsolutePath(String(cString: fsr)) + #endif + } + + func changeCurrentWorkingDirectory(to path: AbsolutePath) throws { + guard isDirectory(path) else { + throw FileSystemError(.notDirectory, path) + } + + guard FileManager.default.changeCurrentDirectoryPath(path.pathString) else { + throw FileSystemError(.couldNotChangeDirectory, path) + } + } + + var homeDirectory: AbsolutePath { + get throws { + return try AbsolutePath(validating: NSHomeDirectory()) + } + } + + var cachesDirectory: AbsolutePath? { + return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + .flatMap { try? AbsolutePath(validating: $0.path) } + } + + var tempDirectory: AbsolutePath { + get throws { + return try AbsolutePath(validating: NSTemporaryDirectory()) + } + } + + func _getDirectoryContents( + _ path: AbsolutePath, + includingPropertiesForKeys: [URLResourceKey]?, + options: FileManager.DirectoryEnumerationOptions + ) throws -> [AbsolutePath] { + return try FileManager.default.contentsOfDirectory( + at: URL(fileURLWithPath: path.pathString), + includingPropertiesForKeys: includingPropertiesForKeys, + options: options + ).compactMap { try? AbsolutePath(validating: $0.path) } + } + + func createDirectory(_ path: AbsolutePath, recursive: Bool) throws { + // Don't fail if path is already a directory. + if isDirectory(path) { return } + + try FileManager.default.createDirectory( + atPath: path.pathString, + withIntermediateDirectories: recursive, + attributes: [:] + ) + } + + func createSymbolicLink( + _ path: AbsolutePath, + pointingAt destination: AbsolutePath, + relative: Bool + ) throws { + let destString = relative ? destination.relative(to: path.parentDirectory) + .pathString : destination.pathString + try FileManager.default.createSymbolicLink( + atPath: path.pathString, + withDestinationPath: destString + ) + } + + func data(_ path: AbsolutePath) throws -> Data { + try Data(contentsOf: URL(fileURLWithPath: path.pathString)) + } + + func readFileContents(_ path: AbsolutePath) throws -> ByteString { + // Open the file. + guard let fp = fopen(path.pathString, "rb") else { + throw FileSystemError(errno: errno, path) + } + defer { fclose(fp) } + + // Read the data one block at a time. + let data = BufferedOutputByteStream() + var tmpBuffer = [UInt8](repeating: 0, count: 1 << 12) + while true { + let n = fread(&tmpBuffer, 1, tmpBuffer.count, fp) + if n < 0 { + if errno == EINTR { continue } + throw FileSystemError(.ioError(code: errno), path) + } + if n == 0 { + let errno = ferror(fp) + if errno != 0 { + throw FileSystemError(.ioError(code: errno), path) + } + break + } + data.send(tmpBuffer[0..) throws { + guard exists(path) else { return } + func setMode(path: String) throws { + let attrs = try FileManager.default.attributesOfItem(atPath: path) + // Skip if only files should be changed. + if options.contains(.onlyFiles) && attrs[.type] as? FileAttributeType != .typeRegular { + return + } + + // Compute the new mode for this file. + let currentMode = attrs[.posixPermissions] as! Int16 + let newMode = mode.setMode(currentMode) + guard newMode != currentMode else { return } + try FileManager.default.setAttributes( + [.posixPermissions: newMode], + ofItemAtPath: path + ) + } + + try setMode(path: path.pathString) + guard isDirectory(path) else { return } + + guard let traverse = FileManager.default.enumerator( + at: URL(fileURLWithPath: path.pathString), + includingPropertiesForKeys: nil + ) else { + throw FileSystemError(.noEntry, path) + } + + if !options.contains(.recursive) { + traverse.skipDescendants() + } + + while let path = traverse.nextObject() { + try setMode(path: (path as! URL).path) + } + } + + func copy(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws { + guard exists(sourcePath) else { throw FileSystemError(.noEntry, sourcePath) } + guard !exists(destinationPath) + else { throw FileSystemError(.alreadyExistsAtDestination, destinationPath) } + try FileManager.default.copyItem(at: sourcePath.asURL, to: destinationPath.asURL) + } + + func move(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws { + guard exists(sourcePath) else { throw FileSystemError(.noEntry, sourcePath) } + guard !exists(destinationPath) + else { throw FileSystemError(.alreadyExistsAtDestination, destinationPath) } + try FileManager.default.moveItem(at: sourcePath.asURL, to: destinationPath.asURL) + } + + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () throws -> T + ) throws -> T { + try FileLock.withLock(fileToLock: path, type: type, blocking: blocking, body: body) + } + + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () async throws -> T + ) async throws -> T { + try await FileLock.withLock(fileToLock: path, type: type, blocking: blocking, body: body) + } + + func itemReplacementDirectories(for path: AbsolutePath) throws -> [AbsolutePath] { + let result = try FileManager.default.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: path.asURL, + create: false + ) + let path = try AbsolutePath(validating: result.path) + // Foundation returns a path that is unique every time, so we return both that path, as well + // as its parent. + return [path, path.parentDirectory] + } +} + +/// Concrete FileSystem implementation which simulates an empty disk. +public final class InMemoryFileSystem: FileSystem { + /// Private internal representation of a file system node. + /// Not thread-safe. + private class Node { + /// The actual node data. + let contents: NodeContents + + init(_ contents: NodeContents) { + self.contents = contents + } + + /// Creates deep copy of the object. + func copy() -> Node { + return Node(contents.copy()) + } + } + + /// Private internal representation the contents of a file system node. + /// Not thread-safe. + private enum NodeContents { + case file(ByteString) + case directory(DirectoryContents) + case symlink(String) + + /// Creates deep copy of the object. + func copy() -> NodeContents { + switch self { + case let .file(bytes): + return .file(bytes) + case let .directory(contents): + return .directory(contents.copy()) + case let .symlink(path): + return .symlink(path) + } + } + } + + /// Private internal representation the contents of a directory. + /// Not thread-safe. + private final class DirectoryContents { + var entries: [String: Node] + + init(entries: [String: Node] = [:]) { + self.entries = entries + } + + /// Creates deep copy of the object. + func copy() -> DirectoryContents { + let contents = DirectoryContents() + for (key, node) in entries { + contents.entries[key] = node.copy() + } + return contents + } + } + + /// The root node of the filesystem. + private var root: Node + + /// Protects `root` and everything underneath it. + /// FIXME: Using a single lock for this is a performance problem, but in + /// reality, the only practical use for InMemoryFileSystem is for unit + /// tests. + private let lock = NSLock() + /// A map that keeps weak references to all locked files. + private var lockFiles = [AbsolutePath: WeakReference]() + /// Used to access lockFiles in a thread safe manner. + private let lockFilesLock = NSLock() + + /// Exclusive file system lock vended to clients through `withLock()`. + /// Used to ensure that DispatchQueues are released when they are no longer in use. + private struct WeakReference { + weak var reference: Value? + + init(_ value: Value?) { + reference = value + } + } + + public init() { + root = Node(.directory(DirectoryContents())) + } + + /// Creates deep copy of the object. + public func copy() -> InMemoryFileSystem { + return lock.withLock { + let fs = InMemoryFileSystem() + fs.root = root.copy() + return fs + } + } + + /// Private function to look up the node corresponding to a path. + /// Not thread-safe. + private func getNode(_ path: AbsolutePath, followSymlink: Bool = true) throws -> Node? { + func getNodeInternal(_ path: AbsolutePath) throws -> Node? { + // If this is the root node, return it. + if path.isRoot { + return root + } + + // Otherwise, get the parent node. + guard let parent = try getNodeInternal(path.parentDirectory) else { + return nil + } + + // If we didn't find a directory, this is an error. + guard case let .directory(contents) = parent.contents else { + throw FileSystemError(.notDirectory, path.parentDirectory) + } + + // Return the directory entry. + let node = contents.entries[path.basename] + + switch node?.contents { + case .directory, .file: + return node + case let .symlink(destination): + let destination = try AbsolutePath( + validating: destination, + relativeTo: path.parentDirectory + ) + return followSymlink ? try getNodeInternal(destination) : node + case .none: + return nil + } + } + + // Get the node that corresponds to the path. + return try getNodeInternal(path) + } + + // MARK: FileSystem Implementation + + public func exists(_ path: AbsolutePath, followSymlink: Bool) -> Bool { + return lock.withLock { + do { + switch try getNode(path, followSymlink: followSymlink)?.contents { + case .file, .directory, .symlink: return true + case .none: return false + } + } catch { + return false + } + } + } + + public func isDirectory(_ path: AbsolutePath) -> Bool { + return lock.withLock { + do { + if case .directory? = try getNode(path)?.contents { + return true + } + return false + } catch { + return false + } + } + } + + public func isFile(_ path: AbsolutePath) -> Bool { + return lock.withLock { + do { + if case .file? = try getNode(path)?.contents { + return true + } + return false + } catch { + return false + } + } + } + + public func isSymlink(_ path: AbsolutePath) -> Bool { + return lock.withLock { + do { + if case .symlink? = try getNode(path, followSymlink: false)?.contents { + return true + } + return false + } catch { + return false + } + } + } + + public func isReadable(_ path: AbsolutePath) -> Bool { + exists(path) + } + + public func isWritable(_ path: AbsolutePath) -> Bool { + exists(path) + } + + public func isExecutableFile(_: AbsolutePath) -> Bool { + // FIXME: Always return false until in-memory implementation + // gets permission semantics. + return false + } + + /// Virtualized current working directory. + public var currentWorkingDirectory: AbsolutePath? { + return try? AbsolutePath(validating: "/") + } + + public func changeCurrentWorkingDirectory(to path: AbsolutePath) throws { + throw FileSystemError(.unsupported, path) + } + + public var homeDirectory: AbsolutePath { + get throws { + // FIXME: Maybe we should allow setting this when creating the fs. + return try AbsolutePath(validating: "/home/user") + } + } + + public var cachesDirectory: AbsolutePath? { + return try? homeDirectory.appending(component: "caches") + } + + public var tempDirectory: AbsolutePath { + get throws { + return try AbsolutePath(validating: "/tmp") + } + } + + public func _getDirectoryContents( + _ path: AbsolutePath, + includingPropertiesForKeys: [URLResourceKey]?, + options: FileManager.DirectoryEnumerationOptions + ) throws -> [AbsolutePath] { + return try lock.withLock { + guard let node = try getNode(path) else { + throw FileSystemError(.noEntry, path) + } + guard case let .directory(contents) = node.contents else { + throw FileSystemError(.notDirectory, path) + } + + // FIXME: Perhaps we should change the protocol to allow lazy behavior. + return [String](contents.entries.keys).map { + path.appending(component: $0) + } + } + } + + /// Not thread-safe. + private func _createDirectory(_ path: AbsolutePath, recursive: Bool) throws { + // Ignore if client passes root. + guard !path.isRoot else { + return + } + // Get the parent directory node. + let parentPath = path.parentDirectory + guard let parent = try getNode(parentPath) else { + // If the parent doesn't exist, and we are recursive, then attempt + // to create the parent and retry. + if recursive && path != parentPath { + // Attempt to create the parent. + try _createDirectory(parentPath, recursive: true) + + // Re-attempt creation, non-recursively. + return try _createDirectory(path, recursive: false) + } else { + // Otherwise, we failed. + throw FileSystemError(.noEntry, parentPath) + } + } + + // Check that the parent is a directory. + guard case let .directory(contents) = parent.contents else { + // The parent isn't a directory, this is an error. + throw FileSystemError(.notDirectory, parentPath) + } + + // Check if the node already exists. + if let node = contents.entries[path.basename] { + // Verify it is a directory. + guard case .directory = node.contents else { + // The path itself isn't a directory, this is an error. + throw FileSystemError(.notDirectory, path) + } + + // We are done. + return + } + + // Otherwise, the node does not exist, create it. + contents.entries[path.basename] = Node(.directory(DirectoryContents())) + } + + public func createDirectory(_ path: AbsolutePath, recursive: Bool) throws { + return try lock.withLock { + try _createDirectory(path, recursive: recursive) + } + } + + public func createSymbolicLink( + _ path: AbsolutePath, + pointingAt destination: AbsolutePath, + relative: Bool + ) throws { + return try lock.withLock { + // Create directory to destination parent. + guard let destinationParent = try getNode(path.parentDirectory) else { + throw FileSystemError(.noEntry, path.parentDirectory) + } + + // Check that the parent is a directory. + guard case let .directory(contents) = destinationParent.contents else { + throw FileSystemError(.notDirectory, path.parentDirectory) + } + + guard contents.entries[path.basename] == nil else { + throw FileSystemError(.alreadyExistsAtDestination, path) + } + + let destination = relative ? destination.relative(to: path.parentDirectory) + .pathString : destination.pathString + + contents.entries[path.basename] = Node(.symlink(destination)) + } + } + + public func data(_ path: AbsolutePath) throws -> Data { + return try lock.withLock { + // Get the node. + guard let node = try getNode(path) else { + throw FileSystemError(.noEntry, path) + } + + // Check that the node is a file. + guard case let .file(contents) = node.contents else { + // The path is a directory, this is an error. + throw FileSystemError(.isDirectory, path) + } + + // Return the file contents. + return contents.withData { $0 } + } + } + + public func readFileContents(_ path: AbsolutePath) throws -> ByteString { + return try lock.withLock { + // Get the node. + guard let node = try getNode(path) else { + throw FileSystemError(.noEntry, path) + } + + // Check that the node is a file. + guard case let .file(contents) = node.contents else { + // The path is a directory, this is an error. + throw FileSystemError(.isDirectory, path) + } + + // Return the file contents. + return contents + } + } + + public func writeFileContents(_ path: AbsolutePath, bytes: ByteString) throws { + return try lock.withLock { + // It is an error if this is the root node. + let parentPath = path.parentDirectory + guard path != parentPath else { + throw FileSystemError(.isDirectory, path) + } + + // Get the parent node. + guard let parent = try getNode(parentPath) else { + throw FileSystemError(.noEntry, parentPath) + } + + // Check that the parent is a directory. + guard case let .directory(contents) = parent.contents else { + // The parent isn't a directory, this is an error. + throw FileSystemError(.notDirectory, parentPath) + } + + // Check if the node exists. + if let node = contents.entries[path.basename] { + // Verify it is a file. + guard case .file = node.contents else { + // The path is a directory, this is an error. + throw FileSystemError(.isDirectory, path) + } + } + + // Write the file. + contents.entries[path.basename] = Node(.file(bytes)) + } + } + + public func writeFileContents( + _ path: AbsolutePath, + bytes: ByteString, + atomically: Bool + ) throws { + // In memory file system's writeFileContents is already atomic, so ignore the parameter here + // and just call the base implementation. + try writeFileContents(path, bytes: bytes) + } + + public func removeFileTree(_ path: AbsolutePath) throws { + return lock.withLock { + // Ignore root and get the parent node's content if its a directory. + guard !path.isRoot, + let parent = try? getNode(path.parentDirectory), + case let .directory(contents) = parent.contents + else { + return + } + // Set it to nil to release the contents. + contents.entries[path.basename] = nil + } + } + + public func chmod(_ mode: FileMode, path: AbsolutePath, options: Set) throws { + // FIXME: We don't have these semantics in InMemoryFileSystem. + } + + /// Private implementation of core copying function. + /// Not thread-safe. + private func _copy(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws { + // Get the source node. + guard let source = try getNode(sourcePath) else { + throw FileSystemError(.noEntry, sourcePath) + } + + // Create directory to destination parent. + guard let destinationParent = try getNode(destinationPath.parentDirectory) else { + throw FileSystemError(.noEntry, destinationPath.parentDirectory) + } + + // Check that the parent is a directory. + guard case let .directory(contents) = destinationParent.contents else { + throw FileSystemError(.notDirectory, destinationPath.parentDirectory) + } + + guard contents.entries[destinationPath.basename] == nil else { + throw FileSystemError(.alreadyExistsAtDestination, destinationPath) + } + + contents.entries[destinationPath.basename] = source + } + + public func copy(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws { + return try lock.withLock { + try _copy(from: sourcePath, to: destinationPath) + } + } + + public func move(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws { + return try lock.withLock { + // Get the source parent node. + guard let sourceParent = try getNode(sourcePath.parentDirectory) else { + throw FileSystemError(.noEntry, sourcePath.parentDirectory) + } + + // Check that the parent is a directory. + guard case let .directory(contents) = sourceParent.contents else { + throw FileSystemError(.notDirectory, sourcePath.parentDirectory) + } + + try _copy(from: sourcePath, to: destinationPath) + + contents.entries[sourcePath.basename] = nil + } + } + + public func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () throws -> T + ) throws -> T { + if !blocking { + throw FileSystemError(.unsupported, path) + } + + let resolvedPath: AbsolutePath = try lock.withLock { + if case let .symlink(destination) = try getNode(path)?.contents { + return try AbsolutePath(validating: destination, relativeTo: path.parentDirectory) + } else { + return path + } + } + + let fileQueue: DispatchQueue = lockFilesLock.withLock { + if let queueReference = lockFiles[resolvedPath], let queue = queueReference.reference { + return queue + } else { + let queue = DispatchQueue( + label: "org.swift.swiftpm.in-memory-file-system.file-queue", + attributes: .concurrent + ) + lockFiles[resolvedPath] = WeakReference(queue) + return queue + } + } + + return try fileQueue.sync(flags: type == .exclusive ? .barrier : .init(), execute: body) + } +} + +// Internal state of `InMemoryFileSystem` is protected with a lock in all of its `public` methods. +#if compiler(>=5.7) +extension InMemoryFileSystem: @unchecked Sendable {} +#else +extension InMemoryFileSystem: UnsafeSendable {} +#endif + +private var _localFileSystem: FileSystem = LocalFileSystem() + +/// Public access to the local FS proxy. +public var localFileSystem: FileSystem { + return _localFileSystem +} + +public extension FileSystem { + /// Print the filesystem tree of the given path. + /// + /// For debugging only. + func dumpTree(at path: AbsolutePath = .root) { + print(".") + do { + try recurse(fs: self, path: path) + } catch { + print("\(error)") + } + } + + /// Write bytes to the path if the given contents are different. + func writeIfChanged(path: AbsolutePath, bytes: ByteString) throws { + try createDirectory(path.parentDirectory, recursive: true) + + // Return if the contents are same. + if isFile(path), try readFileContents(path) == bytes { + return + } + + try writeFileContents(path, bytes: bytes) + } + + func getDirectoryContents( + at path: AbsolutePath, + includingPropertiesForKeys: [URLResourceKey]? = nil, + options: FileManager.DirectoryEnumerationOptions = [] + ) throws -> [AbsolutePath] { + return try _getDirectoryContents( + path, + includingPropertiesForKeys: includingPropertiesForKeys, + options: options + ) + } + + /// Helper method to recurse and print the tree. + private func recurse(fs: FileSystem, path: AbsolutePath, prefix: String = "") throws { + let contents = (try fs.getDirectoryContents(at: path)).map(\.basename) + + for (idx, entry) in contents.enumerated() { + let isLast = idx == contents.count - 1 + let line = prefix + (isLast ? "└── " : "├── ") + entry + print(line) + + let entryPath = path.appending(component: entry) + if fs.isDirectory(entryPath) { + let childPrefix = prefix + (isLast ? " " : "│ ") + try recurse(fs: fs, path: entryPath, prefix: String(childPrefix)) + } + } + } +} + diff --git a/Tool/Sources/FileSystem/Lock.swift b/Tool/Sources/FileSystem/Lock.swift new file mode 100644 index 00000000..88160822 --- /dev/null +++ b/Tool/Sources/FileSystem/Lock.swift @@ -0,0 +1,214 @@ +import Foundation + +public enum ProcessLockError: Error { + case unableToAcquireLock(errno: Int32) +} + +extension ProcessLockError: CustomNSError { + public var errorUserInfo: [String : Any] { + return [NSLocalizedDescriptionKey: "\(self)"] + } +} + +/// Provides functionality to acquire a lock on a file via POSIX's flock() method. +/// It can be used for things like serializing concurrent mutations on a shared resource +/// by multiple instances of a process. The `FileLock` is not thread-safe. +public final class FileLock { + + public enum LockType { + case exclusive + case shared + } + + /// File descriptor to the lock file. + #if os(Windows) + private var handle: HANDLE? + #else + private var fileDescriptor: CInt? + #endif + + /// Path to the lock file. + private let lockFile: AbsolutePath + + /// Create an instance of FileLock at the path specified + /// + /// Note: The parent directory path should be a valid directory. + internal init(at lockFile: AbsolutePath) { + self.lockFile = lockFile + } + + @available(*, deprecated, message: "use init(at:) instead") + public convenience init(name: String, cachePath: AbsolutePath) { + self.init(at: cachePath.appending(component: name + ".lock")) + } + + /// Try to acquire a lock. This method will block until lock the already acquired by other process. + /// + /// Note: This method can throw if underlying POSIX methods fail. + public func lock(type: LockType = .exclusive, blocking: Bool = true) throws { + #if os(Windows) + if handle == nil { + let h: HANDLE = lockFile.pathString.withCString(encodedAs: UTF16.self, { + CreateFileW( + $0, + UInt32(GENERIC_READ) | UInt32(GENERIC_WRITE), + UInt32(FILE_SHARE_READ) | UInt32(FILE_SHARE_WRITE), + nil, + DWORD(OPEN_ALWAYS), + DWORD(FILE_ATTRIBUTE_NORMAL), + nil + ) + }) + if h == INVALID_HANDLE_VALUE { + throw FileSystemError(errno: Int32(GetLastError()), lockFile) + } + self.handle = h + } + var overlapped = OVERLAPPED() + overlapped.Offset = 0 + overlapped.OffsetHigh = 0 + overlapped.hEvent = nil + var dwFlags = Int32(0) + switch type { + case .exclusive: dwFlags |= LOCKFILE_EXCLUSIVE_LOCK + case .shared: break + } + if !blocking { + dwFlags |= LOCKFILE_FAIL_IMMEDIATELY + } + if !LockFileEx(handle, DWORD(dwFlags), 0, + UInt32.max, UInt32.max, &overlapped) { + throw ProcessLockError.unableToAcquireLock(errno: Int32(GetLastError())) + } + #else + // Open the lock file. + if fileDescriptor == nil { + let fd = open(lockFile.pathString, O_WRONLY | O_CREAT | O_CLOEXEC, 0o666) + if fd == -1 { + throw FileSystemError(errno: errno, lockFile) + } + self.fileDescriptor = fd + } + var flags = Int32(0) + switch type { + case .exclusive: flags = LOCK_EX + case .shared: flags = LOCK_SH + } + if !blocking { + flags |= LOCK_NB + } + // Acquire lock on the file. + while true { + if flock(fileDescriptor!, flags) == 0 { + break + } + // Retry if interrupted. + if errno == EINTR { continue } + throw ProcessLockError.unableToAcquireLock(errno: errno) + } + #endif + } + + /// Unlock the held lock. + public func unlock() { + #if os(Windows) + var overlapped = OVERLAPPED() + overlapped.Offset = 0 + overlapped.OffsetHigh = 0 + overlapped.hEvent = nil + UnlockFileEx(handle, 0, UInt32.max, UInt32.max, &overlapped) + #else + guard let fd = fileDescriptor else { return } + flock(fd, LOCK_UN) + #endif + } + + deinit { + #if os(Windows) + guard let handle = handle else { return } + CloseHandle(handle) + #else + guard let fd = fileDescriptor else { return } + close(fd) + #endif + } + + /// Execute the given block while holding the lock. + public func withLock(type: LockType = .exclusive, blocking: Bool = true, _ body: () throws -> T) throws -> T { + try lock(type: type, blocking: blocking) + defer { unlock() } + return try body() + } + + /// Execute the given block while holding the lock. + public func withLock(type: LockType = .exclusive, blocking: Bool = true, _ body: () async throws -> T) async throws -> T { + try lock(type: type, blocking: blocking) + defer { unlock() } + return try await body() + } + + public static func prepareLock( + fileToLock: AbsolutePath, + at lockFilesDirectory: AbsolutePath? = nil + ) throws -> FileLock { + // unless specified, we use the tempDirectory to store lock files + let lockFilesDirectory = try lockFilesDirectory ?? localFileSystem.tempDirectory + if !localFileSystem.exists(lockFilesDirectory) { + throw FileSystemError(.noEntry, lockFilesDirectory) + } + if !localFileSystem.isDirectory(lockFilesDirectory) { + throw FileSystemError(.notDirectory, lockFilesDirectory) + } + // use the parent path to generate unique filename in temp + var lockFileName = try (resolveSymlinks(fileToLock.parentDirectory) + .appending(component: fileToLock.basename)) + .components.joined(separator: "_") + .replacingOccurrences(of: ":", with: "_") + ".lock" +#if os(Windows) + // NTFS has an ARC limit of 255 codepoints + var lockFileUTF16 = lockFileName.utf16.suffix(255) + while String(lockFileUTF16) == nil { + lockFileUTF16 = lockFileUTF16.dropFirst() + } + lockFileName = String(lockFileUTF16) ?? lockFileName +#else + if lockFileName.hasPrefix(AbsolutePath.root.pathString) { + lockFileName = String(lockFileName.dropFirst(AbsolutePath.root.pathString.count)) + } + // back off until it occupies at most `NAME_MAX` UTF-8 bytes but without splitting scalars + // (we might split clusters but it's not worth the effort to keep them together as long as we get a valid file name) + var lockFileUTF8 = lockFileName.utf8.suffix(Int(NAME_MAX)) + while String(lockFileUTF8) == nil { + // in practice this will only be a few iterations + lockFileUTF8 = lockFileUTF8.dropFirst() + } + // we will never end up with nil since we have ASCII characters at the end + lockFileName = String(lockFileUTF8) ?? lockFileName +#endif + let lockFilePath = lockFilesDirectory.appending(component: lockFileName) + + return FileLock(at: lockFilePath) + } + + public static func withLock( + fileToLock: AbsolutePath, + lockFilesDirectory: AbsolutePath? = nil, + type: LockType = .exclusive, + blocking: Bool = true, + body: () throws -> T + ) throws -> T { + let lock = try Self.prepareLock(fileToLock: fileToLock, at: lockFilesDirectory) + return try lock.withLock(type: type, blocking: blocking, body) + } + + public static func withLock( + fileToLock: AbsolutePath, + lockFilesDirectory: AbsolutePath? = nil, + type: LockType = .exclusive, + blocking: Bool = true, + body: () async throws -> T + ) async throws -> T { + let lock = try Self.prepareLock(fileToLock: fileToLock, at: lockFilesDirectory) + return try await lock.withLock(type: type, blocking: blocking, body) + } +} diff --git a/Tool/Sources/FileSystem/Misc.swift b/Tool/Sources/FileSystem/Misc.swift new file mode 100644 index 00000000..f016cfdc --- /dev/null +++ b/Tool/Sources/FileSystem/Misc.swift @@ -0,0 +1,426 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +#if canImport(Glibc) +@_exported import Glibc +#elseif canImport(Musl) +@_exported import Musl +#elseif os(Windows) +@_exported import CRT +@_exported import WinSDK +#else +@_exported import Darwin.C +#endif + +/// `CStringArray` represents a C null-terminated array of pointers to C strings. +/// +/// The lifetime of the C strings will correspond to the lifetime of the `CStringArray` +/// instance so be careful about copying the buffer as it may contain dangling pointers. +public final class CStringArray { + /// The null-terminated array of C string pointers. + public let cArray: [UnsafeMutablePointer?] + + /// Creates an instance from an array of strings. + public init(_ array: [String]) { +#if os(Windows) + cArray = array.map({ $0.withCString({ _strdup($0) }) }) + [nil] +#else + cArray = array.map({ $0.withCString({ strdup($0) }) }) + [nil] +#endif + } + + deinit { + for case let element? in cArray { + free(element) + } + } +} + +import Foundation +#if os(Windows) +import WinSDK +#endif + +#if os(Windows) +public let executableFileSuffix = ".exe" +#else +public let executableFileSuffix = "" +#endif + +#if os(Windows) +private func quote(_ arguments: [String]) -> String { + func quote(argument: String) -> String { + if !argument.contains(where: { " \t\n\"".contains($0) }) { + return argument + } + + // To escape the command line, we surround the argument with quotes. + // However, the complication comes due to how the Windows command line + // parser treats backslashes (\) and quotes ("). + // + // - \ is normally treated as a literal backslash + // e.g. alpha\beta\gamma => alpha\beta\gamma + // - The sequence \" is treated as a literal " + // e.g. alpha\"beta => alpha"beta + // + // But then what if we are given a path that ends with a \? + // + // Surrounding alpha\beta\ with " would be "alpha\beta\" which would be + // an unterminated string since it ends on a literal quote. To allow + // this case the parser treats: + // + // - \\" as \ followed by the " metacharacter + // - \\\" as \ followed by a literal " + // + // In general: + // - 2n \ followed by " => n \ followed by the " metacharacter + // - 2n + 1 \ followed by " => n \ followed by a literal " + + var quoted = "\"" + var unquoted = argument.unicodeScalars + + while !unquoted.isEmpty { + guard let firstNonBS = unquoted.firstIndex(where: { $0 != "\\" }) else { + // String ends with a backslash (e.g. first\second\), escape all + // the backslashes then add the metacharacter ". + let count = unquoted.count + quoted.append(String(repeating: "\\", count: 2 * count)) + break + } + + let count = unquoted.distance(from: unquoted.startIndex, to: firstNonBS) + if unquoted[firstNonBS] == "\"" { + // This is a string of \ followed by a " (e.g. first\"second). + // Escape the backslashes and the quote. + quoted.append(String(repeating: "\\", count: 2 * count + 1)) + } else { + // These are just literal backslashes + quoted.append(String(repeating: "\\", count: count)) + } + + quoted.append(String(unquoted[firstNonBS])) + + // Drop the backslashes and the following character + unquoted.removeFirst(count + 1) + } + quoted.append("\"") + + return quoted + } + return arguments.map(quote(argument:)).joined(separator: " ") +} +#endif + +/// Replace the current process image with a new process image. +/// +/// - Parameters: +/// - path: Absolute path to the executable. +/// - args: The executable arguments. +public func exec(path: String, args: [String]) throws -> Never { + let cArgs = CStringArray(args) + #if os(Windows) + var hJob: HANDLE + + hJob = CreateJobObjectA(nil, nil) + if hJob == HANDLE(bitPattern: 0) { + throw SystemError.exec(Int32(GetLastError()), path: path, args: args) + } + defer { CloseHandle(hJob) } + + let hPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, nil, 0, 1) + if hPort == HANDLE(bitPattern: 0) { + throw SystemError.exec(Int32(GetLastError()), path: path, args: args) + } + + var acpAssociation: JOBOBJECT_ASSOCIATE_COMPLETION_PORT = JOBOBJECT_ASSOCIATE_COMPLETION_PORT() + acpAssociation.CompletionKey = hJob + acpAssociation.CompletionPort = hPort + if !SetInformationJobObject(hJob, JobObjectAssociateCompletionPortInformation, + &acpAssociation, DWORD(MemoryLayout.size)) { + throw SystemError.exec(Int32(GetLastError()), path: path, args: args) + } + + var eliLimits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + eliLimits.BasicLimitInformation.LimitFlags = + DWORD(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) | DWORD(JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK) + if !SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &eliLimits, + DWORD(MemoryLayout.size)) { + throw SystemError.exec(Int32(GetLastError()), path: path, args: args) + } + + + var siInfo: STARTUPINFOW = STARTUPINFOW() + siInfo.cb = DWORD(MemoryLayout.size) + + var piInfo: PROCESS_INFORMATION = PROCESS_INFORMATION() + + try quote(args).withCString(encodedAs: UTF16.self) { pwszCommandLine in + if !CreateProcessW(nil, + UnsafeMutablePointer(mutating: pwszCommandLine), + nil, nil, false, + DWORD(CREATE_SUSPENDED) | DWORD(CREATE_NEW_PROCESS_GROUP), + nil, nil, &siInfo, &piInfo) { + throw SystemError.exec(Int32(GetLastError()), path: path, args: args) + } + } + + defer { CloseHandle(piInfo.hThread) } + defer { CloseHandle(piInfo.hProcess) } + + if !AssignProcessToJobObject(hJob, piInfo.hProcess) { + throw SystemError.exec(Int32(GetLastError()), path: path, args: args) + } + + _ = ResumeThread(piInfo.hThread) + + var dwCompletionCode: DWORD = 0 + var ulCompletionKey: ULONG_PTR = 0 + var lpOverlapped: LPOVERLAPPED? + repeat { + } while GetQueuedCompletionStatus(hPort, &dwCompletionCode, &ulCompletionKey, + &lpOverlapped, INFINITE) && + !(ulCompletionKey == ULONG_PTR(UInt(bitPattern: hJob)) && + dwCompletionCode == JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO) + + var dwExitCode: DWORD = DWORD(bitPattern: -1) + _ = GetExitCodeProcess(piInfo.hProcess, &dwExitCode) + _exit(Int32(bitPattern: dwExitCode)) + #elseif (!canImport(Darwin) || os(macOS)) + guard execv(path, cArgs.cArray) != -1 else { + throw SystemError.exec(errno, path: path, args: args) + } + fatalError("unreachable") + #else + fatalError("not implemented") + #endif +} + +@_disfavoredOverload +@available(*, deprecated, message: "Use the overload which returns Never") +public func exec(path: String, args: [String]) throws { + try exec(path: path, args: args) +} + +// MARK: TSCUtility function for searching for executables + +/// Create a list of AbsolutePath search paths from a string, such as the PATH environment variable. +/// +/// - Parameters: +/// - pathString: The path string to parse. +/// - currentWorkingDirectory: The current working directory, the relative paths will be converted to absolute paths +/// based on this path. +/// - Returns: List of search paths. +public func getEnvSearchPaths( + pathString: String?, + currentWorkingDirectory: AbsolutePath? +) -> [AbsolutePath] { + // Compute search paths from PATH variable. +#if os(Windows) + let pathSeparator: Character = ";" +#else + let pathSeparator: Character = ":" +#endif + return (pathString ?? "").split(separator: pathSeparator).map(String.init).compactMap({ pathString in + if let cwd = currentWorkingDirectory { + return try? AbsolutePath(validating: pathString, relativeTo: cwd) + } + return try? AbsolutePath(validating: pathString) + }) +} + +/// Lookup an executable path from an environment variable value, current working +/// directory or search paths. Only return a value that is both found and executable. +/// +/// This method searches in the following order: +/// * If env value is a valid absolute path, return it. +/// * If env value is relative path, first try to locate it in current working directory. +/// * Otherwise, in provided search paths. +/// +/// - Parameters: +/// - filename: The name of the file to find. +/// - currentWorkingDirectory: The current working directory to look in. +/// - searchPaths: The additional search paths to look in if not found in cwd. +/// - Returns: Valid path to executable if present, otherwise nil. +public func lookupExecutablePath( + filename value: String?, + currentWorkingDirectory: AbsolutePath? = localFileSystem.currentWorkingDirectory, + searchPaths: [AbsolutePath] = [] +) -> AbsolutePath? { + + // We should have a value to continue. + guard let value = value, !value.isEmpty else { + return nil + } + + var paths: [AbsolutePath] = [] + + if let cwd = currentWorkingDirectory, let path = try? AbsolutePath(validating: value, relativeTo: cwd) { + // We have a value, but it could be an absolute or a relative path. + paths.append(path) + } else if let absPath = try? AbsolutePath(validating: value) { + // Current directory not being available is not a problem + // for the absolute-specified paths. + paths.append(absPath) + } + + // Ensure the value is not a path. + if !value.contains("/") { + // Try to locate in search paths. + paths.append(contentsOf: searchPaths.map({ $0.appending(component: value) })) + } + + return paths.first(where: { localFileSystem.isExecutableFile($0) }) +} + +/// A wrapper for Range to make it Codable. +/// +/// Technically, we can use conditional conformance and make +/// stdlib's Range Codable but since extensions leak out, it +/// is not a good idea to extend types that you don't own. +/// +/// Range conformance will be added soon to stdlib so we can remove +/// this type in the future. +public struct CodableRange where Bound: Comparable & Codable { + + /// The underlying range. + public let range: Range + + /// Create a CodableRange instance. + public init(_ range: Range) { + self.range = range + } +} + +extension CodableRange: Sendable where Bound: Sendable {} + +extension CodableRange: Codable { + private enum CodingKeys: String, CodingKey { + case lowerBound, upperBound + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(range.lowerBound, forKey: .lowerBound) + try container.encode(range.upperBound, forKey: .upperBound) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let lowerBound = try container.decode(Bound.self, forKey: .lowerBound) + let upperBound = try container.decode(Bound.self, forKey: .upperBound) + self.init(Range(uncheckedBounds: (lowerBound, upperBound))) + } +} + +extension AbsolutePath { + /// File URL created from the normalized string representation of the path. + public var asURL: Foundation.URL { + return URL(fileURLWithPath: pathString) + } + + public init(_ url: URL) throws { + try self.init(validating: url.path) + } +} + +// FIXME: Eliminate or find a proper place for this. +public enum SystemError: Error { + case chdir(Int32, String) + case close(Int32) + case exec(Int32, path: String, args: [String]) + case pipe(Int32) + case posix_spawn(Int32, [String]) + case read(Int32) + case setenv(Int32, String) + case stat(Int32, String) + case symlink(Int32, String, dest: String) + case unsetenv(Int32, String) + case waitpid(Int32) +} + +extension SystemError: CustomStringConvertible { + public var description: String { + func strerror(_ errno: Int32) -> String { + #if os(Windows) + let cap = 128 + var buf = [Int8](repeating: 0, count: cap) + let _ = strerror_s(&buf, 128, errno) + return "\(String(cString: buf)) (\(errno))" + #else + var cap = 64 + while cap <= 16 * 1024 { + var buf = [Int8](repeating: 0, count: cap) + let err = strerror_r(errno, &buf, buf.count) + if err == EINVAL { + return "Unknown error \(errno)" + } + if err == ERANGE { + cap *= 2 + continue + } + if err != 0 { + fatalError("strerror_r error: \(err)") + } + return "\(String(cString: buf)) (\(errno))" + } + fatalError("strerror_r error: \(ERANGE)") + #endif + } + + switch self { + case .chdir(let errno, let path): + return "chdir error: \(strerror(errno)): \(path)" + case .close(let err): + let errorMessage: String + if err == -1 { // if the return code is -1, we need to consult the global `errno` + errorMessage = strerror(errno) + } else { + errorMessage = strerror(err) + } + return "close error: \(errorMessage)" + case .exec(let errno, let path, let args): + let joinedArgs = args.joined(separator: " ") + return "exec error: \(strerror(errno)): \(path) \(joinedArgs)" + case .pipe(let errno): + return "pipe error: \(strerror(errno))" + case .posix_spawn(let errno, let args): + return "posix_spawn error: \(strerror(errno)), `\(args)`" + case .read(let errno): + return "read error: \(strerror(errno))" + case .setenv(let errno, let key): + return "setenv error: \(strerror(errno)): \(key)" + case .stat(let errno, _): + return "stat error: \(strerror(errno))" + case .symlink(let errno, let path, let dest): + return "symlink error: \(strerror(errno)): \(path) -> \(dest)" + case .unsetenv(let errno, let key): + return "unsetenv error: \(strerror(errno)): \(key)" + case .waitpid(let errno): + return "waitpid error: \(strerror(errno))" + } + } +} + +extension SystemError: CustomNSError { + public var errorUserInfo: [String : Any] { + return [NSLocalizedDescriptionKey: self.description] + } +} + +/// Memoizes a costly computation to a cache variable. +func memoize(to cache: inout T?, build: () throws -> T) rethrows -> T { + if let value = cache { + return value + } else { + let value = try build() + cache = value + return value + } +} diff --git a/Tool/Sources/FileSystem/Path.swift b/Tool/Sources/FileSystem/Path.swift new file mode 100644 index 00000000..d32c7f32 --- /dev/null +++ b/Tool/Sources/FileSystem/Path.swift @@ -0,0 +1,1058 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2018 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ +#if os(Windows) +import Foundation +import WinSDK +#endif + +#if os(Windows) +private typealias PathImpl = WindowsPath +#else +private typealias PathImpl = UNIXPath +#endif + +import protocol Foundation.CustomNSError +import var Foundation.NSLocalizedDescriptionKey + +/// Represents an absolute file system path, independently of what (or whether +/// anything at all) exists at that path in the file system at any given time. +/// An absolute path always starts with a `/` character, and holds a normalized +/// string representation. This normalization is strictly syntactic, and does +/// not access the file system in any way. +/// +/// The absolute path string is normalized by: +/// - Collapsing `..` path components +/// - Removing `.` path components +/// - Removing any trailing path separator +/// - Removing any redundant path separators +/// +/// This string manipulation may change the meaning of a path if any of the +/// path components are symbolic links on disk. However, the file system is +/// never accessed in any way when initializing an AbsolutePath. +/// +/// Note that `~` (home directory resolution) is *not* done as part of path +/// normalization, because it is normally the responsibility of the shell and +/// not the program being invoked (e.g. when invoking `cd ~`, it is the shell +/// that evaluates the tilde; the `cd` command receives an absolute path). +public struct AbsolutePath: Hashable, Sendable { + /// Check if the given name is a valid individual path component. + /// + /// This only checks with regard to the semantics enforced by `AbsolutePath` + /// and `RelativePath`; particular file systems may have their own + /// additional requirements. + static func isValidComponent(_ name: String) -> Bool { + return PathImpl.isValidComponent(name) + } + + /// Private implementation details, shared with the RelativePath struct. + private let _impl: PathImpl + + /// Private initializer when the backing storage is known. + private init(_ impl: PathImpl) { + _impl = impl + } + + /// Initializes an AbsolutePath from a string that may be either absolute + /// or relative; if relative, `basePath` is used as the anchor; if absolute, + /// it is used as is, and in this case `basePath` is ignored. + public init(validating str: String, relativeTo basePath: AbsolutePath) throws { + if PathImpl(string: str).isAbsolute { + try self.init(validating: str) + } else { +#if os(Windows) + assert(!basePath.pathString.isEmpty) + guard !str.isEmpty else { + self.init(basePath._impl) + return + } + + let base: UnsafePointer = + basePath.pathString.fileSystemRepresentation + defer { base.deallocate() } + + let path: UnsafePointer = str.fileSystemRepresentation + defer { path.deallocate() } + + var pwszResult: PWSTR! + _ = String(cString: base).withCString(encodedAs: UTF16.self) { pwszBase in + String(cString: path).withCString(encodedAs: UTF16.self) { pwszPath in + PathAllocCombine(pwszBase, pwszPath, ULONG(PATHCCH_ALLOW_LONG_PATHS.rawValue), &pwszResult) + } + } + defer { LocalFree(pwszResult) } + + self.init(String(decodingCString: pwszResult, as: UTF16.self)) +#else + try self.init(basePath, RelativePath(validating: str)) +#endif + } + } + + /// Initializes the AbsolutePath by concatenating a relative path to an + /// existing absolute path, and renormalizing if necessary. + public init(_ absPath: AbsolutePath, _ relPath: RelativePath) { + self.init(absPath._impl.appending(relativePath: relPath._impl)) + } + + /// Convenience initializer that appends a string to a relative path. + public init(_ absPath: AbsolutePath, validating relStr: String) throws { + try self.init(absPath, RelativePath(validating: relStr)) + } + + /// Initializes the AbsolutePath from `absStr`, which must be an absolute + /// path (i.e. it must begin with a path separator; this initializer does + /// not interpret leading `~` characters as home directory specifiers). + /// The input string will be normalized if needed, as described in the + /// documentation for AbsolutePath. + public init(validating path: String) throws { + try self.init(PathImpl(validatingAbsolutePath: path)) + } + + /// Directory component. An absolute path always has a non-empty directory + /// component (the directory component of the root path is the root itself). + public var dirname: String { + return _impl.dirname + } + + /// Last path component (including the suffix, if any). it is never empty. + public var basename: String { + return _impl.basename + } + + /// Returns the basename without the extension. + public var basenameWithoutExt: String { + if let ext = self.extension { + return String(basename.dropLast(ext.count + 1)) + } + return basename + } + + /// Suffix (including leading `.` character) if any. Note that a basename + /// that starts with a `.` character is not considered a suffix, nor is a + /// trailing `.` character. + public var suffix: String? { + return _impl.suffix + } + + /// Extension of the give path's basename. This follow same rules as + /// suffix except that it doesn't include leading `.` character. + public var `extension`: String? { + return _impl.extension + } + + /// Absolute path of parent directory. This always returns a path, because + /// every directory has a parent (the parent directory of the root directory + /// is considered to be the root directory itself). + public var parentDirectory: AbsolutePath { + return AbsolutePath(_impl.parentDirectory) + } + + /// True if the path is the root directory. + public var isRoot: Bool { + return _impl.isRoot + } + + /// Returns the absolute path with the relative path applied. + public func appending(_ subpath: RelativePath) -> AbsolutePath { + return AbsolutePath(self, subpath) + } + + /// Returns the absolute path with an additional literal component appended. + /// + /// This method accepts pseudo-path like '.' or '..', but should not contain "/". + public func appending(component: String) -> AbsolutePath { + return AbsolutePath(_impl.appending(component: component)) + } + + /// Returns the absolute path with additional literal components appended. + /// + /// This method should only be used in cases where the input is guaranteed + /// to be a valid path component (i.e., it cannot be empty, contain a path + /// separator, or be a pseudo-path like '.' or '..'). + public func appending(components names: [String]) -> AbsolutePath { + // FIXME: This doesn't seem a particularly efficient way to do this. + return names.reduce(self, { path, name in + path.appending(component: name) + }) + } + + public func appending(components names: String...) -> AbsolutePath { + appending(components: names) + } + + /// NOTE: We will most likely want to add other `appending()` methods, such + /// as `appending(suffix:)`, and also perhaps `replacing()` methods, + /// such as `replacing(suffix:)` or `replacing(basename:)` for some + /// of the more common path operations. + + /// NOTE: We may want to consider adding operators such as `+` for appending + /// a path component. + + /// NOTE: We will want to add a method to return the lowest common ancestor + /// path. + + /// Root directory (whose string representation is just a path separator). + public static let root = AbsolutePath(PathImpl.root) + + /// Normalized string representation (the normalization rules are described + /// in the documentation of the initializer). This string is never empty. + public var pathString: String { + return _impl.string + } + + /// Returns an array of strings that make up the path components of the + /// absolute path. This is the same sequence of strings as the basenames + /// of each successive path component, starting from the root. Therefore + /// the first path component of an absolute path is always `/`. + public var components: [String] { + return _impl.components + } +} + +/// Represents a relative file system path. A relative path never starts with +/// a `/` character, and holds a normalized string representation. As with +/// AbsolutePath, the normalization is strictly syntactic, and does not access +/// the file system in any way. +/// +/// The relative path string is normalized by: +/// - Collapsing `..` path components that aren't at the beginning +/// - Removing extraneous `.` path components +/// - Removing any trailing path separator +/// - Removing any redundant path separators +/// - Replacing a completely empty path with a `.` +/// +/// This string manipulation may change the meaning of a path if any of the +/// path components are symbolic links on disk. However, the file system is +/// never accessed in any way when initializing a RelativePath. +public struct RelativePath: Hashable, Sendable { + /// Private implementation details, shared with the AbsolutePath struct. + fileprivate let _impl: PathImpl + + /// Private initializer when the backing storage is known. + private init(_ impl: PathImpl) { + _impl = impl + } + + /// Convenience initializer that verifies that the path is relative. + public init(validating path: String) throws { + try self.init(PathImpl(validatingRelativePath: path)) + } + + /// Directory component. For a relative path without any path separators, + /// this is the `.` string instead of the empty string. + public var dirname: String { + return _impl.dirname + } + + /// Last path component (including the suffix, if any). It is never empty. + public var basename: String { + return _impl.basename + } + + /// Returns the basename without the extension. + public var basenameWithoutExt: String { + if let ext = self.extension { + return String(basename.dropLast(ext.count + 1)) + } + return basename + } + + /// Suffix (including leading `.` character) if any. Note that a basename + /// that starts with a `.` character is not considered a suffix, nor is a + /// trailing `.` character. + public var suffix: String? { + return _impl.suffix + } + + /// Extension of the give path's basename. This follow same rules as + /// suffix except that it doesn't include leading `.` character. + public var `extension`: String? { + return _impl.extension + } + + /// Normalized string representation (the normalization rules are described + /// in the documentation of the initializer). This string is never empty. + public var pathString: String { + return _impl.string + } + + /// Returns an array of strings that make up the path components of the + /// relative path. This is the same sequence of strings as the basenames + /// of each successive path component. Therefore the returned array of + /// path components is never empty; even an empty path has a single path + /// component: the `.` string. + public var components: [String] { + return _impl.components + } + + /// Returns the relative path with the given relative path applied. + public func appending(_ subpath: RelativePath) -> RelativePath { + return RelativePath(_impl.appending(relativePath: subpath._impl)) + } + + /// Returns the relative path with an additional literal component appended. + /// + /// This method accepts pseudo-path like '.' or '..', but should not contain "/". + public func appending(component: String) -> RelativePath { + return RelativePath(_impl.appending(component: component)) + } + + /// Returns the relative path with additional literal components appended. + /// + /// This method should only be used in cases where the input is guaranteed + /// to be a valid path component (i.e., it cannot be empty, contain a path + /// separator, or be a pseudo-path like '.' or '..'). + public func appending(components names: [String]) -> RelativePath { + // FIXME: This doesn't seem a particularly efficient way to do this. + return names.reduce(self, { path, name in + path.appending(component: name) + }) + } + + public func appending(components names: String...) -> RelativePath { + appending(components: names) + } +} + +extension AbsolutePath: Codable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(pathString) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + try self.init(validating: container.decode(String.self)) + } +} + +extension RelativePath: Codable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(pathString) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + try self.init(validating: container.decode(String.self)) + } +} + +// Make absolute paths Comparable. +extension AbsolutePath: Comparable { + public static func < (lhs: AbsolutePath, rhs: AbsolutePath) -> Bool { + return lhs.pathString < rhs.pathString + } +} + +/// Make absolute paths CustomStringConvertible and CustomDebugStringConvertible. +extension AbsolutePath: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + return pathString + } + + public var debugDescription: String { + // FIXME: We should really be escaping backslashes and quotes here. + return "" + } +} + +/// Make relative paths CustomStringConvertible and CustomDebugStringConvertible. +extension RelativePath: CustomStringConvertible { + public var description: String { + return _impl.string + } + + public var debugDescription: String { + // FIXME: We should really be escaping backslashes and quotes here. + return "" + } +} + +/// Private implementation shared between AbsolutePath and RelativePath. +protocol Path: Hashable { + + /// Root directory. + static var root: Self { get } + + /// Checks if a string is a valid component. + static func isValidComponent(_ name: String) -> Bool + + /// Normalized string of the (absolute or relative) path. Never empty. + var string: String { get } + + /// Returns whether the path is the root path. + var isRoot: Bool { get } + + /// Returns whether the path is an absolute path. + var isAbsolute: Bool { get } + + /// Returns the directory part of the stored path (relying on the fact that it has been normalized). Returns a + /// string consisting of just `.` if there is no directory part (which is the case if and only if there is no path + /// separator). + var dirname: String { get } + + /// Returns the last past component. + var basename: String { get } + + /// Returns the components of the path between each path separator. + var components: [String] { get } + + /// Path of parent directory. This always returns a path, because every directory has a parent (the parent + /// directory of the root directory is considered to be the root directory itself). + var parentDirectory: Self { get } + + /// Creates a path from its normalized string representation. + init(string: String) + + /// Creates a path from a string representation, validates that it is a valid absolute path and normalizes it. + init(validatingAbsolutePath: String) throws + + /// Creates a path from a string representation, validates that it is a valid relative path and normalizes it. + init(validatingRelativePath: String) throws + + /// Returns suffix with leading `.` if withDot is true otherwise without it. + func suffix(withDot: Bool) -> String? + + /// Returns a new Path by appending the path component. + func appending(component: String) -> Self + + /// Returns a path by concatenating a relative path and renormalizing if necessary. + func appending(relativePath: Self) -> Self +} + +extension Path { + var suffix: String? { + return suffix(withDot: true) + } + + var `extension`: String? { + return suffix(withDot: false) + } +} + +#if os(Windows) +private struct WindowsPath: Path, Sendable { + let string: String + + // NOTE: this is *NOT* a root path. It is a drive-relative path that needs + // to be specified due to assumptions in the APIs. Use the platform + // specific path separator as we should be normalizing the path normally. + // This is required to make the `InMemoryFileSystem` correctly iterate + // paths. + static let root = Self(string: "\\") + + static func isValidComponent(_ name: String) -> Bool { + return name != "" && name != "." && name != ".." && !name.contains("/") + } + + static func isAbsolutePath(_ path: String) -> Bool { + return !path.withCString(encodedAs: UTF16.self, PathIsRelativeW) + } + + var dirname: String { + let fsr: UnsafePointer = self.string.fileSystemRepresentation + defer { fsr.deallocate() } + + let path: String = String(cString: fsr) + return path.withCString(encodedAs: UTF16.self) { + let data = UnsafeMutablePointer(mutating: $0) + PathCchRemoveFileSpec(data, path.count) + return String(decodingCString: data, as: UTF16.self) + } + } + + var isAbsolute: Bool { + return Self.isAbsolutePath(self.string) + } + + public var isRoot: Bool { + return self.string.withCString(encodedAs: UTF16.self, PathCchIsRoot) + } + + var basename: String { + let path: String = self.string + return path.withCString(encodedAs: UTF16.self) { + PathStripPathW(UnsafeMutablePointer(mutating: $0)) + return String(decodingCString: $0, as: UTF16.self) + } + } + + // FIXME: We should investigate if it would be more efficient to instead + // return a path component iterator that does all its work lazily, moving + // from one path separator to the next on-demand. + // + var components: [String] { + let normalized: UnsafePointer = string.fileSystemRepresentation + defer { normalized.deallocate() } + + return String(cString: normalized).components(separatedBy: "\\").filter { !$0.isEmpty } + } + + var parentDirectory: Self { + return self == .root ? self : Self(string: dirname) + } + + init(string: String) { + if string.first?.isASCII ?? false, string.first?.isLetter ?? false, string.first?.isLowercase ?? false, + string.count > 1, string[string.index(string.startIndex, offsetBy: 1)] == ":" + { + self.string = "\(string.first!.uppercased())\(string.dropFirst(1))" + } else { + self.string = string + } + } + + private static func repr(_ path: String) -> String { + guard !path.isEmpty else { return "" } + let representation: UnsafePointer = path.fileSystemRepresentation + defer { representation.deallocate() } + return String(cString: representation) + } + + init(validatingAbsolutePath path: String) throws { + let realpath = Self.repr(path) + if !Self.isAbsolutePath(realpath) { + throw PathValidationError.invalidAbsolutePath(path) + } + self.init(string: realpath) + } + + init(validatingRelativePath path: String) throws { + if path.isEmpty || path == "." { + self.init(string: ".") + } else { + let realpath: String = Self.repr(path) + // Treat a relative path as an invalid relative path... + if Self.isAbsolutePath(realpath) || realpath.first == "\\" { + throw PathValidationError.invalidRelativePath(path) + } + self.init(string: realpath) + } + } + + func suffix(withDot: Bool) -> String? { + return self.string.withCString(encodedAs: UTF16.self) { + if let pointer = PathFindExtensionW($0) { + let substring = String(decodingCString: pointer, as: UTF16.self) + guard substring.length > 0 else { return nil } + return withDot ? substring : String(substring.dropFirst(1)) + } + return nil + } + } + + func appending(component name: String) -> Self { + var result: PWSTR? + _ = string.withCString(encodedAs: UTF16.self) { root in + name.withCString(encodedAs: UTF16.self) { path in + PathAllocCombine(root, path, ULONG(PATHCCH_ALLOW_LONG_PATHS.rawValue), &result) + } + } + defer { LocalFree(result) } + return Self(string: String(decodingCString: result!, as: UTF16.self)) + } + + func appending(relativePath: Self) -> Self { + var result: PWSTR? + _ = string.withCString(encodedAs: UTF16.self) { root in + relativePath.string.withCString(encodedAs: UTF16.self) { path in + PathAllocCombine(root, path, ULONG(PATHCCH_ALLOW_LONG_PATHS.rawValue), &result) + } + } + defer { LocalFree(result) } + return Self(string: String(decodingCString: result!, as: UTF16.self)) + } +} +#else +private struct UNIXPath: Path, Sendable { + let string: String + + static let root = Self(string: "/") + + static func isValidComponent(_ name: String) -> Bool { + return name != "" && name != "." && name != ".." && !name.contains("/") + } + + var dirname: String { + // FIXME: This method seems too complicated; it should be simplified, + // if possible, and certainly optimized (using UTF8View). + // Find the last path separator. + guard let idx = string.lastIndex(of: "/") else { + // No path separators, so the directory name is `.`. + return "." + } + // Check if it's the only one in the string. + if idx == string.startIndex { + // Just one path separator, so the directory name is `/`. + return "/" + } + // Otherwise, it's the string up to (but not including) the last path + // separator. + return String(string.prefix(upTo: idx)) + } + + var isAbsolute: Bool { + return string.hasPrefix("/") + } + + var isRoot: Bool { + return self == Self.root + } + + var basename: String { + // Find the last path separator. + guard let idx = string.lastIndex(of: "/") else { + // No path separators, so the basename is the whole string. + return string + } + // Otherwise, it's the string from (but not including) the last path + // separator. + return String(string.suffix(from: string.index(after: idx))) + } + + // FIXME: We should investigate if it would be more efficient to instead + // return a path component iterator that does all its work lazily, moving + // from one path separator to the next on-demand. + // + var components: [String] { + // FIXME: This isn't particularly efficient; needs optimization, and + // in fact, it might well be best to return a custom iterator so we + // don't have to allocate everything up-front. It would be backed by + // the path string and just return a slice at a time. + let components = string.components(separatedBy: "/").filter({ !$0.isEmpty }) + + if string.hasPrefix("/") { + return ["/"] + components + } else { + return components + } + } + + var parentDirectory: Self { + return self == .root ? self : Self(string: dirname) + } + + init(string: String) { + self.string = string + } + + init(normalizingAbsolutePath path: String) { + precondition(path.first == "/", "Failure normalizing \(path), absolute paths should start with '/'") + + // At this point we expect to have a path separator as first character. + assert(path.first == "/") + // Fast path. + if !mayNeedNormalization(absolute: path) { + self.init(string: path) + } + + // Split the character array into parts, folding components as we go. + // As we do so, we count the number of characters we'll end up with in + // the normalized string representation. + var parts: [String] = [] + var capacity = 0 + for part in path.split(separator: "/") { + switch part.count { + case 0: + // Ignore empty path components. + continue + case 1 where part.first == ".": + // Ignore `.` path components. + continue + case 2 where part.first == "." && part.last == ".": + // If there's a previous part, drop it; otherwise, do nothing. + if let prev = parts.last { + parts.removeLast() + capacity -= prev.count + } + default: + // Any other component gets appended. + parts.append(String(part)) + capacity += part.count + } + } + capacity += max(parts.count, 1) + + // Create an output buffer using the capacity we've calculated. + // FIXME: Determine the most efficient way to reassemble a string. + var result = "" + result.reserveCapacity(capacity) + + // Put the normalized parts back together again. + var iter = parts.makeIterator() + result.append("/") + if let first = iter.next() { + result.append(contentsOf: first) + while let next = iter.next() { + result.append("/") + result.append(contentsOf: next) + } + } + + // Sanity-check the result (including the capacity we reserved). + assert(!result.isEmpty, "unexpected empty string") + assert(result.count == capacity, "count: " + + "\(result.count), cap: \(capacity)") + + // Use the result as our stored string. + self.init(string: result) + } + + init(normalizingRelativePath path: String) { + precondition(path.first != "/") + + // FIXME: Here we should also keep track of whether anything actually has + // to be changed in the string, and if not, just return the existing one. + + // Split the character array into parts, folding components as we go. + // As we do so, we count the number of characters we'll end up with in + // the normalized string representation. + var parts: [String] = [] + var capacity = 0 + for part in path.split(separator: "/") { + switch part.count { + case 0: + // Ignore empty path components. + continue + case 1 where part.first == ".": + // Ignore `.` path components. + continue + case 2 where part.first == "." && part.last == ".": + // If at beginning, fall through to treat the `..` literally. + guard let prev = parts.last else { + fallthrough + } + // If previous component is anything other than `..`, drop it. + if !(prev.count == 2 && prev.first == "." && prev.last == ".") { + parts.removeLast() + capacity -= prev.count + continue + } + // Otherwise, fall through to treat the `..` literally. + fallthrough + default: + // Any other component gets appended. + parts.append(String(part)) + capacity += part.count + } + } + capacity += max(parts.count - 1, 0) + + // Create an output buffer using the capacity we've calculated. + // FIXME: Determine the most efficient way to reassemble a string. + var result = "" + result.reserveCapacity(capacity) + + // Put the normalized parts back together again. + var iter = parts.makeIterator() + if let first = iter.next() { + result.append(contentsOf: first) + while let next = iter.next() { + result.append("/") + result.append(contentsOf: next) + } + } + + // Sanity-check the result (including the capacity we reserved). + assert(result.count == capacity, "count: " + + "\(result.count), cap: \(capacity)") + + // If the result is empty, return `.`, otherwise we return it as a string. + self.init(string: result.isEmpty ? "." : result) + } + + init(validatingAbsolutePath path: String) throws { + switch path.first { + case "/": + self.init(normalizingAbsolutePath: path) + case "~": + throw PathValidationError.startsWithTilde(path) + default: + throw PathValidationError.invalidAbsolutePath(path) + } + } + + init(validatingRelativePath path: String) throws { + switch path.first { + case "/": + throw PathValidationError.invalidRelativePath(path) + default: + self.init(normalizingRelativePath: path) + } + } + + func suffix(withDot: Bool) -> String? { + // FIXME: This method seems too complicated; it should be simplified, + // if possible, and certainly optimized (using UTF8View). + // Find the last path separator, if any. + let sIdx = string.lastIndex(of: "/") + // Find the start of the basename. + let bIdx = (sIdx != nil) ? string.index(after: sIdx!) : string.startIndex + // Find the last `.` (if any), starting from the second character of + // the basename (a leading `.` does not make the whole path component + // a suffix). + let fIdx = string.index(bIdx, offsetBy: 1, limitedBy: string.endIndex) ?? string.startIndex + if let idx = string[fIdx...].lastIndex(of: ".") { + // Unless it's just a `.` at the end, we have found a suffix. + if string.distance(from: idx, to: string.endIndex) > 1 { + let fromIndex = withDot ? idx : string.index(idx, offsetBy: 1) + return String(string.suffix(from: fromIndex)) + } else { + return nil + } + } + // If we get this far, there is no suffix. + return nil + } + + func appending(component name: String) -> Self { + assert(!name.contains("/"), "\(name) is invalid path component") + + // Handle pseudo paths. + switch name { + case "", ".": + return self + case "..": + return self.parentDirectory + default: + break + } + + if self == Self.root { + return Self(string: "/" + name) + } else { + return Self(string: string + "/" + name) + } + } + + func appending(relativePath: Self) -> Self { + // Both paths are already normalized. The only case in which we have + // to renormalize their concatenation is if the relative path starts + // with a `..` path component. + var newPathString = string + if self != .root { + newPathString.append("/") + } + + let relativePathString = relativePath.string + newPathString.append(relativePathString) + + // If the relative string starts with `.` or `..`, we need to normalize + // the resulting string. + // FIXME: We can actually optimize that case, since we know that the + // normalization of a relative path can leave `..` path components at + // the beginning of the path only. + if relativePathString.hasPrefix(".") { + if newPathString.hasPrefix("/") { + return Self(normalizingAbsolutePath: newPathString) + } else { + return Self(normalizingRelativePath: newPathString) + } + } else { + return Self(string: newPathString) + } + } +} +#endif + +/// Describes the way in which a path is invalid. +public enum PathValidationError: Error { + case startsWithTilde(String) + case invalidAbsolutePath(String) + case invalidRelativePath(String) +} + +extension PathValidationError: CustomStringConvertible { + public var description: String { + switch self { + case .startsWithTilde(let path): + return "invalid absolute path '\(path)'; absolute path must begin with '/'" + case .invalidAbsolutePath(let path): + return "invalid absolute path '\(path)'" + case .invalidRelativePath(let path): + return "invalid relative path '\(path)'; relative path should not begin with '\(AbsolutePath.root.pathString)'" + } + } +} + +extension AbsolutePath { + /// Returns a relative path that, when concatenated to `base`, yields the + /// callee path itself. If `base` is not an ancestor of the callee, the + /// returned path will begin with one or more `..` path components. + /// + /// Because both paths are absolute, they always have a common ancestor + /// (the root path, if nothing else). Therefore, any path can be made + /// relative to any other path by using a sufficient number of `..` path + /// components. + /// + /// This method is strictly syntactic and does not access the file system + /// in any way. Therefore, it does not take symbolic links into account. + public func relative(to base: AbsolutePath) -> RelativePath { + let result: RelativePath + // Split the two paths into their components. + // FIXME: The is needs to be optimized to avoid unnecessary copying. + let pathComps = self.components + let baseComps = base.components + + // It's common for the base to be an ancestor, so try that first. + if pathComps.starts(with: baseComps) { + // Special case, which is a plain path without `..` components. It + // might be an empty path (when self and the base are equal). + let relComps = pathComps.dropFirst(baseComps.count) +#if os(Windows) + let pathString = relComps.joined(separator: "\\") +#else + let pathString = relComps.joined(separator: "/") +#endif + do { + result = try RelativePath(validating: pathString) + } catch { + preconditionFailure("invalid relative path computed from \(pathString)") + } + + } else { + // General case, in which we might well need `..` components to go + // "up" before we can go "down" the directory tree. + var newPathComps = ArraySlice(pathComps) + var newBaseComps = ArraySlice(baseComps) + while newPathComps.prefix(1) == newBaseComps.prefix(1) { + // First component matches, so drop it. + newPathComps = newPathComps.dropFirst() + newBaseComps = newBaseComps.dropFirst() + } + // Now construct a path consisting of as many `..`s as are in the + // `newBaseComps` followed by what remains in `newPathComps`. + var relComps = Array(repeating: "..", count: newBaseComps.count) + relComps.append(contentsOf: newPathComps) +#if os(Windows) + let pathString = relComps.joined(separator: "\\") +#else + let pathString = relComps.joined(separator: "/") +#endif + do { + result = try RelativePath(validating: pathString) + } catch { + preconditionFailure("invalid relative path computed from \(pathString)") + } + } + + assert(AbsolutePath(base, result) == self) + return result + } + + /// Returns true if the path contains the given path. + /// + /// This method is strictly syntactic and does not access the file system + /// in any way. + @available(*, deprecated, renamed: "isDescendantOfOrEqual(to:)") + public func contains(_ other: AbsolutePath) -> Bool { + return isDescendantOfOrEqual(to: other) + } + + /// Returns true if the path is an ancestor of the given path. + /// + /// This method is strictly syntactic and does not access the file system + /// in any way. + public func isAncestor(of descendant: AbsolutePath) -> Bool { + return descendant.components.dropLast().starts(with: self.components) + } + + /// Returns true if the path is an ancestor of or equal to the given path. + /// + /// This method is strictly syntactic and does not access the file system + /// in any way. + public func isAncestorOfOrEqual(to descendant: AbsolutePath) -> Bool { + return descendant.components.starts(with: self.components) + } + + /// Returns true if the path is a descendant of the given path. + /// + /// This method is strictly syntactic and does not access the file system + /// in any way. + public func isDescendant(of ancestor: AbsolutePath) -> Bool { + return self.components.dropLast().starts(with: ancestor.components) + } + + /// Returns true if the path is a descendant of or equal to the given path. + /// + /// This method is strictly syntactic and does not access the file system + /// in any way. + public func isDescendantOfOrEqual(to ancestor: AbsolutePath) -> Bool { + return self.components.starts(with: ancestor.components) + } +} + +extension PathValidationError: CustomNSError { + public var errorUserInfo: [String : Any] { + return [NSLocalizedDescriptionKey: self.description] + } +} + +// FIXME: We should consider whether to merge the two `normalize()` functions. +// The argument for doing so is that some of the code is repeated; the argument +// against doing so is that some of the details are different, and since any +// given path is either absolute or relative, it's wasteful to keep checking +// for whether it's relative or absolute. Possibly we can do both by clever +// use of generics that abstract away the differences. + +/// Fast check for if a string might need normalization. +/// +/// This assumes that paths containing dotfiles are rare: +private func mayNeedNormalization(absolute string: String) -> Bool { + var last = UInt8(ascii: "0") + for c in string.utf8 { + switch c { + case UInt8(ascii: "/") where last == UInt8(ascii: "/"): + return true + case UInt8(ascii: ".") where last == UInt8(ascii: "/"): + return true + default: + break + } + last = c + } + if last == UInt8(ascii: "/") { + return true + } + return false +} + +// MARK: - `AbsolutePath` backwards compatibility, delete after deprecation period. + +extension AbsolutePath { + @_disfavoredOverload + @available(*, deprecated, message: "use throwing `init(validating:)` variant instead") + public init(_ absStr: String) { + try! self.init(validating: absStr) + } + + @_disfavoredOverload + @available(*, deprecated, message: "use throwing `init(validating:relativeTo:)` variant instead") + public init(_ str: String, relativeTo basePath: AbsolutePath) { + try! self.init(validating: str, relativeTo: basePath) + } + + @_disfavoredOverload + @available(*, deprecated, message: "use throwing variant instead") + public init(_ absPath: AbsolutePath, _ relStr: String) { + try! self.init(absPath, validating: relStr) + } +} + +// MARK: - `AbsolutePath` backwards compatibility, delete after deprecation period. + +extension RelativePath { + @_disfavoredOverload + @available(*, deprecated, message: "use throwing variant instead") + public init(_ string: String) { + try! self.init(validating: string) + } +} diff --git a/Tool/Sources/FileSystem/PathShim.swift b/Tool/Sources/FileSystem/PathShim.swift new file mode 100644 index 00000000..aacf2fca --- /dev/null +++ b/Tool/Sources/FileSystem/PathShim.swift @@ -0,0 +1,229 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors + ------------------------------------------------------------------------- + + This file contains temporary shim functions for use during the adoption of + AbsolutePath and RelativePath. The eventual plan is to use the FileSystem + API for all of this, at which time this file will go way. But since it is + important to have a quality FileSystem API, we will evolve it slowly. + + Meanwhile this file bridges the gap to let call sites be as clean as possible, + while making it fairly easy to find those calls later. + */ + +import Foundation + +#if canImport(Glibc) +@_exported import Glibc +#elseif canImport(Musl) +@_exported import Musl +#elseif os(Windows) +@_exported import CRT +@_exported import WinSDK +#else +@_exported import Darwin.C +#endif + +/// Returns the "real path" corresponding to `path` by resolving any symbolic links. +public func resolveSymlinks(_ path: AbsolutePath) throws -> AbsolutePath { + #if os(Windows) + let handle: HANDLE = path.pathString.withCString(encodedAs: UTF16.self) { + CreateFileW( + $0, + GENERIC_READ, + DWORD(FILE_SHARE_READ), + nil, + DWORD(OPEN_EXISTING), + DWORD(FILE_FLAG_BACKUP_SEMANTICS), + nil + ) + } + if handle == INVALID_HANDLE_VALUE { return path } + defer { CloseHandle(handle) } + return try withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: 261) { + let dwLength: DWORD = + GetFinalPathNameByHandleW( + handle, + $0.baseAddress!, + DWORD($0.count), + DWORD(FILE_NAME_NORMALIZED) + ) + let path = String(decodingCString: $0.baseAddress!, as: UTF16.self) + return try AbsolutePath(path) + } + #else + let pathStr = path.pathString + + // FIXME: We can't use FileManager's destinationOfSymbolicLink because + // that implements readlink and not realpath. + if let resultPtr = realpath(pathStr, nil) { + let result = String(cString: resultPtr) + // If `resolved_path` is specified as NULL, then `realpath` uses + // malloc(3) to allocate a buffer [...]. The caller should deallocate + // this buffer using free(3). + // + // String.init(cString:) creates a new string by copying the + // null-terminated UTF-8 data referenced by the given pointer. + resultPtr.deallocate() + // FIXME: We should measure if it's really more efficient to compare the strings first. + return result == pathStr ? path : try AbsolutePath(validating: result) + } + + return path + #endif +} + +/// Creates a new, empty directory at `path`. If needed, any non-existent ancestor paths are also +/// created. If there is +/// already a directory at `path`, this function does nothing (in particular, this is not considered +/// to be an error). +public func makeDirectories(_ path: AbsolutePath) throws { + try FileManager.default.createDirectory( + atPath: path.pathString, + withIntermediateDirectories: true, + attributes: [:] + ) +} + +/// Creates a symbolic link at `path` whose content points to `dest`. If `relative` is true, the +/// symlink contents will +/// be a relative path, otherwise it will be absolute. +@available(*, deprecated, renamed: "localFileSystem.createSymbolicLink") +public func createSymlink( + _ path: AbsolutePath, + pointingAt dest: AbsolutePath, + relative: Bool = true +) throws { + let destString = relative ? dest.relative(to: path.parentDirectory).pathString : dest.pathString + try FileManager.default.createSymbolicLink( + atPath: path.pathString, + withDestinationPath: destString + ) +} + +/** + - Returns: a generator that walks the specified directory producing all + files therein. If recursively is true will enter any directories + encountered recursively. + + - Warning: directories that cannot be entered due to permission problems + are silently ignored. So keep that in mind. + + - Warning: Symbolic links that point to directories are *not* followed. + + - Note: setting recursively to `false` still causes the generator to feed + you the directory; just not its contents. + */ +public func walk( + _ path: AbsolutePath, + fileSystem: FileSystem = localFileSystem, + recursively: Bool = true +) throws -> RecursibleDirectoryContentsGenerator { + return try RecursibleDirectoryContentsGenerator( + path: path, + fileSystem: fileSystem, + recursionFilter: { _ in recursively } + ) +} + +/** + - Returns: a generator that walks the specified directory producing all + files therein. Directories are recursed based on the return value of + `recursing`. + + - Warning: directories that cannot be entered due to permissions problems + are silently ignored. So keep that in mind. + + - Warning: Symbolic links that point to directories are *not* followed. + + - Note: returning `false` from `recursing` still produces that directory + from the generator; just not its contents. + */ +public func walk( + _ path: AbsolutePath, + fileSystem: FileSystem = localFileSystem, + recursing: @escaping (AbsolutePath) -> Bool +) throws -> RecursibleDirectoryContentsGenerator { + return try RecursibleDirectoryContentsGenerator( + path: path, + fileSystem: fileSystem, + recursionFilter: recursing + ) +} + +/** + Produced by `walk`. + */ +public class RecursibleDirectoryContentsGenerator: IteratorProtocol, Sequence { + private var current: (path: AbsolutePath, iterator: IndexingIterator<[String]>) + private var towalk = [AbsolutePath]() + + private let shouldRecurse: (AbsolutePath) -> Bool + private let fileSystem: FileSystem + + fileprivate init( + path: AbsolutePath, + fileSystem: FileSystem, + recursionFilter: @escaping (AbsolutePath) -> Bool + ) throws { + self.fileSystem = fileSystem + // FIXME: getDirectoryContents should have an iterator version. + current = try ( + path, + fileSystem.getDirectoryContents(at: path).map(\.basename).makeIterator() + ) + shouldRecurse = recursionFilter + } + + public func next() -> AbsolutePath? { + outer: while true { + guard let entry = current.iterator.next() else { + while !towalk.isEmpty { + // FIXME: This looks inefficient. + let path = towalk.removeFirst() + guard shouldRecurse(path) else { continue } + // Ignore if we can't get content for this path. + guard let current = try? fileSystem.getDirectoryContents(at: path) + .map(\.basename) + .makeIterator() else { continue } + self.current = (path, current) + continue outer + } + return nil + } + + let path = current.path.appending(component: entry) + if fileSystem.isDirectory(path) && !fileSystem.isSymlink(path) { + towalk.append(path) + } + return path + } + } +} + +public extension AbsolutePath { + /// Returns a path suitable for display to the user (if possible, it is made + /// to be relative to the current working directory). + func prettyPath(cwd: AbsolutePath? = localFileSystem.currentWorkingDirectory) -> String { + guard let dir = cwd else { + // No current directory, display as is. + return pathString + } + // FIXME: Instead of string prefix comparison we should add a proper API + // to AbsolutePath to determine ancestry. + if self == dir { + return "." + } else if pathString.hasPrefix(dir.pathString + "/") { + return "./" + relative(to: dir).pathString + } else { + return pathString + } + } +} + diff --git a/Tool/Sources/FileSystem/WritableByteStream.swift b/Tool/Sources/FileSystem/WritableByteStream.swift new file mode 100644 index 00000000..3dcc4eff --- /dev/null +++ b/Tool/Sources/FileSystem/WritableByteStream.swift @@ -0,0 +1,846 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +/// Closable entity is one that manages underlying resources and needs to be closed for cleanup +/// The intent of this method is for the sole owner of the refernece/handle of the resource to close it completely, compared to releasing a shared resource. +public protocol Closable { + func close() throws +} + +import Dispatch + +#if canImport(Glibc) +@_exported import Glibc +#elseif canImport(Musl) +@_exported import Musl +#elseif os(Windows) +@_exported import CRT +@_exported import WinSDK +#else +@_exported import Darwin.C +#endif + +/// Convert an integer in 0..<16 to its hexadecimal ASCII character. +private func hexdigit(_ value: UInt8) -> UInt8 { + return value < 10 ? (0x30 + value) : (0x41 + value - 10) +} + +/// Describes a type which can be written to a byte stream. +public protocol ByteStreamable { + func write(to stream: WritableByteStream) +} + +/// An output byte stream. +/// +/// This protocol is designed to be able to support efficient streaming to +/// different output destinations, e.g., a file or an in memory buffer. This is +/// loosely modeled on LLVM's llvm::raw_ostream class. +/// +/// The stream is generally used in conjunction with the `appending` function. +/// For example: +/// +/// let stream = BufferedOutputByteStream() +/// stream.appending("Hello, world!") +/// +/// would write the UTF8 encoding of "Hello, world!" to the stream. +/// +/// The stream accepts a number of custom formatting operators which are defined +/// in the `Format` struct (used for namespacing purposes). For example: +/// +/// let items = ["hello", "world"] +/// stream.appending(Format.asSeparatedList(items, separator: " ")) +/// +/// would write each item in the list to the stream, separating them with a +/// space. +public protocol WritableByteStream: AnyObject, TextOutputStream, Closable { + /// The current offset within the output stream. + var position: Int { get } + + /// Write an individual byte to the buffer. + func write(_ byte: UInt8) + + /// Write a collection of bytes to the buffer. + func write(_ bytes: C) where C.Element == UInt8 + + /// Flush the stream's buffer. + func flush() +} + +// Default noop implementation of close to avoid source-breaking downstream dependents with the addition of the close +// API. +public extension WritableByteStream { + func close() throws { } +} + +// Public alias to the old name to not introduce API compatibility. +public typealias OutputByteStream = WritableByteStream + +#if os(Android) || canImport(Musl) +public typealias FILEPointer = OpaquePointer +#else +public typealias FILEPointer = UnsafeMutablePointer +#endif + +extension WritableByteStream { + /// Write a sequence of bytes to the buffer. + public func write(sequence: S) where S.Iterator.Element == UInt8 { + // Iterate the sequence and append byte by byte since sequence's append + // is not performant anyway. + for byte in sequence { + write(byte) + } + } + + /// Write a string to the buffer (as UTF8). + public func write(_ string: String) { + // FIXME(performance): Use `string.utf8._copyContents(initializing:)`. + write(string.utf8) + } + + /// Write a string (as UTF8) to the buffer, with escaping appropriate for + /// embedding within a JSON document. + /// + /// - Note: This writes the literal data applying JSON string escaping, but + /// does not write any other characters (like the quotes that would surround + /// a JSON string). + public func writeJSONEscaped(_ string: String) { + // See RFC7159 for reference: https://tools.ietf.org/html/rfc7159 + for character in string.utf8 { + // Handle string escapes; we use constants here to directly match the RFC. + switch character { + // Literal characters. + case 0x20...0x21, 0x23...0x5B, 0x5D...0xFF: + write(character) + + // Single-character escaped characters. + case 0x22: // '"' + write(0x5C) // '\' + write(0x22) // '"' + case 0x5C: // '\\' + write(0x5C) // '\' + write(0x5C) // '\' + case 0x08: // '\b' + write(0x5C) // '\' + write(0x62) // 'b' + case 0x0C: // '\f' + write(0x5C) // '\' + write(0x66) // 'b' + case 0x0A: // '\n' + write(0x5C) // '\' + write(0x6E) // 'n' + case 0x0D: // '\r' + write(0x5C) // '\' + write(0x72) // 'r' + case 0x09: // '\t' + write(0x5C) // '\' + write(0x74) // 't' + + // Multi-character escaped characters. + default: + write(0x5C) // '\' + write(0x75) // 'u' + write(hexdigit(0)) + write(hexdigit(0)) + write(hexdigit(character >> 4)) + write(hexdigit(character & 0xF)) + } + } + } + + // MARK: helpers that return `self` + + // FIXME: This override shouldn't be necessary but removing it causes a 30% performance regression. This problem is + // tracked by the following bug: https://bugs.swift.org/browse/SR-8535 + @discardableResult + public func send(_ value: ArraySlice) -> WritableByteStream { + value.write(to: self) + return self + } + + @discardableResult + public func send(_ value: ByteStreamable) -> WritableByteStream { + value.write(to: self) + return self + } + + @discardableResult + public func send(_ value: CustomStringConvertible) -> WritableByteStream { + value.description.write(to: self) + return self + } + + @discardableResult + public func send(_ value: ByteStreamable & CustomStringConvertible) -> WritableByteStream { + value.write(to: self) + return self + } +} + +/// The `WritableByteStream` base class. +/// +/// This class provides a base and efficient implementation of the `WritableByteStream` +/// protocol. It can not be used as is-as subclasses as several functions need to be +/// implemented in subclasses. +public class _WritableByteStreamBase: WritableByteStream { + /// If buffering is enabled + @usableFromInline let _buffered : Bool + + /// The data buffer. + /// - Note: Minimum Buffer size should be one. + @usableFromInline var _buffer: [UInt8] + + /// Default buffer size of the data buffer. + private static let bufferSize = 1024 + + /// Queue to protect mutating operation. + fileprivate let queue = DispatchQueue(label: "org.swift.swiftpm.basic.stream") + + init(buffered: Bool) { + self._buffered = buffered + self._buffer = [] + + // When not buffered we still reserve 1 byte, as it is used by the + // by the single byte write() variant. + self._buffer.reserveCapacity(buffered ? _WritableByteStreamBase.bufferSize : 1) + } + + // MARK: Data Access API + + /// The current offset within the output stream. + public var position: Int { + return _buffer.count + } + + /// Currently available buffer size. + @usableFromInline var _availableBufferSize: Int { + return _buffer.capacity - _buffer.count + } + + /// Clears the buffer maintaining current capacity. + @usableFromInline func _clearBuffer() { + _buffer.removeAll(keepingCapacity: true) + } + + // MARK: Data Output API + + public final func flush() { + writeImpl(ArraySlice(_buffer)) + _clearBuffer() + flushImpl() + } + + @usableFromInline func flushImpl() { + // Do nothing. + } + + public final func close() throws { + try closeImpl() + } + + @usableFromInline func closeImpl() throws { + fatalError("Subclasses must implement this") + } + + @usableFromInline func writeImpl(_ bytes: C) where C.Iterator.Element == UInt8 { + fatalError("Subclasses must implement this") + } + + @usableFromInline func writeImpl(_ bytes: ArraySlice) { + fatalError("Subclasses must implement this") + } + + /// Write an individual byte to the buffer. + public final func write(_ byte: UInt8) { + guard _buffered else { + _buffer.append(byte) + writeImpl(ArraySlice(_buffer)) + flushImpl() + _clearBuffer() + return + } + + // If buffer is full, write and clear it. + if _availableBufferSize == 0 { + writeImpl(ArraySlice(_buffer)) + _clearBuffer() + } + + // This will need to change change if we ever have unbuffered stream. + precondition(_availableBufferSize > 0) + _buffer.append(byte) + } + + /// Write a collection of bytes to the buffer. + @inlinable public final func write(_ bytes: C) where C.Element == UInt8 { + guard _buffered else { + if let b = bytes as? ArraySlice { + // Fast path for unbuffered ArraySlice + writeImpl(b) + } else if let b = bytes as? Array { + // Fast path for unbuffered Array + writeImpl(ArraySlice(b)) + } else { + // generic collection unfortunately must be temporarily buffered + writeImpl(bytes) + } + flushImpl() + return + } + + // This is based on LLVM's raw_ostream. + let availableBufferSize = self._availableBufferSize + let byteCount = Int(bytes.count) + + // If we have to insert more than the available space in buffer. + if byteCount > availableBufferSize { + // If buffer is empty, start writing and keep the last chunk in buffer. + if _buffer.isEmpty { + let bytesToWrite = byteCount - (byteCount % availableBufferSize) + let writeUptoIndex = bytes.index(bytes.startIndex, offsetBy: numericCast(bytesToWrite)) + writeImpl(bytes.prefix(upTo: writeUptoIndex)) + + // If remaining bytes is more than buffer size write everything. + let bytesRemaining = byteCount - bytesToWrite + if bytesRemaining > availableBufferSize { + writeImpl(bytes.suffix(from: writeUptoIndex)) + return + } + // Otherwise keep remaining in buffer. + _buffer += bytes.suffix(from: writeUptoIndex) + return + } + + let writeUptoIndex = bytes.index(bytes.startIndex, offsetBy: numericCast(availableBufferSize)) + // Append whatever we can accommodate. + _buffer += bytes.prefix(upTo: writeUptoIndex) + + writeImpl(ArraySlice(_buffer)) + _clearBuffer() + + // FIXME: We should start again with remaining chunk but this doesn't work. Write everything for now. + //write(collection: bytes.suffix(from: writeUptoIndex)) + writeImpl(bytes.suffix(from: writeUptoIndex)) + return + } + _buffer += bytes + } +} + +/// The thread-safe wrapper around output byte streams. +/// +/// This class wraps any `WritableByteStream` conforming type to provide a type-safe +/// access to its operations. If the provided stream inherits from `_WritableByteStreamBase`, +/// it will also ensure it is type-safe will all other `ThreadSafeOutputByteStream` instances +/// around the same stream. +public final class ThreadSafeOutputByteStream: WritableByteStream { + private static let defaultQueue = DispatchQueue(label: "org.swift.swiftpm.basic.thread-safe-output-byte-stream") + public let stream: WritableByteStream + private let queue: DispatchQueue + + public var position: Int { + return queue.sync { + stream.position + } + } + + public init(_ stream: WritableByteStream) { + self.stream = stream + self.queue = (stream as? _WritableByteStreamBase)?.queue ?? ThreadSafeOutputByteStream.defaultQueue + } + + public func write(_ byte: UInt8) { + queue.sync { + stream.write(byte) + } + } + + public func write(_ bytes: C) where C.Element == UInt8 { + queue.sync { + stream.write(bytes) + } + } + + public func flush() { + queue.sync { + stream.flush() + } + } + + public func write(sequence: S) where S.Iterator.Element == UInt8 { + queue.sync { + stream.write(sequence: sequence) + } + } + + public func writeJSONEscaped(_ string: String) { + queue.sync { + stream.writeJSONEscaped(string) + } + } + + public func close() throws { + try queue.sync { + try stream.close() + } + } +} + + +#if swift(<5.6) +extension ThreadSafeOutputByteStream: UnsafeSendable {} +#else +extension ThreadSafeOutputByteStream: @unchecked Sendable {} +#endif + +/// Define an output stream operator. We need it to be left associative, so we +/// use `<<<`. +infix operator <<< : StreamingPrecedence +precedencegroup StreamingPrecedence { + associativity: left +} + +// MARK: Output Operator Implementations + +// FIXME: This override shouldn't be necessary but removing it causes a 30% performance regression. This problem is +// tracked by the following bug: https://bugs.swift.org/browse/SR-8535 + +@available(*, deprecated, message: "use send(_:) function on WritableByteStream instead") +@discardableResult +public func <<< (stream: WritableByteStream, value: ArraySlice) -> WritableByteStream { + value.write(to: stream) + return stream +} + +@available(*, deprecated, message: "use send(_:) function on WritableByteStream instead") +@discardableResult +public func <<< (stream: WritableByteStream, value: ByteStreamable) -> WritableByteStream { + value.write(to: stream) + return stream +} + +@available(*, deprecated, message: "use send(_:) function on WritableByteStream instead") +@discardableResult +public func <<< (stream: WritableByteStream, value: CustomStringConvertible) -> WritableByteStream { + value.description.write(to: stream) + return stream +} + +@available(*, deprecated, message: "use send(_:) function on WritableByteStream instead") +@discardableResult +public func <<< (stream: WritableByteStream, value: ByteStreamable & CustomStringConvertible) -> WritableByteStream { + value.write(to: stream) + return stream +} + +extension UInt8: ByteStreamable { + public func write(to stream: WritableByteStream) { + stream.write(self) + } +} + +extension Character: ByteStreamable { + public func write(to stream: WritableByteStream) { + stream.write(String(self)) + } +} + +extension String: ByteStreamable { + public func write(to stream: WritableByteStream) { + stream.write(self.utf8) + } +} + +extension Substring: ByteStreamable { + public func write(to stream: WritableByteStream) { + stream.write(self.utf8) + } +} + +extension StaticString: ByteStreamable { + public func write(to stream: WritableByteStream) { + withUTF8Buffer { stream.write($0) } + } +} + +extension Array: ByteStreamable where Element == UInt8 { + public func write(to stream: WritableByteStream) { + stream.write(self) + } +} + +extension ArraySlice: ByteStreamable where Element == UInt8 { + public func write(to stream: WritableByteStream) { + stream.write(self) + } +} + +extension ContiguousArray: ByteStreamable where Element == UInt8 { + public func write(to stream: WritableByteStream) { + stream.write(self) + } +} + +// MARK: Formatted Streaming Output + +/// Provides operations for returning derived streamable objects to implement various forms of formatted output. +public struct Format { + /// Write the input boolean encoded as a JSON object. + static public func asJSON(_ value: Bool) -> ByteStreamable { + return JSONEscapedBoolStreamable(value: value) + } + private struct JSONEscapedBoolStreamable: ByteStreamable { + let value: Bool + + func write(to stream: WritableByteStream) { + stream.send(value ? "true" : "false") + } + } + + /// Write the input integer encoded as a JSON object. + static public func asJSON(_ value: Int) -> ByteStreamable { + return JSONEscapedIntStreamable(value: value) + } + private struct JSONEscapedIntStreamable: ByteStreamable { + let value: Int + + func write(to stream: WritableByteStream) { + // FIXME: Diagnose integers which cannot be represented in JSON. + stream.send(value.description) + } + } + + /// Write the input double encoded as a JSON object. + static public func asJSON(_ value: Double) -> ByteStreamable { + return JSONEscapedDoubleStreamable(value: value) + } + private struct JSONEscapedDoubleStreamable: ByteStreamable { + let value: Double + + func write(to stream: WritableByteStream) { + // FIXME: What should we do about NaN, etc.? + // + // FIXME: Is Double.debugDescription the best representation? + stream.send(value.debugDescription) + } + } + + /// Write the input CustomStringConvertible encoded as a JSON object. + static public func asJSON(_ value: T) -> ByteStreamable { + return JSONEscapedStringStreamable(value: value.description) + } + /// Write the input string encoded as a JSON object. + static public func asJSON(_ string: String) -> ByteStreamable { + return JSONEscapedStringStreamable(value: string) + } + private struct JSONEscapedStringStreamable: ByteStreamable { + let value: String + + func write(to stream: WritableByteStream) { + stream.send(UInt8(ascii: "\"")) + stream.writeJSONEscaped(value) + stream.send(UInt8(ascii: "\"")) + } + } + + /// Write the input string list encoded as a JSON object. + static public func asJSON(_ items: [T]) -> ByteStreamable { + return JSONEscapedStringListStreamable(items: items.map({ $0.description })) + } + /// Write the input string list encoded as a JSON object. + // + // FIXME: We might be able to make this more generic through the use of a "JSONEncodable" protocol. + static public func asJSON(_ items: [String]) -> ByteStreamable { + return JSONEscapedStringListStreamable(items: items) + } + private struct JSONEscapedStringListStreamable: ByteStreamable { + let items: [String] + + func write(to stream: WritableByteStream) { + stream.send(UInt8(ascii: "[")) + for (i, item) in items.enumerated() { + if i != 0 { stream.send(",") } + stream.send(Format.asJSON(item)) + } + stream.send(UInt8(ascii: "]")) + } + } + + /// Write the input dictionary encoded as a JSON object. + static public func asJSON(_ items: [String: String]) -> ByteStreamable { + return JSONEscapedDictionaryStreamable(items: items) + } + private struct JSONEscapedDictionaryStreamable: ByteStreamable { + let items: [String: String] + + func write(to stream: WritableByteStream) { + stream.send(UInt8(ascii: "{")) + for (offset: i, element: (key: key, value: value)) in items.enumerated() { + if i != 0 { stream.send(",") } + stream.send(Format.asJSON(key)).send(":").send(Format.asJSON(value)) + } + stream.send(UInt8(ascii: "}")) + } + } + + /// Write the input list (after applying a transform to each item) encoded as a JSON object. + // + // FIXME: We might be able to make this more generic through the use of a "JSONEncodable" protocol. + static public func asJSON(_ items: [T], transform: @escaping (T) -> String) -> ByteStreamable { + return JSONEscapedTransformedStringListStreamable(items: items, transform: transform) + } + private struct JSONEscapedTransformedStringListStreamable: ByteStreamable { + let items: [T] + let transform: (T) -> String + + func write(to stream: WritableByteStream) { + stream.send(UInt8(ascii: "[")) + for (i, item) in items.enumerated() { + if i != 0 { stream.send(",") } + stream.send(Format.asJSON(transform(item))) + } + stream.send(UInt8(ascii: "]")) + } + } + + /// Write the input list to the stream with the given separator between items. + static public func asSeparatedList(_ items: [T], separator: String) -> ByteStreamable { + return SeparatedListStreamable(items: items, separator: separator) + } + private struct SeparatedListStreamable: ByteStreamable { + let items: [T] + let separator: String + + func write(to stream: WritableByteStream) { + for (i, item) in items.enumerated() { + // Add the separator, if necessary. + if i != 0 { + stream.send(separator) + } + + stream.send(item) + } + } + } + + /// Write the input list to the stream (after applying a transform to each item) with the given separator between + /// items. + static public func asSeparatedList( + _ items: [T], + transform: @escaping (T) -> ByteStreamable, + separator: String + ) -> ByteStreamable { + return TransformedSeparatedListStreamable(items: items, transform: transform, separator: separator) + } + private struct TransformedSeparatedListStreamable: ByteStreamable { + let items: [T] + let transform: (T) -> ByteStreamable + let separator: String + + func write(to stream: WritableByteStream) { + for (i, item) in items.enumerated() { + if i != 0 { stream.send(separator) } + stream.send(transform(item)) + } + } + } + + static public func asRepeating(string: String, count: Int) -> ByteStreamable { + return RepeatingStringStreamable(string: string, count: count) + } + private struct RepeatingStringStreamable: ByteStreamable { + let string: String + let count: Int + + init(string: String, count: Int) { + precondition(count >= 0, "Count should be >= zero") + self.string = string + self.count = count + } + + func write(to stream: WritableByteStream) { + for _ in 0..(_ bytes: C) where C.Iterator.Element == UInt8 { + contents += bytes + } + override final func writeImpl(_ bytes: ArraySlice) { + contents += bytes + } + + override final func closeImpl() throws { + // Do nothing. The protocol does not require to stop receiving writes, close only signals that resources could + // be released at this point should we need to. + } +} + +/// Represents a stream which is backed to a file. Not for instantiating. +public class FileOutputByteStream: _WritableByteStreamBase { + + public override final func closeImpl() throws { + flush() + try fileCloseImpl() + } + + /// Closes the file flushing any buffered data. + func fileCloseImpl() throws { + fatalError("fileCloseImpl() should be implemented by a subclass") + } +} + +/// Implements file output stream for local file system. +public final class LocalFileOutputByteStream: FileOutputByteStream { + + /// The pointer to the file. + let filePointer: FILEPointer + + /// Set to an error value if there were any IO error during writing. + private var error: FileSystemError? + + /// Closes the file on deinit if true. + private var closeOnDeinit: Bool + + /// Path to the file this stream should operate on. + private let path: AbsolutePath? + + /// Instantiate using the file pointer. + public init(filePointer: FILEPointer, closeOnDeinit: Bool = true, buffered: Bool = true) throws { + self.filePointer = filePointer + self.closeOnDeinit = closeOnDeinit + self.path = nil + super.init(buffered: buffered) + } + + /// Opens the file for writing at the provided path. + /// + /// - Parameters: + /// - path: Path to the file this stream should operate on. + /// - closeOnDeinit: If true closes the file on deinit. clients can use + /// close() if they want to close themselves or catch + /// errors encountered during writing to the file. + /// Default value is true. + /// - buffered: If true buffers writes in memory until full or flush(). + /// Otherwise, writes are processed and flushed immediately. + /// Default value is true. + /// + /// - Throws: FileSystemError + public init(_ path: AbsolutePath, closeOnDeinit: Bool = true, buffered: Bool = true) throws { + guard let filePointer = fopen(path.pathString, "wb") else { + throw FileSystemError(errno: errno, path) + } + self.path = path + self.filePointer = filePointer + self.closeOnDeinit = closeOnDeinit + super.init(buffered: buffered) + } + + deinit { + if closeOnDeinit { + fclose(filePointer) + } + } + + func errorDetected(code: Int32?) { + if let code = code { + error = .init(.ioError(code: code), path) + } else { + error = .init(.unknownOSError, path) + } + } + + override final func writeImpl(_ bytes: C) where C.Iterator.Element == UInt8 { + // FIXME: This will be copying bytes but we don't have option currently. + var contents = [UInt8](bytes) + while true { + let n = fwrite(&contents, 1, contents.count, filePointer) + if n < 0 { + if errno == EINTR { continue } + errorDetected(code: errno) + } else if n != contents.count { + errorDetected(code: nil) + } + break + } + } + + override final func writeImpl(_ bytes: ArraySlice) { + bytes.withUnsafeBytes { bytesPtr in + while true { + let n = fwrite(bytesPtr.baseAddress!, 1, bytesPtr.count, filePointer) + if n < 0 { + if errno == EINTR { continue } + errorDetected(code: errno) + } else if n != bytesPtr.count { + errorDetected(code: nil) + } + break + } + } + } + + override final func flushImpl() { + fflush(filePointer) + } + + override final func fileCloseImpl() throws { + defer { + fclose(filePointer) + // If clients called close we shouldn't call fclose again in deinit. + closeOnDeinit = false + } + // Throw if errors were found during writing. + if let error = error { + throw error + } + } +} + +/// Public stdout stream instance. +public var stdoutStream: ThreadSafeOutputByteStream = try! ThreadSafeOutputByteStream(LocalFileOutputByteStream( + filePointer: stdout, + closeOnDeinit: false)) + +/// Public stderr stream instance. +public var stderrStream: ThreadSafeOutputByteStream = try! ThreadSafeOutputByteStream(LocalFileOutputByteStream( + filePointer: stderr, + closeOnDeinit: false)) diff --git a/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift new file mode 100644 index 00000000..817fe704 --- /dev/null +++ b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift @@ -0,0 +1,179 @@ +import Foundation +import SuggestionBasic + +public struct ActiveDocumentContext: Sendable { + public var documentURL: URL + public var relativePath: String + public var language: CodeLanguage + public var fileContent: String + public var lines: [String] + public var selectedCode: String + public var selectionRange: CursorRange + public var lineAnnotations: [EditorInformation.LineAnnotation] + public var imports: [String] + public var includes: [String] + + public struct FocusedContext: Sendable { + public struct Context: Equatable, Sendable { + public var signature: String + public var name: String + public var range: CursorRange + + public init(signature: String, name: String, range: CursorRange) { + self.signature = signature + self.name = name + self.range = range + } + } + + public var context: [Context] + public var contextRange: CursorRange + public var smallestContextRange: CursorRange + public var codeRange: CursorRange + public var code: String + public var lineAnnotations: [EditorInformation.LineAnnotation] + public var otherLineAnnotations: [EditorInformation.LineAnnotation] + + public init( + context: [Context], + contextRange: CursorRange, + smallestContextRange: CursorRange, + codeRange: CursorRange, + code: String, + lineAnnotations: [EditorInformation.LineAnnotation], + otherLineAnnotations: [EditorInformation.LineAnnotation] + ) { + self.context = context + self.contextRange = contextRange + self.smallestContextRange = smallestContextRange + self.codeRange = codeRange + self.code = code + self.lineAnnotations = lineAnnotations + self.otherLineAnnotations = otherLineAnnotations + } + } + + public var focusedContext: FocusedContext? + + public init( + documentURL: URL, + relativePath: String, + language: CodeLanguage, + fileContent: String, + lines: [String], + selectedCode: String, + selectionRange: CursorRange, + lineAnnotations: [EditorInformation.LineAnnotation], + imports: [String], + includes: [String], + focusedContext: FocusedContext? = nil + ) { + self.documentURL = documentURL + self.relativePath = relativePath + self.language = language + self.fileContent = fileContent + self.lines = lines + self.selectedCode = selectedCode + self.selectionRange = selectionRange + self.lineAnnotations = lineAnnotations + self.imports = imports + self.includes = includes + self.focusedContext = focusedContext + } + + public static func empty() -> ActiveDocumentContext { + .init( + documentURL: .init(fileURLWithPath: "/"), + relativePath: "", + language: .builtIn(.swift), + fileContent: "", + lines: [], + selectedCode: "", + selectionRange: .outOfScope, + lineAnnotations: [], + imports: [], + includes: [] + ) + } + + public mutating func moveToFocusedCode() { + moveToCodeContainingRange(selectionRange) + } + + public mutating func moveToCodeAroundLine(_ line: Int) { + moveToCodeContainingRange(.init( + start: .init(line: line, character: 0), + end: .init(line: line, character: 0) + )) + } + + public mutating func expandFocusedRangeToContextRange() { + guard let focusedContext else { return } + moveToCodeContainingRange(focusedContext.contextRange) + } + + public mutating func moveToCodeContainingRange(_ range: CursorRange) { + let finder = FocusedCodeFinder( + maxFocusedCodeLineCount: UserDefaults.shared.value(for: \.maxFocusedCodeLineCount) + ) + + let codeContext = finder.findFocusedCode( + in: .init(documentURL: documentURL, content: fileContent, lines: lines), + containingRange: range, + language: language + ) + + imports = codeContext.imports + includes = codeContext.includes + + let startLine = codeContext.focusedRange.start.line + let endLine = codeContext.focusedRange.end.line + var matchedAnnotations = [EditorInformation.LineAnnotation]() + var otherAnnotations = [EditorInformation.LineAnnotation]() + for annotation in lineAnnotations { + if annotation.line >= startLine, annotation.line <= endLine { + matchedAnnotations.append(annotation) + } else { + otherAnnotations.append(annotation) + } + } + + focusedContext = .init( + context: codeContext.scopeContexts, + contextRange: codeContext.contextRange, + smallestContextRange: codeContext.smallestContextRange, + codeRange: codeContext.focusedRange, + code: codeContext.focusedCode, + lineAnnotations: matchedAnnotations, + otherLineAnnotations: otherAnnotations + ) + } + + public mutating func update(_ info: EditorInformation) { + /// Whenever the file content, relative path, or selection range changes, + /// we should reset the context. + let changed: Bool = { + if info.relativePath != relativePath { return true } + if info.editorContent?.content != fileContent { return true } + if let range = info.editorContent?.selections.first, + range != selectionRange { return true } + return false + }() + + documentURL = info.documentURL + relativePath = info.relativePath + language = info.language + fileContent = info.editorContent?.content ?? "" + lines = info.editorContent?.lines ?? [] + selectedCode = info.selectedContent + selectionRange = info.editorContent?.selections.first ?? .zero + lineAnnotations = info.editorContent?.lineAnnotations ?? [] + imports = [] + includes = [] + + if changed { + moveToFocusedCode() + } + } +} + diff --git a/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift new file mode 100644 index 00000000..f3048335 --- /dev/null +++ b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift @@ -0,0 +1,113 @@ +import Foundation +import SuggestionBasic + +public struct CodeContext: Equatable { + public typealias ScopeContext = ActiveDocumentContext.FocusedContext.Context + + public enum Scope: Equatable { + case file + case top + case scope(signature: [ScopeContext]) + } + + public var scopeContexts: [ScopeContext] { + switch scope { + case .file: + return [] + case .top: + return [] + case let .scope(contexts): + return contexts + } + } + + public var scope: Scope + public var contextRange: CursorRange + public var smallestContextRange: CursorRange + public var focusedRange: CursorRange + public var focusedCode: String + public var imports: [String] + public var includes: [String] + + public static var empty: CodeContext { + .init( + scope: .file, + contextRange: .zero, + smallestContextRange: .zero, + focusedRange: .zero, + focusedCode: "", + imports: [], + includes: [] + ) + } + + public init( + scope: Scope, + contextRange: CursorRange, + smallestContextRange: CursorRange, + focusedRange: CursorRange, + focusedCode: String, + imports: [String], + includes: [String] + ) { + self.scope = scope + self.contextRange = contextRange + self.smallestContextRange = smallestContextRange + self.focusedRange = focusedRange + self.focusedCode = focusedCode + self.imports = imports + self.includes = includes + } +} + +public struct FocusedCodeFinder { + public let maxFocusedCodeLineCount: Int + + public init(maxFocusedCodeLineCount: Int) { + self.maxFocusedCodeLineCount = maxFocusedCodeLineCount + } + + public struct Document { + var documentURL: URL + var content: String + var lines: [String] + + public init(documentURL: URL, content: String, lines: [String]) { + self.documentURL = documentURL + self.content = content + self.lines = lines + } + } + + public func findFocusedCode( + in document: Document, + containingRange: CursorRange, + language: CodeLanguage + ) -> CodeContext { + let finder: FocusedCodeFinderType = { + switch language { + case .builtIn(.swift): + return SwiftFocusedCodeFinder(maxFocusedCodeLineCount: maxFocusedCodeLineCount) + case .builtIn(.objc), .builtIn(.objcpp), .builtIn(.c): + #warning( + "TODO: Implement C++ focused code finder, use it for C and metal shading language" + ) + return ObjectiveCFocusedCodeFinder(maxFocusedCodeLineCount: maxFocusedCodeLineCount) + default: + return UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + } + }() + + return finder.findFocusedCode(in: document, containingRange: containingRange) + } +} + +public protocol FocusedCodeFinderType { + typealias Document = FocusedCodeFinder.Document + + func findFocusedCode( + in document: Document, + containingRange: CursorRange + ) -> CodeContext +} + diff --git a/Tool/Sources/FocusedCodeFinder/KnownLanguageFocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/KnownLanguageFocusedCodeFinder.swift new file mode 100644 index 00000000..1571a770 --- /dev/null +++ b/Tool/Sources/FocusedCodeFinder/KnownLanguageFocusedCodeFinder.swift @@ -0,0 +1,214 @@ +import Foundation +import Preferences +import SuggestionBasic + +public typealias KnownLanguageFocusedCodeFinder = + BaseKnownLanguageFocusedCodeFinder & + KnownLanguageFocusedCodeFinderType + +public class BaseKnownLanguageFocusedCodeFinder { + public typealias TextProvider = (TextPosition) -> String + public typealias RangeConverter = (Node) -> CursorRange + + public struct NodeInfo { + var node: Node + var signature: String + var name: String + var canBeUsedAsCodeRange: Bool = true + } + + public struct ContextInfo { + var nodes: [Node] + var includes: [String] + var imports: [String] + } + + public let maxFocusedCodeLineCount: Int + + init( + maxFocusedCodeLineCount: Int = UserDefaults.shared.value(for: \.maxFocusedCodeLineCount) + ) { + self.maxFocusedCodeLineCount = maxFocusedCodeLineCount + } +} + +public protocol KnownLanguageFocusedCodeFinderType: FocusedCodeFinderType { + associatedtype Tree + associatedtype Node + associatedtype TextPosition + typealias Document = FocusedCodeFinder.Document + typealias Finder = BaseKnownLanguageFocusedCodeFinder + typealias NodeInfo = Finder.NodeInfo + typealias ContextInfo = Finder.ContextInfo + typealias TextProvider = Finder.TextProvider + typealias RangeConverter = Finder.RangeConverter + + var maxFocusedCodeLineCount: Int { get } + + func parseSyntaxTree(from document: Document) -> Tree? + + func collectContextNodes( + in document: Document, + tree: Tree, + containingRange: SuggestionBasic.CursorRange, + textProvider: @escaping TextProvider, + rangeConverter: @escaping RangeConverter + ) -> ContextInfo + + func contextContainingNode( + _ node: Node, + textProvider: @escaping TextProvider, + rangeConverter: @escaping RangeConverter + ) -> NodeInfo? + + func createTextProviderAndRangeConverter( + for document: Document, + tree: Tree + ) -> (TextProvider, RangeConverter) +} + +public extension KnownLanguageFocusedCodeFinderType { + func findFocusedCode( + in document: Document, + containingRange range: SuggestionBasic.CursorRange + ) -> CodeContext { + guard let tree = parseSyntaxTree(from: document) else { return .empty } + + let (textProvider, rangeConverter) = createTextProviderAndRangeConverter( + for: document, + tree: tree + ) + var contextInfo = collectContextNodes( + in: document, + tree: tree, + containingRange: range, + textProvider: textProvider, + rangeConverter: rangeConverter + ) + var codeRange: CursorRange + + let noSelection = range.isEmpty + if noSelection { + // use the first scope as code, the second as context + var focusedNode: Node? + while let node = contextInfo.nodes.first { + contextInfo.nodes.removeFirst() + let nodeInfo = contextContainingNode( + node, + textProvider: textProvider, + rangeConverter: rangeConverter + ) + if nodeInfo?.canBeUsedAsCodeRange ?? false { + focusedNode = node + break + } + } + guard let focusedNode else { + // fallback to unknown language focused code finder when no scope found + var result = UnknownLanguageFocusedCodeFinder(proposedSearchRange: 8) + .findFocusedCode(in: document, containingRange: range) + result.imports = contextInfo.imports + result.includes = contextInfo.includes + return result + } + codeRange = rangeConverter(focusedNode) + } else { + // use the selection as code, the first scope as context + codeRange = range + } + + let (code, _, focusedRange) = extractFocusedCode( + in: codeRange, + in: document, + containingRange: range + ) + + let (contextRange, scopeContexts) = extractScopeContext( + contextNodes: contextInfo.nodes, + textProvider: textProvider, + rangeConverter: rangeConverter + ) + + return .init( + scope: scopeContexts.isEmpty ? .file : .scope(signature: scopeContexts), + contextRange: contextRange, + smallestContextRange: codeRange, + focusedRange: focusedRange, + focusedCode: code, + imports: contextInfo.imports, + includes: contextInfo.includes + ) + } +} + +extension KnownLanguageFocusedCodeFinderType { + func extractFocusedCode( + in codeRange: CursorRange, + in document: Document, + containingRange range: SuggestionBasic.CursorRange + ) -> (code: String, lines: [String], codeRange: CursorRange) { + var codeRange = codeRange + let codeInCodeRange = EditorInformation.code( + in: document.lines, + inside: codeRange, + ignoreColumns: true + ) + + var code = codeInCodeRange.code + var lines = codeInCodeRange.lines + + if range.isEmpty, codeInCodeRange.lines.count > maxFocusedCodeLineCount { + // if the focused code is too long, truncate it to be shorter + let centerLine = range.start.line + let relativeCenterLine = centerLine - codeRange.start.line + let startLine = max(0, relativeCenterLine - maxFocusedCodeLineCount / 2) + let endLine = max( + startLine, + min(codeInCodeRange.lines.count - 1, startLine + maxFocusedCodeLineCount - 1) + ) + + lines = Array(codeInCodeRange.lines[startLine...endLine]) + code = lines.joined() + codeRange = .init( + start: .init(line: startLine + codeRange.start.line, character: 0), + end: .init( + line: endLine + codeRange.start.line, + character: codeInCodeRange.lines[endLine].count + ) + ) + } + + return (code, lines, codeRange) + } + + func extractScopeContext( + contextNodes: [Node], + textProvider: @escaping TextProvider, + rangeConverter: @escaping RangeConverter + ) -> (contextRange: CursorRange, scopeContexts: [CodeContext.ScopeContext]) { + var nodes = contextNodes + var contextRange = CursorRange.zero + var signature = [CodeContext.ScopeContext]() + + while let node = nodes.first { + nodes.removeFirst() + let context = contextContainingNode( + node, + textProvider: textProvider, + rangeConverter: rangeConverter + ) + + if let context { + contextRange = rangeConverter(context.node) + signature.insert(.init( + signature: context.signature, + name: context.name, + range: contextRange + ), at: 0) + } + } + + return (contextRange, signature) + } +} + diff --git a/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCCodeFinder.swift new file mode 100644 index 00000000..1c72906e --- /dev/null +++ b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCCodeFinder.swift @@ -0,0 +1,296 @@ +import ASTParser +import Foundation +import Preferences +import SuggestionBasic +import SwiftTreeSitter + +public enum TreeSitterTextPosition { + case node(ASTNode) + case range(range: NSRange, pointRange: Range) +} + +public class ObjectiveCFocusedCodeFinder: KnownLanguageFocusedCodeFinder< + ASTTree, + ASTNode, + TreeSitterTextPosition +> { + override public init(maxFocusedCodeLineCount: Int) { + super.init(maxFocusedCodeLineCount: maxFocusedCodeLineCount) + } + + public func parseSyntaxTree(from document: Document) -> ASTTree? { + let parser = ASTParser(language: .objectiveC) + return parser.parse(document.content) + } + + public func collectContextNodes( + in document: Document, + tree: ASTTree, + containingRange range: CursorRange, + textProvider: @escaping TextProvider, + rangeConverter: @escaping RangeConverter + ) -> ContextInfo { + let visitor = ObjectiveCScopeHierarchySyntaxVisitor( + tree: tree, + code: document.content, + textProvider: { node in + textProvider(.node(node)) + }, + range: range + ) + + let nodes = visitor.findScopeHierarchy() + + return .init(nodes: nodes, includes: visitor.includes, imports: visitor.imports) + } + + public func createTextProviderAndRangeConverter( + for document: Document, + tree: ASTTree + ) -> (TextProvider, RangeConverter) { + ( + { position in + switch position { + case let .node(node): + return document.content.cursorTextProvider(node.range, node.pointRange) ?? "" + case let .range(range, pointRange): + return document.content.cursorTextProvider(range, pointRange) ?? "" + } + }, + { node in + CursorRange(pointRange: node.pointRange) + } + ) + } + + public func contextContainingNode( + _ node: Node, + textProvider: @escaping TextProvider, + rangeConverter: @escaping RangeConverter + ) -> NodeInfo? { + switch ObjectiveCNodeType(rawValue: node.nodeType ?? "") { + case .classInterface, .categoryInterface: + return parseDeclarationInterfaceNode(node, textProvider: textProvider) + case .classImplementation, .categoryImplementation: + return parseDeclarationInterfaceNode(node, textProvider: textProvider) + case .protocolDeclaration: + return parseDeclarationInterfaceNode(node, textProvider: textProvider) + case .methodDefinition: + return parseMethodDefinitionNode(node, textProvider: textProvider) + case .functionDefinition: + return parseFunctionDefinitionNode(node, textProvider: textProvider) + case .structSpecifier, .enumSpecifier, .nsEnumSpecifier: + return parseTypeSpecifierNode(node, textProvider: textProvider) + case .typeDefinition: + return parseTypedefNode(node, textProvider: textProvider) + default: + return nil + } + } + + func parseDeclarationInterfaceNode( + _ node: ASTNode, + textProvider: @escaping TextProvider + ) -> NodeInfo? { + var name = "" + var category = "" + /// Attributes, declaration kind, and name. + var prefix = "" + /// Generics, super class, etc. + var extra = "" + + if let nameNode = node.child(byFieldName: "name") { + name = textProvider(.node(nameNode)) + prefix = textProvider(.range( + range: node.range.notSurpassing(nameNode.range), + pointRange: node.pointRange.notSurpassing(nameNode.pointRange) + )) + } + if let categoryNode = node.child(byFieldName: "category") { + category = textProvider(.node(categoryNode)) + } + + for i in 0.. NodeInfo? { + parseSignatureBeforeBody(node, fieldNameForName: "selector", textProvider: textProvider) + } + + func parseTypeSpecifierNode( + _ node: ASTNode, + textProvider: @escaping TextProvider + ) -> NodeInfo? { + parseSignatureBeforeBody(node, textProvider: textProvider) + } + + func parseTypedefNode( + _ node: ASTNode, + textProvider: @escaping TextProvider + ) -> NodeInfo? { + guard let typeNode = node.child(byFieldName: "type") else { return nil } + guard var result = parseSignatureBeforeBody(typeNode, textProvider: textProvider) + else { return nil } + result.signature = "typedef \(result.signature)" + result.node = node + return result + } + + func parseFunctionDefinitionNode( + _ node: ASTNode, + textProvider: @escaping TextProvider + ) -> NodeInfo? { + let declaratorNode = node.child(byFieldName: "declarator") + let name = declaratorNode?.contentOfChild( + withFieldName: "declarator", + textProvider: textProvider + ) + let ( + _, + signatureRange, + signaturePointRange + ) = node.extractInformationBeforeNode(withFieldName: "body") + let signature = textProvider(.range(range: signatureRange, pointRange: signaturePointRange)) + .breakLines(proposedLineEnding: " ", appendLineBreakToLastLine: false) + .joined() + .trimmingCharacters(in: .whitespacesAndNewlines) + if signature.isEmpty { return nil } + return .init( + node: node, + signature: signature, + name: name ?? "N/A", + canBeUsedAsCodeRange: true + ) + } +} + +// MARK: - Shared Parser + +extension ObjectiveCFocusedCodeFinder { + func parseSignatureBeforeBody( + _ node: ASTNode, + fieldNameForName: String = "name", + textProvider: @escaping TextProvider + ) -> NodeInfo? { + let name = node.contentOfChild(withFieldName: fieldNameForName, textProvider: textProvider) + let ( + _, + signatureRange, + signaturePointRange + ) = node.extractInformationBeforeNode(withFieldName: "body") + let signature = textProvider(.range(range: signatureRange, pointRange: signaturePointRange)) + .breakLines(proposedLineEnding: " ", appendLineBreakToLastLine: false) + .joined() + .trimmingCharacters(in: .whitespacesAndNewlines) + if signature.isEmpty { return nil } + return .init( + node: node, + signature: signature, + name: name ?? "N/A", + canBeUsedAsCodeRange: true + ) + } +} + +extension ASTNode { + func contentOfChild( + withFieldName name: String, + textProvider: (TreeSitterTextPosition) -> String + ) -> String? { + guard let child = child(byFieldName: name) else { return nil } + return textProvider(.node(child)) + } + + func extractInformationBeforeNode(withFieldName name: String) -> ( + postfixNode: ASTNode?, + range: NSRange, + pointRange: Range + ) { + guard let postfixNode = child(byFieldName: name) else { + return (nil, range, pointRange) + } + + let range = self.range.subtracting(postfixNode.range) + let pointRange = self.pointRange.subtracting(postfixNode.pointRange) + return (postfixNode, range, pointRange) + } +} + +extension NSRange { + func subtracting(_ range: NSRange) -> NSRange { + let start = lowerBound + let end = Swift.max(lowerBound, Swift.min(upperBound, range.lowerBound)) + return NSRange(location: start, length: end - start) + } + + func notSurpassing(_ range: NSRange) -> NSRange { + let start = lowerBound + let end = Swift.max(lowerBound, Swift.min(upperBound, range.upperBound)) + return NSRange(location: start, length: end - start) + } +} + +extension Range where Bound == Point { + func subtracting(_ range: Range) -> Range { + let start = lowerBound + let end = if range.lowerBound >= upperBound { + upperBound + } else { + Swift.max(range.lowerBound, lowerBound) + } + return Range(uncheckedBounds: (start, end)) + } + + func notSurpassing(_ range: Range) -> Range { + let start = lowerBound + let end = if range.lowerBound >= upperBound { + upperBound + } else { + Swift.max(range.upperBound, lowerBound) + } + return Range(uncheckedBounds: (start, end)) + } +} + diff --git a/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCScopeHierarchySyntaxVisitor.swift b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCScopeHierarchySyntaxVisitor.swift new file mode 100644 index 00000000..66761894 --- /dev/null +++ b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCScopeHierarchySyntaxVisitor.swift @@ -0,0 +1,114 @@ +import ASTParser +import Foundation +import Preferences +import SuggestionBasic +import SwiftTreeSitter + +final class ObjectiveCScopeHierarchySyntaxVisitor: ASTTreeVisitor { + let range: CursorRange + let code: String + let textProvider: (ASTNode) -> String + var includes: [String] = [] + var imports: [String] = [] + private var _scopeHierarchy: [ASTNode] = [] + + init( + tree: ASTTree, + code: String, + textProvider: @escaping (ASTNode) -> String, + range: CursorRange + ) { + self.range = range + self.code = code + self.textProvider = textProvider + super.init(tree: tree) + } + + /// The nodes containing the current range, sorted from inner to outer. + func findScopeHierarchy(_ node: ASTNode) -> [ASTNode] { + walk(node) + return _scopeHierarchy.sorted { $0.range.location > $1.range.location } + } + + /// The nodes containing the current range, sorted from inner to outer. + func findScopeHierarchy() -> [ASTNode] { + walk() + return _scopeHierarchy.sorted { $0.range.location > $1.range.location } + } + + override func visit(_ node: ASTNode) -> ASTTreeVisitorContinueKind { + let cursorRange = CursorRange(pointRange: node.pointRange) + + switch ObjectiveCNodeType(rawValue: node.nodeType ?? "") { + case .translationUnit: + return .visitChildren + case .preprocInclude: + handlePreprocInclude(node) + return .skipChildren + case .preprocImport: + handlePreprocImport(node) + return .skipChildren + case .moduleImport: + handleModuleImport(node) + return .skipChildren + case .classInterface, .categoryInterface, .protocolDeclaration: + guard cursorRange.strictlyContains(range) else { return .skipChildren } + _scopeHierarchy.append(node) + return .visitChildren + case .classImplementation, .categoryImplementation: + guard cursorRange.strictlyContains(range) else { return .skipChildren } + _scopeHierarchy.append(node) + return .visitChildren + case .methodDefinition: + guard cursorRange.strictlyContains(range) else { return .skipChildren } + _scopeHierarchy.append(node) + return .skipChildren + case .typeDefinition: + guard cursorRange.strictlyContains(range) else { return .skipChildren } + _scopeHierarchy.append(node) + return .skipChildren + case .structSpecifier, .enumSpecifier, .nsEnumSpecifier: + guard cursorRange.strictlyContains(range) else { return .skipChildren } + _scopeHierarchy.append(node) + return .skipChildren + case .functionDefinition: + guard cursorRange.strictlyContains(range) else { return .skipChildren } + _scopeHierarchy.append(node) + return .skipChildren + default: + return .skipChildren + } + } + + override func visitPost(_: ASTNode) {} + + // MARK: Imports + + func handlePreprocInclude(_ node: ASTNode) { + if let pathNode = node.child(byFieldName: "path") { + let path = textProvider(pathNode) + if !path.isEmpty { + includes.append(path) + } + } + } + + func handlePreprocImport(_ node: ASTNode) { + if let pathNode = node.child(byFieldName: "path") { + let path = textProvider(pathNode) + if !path.isEmpty { + imports.append(path) + } + } + } + + func handleModuleImport(_ node: ASTNode) { + if let pathNode = node.child(byFieldName: "module") { + let path = textProvider(pathNode) + if !path.isEmpty { + imports.append(path) + } + } + } +} + diff --git a/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCSyntax.swift b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCSyntax.swift new file mode 100644 index 00000000..f49c3280 --- /dev/null +++ b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCSyntax.swift @@ -0,0 +1,85 @@ +import Foundation + +/// https://github.com/lukepistrol/tree-sitter-objc/blob/feature/spm/test/corpus/imports.txt +/// https://github.com/lukepistrol/tree-sitter-objc/blob/feature/spm/test/corpus/expressions.txt +/// https://github.com/lukepistrol/tree-sitter-objc/blob/feature/spm/test/corpus/declarations.txt +/// https://github.com/lukepistrol/tree-sitter-objc/blob/feature/spm/node-types.json +/// Some of the test cases are actually incorrect? +enum ObjectiveCNodeType: String { + /// The top most item + case translationUnit = "translation_unit" + /// `#include` + case preprocInclude = "preproc_include" + /// `#import "bar.h"` + case preprocImport = "preproc_import" + /// `@import foo.bar` + case moduleImport = "module_import" + /// ```objc + /// @interface ClassName(Category): SuperClass { + /// type1 iv1; + /// type2 iv2; + /// } + /// @property (readwrite, copy) float value; + /// + (tr)k1:(t1)a1 : (t2)a2 k2: a3; + /// @end + /// ``` + case classInterface = "class_interface" + /// `@implementation` + case classImplementation = "class_implementation" + /// Similar to class interface. + case categoryInterface = "category_interface" + /// Similar to class implementation. + case categoryImplementation = "category_implementation" + /// Similar to class interface. + case protocolDeclaration = "protocol_declaration" + /// `@protocol ` + case protocolDeclarationList = "protocol_declaration_list" + /// ```objc + /// @class C1, C2; + /// ``` + case classDeclarationList = "class_declaration_list" + /// ``` + /// + (tr)k1: (t1)a1 : (t2)a2 k2: a3; + /// ``` + case propertyDeclaration = "property_declaration" + /// ```objc + /// + (tr)k1: (t1)a1 : (t2)a2 k2: a3; + /// ``` + case methodDeclaration = "method_declaration" + /// `- (rt)sel {}` + case methodDefinition = "method_definition" + /// function definitions + case functionDefinition = "function_definition" + /// Names of symbols + case identifier = "identifier" + /// Type identifiers + case typeIdentifier = "type_identifier" + /// Compound statements, such as `{ ... }` + case compoundStatement = "compound_statement" + /// Typedef. + case typeDefinition = "type_definition" + /// `struct {}`. + case structSpecifier = "struct_specifier" + /// `enum {}`. + case enumSpecifier = "enum_specifier" + /// `NS_ENUM {}` and `NS_OPTIONS {}`. + case nsEnumSpecifier = "ns_enum_specifier" + /// Fields inside a type definition. + case fieldDeclarationList = "field_declaration_list" + /// Protocols that a type conforms. + case protocolQualifiers = "protocol_qualifiers" + /// Superclass of a type. + case superclassReference = "superclass_reference" + /// The generic type arguments. + case parameterizedClassTypeArguments = "parameterized_class_type_arguments" + /// `__GENERICS` in category interface and implementation. + case genericsTypeReference = "generics_type_reference" + /// `IB_DESIGNABLE`, etc. The typo is from the original source. + case classInterfaceAttributeSpecifier = "class_interface_attribute_specifier" +} + +extension ObjectiveCNodeType { + init?(rawValue: String?) { + self.init(rawValue: rawValue ?? "") + } +} diff --git a/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift new file mode 100644 index 00000000..6676008d --- /dev/null +++ b/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift @@ -0,0 +1,399 @@ +import ASTParser +import Foundation +import Preferences +import SuggestionBasic +import SwiftParser +import SwiftSyntax + +public class SwiftFocusedCodeFinder: KnownLanguageFocusedCodeFinder< + SourceFileSyntax, + SyntaxProtocol, + SyntaxProtocol +> { + override public init(maxFocusedCodeLineCount: Int) { + super.init(maxFocusedCodeLineCount: maxFocusedCodeLineCount) + } + + public func parseSyntaxTree(from document: Document) -> SourceFileSyntax? { + Parser.parse(source: document.content) + } + + public func collectContextNodes( + in document: Document, + tree: SourceFileSyntax, + containingRange range: CursorRange, + textProvider: @escaping TextProvider, + rangeConverter: @escaping RangeConverter + ) -> ContextInfo { + let visitor = SwiftScopeHierarchySyntaxVisitor( + tree: tree, + code: document.content, + range: range, + rangeConverter: rangeConverter + ) + + let nodes = visitor.findScopeHierarchy() + return .init( + nodes: nodes, + includes: [], + imports: visitor.imports + ) + } + + public func createTextProviderAndRangeConverter( + for document: Document, + tree: SourceFileSyntax + ) -> (TextProvider, RangeConverter) { + let locationConverter = SourceLocationConverter( + fileName: document.documentURL.path, + tree: tree + ) + return ( + { node in + let range = CursorRange(sourceRange: node.sourceRange(converter: locationConverter)) + return EditorInformation.code(in: document.lines, inside: range).code + }, + { node in + let range = CursorRange(sourceRange: node.sourceRange(converter: locationConverter)) + return range + } + ) + } + + public func contextContainingNode( + _ node: SyntaxProtocol, + textProvider: @escaping TextProvider, + rangeConverter: @escaping RangeConverter + ) -> NodeInfo? { + func extractText(_ node: SyntaxProtocol) -> String { + textProvider(node) + } + + switch node { + case let node as StructDeclSyntax: + let type = node.structKeyword.text + let name = node.name.text + return .init( + node: node, + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: name + ) + + case let node as ClassDeclSyntax: + let type = node.classKeyword.text + let name = node.name.text + return .init( + node: node, + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: name + ) + + case let node as EnumDeclSyntax: + let type = node.enumKeyword.text + let name = node.name.text + return .init( + node: node, + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: name + ) + + case let node as ActorDeclSyntax: + let type = node.actorKeyword.text + let name = node.name.text + return .init( + node: node, + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: name + ) + + case let node as MacroDeclSyntax: + let type = node.macroKeyword.text + let name = node.name.text + return .init( + node: node, + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: name + ) + + case let node as ProtocolDeclSyntax: + let type = node.protocolKeyword.text + let name = node.name.text + return .init( + node: node, + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: name + ) + + case let node as ExtensionDeclSyntax: + let type = node.extensionKeyword.text + let name = node.extendedType.trimmedDescription + return .init( + node: node, + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: name + ) + + case let node as FunctionDeclSyntax: + let type = node.funcKeyword.text + let name = node.name.text + let signature = node.signature.trimmedDescription + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .joined(separator: " ") + + return .init( + node: node, + signature: "\(type) \(name)\(signature)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)), + name: name + ) + + case let node as VariableDeclSyntax: + let type = node.bindingSpecifier.trimmedDescription + let name = node.bindings.first?.pattern.trimmedDescription ?? "" + let signature = node.bindings.first?.typeAnnotation?.trimmedDescription ?? "" + + return .init( + node: node, + signature: "\(type) \(name)\(signature.isEmpty ? "" : "\(signature)")" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: name, + canBeUsedAsCodeRange: node.bindings.first?.accessorBlock != nil + ) + + case let node as AccessorDeclSyntax: + let keyword = node.accessorSpecifier.text + let signature = keyword + + return .init( + node: node, + signature: signature + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: keyword + ) + + case let node as SubscriptDeclSyntax: + let genericPClause = node.genericWhereClause?.trimmedDescription ?? "" + let pClause = node.parameterClause.trimmedDescription + let whereClause = node.genericWhereClause?.trimmedDescription ?? "" + let signature = "subscript\(genericPClause)(\(pClause))\(whereClause)" + + return .init( + node: node, + signature: signature + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: "subscript" + ) + + case let node as InitializerDeclSyntax: + let signature = "init" + + return .init( + node: node, + signature: "\(signature)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: "init" + ) + + case let node as DeinitializerDeclSyntax: + let signature = "deinit" + + return .init( + node: node, + signature: signature + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: "deinit" + ) + + case let node as ClosureExprSyntax: + let signature = "closure" + let range = rangeConverter(node) + + return .init( + node: node, + signature: signature + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: "closure", + canBeUsedAsCodeRange: range.lineCount > 80 + ) + + case let node as FunctionCallExprSyntax: + let signature = "function call" + return .init( + node: node, + signature: signature + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: "function call", + canBeUsedAsCodeRange: false + ) + + case let node as SwitchCaseSyntax: + let range = rangeConverter(node) + + return .init( + node: node, + signature: node.trimmedDescription + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .joined(separator: " "), + name: "switch", + canBeUsedAsCodeRange: range.lineCount > 80 + ) + + default: + return nil + } + } + + func findAssigningToVariable(_ node: SyntaxProtocol) + -> (type: String, name: String, signature: String)? + { + if let node = node as? VariableDeclSyntax { + let type = node.bindingSpecifier.trimmedDescription + let name = node.bindings.first?.pattern.trimmedDescription ?? "" + let sig = node.bindings.first?.initializer?.value.trimmedDescription ?? "" + return (type, name, sig) + } + return nil + } + + func findTypeNameFromNode(_ node: SyntaxProtocol) -> String? { + switch node { + case let node as ClassDeclSyntax: + return node.name.text + case let node as StructDeclSyntax: + return node.name.text + case let node as EnumDeclSyntax: + return node.name.text + case let node as ActorDeclSyntax: + return node.name.text + case let node as ProtocolDeclSyntax: + return node.name.text + case let node as ExtensionDeclSyntax: + return node.extendedType.trimmedDescription + default: + return nil + } + } +} + +extension CursorRange { + init(sourceRange: SourceRange) { + self.init( + start: .init(line: sourceRange.start.line - 1, character: sourceRange.start.column - 1), + end: .init(line: sourceRange.end.line - 1, character: sourceRange.end.column - 1) + ) + } +} + +// MARK: - Helper Types + +protocol AttributeAndModifierApplicableSyntax { + var attributes: AttributeListSyntax { get } + var modifiers: DeclModifierListSyntax { get } +} + +extension AttributeAndModifierApplicableSyntax { + func modifierAndAttributeText(_ extractText: (SyntaxProtocol) -> String) -> String { + let attributeTexts = attributes.map { attribute in + extractText(attribute) + } + let modifierTexts = modifiers.map { modifier in + extractText(modifier) + } + let prefix = (attributeTexts + modifierTexts).joined(separator: " ") + return prefix + } +} + +extension StructDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension ClassDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension EnumDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension ActorDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension MacroDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension MacroExpansionDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension ProtocolDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension ExtensionDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension FunctionDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension VariableDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension InitializerDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension DeinitializerDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension AccessorDeclSyntax: AttributeAndModifierApplicableSyntax { + var modifiers: SwiftSyntax.DeclModifierListSyntax { [] } +} + +extension SubscriptDeclSyntax: AttributeAndModifierApplicableSyntax {} + +protocol InheritanceClauseApplicableSyntax { + var inheritanceClause: InheritanceClauseSyntax? { get } +} + +extension StructDeclSyntax: InheritanceClauseApplicableSyntax {} +extension ClassDeclSyntax: InheritanceClauseApplicableSyntax {} +extension EnumDeclSyntax: InheritanceClauseApplicableSyntax {} +extension ActorDeclSyntax: InheritanceClauseApplicableSyntax {} +extension ProtocolDeclSyntax: InheritanceClauseApplicableSyntax {} +extension ExtensionDeclSyntax: InheritanceClauseApplicableSyntax {} + +extension InheritanceClauseApplicableSyntax { + func inheritanceClauseTexts(_ extractText: (SyntaxProtocol) -> String) -> String { + inheritanceClause?.inheritedTypes.map { clause in + extractText(clause).trimmingCharacters(in: [","]) + }.joined(separator: ", ") ?? "" + } +} + +extension String { + func prefixedModifiers(_ text: String) -> String { + if text.isEmpty { + return self + } + return "\(text) \(self)" + } + + func suffixedInheritance(_ text: String) -> String { + if text.isEmpty { + return self + } + return "\(self): \(text)" + } +} + diff --git a/Tool/Sources/FocusedCodeFinder/Swift/SwiftScopeHierarchySyntaxVisitor.swift b/Tool/Sources/FocusedCodeFinder/Swift/SwiftScopeHierarchySyntaxVisitor.swift new file mode 100644 index 00000000..bdd224d1 --- /dev/null +++ b/Tool/Sources/FocusedCodeFinder/Swift/SwiftScopeHierarchySyntaxVisitor.swift @@ -0,0 +1,141 @@ +import ASTParser +import Foundation +import Preferences +import SuggestionBasic +import SwiftParser +import SwiftSyntax + +final class SwiftScopeHierarchySyntaxVisitor: SyntaxVisitor { + let tree: SyntaxProtocol + let code: String + let range: CursorRange + let rangeConverter: (SyntaxProtocol) -> CursorRange + + var imports: [String] = [] + private var _scopeHierarchy: [SyntaxProtocol] = [] + + /// The nodes containing the current range, sorted from inner to outer. + func findScopeHierarchy(_ node: some SyntaxProtocol) -> [SyntaxProtocol] { + walk(node) + return _scopeHierarchy.sorted { $0.position.utf8Offset > $1.position.utf8Offset } + } + + /// The nodes containing the current range, sorted from inner to outer. + func findScopeHierarchy() -> [SyntaxProtocol] { + walk(tree) + return _scopeHierarchy.sorted { $0.position.utf8Offset > $1.position.utf8Offset } + } + + init( + tree: SyntaxProtocol, + code: String, + range: CursorRange, + rangeConverter: @escaping (SyntaxProtocol) -> CursorRange + ) { + self.tree = tree + self.code = code + self.range = range + self.rangeConverter = rangeConverter + super.init(viewMode: .sourceAccurate) + } + + func skipChildrenIfPossible(_ node: SyntaxProtocol) -> SyntaxVisitorContinueKind { + if _scopeHierarchy.count > 5 { return .skipChildren } + if !nodeContainsRange(node) { return .skipChildren } + return .visitChildren + } + + func captureNodeIfPossible(_ node: SyntaxProtocol) -> SyntaxVisitorContinueKind { + if _scopeHierarchy.count > 5 { return .skipChildren } + if !nodeContainsRange(node) { return .skipChildren } + _scopeHierarchy.append(node) + return .visitChildren + } + + func nodeContainsRange(_ node: SyntaxProtocol) -> Bool { + let cursorRange = rangeConverter(node) + return cursorRange.strictlyContains(range) + } + + // skip if possible + + override func visit(_ node: MemberBlockSyntax) -> SyntaxVisitorContinueKind { + skipChildrenIfPossible(node) + } + + override func visit(_ node: MemberBlockItemSyntax) -> SyntaxVisitorContinueKind { + skipChildrenIfPossible(node) + } + + // capture if possible + + override func visit(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind { + imports.append(node.path.trimmedDescription) + return .skipChildren + } + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: MacroDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: SubscriptDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ClosureExprSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: AccessorDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: DeinitializerDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: SwitchCaseSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } +} + diff --git a/Tool/Sources/FocusedCodeFinder/UnknownLanguageFocusCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/UnknownLanguageFocusCodeFinder.swift new file mode 100644 index 00000000..53b12095 --- /dev/null +++ b/Tool/Sources/FocusedCodeFinder/UnknownLanguageFocusCodeFinder.swift @@ -0,0 +1,85 @@ +import Foundation +import Preferences +import SuggestionBasic + +/// Used when the language is not supported by the app +/// or that the code is too long to be returned by a focused code finder. +public struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinderType { + let proposedSearchRange: Int + + public init(proposedSearchRange: Int) { + self.proposedSearchRange = proposedSearchRange + } + + public func findFocusedCode( + in document: Document, + containingRange: CursorRange + ) -> CodeContext { + guard !document.lines.isEmpty else { return .empty } + + // when user is not selecting any code. + if containingRange.start == containingRange.end { + // search up and down for up to `proposedSearchRange * 2 + 1` lines. + let lines = document.lines + let proposedLineCount = proposedSearchRange * 2 + 1 + let startLineIndex = max(containingRange.start.line - proposedSearchRange, 0) + let endLineIndex = max( + startLineIndex, + min(startLineIndex + proposedLineCount - 1, lines.count - 1) + ) + + if lines.endIndex <= endLineIndex { return .empty } + + let focusedLines = lines[startLineIndex...endLineIndex] + + let contextStartLine = max(startLineIndex - 5, 0) + let contextEndLine = min(endLineIndex + 5, lines.count - 1) + + let contextRange = CursorRange( + start: .init(line: contextStartLine, character: 0), + end: .init(line: contextEndLine, character: lines[contextEndLine].count) + ) + + return .init( + scope: .top, + contextRange: contextRange, + smallestContextRange: contextRange, + focusedRange: .init( + start: .init(line: startLineIndex, character: 0), + end: .init(line: endLineIndex, character: lines[endLineIndex].count) + ), + focusedCode: focusedLines.joined(), + imports: [], + includes: [] + ) + } + + let startLine = max(containingRange.start.line, 0) + let endLine = min(containingRange.end.line, document.lines.count - 1) + + if endLine < startLine { return .empty } + + let focusedLines = document.lines[startLine...endLine] + let contextStartLine = max(startLine - 3, 0) + let contextEndLine = min(endLine + 3, document.lines.count - 1) + + let contextRange = CursorRange( + start: .init(line: contextStartLine, character: 0), + end: .init( + line: contextEndLine, + character: document.lines[contextEndLine].count + ) + ) + + return CodeContext( + scope: .top, + contextRange: contextRange, + smallestContextRange: contextRange, + focusedRange: containingRange, + focusedCode: focusedLines.joined(), + imports: [], + includes: [] + ) + } +} + diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift new file mode 100644 index 00000000..606499a4 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -0,0 +1,332 @@ +import BuiltinExtension +import CopilotForXcodeKit +import Dependencies +import Foundation +import LanguageServerProtocol +import Logger +import Preferences +import Toast +import Workspace + +public final class GitHubCopilotExtension: BuiltinExtension { + public var extensionIdentifier: String { "com.github.copilot" } + + public let suggestionService: GitHubCopilotSuggestionService + public let chatService: GitHubCopilotChatService + + private var extensionUsage = ExtensionUsage( + isSuggestionServiceInUse: false, + isChatServiceInUse: false + ) + private var isLanguageServerInUse: Bool { + extensionUsage.isSuggestionServiceInUse || extensionUsage.isChatServiceInUse + } + + @Dependency(\.toastController) var toast + + let workspacePool: WorkspacePool + + let serviceLocator: ServiceLocatorType + + public init(workspacePool: WorkspacePool) { + self.workspacePool = workspacePool + serviceLocator = ServiceLocator(workspacePool: workspacePool) + suggestionService = .init(serviceLocator: serviceLocator) + chatService = .init(serviceLocator: serviceLocator) + } + + public func workspaceDidOpen(_: WorkspaceInfo) {} + + public func workspaceDidClose(_: WorkspaceInfo) {} + + public func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } + // check if file size is larger than 15MB, if so, return immediately + if let attrs = try? FileManager.default + .attributesOfItem(atPath: documentURL.path), + let fileSize = attrs[FileAttributeKey.size] as? UInt64, + fileSize > 15 * 1024 * 1024 + { return } + + Task { + do { + let content = try String(contentsOf: documentURL, encoding: .utf8) + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) + } catch let error as ServerError { + let e = GitHubCopilotError.languageServerError(error) + Logger.gitHubCopilot.error(e.localizedDescription) + + switch error { + case .serverUnavailable, .serverError: + toast.toast(content: e.localizedDescription, type: .error, duration: 10.0) + default: + break + } + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func workspace(_ workspace: WorkspaceInfo, didSaveDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } + Task { + do { + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifySaveTextDocument(fileURL: documentURL) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func workspace(_ workspace: WorkspaceInfo, didCloseDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } + Task { + do { + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyCloseTextDocument(fileURL: documentURL) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String? + ) { + guard isLanguageServerInUse else { return } + // check if file size is larger than 15MB, if so, return immediately + if let attrs = try? FileManager.default + .attributesOfItem(atPath: documentURL.path), + let fileSize = attrs[FileAttributeKey.size] as? UInt64, + fileSize > 15 * 1024 * 1024 + { return } + + Task { + guard let content else { return } + guard let service = await serviceLocator.getService(from: workspace) else { return } + do { + try await service.notifyChangeTextDocument( + fileURL: documentURL, + content: content, + version: 0 + ) + } catch let error as ServerError { + switch error { + case .serverError(-32602, _, _): // parameter incorrect + Logger.gitHubCopilot.error(error.localizedDescription) + // Reopen document if it's not found in the language server + self.workspace(workspace, didOpenDocumentAt: documentURL) + default: + Logger.gitHubCopilot.error(error.localizedDescription) + } + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func extensionUsageDidChange(_ usage: ExtensionUsage) { + extensionUsage = usage + if !usage.isChatServiceInUse && !usage.isSuggestionServiceInUse { + terminate() + } + } + + public func terminate() { + for workspace in workspacePool.workspaces.values { + guard let plugin = workspace.plugin(for: GitHubCopilotWorkspacePlugin.self) + else { continue } + plugin.terminate() + } + } +} + +protocol ServiceLocatorType { + func getService(from workspace: WorkspaceInfo) async -> GitHubCopilotService? +} + +class ServiceLocator: ServiceLocatorType { + let workspacePool: WorkspacePool + + init(workspacePool: WorkspacePool) { + self.workspacePool = workspacePool + } + + func getService(from workspace: WorkspaceInfo) async -> GitHubCopilotService? { + guard let workspace = workspacePool.workspaces[workspace.workspaceURL], + let plugin = workspace.plugin(for: GitHubCopilotWorkspacePlugin.self) + else { return nil } + return await plugin.gitHubCopilotService + } +} + +extension GitHubCopilotExtension { + public struct Token: Codable { +// let codesearch: Bool + public let individual: Bool + public let endpoints: Endpoints + public let chat_enabled: Bool +// public let sku: String +// public let copilotignore_enabled: Bool +// public let limited_user_quotas: String? +// public let tracking_id: String +// public let xcode: Bool +// public let limited_user_reset_date: String? +// public let telemetry: String +// public let prompt_8k: Bool + public let token: String +// public let nes_enabled: Bool +// public let vsc_electron_fetcher_v2: Bool +// public let code_review_enabled: Bool +// public let annotations_enabled: Bool +// public let chat_jetbrains_enabled: Bool +// public let xcode_chat: Bool +// public let refresh_in: Int +// public let snippy_load_test_enabled: Bool +// public let trigger_completion_after_accept: Bool + public let expires_at: Int +// public let public_suggestions: String +// public let code_quote_enabled: Bool + + public struct Endpoints: Codable { + public let api: String + public let proxy: String + public let telemetry: String +// public let origin-tracker: String + } + } + + struct AuthInfo: Codable { + public let user: String + public let oauth_token: String + public let githubAppId: String + } + + static var authInfo: AuthInfo? { + guard let urls = try? GitHubCopilotBaseService.createFoldersIfNeeded() + else { return nil } + let path = urls.supportURL + .appendingPathComponent("undefined") + .appendingPathComponent(".config") + .appendingPathComponent("github-copilot") + .appendingPathComponent("apps.json").path + guard FileManager.default.fileExists(atPath: path) else { return nil } + + do { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + let json = try JSONSerialization + .jsonObject(with: data, options: []) as? [String: [String: String]] + guard let firstEntry = json?.values.first else { return nil } + let jsonData = try JSONSerialization.data(withJSONObject: firstEntry, options: []) + return try JSONDecoder().decode(AuthInfo.self, from: jsonData) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + return nil + } + } + + @MainActor + static var cachedToken: Token? + + public static func fetchToken() async throws -> Token { + guard let authToken = authInfo?.oauth_token + else { throw GitHubCopilotError.notLoggedIn } + + let oldToken = await MainActor.run { cachedToken } + if let oldToken { + let expiresAt = Date(timeIntervalSince1970: TimeInterval(oldToken.expires_at)) + if expiresAt > Date() { + return oldToken + } + } + + let url = URL(string: "https://api.github.com/copilot_internal/v2/token")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("token \(authToken)", forHTTPHeaderField: "authorization") + request.setValue("unknown-editor/0", forHTTPHeaderField: "editor-version") + request.setValue("unknown-editor-plugin/0", forHTTPHeaderField: "editor-plugin-version") + request.setValue("1.236.0", forHTTPHeaderField: "copilot-language-server-version") + request.setValue("GithubCopilot/1.236.0", forHTTPHeaderField: "user-agent") + request.setValue("*/*", forHTTPHeaderField: "accept") + request.setValue("gzip,deflate,br", forHTTPHeaderField: "accept-encoding") + + do { + let (data, _) = try await URLSession.shared.data(for: request) + if let jsonString = String(data: data, encoding: .utf8) { + print(jsonString) + } + let newToken = try JSONDecoder().decode(Token.self, from: data) + await MainActor.run { cachedToken = newToken } + return newToken + } catch { + Logger.service.error(error.localizedDescription) + throw error + } + } + + public static func fetchLLMModels() async throws -> [GitHubCopilotLLMModel] { + let token = try await GitHubCopilotExtension.fetchToken() + guard let endpoint = URL(string: token.endpoints.api + "/models") else { + throw CancellationError() + } + var request = URLRequest(url: endpoint) + request.setValue( + "Copilot for Xcode/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")", + forHTTPHeaderField: "Editor-Version" + ) + request.setValue("Bearer \(token.token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("vscode-chat", forHTTPHeaderField: "Copilot-Integration-Id") + request.setValue("2023-07-07", forHTTPHeaderField: "X-Github-Api-Version") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let response = response as? HTTPURLResponse else { + throw CancellationError() + } + + guard response.statusCode == 200 else { + throw CancellationError() + } + + struct Model: Decodable { + struct Limit: Decodable { + var max_context_window_tokens: Int + } + + struct Capability: Decodable { + var type: String? + var family: String? + var limit: Limit? + } + + var id: String + var capabilities: Capability + } + + struct Body: Decodable { + var data: [Model] + } + + let models = try JSONDecoder().decode(Body.self, from: data) + .data + .filter { + $0.capabilities.type == "chat" + } + .map { + GitHubCopilotLLMModel( + modelId: $0.id, + familyName: $0.capabilities.family ?? "", + contextWindow: $0.capabilities.limit?.max_context_window_tokens ?? 0 + ) + } + return models + } +} + diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift new file mode 100644 index 00000000..405a5402 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift @@ -0,0 +1,110 @@ +import Dependencies +import Foundation +import Logger +import Toast +import Workspace + +public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { + enum Error: Swift.Error, LocalizedError { + case gitHubCopilotLanguageServerMustBeUpdated + var errorDescription: String? { + switch self { + case .gitHubCopilotLanguageServerMustBeUpdated: + return "GitHub Copilot language server must be updated. Update will start immediately. \nIf it fails, please go to Host app > Service > GitHub Copilot and check if there is an update available." + } + } + } + + @Dependency(\.toast) var toast + + let installationManager = GitHubCopilotInstallationManager() + private var _gitHubCopilotService: GitHubCopilotService? + @GitHubCopilotSuggestionActor + var gitHubCopilotService: GitHubCopilotService? { + if let service = _gitHubCopilotService { return service } + do { + return try createGitHubCopilotService() + } catch let error as Error { + toast(error.localizedDescription, .warning) + Task { + await updateLanguageServerIfPossible() + } + return nil + } catch { + Logger.gitHubCopilot.error("Failed to create GitHub Copilot service: \(error)") + toast( + "Failed to start GitHub Copilot language server: \(error.localizedDescription)", + .error + ) + return nil + } + } + + deinit { + if let _gitHubCopilotService { + Task { await _gitHubCopilotService.terminate() } + } + } + + @GitHubCopilotSuggestionActor + func createGitHubCopilotService() throws -> GitHubCopilotService { + if case .outdated(_, _, true) = installationManager.checkInstallation() { + throw Error.gitHubCopilotLanguageServerMustBeUpdated + } + let newService = try GitHubCopilotService(projectRootURL: projectRootURL) + _gitHubCopilotService = newService + newService.localProcessServer?.terminationHandler = { [weak self] in + Logger.gitHubCopilot.error("GitHub Copilot language server terminated") + self?.terminate() + } + Task { + try await Task.sleep(nanoseconds: 1_000_000_000) + finishLaunchingService() + } + return newService + } + + @GitHubCopilotSuggestionActor + func finishLaunchingService() { + guard let workspace, let _gitHubCopilotService else { return } + Task { + for (_, filespace) in workspace.filespaces { + let documentURL = filespace.fileURL + guard let content = try? String(contentsOf: documentURL) else { continue } + try? await _gitHubCopilotService.notifyOpenTextDocument( + fileURL: documentURL, + content: content + ) + } + } + } + + @GitHubCopilotSuggestionActor + func updateLanguageServerIfPossible() async { + guard !GitHubCopilotInstallationManager.isInstalling else { return } + let events = installationManager.installLatestVersion() + do { + for try await event in events { + switch event { + case .downloading: + toast("Updating GitHub Copilot language server", .info) + case .uninstalling: + break + case .decompressing: + break + case .done: + toast("Finished updating GitHub Copilot language server", .info) + } + } + } catch GitHubCopilotInstallationManager.Error.isInstalling { + return + } catch { + toast(error.localizedDescription, .error) + } + } + + func terminate() { + _gitHubCopilotService = nil + } +} + diff --git a/Core/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift similarity index 51% rename from Core/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index cdbeddbd..817a6827 100644 --- a/Core/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -14,11 +14,13 @@ class CopilotLocalProcessServer { private var wrappedServer: CustomJSONRPCLanguageServer? var terminationHandler: (() -> Void)? @MainActor var ongoingCompletionRequestIDs: [JSONId] = [] + @MainActor var ongoingConversationRequestIDs: [String: JSONId] = [:] public convenience init( path: String, arguments: [String], - environment: [String: String]? = nil + environment: [String: String]? = nil, + serverNotificationHandler: ServerNotificationHandler ) { let params = Process.ExecutionParameters( path: path, @@ -26,10 +28,13 @@ class CopilotLocalProcessServer { environment: environment ) - self.init(executionParameters: params) + self.init(executionParameters: params, serverNotificationHandler: serverNotificationHandler) } - init(executionParameters parameters: Process.ExecutionParameters) { + init( + executionParameters parameters: Process.ExecutionParameters, + serverNotificationHandler: ServerNotificationHandler + ) { transport = StdioDataTransport() let framing = SeperatedHTTPHeaderMessageFraming() let messageTransport = MessageTransport( @@ -37,7 +42,10 @@ class CopilotLocalProcessServer { messageProtocol: framing ) customTransport = CustomDataTransport(nextTransport: messageTransport) - wrappedServer = CustomJSONRPCLanguageServer(dataTransport: customTransport) + wrappedServer = CustomJSONRPCLanguageServer( + dataTransport: customTransport, + serverNotificationHandler: serverNotificationHandler + ) process = Process() @@ -45,10 +53,27 @@ class CopilotLocalProcessServer { // we need to get the request IDs from a custom transport before the data // is written to the language server. customTransport.onWriteRequest = { [weak self] request in - if request.method == "getCompletionsCycling" { + if request.method == "getCompletionsCycling" + || request.method == "textDocument/inlineCompletion" + { Task { @MainActor [weak self] in self?.ongoingCompletionRequestIDs.append(request.id) } + } else if request.method == "conversation/create" { + Task { @MainActor [weak self] in + if let paramsData = try? JSONEncoder().encode(request.params) { + do { + let params = try JSONDecoder().decode( + GitHubCopilotRequest.ConversationCreate.RequestBody.self, + from: paramsData + ) + self?.ongoingConversationRequestIDs[params.workDoneToken] = request.id + } catch { + // Handle decoding error + print("Error decoding ConversationCreateParams: \(error)") + } + } + } } } @@ -84,6 +109,10 @@ class CopilotLocalProcessServer { get { return wrappedServer?.logMessages ?? false } set { wrappedServer?.logMessages = newValue } } + + func terminate() { + process.terminate() + } } extension CopilotLocalProcessServer: LanguageServerProtocol.Server { @@ -92,6 +121,7 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server { set { wrappedServer?.requestHandler = newValue } } + @available(*, deprecated, message: "Use `ServerNotificationHandler` instead") public var notificationHandler: NotificationHandler? { get { wrappedServer?.notificationHandler } set { wrappedServer?.notificationHandler = newValue } @@ -108,28 +138,44 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server { server.sendNotification(notif, completionHandler: completionHandler) } - + /// Cancel ongoing completion requests. public func cancelOngoingTasks() async { - guard let server = wrappedServer, process.isRunning else { + guard let _ = wrappedServer, process.isRunning else { return } - + let task = Task { @MainActor in for id in self.ongoingCompletionRequestIDs { - switch id { - case let .numericId(id): - try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) - case let .stringId(id): - try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) - } + await cancelTask(id) } self.ongoingCompletionRequestIDs = [] } - + + await task.value + } + + public func cancelOngoingTask(workDoneToken: String) async { + let task = Task { @MainActor in + guard let id = ongoingConversationRequestIDs[workDoneToken] else { return } + await cancelTask(id) + } await task.value } + public func cancelTask(_ id: JSONId) async { + guard let server = wrappedServer, process.isRunning else { + return + } + + switch id { + case let .numericId(id): + try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) + case let .stringId(id): + try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) + } + } + public func sendRequest( _ request: ClientRequest, completionHandler: @escaping (ServerResult) -> Void @@ -150,36 +196,47 @@ final class CustomJSONRPCLanguageServer: Server { private let protocolTransport: ProtocolTransport - public var requestHandler: RequestHandler? - public var notificationHandler: NotificationHandler? + var requestHandler: RequestHandler? + var serverNotificationHandler: ServerNotificationHandler + + @available(*, deprecated, message: "Use `serverNotificationHandler` instead.") + var notificationHandler: NotificationHandler? { + get { nil } + set {} + } private var outOfBandError: Error? - init(protocolTransport: ProtocolTransport) { + init( + protocolTransport: ProtocolTransport, + serverNotificationHandler: ServerNotificationHandler + ) { + self.serverNotificationHandler = serverNotificationHandler self.protocolTransport = protocolTransport internalServer = JSONRPCLanguageServer(protocolTransport: protocolTransport) - let previouseRequestHandler = protocolTransport.requestHandler - let previouseNotificationHandler = protocolTransport.notificationHandler + let previousRequestHandler = protocolTransport.requestHandler - protocolTransport - .requestHandler = { [weak self] in - guard let self else { return } - if !self.handleRequest($0, data: $1, callback: $2) { - previouseRequestHandler?($0, $1, $2) - } - } - protocolTransport - .notificationHandler = { [weak self] in - guard let self else { return } - if !self.handleNotification($0, data: $1, block: $2) { - previouseNotificationHandler?($0, $1, $2) - } + protocolTransport.requestHandler = { [weak self] in + guard let self else { return } + if !self.handleRequest($0, data: $1, callback: $2) { + previousRequestHandler?($0, $1, $2) } + } + protocolTransport.notificationHandler = { [weak self] in + guard let self else { return } + self.handleNotification($0, data: $1, block: $2) + } } - convenience init(dataTransport: DataTransport) { - self.init(protocolTransport: ProtocolTransport(dataTransport: dataTransport)) + convenience init( + dataTransport: DataTransport, + serverNotificationHandler: ServerNotificationHandler + ) { + self.init( + protocolTransport: ProtocolTransport(dataTransport: dataTransport), + serverNotificationHandler: serverNotificationHandler + ) } deinit { @@ -198,36 +255,21 @@ extension CustomJSONRPCLanguageServer { _ anyNotification: AnyJSONRPCNotification, data: Data, block: @escaping (Error?) -> Void - ) -> Bool { - let methodName = anyNotification.method - switch methodName { - case "window/logMessage": - if UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) { - Logger.gitHubCopilot - .info("\(anyNotification.method): \(anyNotification.params.debugDescription)") - } - block(nil) - return true - case "LogMessage": - if UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) { - Logger.gitHubCopilot - .info("\(anyNotification.method): \(anyNotification.params.debugDescription)") - } - block(nil) - return true - case "statusNotification": - if UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) { - Logger.gitHubCopilot - .info("\(anyNotification.method): \(anyNotification.params.debugDescription)") + ) { + Task { + do { + try await serverNotificationHandler.handleNotification( + anyNotification, + data: data + ) + block(nil) + } catch { + block(error) } - block(nil) - return true - default: - return false } } - public func sendNotification( + func sendNotification( _ notif: ClientNotification, completionHandler: @escaping (ServerError?) -> Void ) { @@ -254,3 +296,75 @@ extension CustomJSONRPCLanguageServer { } } +@GitHubCopilotSuggestionActor +final class ServerNotificationHandler { + typealias Handler = ( + _ anyNotification: AnyJSONRPCNotification, + _ data: Data + ) async throws -> Bool + + var handlers = [AnyHashable: Handler]() + nonisolated init() {} + + func handleNotification( + _ anyNotification: AnyJSONRPCNotification, + data: Data + ) async throws { + for handler in handlers.values { + do { + let handled = try await handler(anyNotification, data) + if handled { + return + } + } catch { + throw ServerError.notificationDispatchFailed(error) + } + } + + let methodName = anyNotification.method + let debugDescription = { + if let params = anyNotification.params { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + if let jsonData = try? encoder.encode(params), + let text = String(data: jsonData, encoding: .utf8) + { + return text + } + } + return "N/A" + }() + switch methodName { + case "window/logMessage": + if UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) { + Logger.gitHubCopilot + .info("\(anyNotification.method): \(debugDescription)") + } + case "LogMessage": + if UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) { + Logger.gitHubCopilot + .info("\(anyNotification.method): \(debugDescription)") + } + case "statusNotification": + if UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) { + Logger.gitHubCopilot + .info("\(anyNotification.method): \(debugDescription)") + } + case "featureFlagsNotification": + if UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) { + Logger.gitHubCopilot + .info("\(anyNotification.method): \(debugDescription)") + } + case "conversation/preconditionsNotification": + if UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) { + Logger.gitHubCopilot + .info("\(anyNotification.method): \(debugDescription)") + } + case "didChangeStatus": + Logger.gitHubCopilot.info("Did change status: \(debugDescription)") + default: + throw ServerError.handlerUnavailable(methodName) + } + } +} + diff --git a/Core/Sources/GitHubCopilotService/CustomStdioTransport.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift similarity index 100% rename from Core/Sources/GitHubCopilotService/CustomStdioTransport.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotAccountStatus.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotAccountStatus.swift similarity index 100% rename from Core/Sources/GitHubCopilotService/GitHubCopilotAccountStatus.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotAccountStatus.swift diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift similarity index 69% rename from Core/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift index 62617909..edf59a50 100644 --- a/Core/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift @@ -2,22 +2,27 @@ import Foundation import Terminal public struct GitHubCopilotInstallationManager { - private static var isInstalling = false + @GitHubCopilotSuggestionActor + public private(set) static var isInstalling = false static var downloadURL: URL { - let commitHash = "1358e8e45ecedc53daf971924a0541ddf6224faf" + let commitHash = "f89e977c87180519ba3b942200e3d05b17b1e2fc" let link = "https://github.com/github/copilot.vim/archive/\(commitHash).zip" return URL(string: link)! } - static let latestSupportedVersion = "1.8.4" + /// The GitHub's version has quite a lot of changes about `watchedFiles` since the following + /// commit. + /// https://github.com/github/CopilotForXcode/commit/a50045aa3ab3b7d532cadf40c4c10bed32f81169#diff-678798cf677bcd1ce276809cfccd33da9ff594b1b0c557180210a4ed2bd27ffa + static let latestSupportedVersion = "1.57.0" + static let minimumSupportedVersion = "1.32.0" public init() {} public enum InstallationStatus { case notInstalled case installed(String) - case outdated(current: String, latest: String) + case outdated(current: String, latest: String, mandatory: Bool) case unsupported(current: String, latest: String) } @@ -36,9 +41,28 @@ public struct GitHubCopilotInstallationManager { let versionData = try? Data(contentsOf: versionFileURL), let version = String(data: versionData, encoding: .utf8) { - switch version.compare(Self.latestSupportedVersion) { + switch version.compare(Self.latestSupportedVersion, options: .numeric) { case .orderedAscending: - return .outdated(current: version, latest: Self.latestSupportedVersion) + switch version.compare(Self.minimumSupportedVersion) { + case .orderedAscending: + return .outdated( + current: version, + latest: Self.latestSupportedVersion, + mandatory: true + ) + case .orderedSame: + return .outdated( + current: version, + latest: Self.latestSupportedVersion, + mandatory: false + ) + case .orderedDescending: + return .outdated( + current: version, + latest: Self.latestSupportedVersion, + mandatory: false + ) + } case .orderedSame: return .installed(version) case .orderedDescending: @@ -46,7 +70,7 @@ public struct GitHubCopilotInstallationManager { } } - return .outdated(current: "Unknown", latest: Self.latestSupportedVersion) + return .outdated(current: "Unknown", latest: Self.latestSupportedVersion, mandatory: false) } public enum InstallationStep { @@ -75,7 +99,7 @@ public struct GitHubCopilotInstallationManager { public func installLatestVersion() -> AsyncThrowingStream { AsyncThrowingStream { continuation in - Task { + Task { @GitHubCopilotSuggestionActor in guard !GitHubCopilotInstallationManager.isInstalling else { continuation.finish(throwing: Error.isInstalling) return @@ -104,7 +128,7 @@ public struct GitHubCopilotInstallationManager { _ = try await terminal.runCommand( "/usr/bin/unzip", arguments: [targetURL.path], - currentDirectoryPath: urls.executableURL.path, + currentDirectoryURL: urls.executableURL, environment: [:] ) @@ -113,7 +137,7 @@ public struct GitHubCopilotInstallationManager { includingPropertiesForKeys: nil, options: [] ) - + defer { for url in contentURLs { try? FileManager.default.removeItem(at: url) @@ -127,8 +151,26 @@ public struct GitHubCopilotInstallationManager { return } - let lspURL = gitFolderURL.appendingPathComponent("copilot") - let installationURL = urls.executableURL.appendingPathComponent("copilot") + let lspURL = { + let caseA = gitFolderURL.appendingPathComponent("dist") + if FileManager.default.fileExists(atPath: caseA.path) { + return caseA + } + return gitFolderURL + .appendingPathComponent("copilot-language-server") + .appendingPathComponent("dist") + }() + let copilotURL = urls.executableURL.appendingPathComponent("copilot") + + if !FileManager.default.fileExists(atPath: copilotURL.path) { + try FileManager.default.createDirectory( + at: copilotURL, + withIntermediateDirectories: true, + attributes: nil + ) + } + + let installationURL = copilotURL.appendingPathComponent("dist") try FileManager.default.copyItem(at: lspURL, to: installationURL) // update permission 755 diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift new file mode 100644 index 00000000..d5681c1e --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -0,0 +1,505 @@ +import Foundation +import JSONRPC +import LanguageServerProtocol +import SuggestionBasic +import XcodeInspector + +struct GitHubCopilotDoc: Codable { + var source: String + var tabSize: Int + var indentSize: Int + var insertSpaces: Bool + var path: String + var uri: String + var relativePath: String + var languageId: CodeLanguage + var position: Position + var version: Int = 0 +} + +protocol GitHubCopilotRequestType { + associatedtype Response: Codable + var request: ClientRequest { get } +} + +public struct GitHubCopilotCodeSuggestion: Codable, Equatable { + public init( + text: String, + position: CursorPosition, + uuid: String, + range: CursorRange, + displayText: String + ) { + self.text = text + self.position = position + self.uuid = uuid + self.range = range + self.displayText = displayText + } + + /// The new code to be inserted and the original code on the first line. + public var text: String + /// The position of the cursor before generating the completion. + public var position: CursorPosition + /// An id. + public var uuid: String + /// The range of the original code that should be replaced. + public var range: CursorRange + /// The new code to be inserted. + public var displayText: String +} + +enum GitHubCopilotChatSource: String, Codable { + case panel + case inline +} + +enum GitHubCopilotRequest { + struct SetEditorInfo: GitHubCopilotRequestType { + let xcodeVersion: String + + struct Response: Codable {} + + var networkProxy: JSONValue? { + let host = UserDefaults.shared.value(for: \.gitHubCopilotProxyHost) + if host.isEmpty { return nil } + var port = UserDefaults.shared.value(for: \.gitHubCopilotProxyPort) + if port.isEmpty { port = "80" } + let username = UserDefaults.shared.value(for: \.gitHubCopilotProxyUsername) + if username.isEmpty { + return .hash([ + "host": .string(host), + "port": .number(Double(Int(port) ?? 80)), + "rejectUnauthorized": .bool(UserDefaults.shared + .value(for: \.gitHubCopilotUseStrictSSL)), + ]) + } else { + return .hash([ + "host": .string(host), + "port": .number(Double(Int(port) ?? 80)), + "rejectUnauthorized": .bool(UserDefaults.shared + .value(for: \.gitHubCopilotUseStrictSSL)), + "username": .string(username), + "password": .string(UserDefaults.shared + .value(for: \.gitHubCopilotProxyPassword)), + + ]) + } + } + + var http: JSONValue? { + var dict: [String: JSONValue] = [:] + let host = UserDefaults.shared.value(for: \.gitHubCopilotProxyHost) + if host.isEmpty { return nil } + var port = UserDefaults.shared.value(for: \.gitHubCopilotProxyPort) + if port.isEmpty { port = "80" } + let username = UserDefaults.shared.value(for: \.gitHubCopilotProxyUsername) + let password = UserDefaults.shared.value(for: \.gitHubCopilotProxyPassword) + let strictSSL = UserDefaults.shared.value(for: \.gitHubCopilotUseStrictSSL) + + let url = if !username.isEmpty { + "http://\(username):\(password)@\(host):\(port)" + } else { + "http://\(host):\(port)" + } + + dict["proxy"] = .string(url) + dict["proxyStrictSSL"] = .bool(strictSSL) + + if dict.isEmpty { return nil } + + return .hash(dict) + } + + var editorConfiguration: JSONValue? { + var dict: [String: JSONValue] = [:] + dict["http"] = http + + let enterpriseURI = UserDefaults.shared.value(for: \.gitHubCopilotEnterpriseURI) + if !enterpriseURI.isEmpty { + dict["github-enterprise"] = .hash([ + "uri": .string(enterpriseURI), + ]) + } + + if dict.isEmpty { return nil } + return .hash(dict) + } + + var authProvider: JSONValue? { + var dict: [String: JSONValue] = [:] + let enterpriseURI = UserDefaults.shared.value(for: \.gitHubCopilotEnterpriseURI) + if !enterpriseURI.isEmpty { + dict["url"] = .string(enterpriseURI) + } + + if dict.isEmpty { return nil } + return .hash(dict) + } + + var request: ClientRequest { + let pretendToBeVSCode = UserDefaults.shared + .value(for: \.gitHubCopilotPretendIDEToBeVSCode) + var dict: [String: JSONValue] = [ + "editorInfo": pretendToBeVSCode ? .hash([ + "name": "vscode", + "version": "1.99.3", + ]) : .hash([ + "name": "Xcode", + "version": .string(xcodeVersion), + ]), + "editorPluginInfo": .hash([ + "name": "Copilot for Xcode", + "version": .string(Bundle.main + .infoDictionary?["CFBundleShortVersionString"] as? String ?? ""), + ]), + ] + + dict["editorConfiguration"] = editorConfiguration + dict["authProvider"] = authProvider + dict["networkProxy"] = networkProxy + + return .custom("setEditorInfo", .hash(dict)) + } + } + + struct GetVersion: GitHubCopilotRequestType { + struct Response: Codable { + var version: String + } + + var request: ClientRequest { + .custom("getVersion", .hash([:])) + } + } + + struct CheckStatus: GitHubCopilotRequestType { + struct Response: Codable { + var status: GitHubCopilotAccountStatus + } + + var request: ClientRequest { + .custom("checkStatus", .hash([:])) + } + } + + struct SignInInitiate: GitHubCopilotRequestType { + struct Response: Codable { + var verificationUri: String + var status: String + var userCode: String + var expiresIn: Int + var interval: Int + } + + var request: ClientRequest { + .custom("signInInitiate", .hash([:])) + } + } + + struct SignInConfirm: GitHubCopilotRequestType { + struct Response: Codable { + var status: GitHubCopilotAccountStatus + var user: String + } + + var userCode: String + + var request: ClientRequest { + .custom("signInConfirm", .hash([ + "userCode": .string(userCode), + ])) + } + } + + struct SignOut: GitHubCopilotRequestType { + struct Response: Codable { + var status: GitHubCopilotAccountStatus + } + + var request: ClientRequest { + .custom("signOut", .hash([:])) + } + } + + struct GetCompletions: GitHubCopilotRequestType { + struct Response: Codable { + var completions: [GitHubCopilotCodeSuggestion] + } + + var doc: GitHubCopilotDoc + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(doc)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("getCompletions", .hash([ + "doc": dict, + ])) + } + } + + struct GetCompletionsCycling: GitHubCopilotRequestType { + struct Response: Codable { + var completions: [GitHubCopilotCodeSuggestion] + } + + var doc: GitHubCopilotDoc + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(doc)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("getCompletionsCycling", .hash([ + "doc": dict, + ])) + } + } + + struct InlineCompletion: GitHubCopilotRequestType { + struct Response: Codable { + var items: [InlineCompletionItem] + } + + struct InlineCompletionItem: Codable { + var insertText: String + var filterText: String? + var range: Range? + var command: Command? + + struct Range: Codable { + var start: Position + var end: Position + } + + struct Command: Codable { + var title: String + var command: String + var arguments: [String]? + } + } + + var doc: Input + + struct Input: Codable { + var textDocument: _TextDocument; struct _TextDocument: Codable { + var uri: String + var version: Int + } + + var position: Position + var formattingOptions: FormattingOptions + var context: _Context; struct _Context: Codable { + enum TriggerKind: Int, Codable { + case invoked = 1 + case automatic = 2 + } + + var triggerKind: TriggerKind + } + } + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(doc)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("textDocument/inlineCompletion", dict) + } + } + + struct GetPanelCompletions: GitHubCopilotRequestType { + struct Response: Codable { + var completions: [GitHubCopilotCodeSuggestion] + } + + var doc: GitHubCopilotDoc + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(doc)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("getPanelCompletions", .hash([ + "doc": dict, + ])) + } + } + + struct NotifyAccepted: GitHubCopilotRequestType { + struct Response: Codable {} + + var completionUUID: String + + var request: ClientRequest { + .custom("notifyAccepted", .hash([ + "uuid": .string(completionUUID), + ])) + } + } + + struct NotifyRejected: GitHubCopilotRequestType { + struct Response: Codable {} + + var completionUUIDs: [String] + + var request: ClientRequest { + .custom("notifyRejected", .hash([ + "uuids": .array(completionUUIDs.map(JSONValue.string)), + ])) + } + } + + struct ConversationCreate: GitHubCopilotRequestType { + struct Response: Codable { + var conversationId: String + var turnId: String + } + + struct RequestBody: Codable { + public struct Reference: Codable, Equatable, Hashable { + public var type: String = "file" + public let uri: String + public let position: Position? + public let visibleRange: SuggestionBasic.CursorRange? + public let selection: SuggestionBasic.CursorRange? + public let openedAt: String? + public let activeAt: String? + } + + enum ConversationSource: String, Codable { + case panel, inline + } + + enum ConversationMode: String, Codable { + case agent = "Agent" + } + + struct ConversationTurn: Codable { + var request: String + var response: String? + var turnId: String? + } + + var workDoneToken: String + var turns: [ConversationTurn] + var capabilities: Capabilities + var textDocument: GitHubCopilotDoc? + var references: [Reference]? + var computeSuggestions: Bool? + var source: ConversationSource? + var workspaceFolder: String? + var workspaceFolders: [WorkspaceFolder]? + var ignoredSkills: [String]? + var model: String? + var chatMode: ConversationMode? + var userLanguage: String? + + struct Capabilities: Codable { + var skills: [String] + var allSkills: Bool? + } + } + + let requestBody: RequestBody + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(requestBody)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/create", dict) + } + } + + struct ConversationTurn: GitHubCopilotRequestType { + struct Response: Codable {} + + struct RequestBody: Codable { + var workDoneToken: String + var conversationId: String + var message: String + var textDocument: GitHubCopilotDoc? + var ignoredSkills: [String]? + var references: [ConversationCreate.RequestBody.Reference]? + var model: String? + var workspaceFolder: String? + var workspaceFolders: [WorkspaceFolder]? + var chatMode: String? + } + + let requestBody: RequestBody + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(requestBody)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/turn", dict) + } + } + + struct ConversationTurnDelete: GitHubCopilotRequestType { + struct Response: Codable {} + + struct RequestBody: Codable { + var conversationId: String + var turnId: String + var options: [String: String]? + var source: GitHubCopilotChatSource? + } + + let requestBody: RequestBody + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(requestBody)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/turnDelete", dict) + } + } + + struct ConversationDestroy: GitHubCopilotRequestType { + struct Response: Codable {} + + struct RequestBody: Codable { + var conversationId: String + var options: [String: String]? + } + + let requestBody: RequestBody + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(requestBody)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/destroy", dict) + } + } + + struct CopilotModels: GitHubCopilotRequestType { + typealias Response = [GitHubCopilotModel] + + var request: ClientRequest { + .custom("copilot/models", .hash([:])) + } + } +} + +public struct GitHubCopilotModel: Codable, Equatable { + public let modelFamily: String + public let modelName: String + public let id: String +// public let modelPolicy: CopilotModelPolicy? + public let scopes: [GitHubCopilotPromptTemplateScope] + public let preview: Bool + public let isChatDefault: Bool + public let isChatFallback: Bool +// public let capabilities: CopilotModelCapabilities +// public let billing: CopilotModelBilling? +} + +public struct GitHubCopilotLLMModel: Equatable, Decodable, Identifiable { + public var id: String { modelId } + public var modelId: String + public var familyName: String + public var contextWindow: Int +} + +public enum GitHubCopilotPromptTemplateScope: String, Codable, Equatable { + case chatPanel = "chat-panel" + case editPanel = "edit-panel" + case agentPanel = "agent-panel" + case editor + case inline + case completion +} + diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift new file mode 100644 index 00000000..486ddd92 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -0,0 +1,693 @@ +import AppKit +import enum CopilotForXcodeKit.SuggestionServiceError +import Foundation +import JSONRPC +import LanguageClient +import LanguageServerProtocol +import Logger +import Preferences +import SuggestionBasic +import XcodeInspector + +public protocol GitHubCopilotAuthServiceType { + func checkStatus() async throws -> GitHubCopilotAccountStatus + func signInInitiate() async throws -> (verificationUri: String, userCode: String) + func signInConfirm(userCode: String) async throws + -> (username: String, status: GitHubCopilotAccountStatus) + func signOut() async throws -> GitHubCopilotAccountStatus + func version() async throws -> String +} + +public protocol GitHubCopilotSuggestionServiceType { + func getCompletions( + fileURL: URL, + content: String, + originalContent: String, + cursorPosition: CursorPosition, + tabSize: Int, + indentSize: Int, + usesTabsForIndentation: Bool + ) async throws -> [CodeSuggestion] + func notifyAccepted(_ completion: CodeSuggestion) async + func notifyRejected(_ completions: [CodeSuggestion]) async + func notifyOpenTextDocument(fileURL: URL, content: String) async throws + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws + func notifyCloseTextDocument(fileURL: URL) async throws + func notifySaveTextDocument(fileURL: URL) async throws + func cancelRequest() async + func terminate() async + func cancelOngoingTask(workDoneToken: String) async +} + +protocol GitHubCopilotLSP { + func sendRequest( + _ endpoint: E, + timeout: TimeInterval? + ) async throws -> E.Response + func sendNotification(_ notif: ClientNotification) async throws +} + +extension GitHubCopilotLSP { + func sendRequest(_ endpoint: E) async throws -> E.Response { + try await sendRequest(endpoint, timeout: nil) + } +} + +enum GitHubCopilotError: Error, LocalizedError { + case notLoggedIn + case languageServerNotInstalled + case languageServerError(ServerError) + case failedToInstallStartScript + case chatEndsWithError(String) + + var errorDescription: String? { + switch self { + case .notLoggedIn: + return "Not logged in." + case .languageServerNotInstalled: + return "Language server is not installed." + case .failedToInstallStartScript: + return "Failed to install start script." + case let .chatEndsWithError(errorMessage): + return "Chat ended with error message: \(errorMessage)" + case let .languageServerError(error): + switch error { + case let .handlerUnavailable(handler): + return "Language server error: Handler \(handler) unavailable" + case let .unhandledMethod(method): + return "Language server error: Unhandled method \(method)" + case let .notificationDispatchFailed(error): + return "Language server error: Notification dispatch failed: \(error)" + case let .requestDispatchFailed(error): + return "Language server error: Request dispatch failed: \(error)" + case let .clientDataUnavailable(error): + return "Language server error: Client data unavailable: \(error)" + case .serverUnavailable: + return "Language server error: Server unavailable, please make sure that:\n1. The path to node is correctly set.\n2. The node is not a shim executable.\n3. the node version is high enough (v22.0+)." + case .missingExpectedParameter: + return "Language server error: Missing expected parameter" + case .missingExpectedResult: + return "Language server error: Missing expected result" + case let .unableToDecodeRequest(error): + return "Language server error: Unable to decode request: \(error)" + case let .unableToSendRequest(error): + return "Language server error: Unable to send request: \(error)" + case let .unableToSendNotification(error): + return "Language server error: Unable to send notification: \(error)" + case let .serverError(code: code, message: message, data: data): + return "Language server error: Server error: \(code) \(message) \(String(describing: data))" + case .invalidRequest: + return "Language server error: Invalid request" + case .timeout: + return "Language server error: Timeout, please try again later" + } + } + } +} + +public extension Notification.Name { + static let gitHubCopilotShouldRefreshEditorInformation = Notification + .Name("com.intii.CopilotForXcode.GitHubCopilotShouldRefreshEditorInformation") +} + +public class GitHubCopilotBaseService { + let projectRootURL: URL + var server: GitHubCopilotLSP + var localProcessServer: CopilotLocalProcessServer? + let notificationHandler: ServerNotificationHandler + + deinit { + localProcessServer?.terminate() + } + + init(designatedServer: GitHubCopilotLSP) { + projectRootURL = URL(fileURLWithPath: "/") + server = designatedServer + notificationHandler = .init() + } + + init(projectRootURL: URL) throws { + self.projectRootURL = projectRootURL + let notificationHandler = ServerNotificationHandler() + self.notificationHandler = notificationHandler + let (server, localServer) = try { [notificationHandler] in + let urls = try GitHubCopilotBaseService.createFoldersIfNeeded() + let executionParams: Process.ExecutionParameters + let runner = UserDefaults.shared.value(for: \.runNodeWith) +// let watchedFiles = JSONValue( +// booleanLiteral: projectRootURL.path == "/" ? false : true +// ) + + guard let agentJSURL = { () -> URL? in + let languageServerDotJS = urls.executableURL + .appendingPathComponent("copilot/dist/language-server.js") + if FileManager.default.fileExists(atPath: languageServerDotJS.path) { + return languageServerDotJS + } + let agentsDotJS = urls.executableURL.appendingPathComponent("copilot/dist/agent.js") + if FileManager.default.fileExists(atPath: agentsDotJS.path) { + return agentsDotJS + } + return nil + }() else { + throw GitHubCopilotError.languageServerNotInstalled + } + + let indexJSURL: URL = try { + if UserDefaults.shared.value(for: \.gitHubCopilotLoadKeyChainCertificates) { + let url = urls.executableURL + .appendingPathComponent("load-self-signed-cert-1.34.0.js") + if !FileManager.default.fileExists(atPath: url.path) { + let file = Bundle.module.url( + forResource: "load-self-signed-cert-1.34.0", + withExtension: "js" + )! + do { + try FileManager.default.copyItem(at: file, to: url) + } catch { + throw GitHubCopilotError.failedToInstallStartScript + } + } + return url + } else { + return agentJSURL + } + }() + + #if DEBUG + let environment: [String: String] = [ + "GH_COPILOT_DEBUG_UI_PORT": "8080", + "GH_COPILOT_VERBOSE": UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) + ? "true" : "false", + ] + #else + let environment = [String: String]() + #endif + + switch runner { + case .bash: + let nodePath = UserDefaults.shared.value(for: \.nodePath) + let command = [ + nodePath.isEmpty ? "node" : nodePath, + "\"\(indexJSURL.path)\"", + "--stdio", + ].joined(separator: " ") + executionParams = Process.ExecutionParameters( + path: "/bin/bash", + arguments: ["-i", "-l", "-c", command], + environment: environment, + currentDirectoryURL: urls.supportURL + ) + case .shell: + let shell = ProcessInfo.processInfo.shellExecutablePath + let nodePath = UserDefaults.shared.value(for: \.nodePath) + let command = [ + nodePath.isEmpty ? "node" : nodePath, + "\"\(indexJSURL.path)\"", + "--stdio", + ].joined(separator: " ") + executionParams = Process.ExecutionParameters( + path: shell, + arguments: ["-i", "-l", "-c", command], + environment: environment, + currentDirectoryURL: urls.supportURL + ) + case .env: + let userEnvPath = + "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + executionParams = { + let nodePath = UserDefaults.shared.value(for: \.nodePath) + return Process.ExecutionParameters( + path: "/usr/bin/env", + arguments: [ + nodePath.isEmpty ? "node" : nodePath, + indexJSURL.path, + "--stdio", + ], + environment: [ + "PATH": userEnvPath, + ], + currentDirectoryURL: urls.supportURL + ) + }() + } + let localServer = CopilotLocalProcessServer( + executionParameters: executionParams, + serverNotificationHandler: notificationHandler + ) + + localServer.logMessages = UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) + let server = InitializingServer(server: localServer) + + server.initializeParamsProvider = { + let capabilities = ClientCapabilities( + workspace: nil, + textDocument: nil, + window: nil, + general: nil, + experimental: nil + ) + + let pretendToBeVSCode = UserDefaults.shared + .value(for: \.gitHubCopilotPretendIDEToBeVSCode) + return InitializeParams( + processId: Int(ProcessInfo.processInfo.processIdentifier), + clientInfo: .init( + name: Bundle.main + .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String + ?? "Copilot for Xcode" + ), + locale: nil, + rootPath: projectRootURL.path, + rootUri: projectRootURL.path, + initializationOptions: [ + "editorInfo": pretendToBeVSCode ? .hash([ + "name": "vscode", + "version": "1.99.3", + ]) : .hash([ + "name": "Xcode", + "version": .string(xcodeVersion() ?? "16.0"), + ]), + "editorPluginInfo": .hash([ + "name": "Copilot for Xcode", + "version": .string(Bundle.main + .infoDictionary?["CFBundleShortVersionString"] as? String ?? ""), + ]), +// "copilotCapabilities": [ +// /// The editor has support for watching files over LSP +// "watchedFiles": watchedFiles, +// ], + ], + capabilities: capabilities, + trace: .off, + workspaceFolders: [WorkspaceFolder( + uri: projectRootURL.absoluteString, + name: projectRootURL.lastPathComponent + )] + ) + } + + return (server, localServer) + }() + + self.server = server + localProcessServer = localServer + + let notifications = NotificationCenter.default + .notifications(named: .gitHubCopilotShouldRefreshEditorInformation) + Task { [weak self] in + _ = try? await server.sendRequest( + GitHubCopilotRequest.SetEditorInfo(xcodeVersion: xcodeVersion() ?? "16.0") + ) + + for await _ in notifications { + guard self != nil else { return } + _ = try? await server.sendRequest( + GitHubCopilotRequest.SetEditorInfo(xcodeVersion: xcodeVersion() ?? "16.0") + ) + } + } + } + + public static func createFoldersIfNeeded() throws -> ( + applicationSupportURL: URL, + gitHubCopilotURL: URL, + executableURL: URL, + supportURL: URL + ) { + guard let supportURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first?.appendingPathComponent( + Bundle.main + .object(forInfoDictionaryKey: "APPLICATION_SUPPORT_FOLDER") as! String + ) else { + throw CancellationError() + } + + if !FileManager.default.fileExists(atPath: supportURL.path) { + try? FileManager.default + .createDirectory(at: supportURL, withIntermediateDirectories: false) + } + let gitHubCopilotFolderURL = supportURL.appendingPathComponent("GitHub Copilot") + if !FileManager.default.fileExists(atPath: gitHubCopilotFolderURL.path) { + try? FileManager.default + .createDirectory(at: gitHubCopilotFolderURL, withIntermediateDirectories: false) + } + let supportFolderURL = gitHubCopilotFolderURL.appendingPathComponent("support") + if !FileManager.default.fileExists(atPath: supportFolderURL.path) { + try? FileManager.default + .createDirectory(at: supportFolderURL, withIntermediateDirectories: false) + } + let executableFolderURL = gitHubCopilotFolderURL.appendingPathComponent("executable") + if !FileManager.default.fileExists(atPath: executableFolderURL.path) { + try? FileManager.default + .createDirectory(at: executableFolderURL, withIntermediateDirectories: false) + } + + return (supportURL, gitHubCopilotFolderURL, executableFolderURL, supportFolderURL) + } + + func registerNotificationHandler( + id: AnyHashable, + _ block: @escaping ServerNotificationHandler.Handler + ) { + Task { @GitHubCopilotSuggestionActor in + self.notificationHandler.handlers[id] = block + } + } + + func unregisterNotificationHandler(id: AnyHashable) { + Task { @GitHubCopilotSuggestionActor in + self.notificationHandler.handlers[id] = nil + } + } +} + +public final class GitHubCopilotAuthService: GitHubCopilotBaseService, + GitHubCopilotAuthServiceType +{ + public init() throws { + let home = FileManager.default.homeDirectoryForCurrentUser + try super.init(projectRootURL: home) + } + + public func checkStatus() async throws -> GitHubCopilotAccountStatus { + do { + return try await server.sendRequest(GitHubCopilotRequest.CheckStatus()).status + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } + + public func signInInitiate() async throws -> (verificationUri: String, userCode: String) { + do { + let result = try await server.sendRequest(GitHubCopilotRequest.SignInInitiate()) + return (result.verificationUri, result.userCode) + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } + + public func signInConfirm(userCode: String) async throws + -> (username: String, status: GitHubCopilotAccountStatus) + { + do { + let result = try await server + .sendRequest(GitHubCopilotRequest.SignInConfirm(userCode: userCode)) + return (result.user, result.status) + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } + + public func signOut() async throws -> GitHubCopilotAccountStatus { + do { + return try await server.sendRequest(GitHubCopilotRequest.SignOut()).status + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } + + public func version() async throws -> String { + do { + return try await server.sendRequest(GitHubCopilotRequest.GetVersion()).version + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } +} + +@globalActor public enum GitHubCopilotSuggestionActor { + public actor TheActor {} + public static let shared = TheActor() +} + +public final class GitHubCopilotService: GitHubCopilotBaseService, + GitHubCopilotSuggestionServiceType +{ + private var ongoingTasks = Set>() + + override public init(projectRootURL: URL = URL(fileURLWithPath: "/")) throws { + try super.init(projectRootURL: projectRootURL) + } + + override init(designatedServer: GitHubCopilotLSP) { + super.init(designatedServer: designatedServer) + } + + @GitHubCopilotSuggestionActor + public func getCompletions( + fileURL: URL, + content: String, + originalContent: String, + cursorPosition: CursorPosition, + tabSize: Int, + indentSize: Int, + usesTabsForIndentation: Bool + ) async throws -> [CodeSuggestion] { + ongoingTasks.forEach { $0.cancel() } + ongoingTasks.removeAll() + await localProcessServer?.cancelOngoingTasks() + + func sendRequest(maxTry: Int = 5) async throws -> [CodeSuggestion] { + do { + let completions = try await server + .sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init( + textDocument: .init(uri: fileURL.absoluteString, version: 1), + position: cursorPosition, + formattingOptions: .init( + tabSize: tabSize, + insertSpaces: !usesTabsForIndentation + ), + context: .init(triggerKind: .invoked) + ))) + .items + .compactMap { (item: _) -> CodeSuggestion? in + guard let range = item.range else { return nil } + let suggestion = CodeSuggestion( + id: item.command?.arguments?.first ?? UUID().uuidString, + text: item.insertText, + position: cursorPosition, + range: .init(start: range.start, end: range.end) + ) + return suggestion + } + try Task.checkCancellation() + return completions + } catch let error as ServerError { + switch error { + case .serverError(1000, _, _): // not logged-in error + throw SuggestionServiceError + .notice(GitHubCopilotError.languageServerError(error)) + case .serverError: + // sometimes the content inside language server is not new enough, which can + // lead to an version mismatch error. We can try a few times until the content + // is up to date. + if maxTry <= 0 { break } + Logger.gitHubCopilot.error( + "Try getting suggestions again: \(GitHubCopilotError.languageServerError(error).localizedDescription)" + ) + try await Task.sleep(nanoseconds: 200_000_000) + return try await sendRequest(maxTry: maxTry - 1) + default: + break + } + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } + + func recoverContent() async { + try? await notifyChangeTextDocument( + fileURL: fileURL, + content: originalContent, + version: 0 + ) + } + + // since when the language server is no longer using the passed in content to generate + // suggestions, we will need to update the content to the file before we do any request. + // + // And sometimes the language server's content was not up to date and may generate + // weird result when the cursor position exceeds the line. + let task = Task { @GitHubCopilotSuggestionActor in + do { + try await notifyChangeTextDocument( + fileURL: fileURL, + content: content, + version: 1 + ) + } catch let error as ServerError { + switch error { + case .serverUnavailable: + throw SuggestionServiceError + .notice(GitHubCopilotError.languageServerError(error)) + default: + throw error + } + } catch { + throw error + } + + do { + try Task.checkCancellation() + return try await sendRequest() + } catch let error as CancellationError { + if ongoingTasks.isEmpty { + await recoverContent() + } + throw error + } catch { + await recoverContent() + throw error + } + } + + ongoingTasks.insert(task) + + return try await task.value + } + + @GitHubCopilotSuggestionActor + public func cancelRequest() async { + ongoingTasks.forEach { $0.cancel() } + ongoingTasks.removeAll() + await localProcessServer?.cancelOngoingTasks() + } + + @GitHubCopilotSuggestionActor + public func notifyAccepted(_ completion: CodeSuggestion) async { + _ = try? await server.sendRequest( + GitHubCopilotRequest.NotifyAccepted(completionUUID: completion.id) + ) + } + + @GitHubCopilotSuggestionActor + public func notifyRejected(_ completions: [CodeSuggestion]) async { + _ = try? await server.sendRequest( + GitHubCopilotRequest.NotifyRejected(completionUUIDs: completions.map(\.id)) + ) + } + + @GitHubCopilotSuggestionActor + public func notifyOpenTextDocument( + fileURL: URL, + content: String + ) async throws { + let languageId = languageIdentifierFromFileURL(fileURL) + let uri = "file://\(fileURL.path)" +// Logger.service.debug("Open \(uri), \(content.count)") + try await server.sendNotification( + .didOpenTextDocument( + DidOpenTextDocumentParams( + textDocument: .init( + uri: uri, + languageId: languageId.rawValue, + version: 0, + text: content + ) + ) + ) + ) + } + + @GitHubCopilotSuggestionActor + public func notifyChangeTextDocument( + fileURL: URL, + content: String, + version: Int + ) async throws { + let uri = "file://\(fileURL.path)" +// Logger.service.debug("Change \(uri), \(content.count)") + try await server.sendNotification( + .didChangeTextDocument( + DidChangeTextDocumentParams( + uri: uri, + version: version, + contentChange: .init( + range: nil, + rangeLength: nil, + text: content + ) + ) + ) + ) + } + + @GitHubCopilotSuggestionActor + public func notifySaveTextDocument(fileURL: URL) async throws { + let uri = "file://\(fileURL.path)" +// Logger.service.debug("Save \(uri)") + try await server.sendNotification(.didSaveTextDocument(.init(uri: uri))) + } + + @GitHubCopilotSuggestionActor + public func notifyCloseTextDocument(fileURL: URL) async throws { + let uri = "file://\(fileURL.path)" +// Logger.service.debug("Close \(uri)") + try await server.sendNotification(.didCloseTextDocument(.init(uri: uri))) + } + + @GitHubCopilotSuggestionActor + public func terminate() async { + // automatically handled + } + + public func cancelOngoingTask(workDoneToken: String) async { + await localProcessServer?.cancelOngoingTask(workDoneToken: workDoneToken) + } +} + +extension InitializingServer: GitHubCopilotLSP { + func sendRequest( + _ endpoint: E, + timeout: TimeInterval? = nil + ) async throws -> E.Response { + if let timeout { + return try await withCheckedThrowingContinuation { continuation in + self.sendRequest(endpoint.request, timeout: timeout) { result in + continuation.resume(with: result) + } + } + } else { + return try await sendRequest(endpoint.request) + } + } +} + +private func xcodeVersion() -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["xcodebuild", "-version"] + + let pipe = Pipe() + process.standardOutput = pipe + + do { + try process.run() + } catch { + print("Error running xcrun xcodebuild: \(error)") + return nil + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { + return nil + } + + let lines = output.split(separator: "\n") + return lines.first?.split(separator: " ").last.map(String.init) +} + diff --git a/Tool/Sources/GitHubCopilotService/Resources/load-self-signed-cert-1.34.0.js b/Tool/Sources/GitHubCopilotService/Resources/load-self-signed-cert-1.34.0.js new file mode 100644 index 00000000..44bcec49 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Resources/load-self-signed-cert-1.34.0.js @@ -0,0 +1,40 @@ +function initialize() { + if (process.platform !== "darwin") { + return; + } + + const splitPattern = /(?=-----BEGIN\sCERTIFICATE-----)/g; + const systemRootCertsPath = + "/System/Library/Keychains/SystemRootCertificates.keychain"; + const args = ["find-certificate", "-a", "-p"]; + + const childProcess = require("child_process"); + const allTrusted = childProcess + .spawnSync("/usr/bin/security", args) + .stdout.toString() + .split(splitPattern); + + const allRoot = childProcess + .spawnSync("/usr/bin/security", args.concat(systemRootCertsPath)) + .stdout.toString() + .split(splitPattern); + const all = allTrusted.concat(allRoot); + + const tls = require("tls"); + const origCreateSecureContext = tls.createSecureContext; + tls.createSecureContext = (options) => { + const ctx = origCreateSecureContext(options); + all.filter(duplicated).forEach((cert) => { + ctx.context.addCACert(cert.trim()); + }); + return ctx; + }; +} + +function duplicated(cert, index, arr) { + return arr.indexOf(cert) === index; +} + +initialize(); + +require("./copilot/dist/language-server.js"); diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift new file mode 100644 index 00000000..280b7068 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift @@ -0,0 +1,275 @@ +import BuiltinExtension +import ChatBasic +import CopilotForXcodeKit +import Foundation +import LanguageServerProtocol +import XcodeInspector + +public final class GitHubCopilotChatService: BuiltinExtensionChatServiceType { + let serviceLocator: any ServiceLocatorType + + init(serviceLocator: any ServiceLocatorType) { + self.serviceLocator = serviceLocator + } + + /// - note: Let's do it in a naive way for proof of concept. We will create a new chat for each + /// message in this version. + public func sendMessage( + _ message: String, + history: [Message], + references: [RetrievedContent], + workspace: WorkspaceInfo + ) async -> AsyncThrowingStream { + guard let service = await serviceLocator.getService(from: workspace) + else { return .finished(throwing: CancellationError()) } + let id = UUID().uuidString + let editorContent = await XcodeInspector.shared.getFocusedEditorContent() + let workDoneToken = UUID().uuidString + let turns = convertHistory(history: history, message: message) + let doc = GitHubCopilotDoc( + source: editorContent?.editorContent?.content ?? "", + tabSize: 1, + indentSize: 4, + insertSpaces: true, + path: editorContent?.documentURL.absoluteString ?? "", + uri: editorContent?.documentURL.absoluteString ?? "", + relativePath: editorContent?.relativePath ?? "", + languageId: editorContent?.language ?? .plaintext, + position: editorContent?.editorContent?.cursorPosition ?? .zero + ) + let request = GitHubCopilotRequest.ConversationCreate(requestBody: .init( + workDoneToken: workDoneToken, + turns: turns, + capabilities: .init(skills: [], allSkills: false), + textDocument: doc, + source: .panel, + workspaceFolder: workspace.projectURL.absoluteString, + model: { + let selectedModel = UserDefaults.shared.value(for: \.gitHubCopilotModelId) + if selectedModel.isEmpty { + return nil + } + return selectedModel + }(), + userLanguage: { + let language = UserDefaults.shared.value(for: \.chatGPTLanguage) + if language.isEmpty { + return "Auto Detected" + } + return language + }() + )) + + let stream = AsyncThrowingStream { continuation in + let startTimestamp = Date() + + continuation.onTermination = { _ in + Task { + service.unregisterNotificationHandler(id: id) + await service.cancelOngoingTask(workDoneToken: workDoneToken) + } + } + + service.registerNotificationHandler(id: id) { notification, data in + // just incase the conversation is stuck, we will cancel it after timeout + if Date().timeIntervalSince(startTimestamp) > 60 * 30 { + continuation.finish(throwing: CancellationError()) + return false + } + + switch notification.method { + case "$/progress": + do { + let progress = try JSONDecoder().decode( + JSONRPC.self, + from: data + ).params + guard progress.token == workDoneToken else { return false } + if let reply = progress.value.reply, progress.value.kind == "report" { + continuation.yield(reply) + } else if progress.value.kind == "end" { + if let error = progress.value.error?.message, + progress.value.cancellationReason == nil + { + if error.contains("400") { + continuation.finish( + throwing: GitHubCopilotError.chatEndsWithError( + "\(error). Please try enabling pretend IDE to be VSCode and click refresh configuration." + ) + ) + } else if error.contains("No model configuration found") { + continuation.finish( + throwing: GitHubCopilotError.chatEndsWithError( + "\(error). Please try enabling pretend IDE to be VSCode and click refresh configuration." + ) + ) + } else { + continuation.finish( + throwing: GitHubCopilotError.chatEndsWithError(error) + ) + } + } else { + continuation.finish() + } + } + return true + } catch { + return false + } + case "conversation/context": + do { + _ = try JSONDecoder().decode( + JSONRPC.self, + from: data + ) + throw ServerError.clientDataUnavailable(CancellationError()) + } catch { + return false + } + + default: + return false + } + } + + Task { + do { + // this will return when the response is generated. + let createResponse = try await service.server.sendRequest(request, timeout: 120) + _ = try await service.server.sendRequest( + GitHubCopilotRequest.ConversationDestroy(requestBody: .init( + conversationId: createResponse.conversationId + )) + ) + } catch let error as ServerError { + continuation.finish(throwing: GitHubCopilotError.languageServerError(error)) + } catch { + continuation.finish(throwing: error) + } + } + } + + return stream + } +} + +extension GitHubCopilotChatService { + typealias Turn = GitHubCopilotRequest.ConversationCreate.RequestBody.ConversationTurn + func convertHistory(history: [Message], message: String) -> [Turn] { + guard let firstIndexOfUserMessage = history.firstIndex(where: { $0.role == .user }) + else { return [.init(request: message, response: nil)] } + + var currentTurn = Turn(request: "", response: nil) + var turns: [Turn] = [] + let systemPrompt = history + .filter { $0.role == .system }.compactMap(\.content) + .joined(separator: "\n\n") + + if !systemPrompt.isEmpty { + turns.append(.init(request: "[System Prompt]\n\(systemPrompt)", response: "OK!")) + } + + for i in firstIndexOfUserMessage.. String { + return message + } + + struct JSONRPC: Decodable { + var jsonrpc: String + var method: String + var params: Params + } + + struct StreamProgressParams: Decodable { + struct Value: Decodable { + struct Step: Decodable { + var id: String + var title: String + var status: String + } + + struct FollowUp: Decodable { + var id: String + var type: String + var message: String + } + + struct Error: Decodable { + var responseIsIncomplete: Bool? + var message: String? + } + + struct Annotation: Decodable { + var id: Int + } + + var kind: String + var title: String? + var conversationId: String + var turnId: String + var steps: [Step]? + var followUp: FollowUp? + var suggestedTitle: String? + var reply: String? + var annotations: [Annotation]? + var hideText: Bool? + var cancellationReason: String? + var error: Error? + } + + var token: String + var value: Value + } + + struct ConversationContextParams: Decodable { + enum SkillID: String, Decodable { + case currentEditor = "current-editor" + case projectLabels = "project-labels" + case recentFiles = "recent-files" + case references + case problemsInActiveDocument = "problems-in-active-document" + } + + var conversationId: String + var turnId: String + var skillId: String + } + + struct ConversationContextResponseBody: Encodable {} +} + diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift new file mode 100644 index 00000000..f9f8a9b5 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift @@ -0,0 +1,107 @@ +import CopilotForXcodeKit +import Foundation +import SuggestionBasic +import Workspace + +public final class GitHubCopilotSuggestionService: SuggestionServiceType { + public var configuration: SuggestionServiceConfiguration { + .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: false + ) + } + + let serviceLocator: ServiceLocatorType + + init(serviceLocator: ServiceLocatorType) { + self.serviceLocator = serviceLocator + } + + public func getSuggestions( + _ request: SuggestionRequest, + workspace: WorkspaceInfo + ) async throws -> [CopilotForXcodeKit.CodeSuggestion] { + guard let service = await serviceLocator.getService(from: workspace) else { return [] } + return try await service.getCompletions( + fileURL: request.fileURL, + content: request.content, + originalContent: request.originalContent, + cursorPosition: .init( + line: request.cursorPosition.line, + character: request.cursorPosition.character + ), + tabSize: request.tabSize, + indentSize: request.indentSize, + usesTabsForIndentation: request.usesTabsForIndentation + ).map(Self.convert) + } + + public func notifyAccepted( + _ suggestion: CopilotForXcodeKit.CodeSuggestion, + workspace: WorkspaceInfo + ) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.notifyAccepted(Self.convert(suggestion)) + } + + public func notifyRejected( + _ suggestions: [CopilotForXcodeKit.CodeSuggestion], + workspace: WorkspaceInfo + ) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.notifyRejected(suggestions.map(Self.convert)) + } + + public func cancelRequest(workspace: WorkspaceInfo) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.cancelRequest() + } + + static func convert( + _ suggestion: SuggestionBasic.CodeSuggestion + ) -> CopilotForXcodeKit.CodeSuggestion { + .init( + id: suggestion.id, + text: suggestion.text, + position: .init( + line: suggestion.position.line, + character: suggestion.position.character + ), + range: .init( + start: .init( + line: suggestion.range.start.line, + character: suggestion.range.start.character + ), + end: .init( + line: suggestion.range.end.line, + character: suggestion.range.end.character + ) + ) + ) + } + + static func convert( + _ suggestion: CopilotForXcodeKit.CodeSuggestion + ) -> SuggestionBasic.CodeSuggestion { + .init( + id: suggestion.id, + text: suggestion.text, + position: .init( + line: suggestion.position.line, + character: suggestion.position.character + ), + range: .init( + start: .init( + line: suggestion.range.start.line, + character: suggestion.range.start.character + ), + end: .init( + line: suggestion.range.end.line, + character: suggestion.range.end.character + ) + ) + ) + } +} + diff --git a/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift b/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift new file mode 100644 index 00000000..53f5bf39 --- /dev/null +++ b/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift @@ -0,0 +1,105 @@ +import Dependencies +import Foundation +import Terminal +import Preferences + +public struct CheckIfGitIgnoredDependencyKey: DependencyKey { + public static var liveValue: GitIgnoredChecker = DefaultGitIgnoredChecker() + public static var testValue: GitIgnoredChecker = DefaultGitIgnoredChecker(isTest: true) +} + +public extension DependencyValues { + var gitIgnoredChecker: GitIgnoredChecker { + get { self[CheckIfGitIgnoredDependencyKey.self] } + set { self[CheckIfGitIgnoredDependencyKey.self] = newValue } + } +} + +public protocol GitIgnoredChecker { + func checkIfGitIgnored(fileURL: URL) async -> Bool + func checkIfGitIgnored(fileURLs: [URL]) async -> [URL] +} + +public extension GitIgnoredChecker { + func checkIfGitIgnored(filePath: String) async -> Bool { + await checkIfGitIgnored(fileURL: URL(fileURLWithPath: filePath)) + } + + func checkIfGitIgnored(filePaths: [String]) async -> [String] { + await checkIfGitIgnored(fileURLs: filePaths.map { URL(fileURLWithPath: $0) }) + .map(\.path) + } +} + +public struct DefaultGitIgnoredChecker: GitIgnoredChecker { + var isTest = false + + var noCheck: Bool { + if isTest { return true } + return UserDefaults.shared.value(for: \.disableGitIgnoreCheck) + } + + public init() {} + + init(isTest: Bool) { + self.isTest = isTest + } + + public func checkIfGitIgnored(fileURL: URL) async -> Bool { + if noCheck { return false } + let terminal = Terminal() + guard let gitFolderURL = gitFolderURL(forFileURL: fileURL) else { + return false + } + do { + let result = try await terminal.runCommand( + "/bin/bash", + arguments: ["-c", "git check-ignore ${TARGET_FILE}"], + currentDirectoryURL: gitFolderURL, + environment: ["TARGET_FILE": fileURL.path] + ) + if result.isEmpty { return false } + return true + } catch { + return false + } + } + + public func checkIfGitIgnored(fileURLs: [URL]) async -> [URL] { + if noCheck { return [] } + let filePaths = fileURLs.map { "\"\($0.path)\"" }.joined(separator: " ") + guard let firstFileURL = fileURLs.first else { return [] } + let terminal = Terminal() + guard let gitFolderURL = gitFolderURL(forFileURL: firstFileURL) else { + return [] + } + do { + let result = try await terminal.runCommand( + "/bin/bash", + arguments: ["-c", "git check-ignore ${TARGET_FILE}"], + currentDirectoryURL: gitFolderURL, + environment: ["TARGET_FILE": filePaths] + ) + return result + .split(whereSeparator: \.isNewline) + .map(String.init) + .compactMap(URL.init(fileURLWithPath:)) + } catch { + return [] + } + } +} + +func gitFolderURL(forFileURL fileURL: URL) -> URL? { + var currentURL = fileURL + let fileManager = FileManager.default + while currentURL.path != "/" { + let gitFolderURL = currentURL.appendingPathComponent(".git") + if fileManager.fileExists(atPath: gitFolderURL.path) { + return currentURL + } + currentURL = currentURL.deletingLastPathComponent() + } + return nil +} + diff --git a/Tool/Sources/JoinJSON/JoinJSON.swift b/Tool/Sources/JoinJSON/JoinJSON.swift new file mode 100644 index 00000000..26181e88 --- /dev/null +++ b/Tool/Sources/JoinJSON/JoinJSON.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct JoinJSON { + public init() {} + + public func join(_ a: String, with b: String) -> Data { + return join(a.data(using: .utf8) ?? Data(), with: b.data(using: .utf8) ?? Data()) + } + + public func join(_ a: Data, with b: String) -> Data { + return join(a, with: b.data(using: .utf8) ?? Data()) + } + + public func join(_ a: Data, with b: Data) -> Data { + guard let firstDict = try? JSONSerialization.jsonObject(with: a) as? [String: Any], + let secondDict = try? JSONSerialization.jsonObject(with: b) as? [String: Any] + else { + return a + } + + var merged = firstDict + for (key, value) in secondDict { + merged[key] = value + } + + return (try? JSONSerialization.data(withJSONObject: merged)) ?? a + } +} + diff --git a/Tool/Sources/Keychain/Keychain.swift b/Tool/Sources/Keychain/Keychain.swift new file mode 100644 index 00000000..87eca493 --- /dev/null +++ b/Tool/Sources/Keychain/Keychain.swift @@ -0,0 +1,210 @@ +import Configs +import Foundation +import Preferences +import Security + +public protocol KeychainType { + func getAll() throws -> [String: String] + func update(_ value: String, key: String) throws + func get(_ key: String) throws -> String? + func remove(_ key: String) throws +} + +public final class FakeKeyChain: KeychainType { + var values: [String: String] = [:] + + public init() {} + + public func getAll() throws -> [String: String] { + values + } + + public func update(_ value: String, key: String) throws { + values[key] = value + } + + public func get(_ key: String) throws -> String? { + values[key] + } + + public func remove(_ key: String) throws { + values[key] = nil + } +} + +public final class UserDefaultsBaseAPIKeychain: KeychainType { + let defaults = UserDefaults.shared + let scope: String + var key: String { + "UserDefaultsBaseAPIKeychain-\(scope)" + } + + init(scope: String) { + self.scope = scope + } + + public func getAll() throws -> [String : String] { + defaults.dictionary(forKey: key) as? [String: String] ?? [:] + } + + public func update(_ value: String, key: String) throws { + var dict = try getAll() + dict[key] = value + defaults.set(dict, forKey: self.key) + } + + public func get(_ key: String) throws -> String? { + try getAll()[key] + } + + public func remove(_ key: String) throws { + var dict = try getAll() + dict[key] = nil + defaults.set(dict, forKey: self.key) + } +} + +public struct Keychain: KeychainType { + let service = keychainService + let accessGroup = keychainAccessGroup + let scope: String + + public static var apiKey: KeychainType { + if UserDefaults.shared.value(for: \.useUserDefaultsBaseAPIKeychain) { + return UserDefaultsBaseAPIKeychain(scope: "apiKey") + } + return Keychain(scope: "apiKey") + } + + public enum Error: Swift.Error { + case failedToDeleteFromKeyChain + case failedToUpdateOrSetItem + } + + public init(scope: String = "") { + self.scope = scope + } + + func query(_ key: String) -> [String: Any] { + let key = scopeKey(key) + return [ + kSecClass as String: kSecClassGenericPassword as String, + kSecAttrService as String: service, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrAccount as String: key, + kSecUseDataProtectionKeychain as String: true, + ] + } + + func set(_ value: String, key: String) throws { + let query = query(key).merging([ + kSecValueData as String: value.data(using: .utf8) ?? Data(), + ], uniquingKeysWith: { _, b in b }) + + let result = SecItemAdd(query as CFDictionary, nil) + + switch result { + case noErr: + return + default: + throw Error.failedToUpdateOrSetItem + } + } + + func scopeKey(_ key: String) -> String { + if scope.isEmpty { + return key + } + return "\(scope)::\(key)" + } + + func escapeScope(_ key: String) -> String? { + if scope.isEmpty { + return key + } + if !key.hasPrefix("\(scope)::") { return nil } + return key.replacingOccurrences(of: "\(scope)::", with: "") + } + + public func getAll() throws -> [String: String] { + let query = [ + kSecClass as String: kSecClassGenericPassword as String, + kSecAttrService as String: service, + kSecAttrAccessGroup as String: accessGroup, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] as [String: Any] + + var result: AnyObject? + if SecItemCopyMatching(query as CFDictionary, &result) == noErr { + guard let items = result as? [[String: Any]] else { + return [:] + } + + var dict = [String: String]() + for item in items { + guard let key = item[kSecAttrAccount as String] as? String, + let escapedKey = escapeScope(key) + else { continue } + guard let valueData = item[kSecValueData as String] as? Data, + let value = String(data: valueData, encoding: .utf8) + else { continue } + dict[escapedKey] = value + } + return dict + } + + return [:] + } + + public func update(_ value: String, key: String) throws { + let query = query(key).merging([ + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ], uniquingKeysWith: { _, b in b }) + + let attributes: [String: Any] = + [kSecValueData as String: value.data(using: .utf8) ?? Data()] + + let result = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + + switch result { + case noErr: + return + case errSecItemNotFound: + try set(value, key: key) + default: + throw Error.failedToUpdateOrSetItem + } + } + + public func get(_ key: String) throws -> String? { + let query = query(key).merging([ + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + kSecReturnAttributes as String: true, + ], uniquingKeysWith: { _, b in b }) + + var item: CFTypeRef? + if SecItemCopyMatching(query as CFDictionary, &item) == noErr { + if let existingItem = item as? [String: Any], + let passwordData = existingItem[kSecValueData as String] as? Data, + let password = String(data: passwordData, encoding: .utf8) + { + return password + } + return nil + } else { + return nil + } + } + + public func remove(_ key: String) throws { + if SecItemDelete(query(key) as CFDictionary) == noErr { + return + } + throw Error.failedToDeleteFromKeyChain + } +} + diff --git a/Tool/Sources/LangChain/Agent.swift b/Tool/Sources/LangChain/Agent.swift new file mode 100644 index 00000000..0048c1f5 --- /dev/null +++ b/Tool/Sources/LangChain/Agent.swift @@ -0,0 +1,177 @@ +import Foundation + +public struct AgentAction: Equatable { + public var toolName: String + public var toolInput: String + public var log: String + public var observation: String? + + public init(toolName: String, toolInput: String, log: String, observation: String? = nil) { + self.toolName = toolName + self.toolInput = toolInput + self.log = log + self.observation = observation + } + + public func observationAvailable(_ observation: String) -> AgentAction { + var newAction = self + newAction.observation = observation + return newAction + } +} + +public extension CallbackEvents { + struct AgentDidFinish: CallbackEvent { + public let info: AgentFinish + } + + static func agentDidFinish() -> AgentDidFinish.Type { + AgentDidFinish.self + } + + struct AgentActionDidStart: CallbackEvent { + public let info: AgentAction + } + + var agentActionDidStart: AgentActionDidStart.Type { + AgentActionDidStart.self + } + + struct AgentActionDidEnd: CallbackEvent { + public let info: AgentAction + } + + var agentActionDidEnd: AgentActionDidEnd.Type { + AgentActionDidEnd.self + } + + struct AgentFunctionCallingToolReportProgress: CallbackEvent { + public struct Info { + public let functionName: String + public let progress: String + } + + public let info: Info + } + + var agentFunctionCallingToolReportProgress: AgentFunctionCallingToolReportProgress.Type { + AgentFunctionCallingToolReportProgress.self + } +} + +public struct AgentFinish { + public enum ReturnValue { + case structured(Output) + case unstructured(String) + } + + public var returnValue: ReturnValue + public var log: String + + public init(returnValue: ReturnValue, log: String) { + self.returnValue = returnValue + self.log = log + } +} + +extension AgentFinish.ReturnValue: Equatable where Output: Equatable {} + +extension AgentFinish: Equatable where Output: Equatable {} + +public enum AgentNextStep { + case actions([AgentAction]) + case finish(AgentFinish) +} + +extension AgentNextStep: Equatable where Output: Equatable {} + +public struct AgentScratchPad: Equatable { + public var content: Content + + public init(content: Content) { + self.content = content + } +} + +public struct AgentInput { + public var input: T + public var thoughts: AgentScratchPad + + public init(input: T, thoughts: AgentScratchPad) { + self.input = input + self.thoughts = thoughts + } +} + +extension AgentInput: Equatable where T: Equatable {} + +public enum AgentEarlyStopHandleType: Equatable { + case force + case generate +} + +public protocol Agent { + associatedtype Input + associatedtype Output: AgentOutputParsable + associatedtype ScratchPadContent: Equatable + var chatModelChain: ChatModelChain> { get } + + func validateTools(tools: [AgentTool]) throws + func constructScratchpad(intermediateSteps: [AgentAction]) -> AgentScratchPad + func constructFinalScratchpad(intermediateSteps: [AgentAction]) + -> AgentScratchPad + func extraPlan(input: AgentInput) + func parseOutput(_ output: ChatModelChain>.Output) async + -> AgentNextStep +} + +public extension Agent { + func getFullInputs( + input: Input, + intermediateSteps: [AgentAction] + ) -> AgentInput { + let thoughts = constructScratchpad(intermediateSteps: intermediateSteps) + return AgentInput(input: input, thoughts: thoughts) + } + + func plan( + input: Input, + intermediateSteps: [AgentAction], + callbackManagers: [CallbackManager] + ) async throws -> AgentNextStep { + let input = getFullInputs(input: input, intermediateSteps: intermediateSteps) + extraPlan(input: input) + let output = try await chatModelChain.call(input, callbackManagers: callbackManagers) + return await parseOutput(output) + } + + func returnStoppedResponse( + input: Input, + earlyStoppedHandleType: AgentEarlyStopHandleType, + intermediateSteps: [AgentAction], + callbackManagers: [CallbackManager] + ) async throws -> AgentFinish { + switch earlyStoppedHandleType { + case .force: + return AgentFinish( + returnValue: .unstructured("Agent stopped due to iteration limit or time limit."), + log: "" + ) + case .generate: + let thoughts = constructFinalScratchpad(intermediateSteps: intermediateSteps) + let input = AgentInput(input: input, thoughts: thoughts) + let output = try await chatModelChain.call(input, callbackManagers: callbackManagers) + let nextAction = await parseOutput(output) + switch nextAction { + case let .finish(finish): + return finish + case .actions: + return .init( + returnValue: .unstructured(output.content ?? ""), + log: output.content ?? "" + ) + } + } + } +} + diff --git a/Tool/Sources/LangChain/AgentExecutor.swift b/Tool/Sources/LangChain/AgentExecutor.swift new file mode 100644 index 00000000..cfa9d4af --- /dev/null +++ b/Tool/Sources/LangChain/AgentExecutor.swift @@ -0,0 +1,228 @@ +import Foundation + +public actor AgentExecutor: Chain + where InnerAgent.Input == String, InnerAgent.Output: AgentOutputParsable +{ + public typealias Input = String + public struct Output { + public typealias FinalOutput = AgentFinish.ReturnValue + + public let finalOutput: FinalOutput + let intermediateSteps: [AgentAction] + } + + let agent: InnerAgent + let tools: [String: AgentTool] + let maxIteration: Int? + let maxExecutionTime: Double? + var earlyStopHandleType: AgentEarlyStopHandleType + var now: () -> Date = { Date() } + var isCancelled = false + var initialSteps: [AgentAction] + + public init( + agent: InnerAgent, + tools: [AgentTool], + maxIteration: Int? = 10, + maxExecutionTime: Double? = nil, + earlyStopHandleType: AgentEarlyStopHandleType = .generate, + initialSteps: [AgentAction] = [] + ) { + self.agent = agent + self.tools = tools.reduce(into: [:]) { $0[$1.name] = $1 } + self.maxIteration = maxIteration + self.maxExecutionTime = maxExecutionTime + self.earlyStopHandleType = earlyStopHandleType + self.initialSteps = initialSteps + } + + public func callLogic( + _ input: Input, + callbackManagers: [CallbackManager] + ) async throws -> Output { + try agent.validateTools(tools: Array(tools.values)) + + let startTime = now().timeIntervalSince1970 + var iterations = 0 + var intermediateSteps: [AgentAction] = initialSteps + + func shouldContinue() -> Bool { + if isCancelled { return false } + if let maxIteration = maxIteration, iterations >= maxIteration { + return false + } + if let maxExecutionTime = maxExecutionTime, + now().timeIntervalSince1970 - startTime > maxExecutionTime + { + return false + } + return true + } + + while shouldContinue() { + try Task.checkCancellation() + let nextStepOutput = try await takeNextStep( + input: input, + intermediateSteps: intermediateSteps, + callbackManagers: callbackManagers + ) + + try Task.checkCancellation() + switch nextStepOutput { + case let .finish(finish): + return end( + output: finish, + intermediateSteps: intermediateSteps, + callbackManagers: callbackManagers + ) + case let .actions(actions): + intermediateSteps.append(contentsOf: actions) + if actions.count == 1, + let action = actions.first, + let toolFinish = getToolFinish(action: action) + { + return end( + output: toolFinish, + intermediateSteps: intermediateSteps, + callbackManagers: callbackManagers + ) + } + } + iterations += 1 + } + + let output = try await agent.returnStoppedResponse( + input: input, + earlyStoppedHandleType: earlyStopHandleType, + intermediateSteps: intermediateSteps, + callbackManagers: callbackManagers + ) + return end( + output: output, + intermediateSteps: intermediateSteps, + callbackManagers: callbackManagers + ) + } + + public nonisolated func parseOutput(_ output: Output) -> String { + switch output.finalOutput { + case let .unstructured(error): return error + case let .structured(output): return output.botReadableContent + } + } + + public func cancel() { + isCancelled = true + earlyStopHandleType = .force + } +} + +struct InvalidToolError: Error {} + +extension AgentExecutor { + func end( + output: AgentFinish, + intermediateSteps: [AgentAction], + callbackManagers: [CallbackManager] + ) -> Output { + for callbackManager in callbackManagers { + callbackManager.send(CallbackEvents.AgentDidFinish(info: output)) + } + let finalOutput = output.returnValue + return .init(finalOutput: finalOutput, intermediateSteps: intermediateSteps) + } + + /// Plan the scratch pad and let the agent decide what to do next + func takeNextStep( + input: Input, + intermediateSteps: [AgentAction], + callbackManagers: [CallbackManager] + ) async throws -> AgentNextStep { + let output = try await agent.plan( + input: input, + intermediateSteps: intermediateSteps, + callbackManagers: callbackManagers + ) + switch output { + // If the output says finish, then return the output immediately. + case .finish: return output + // If the output contains actions, run them, and append the results to the scratch pad. + case let .actions(actions): + let completedActions = try await withThrowingTaskGroup(of: AgentAction.self) { + taskGroup in + for action in actions { + callbackManagers.send(CallbackEvents.AgentActionDidStart(info: action)) + if action.observation != nil { + taskGroup.addTask { action } + continue + } + guard let tool = tools[action.toolName] else { throw InvalidToolError() } + taskGroup.addTask { + do { + let observation = try await tool.run(input: action.toolInput) + return action.observationAvailable(observation) + } catch { + let observation = error.localizedDescription + return action.observationAvailable(observation) + } + } + } + var completedActions = [AgentAction]() + for try await action in taskGroup { + try Task.checkCancellation() + completedActions.append(action) + callbackManagers.send(CallbackEvents.AgentActionDidEnd(info: action)) + } + return completedActions + } + + return .actions(completedActions) + } + } + + func getToolFinish(action: AgentAction) -> AgentFinish? { + guard let tool = tools[action.toolName] else { return nil } + guard tool.returnDirectly else { return nil } + + do { + let result = try InnerAgent.Output.parse(action.observation ?? "") + return .init(returnValue: .structured(result), log: action.observation ?? "") + } catch { + return .init( + returnValue: .unstructured(action.observation ?? "no observation"), + log: action.observation ?? "" + ) + } + } +} + +// MARK: - AgentOutputParsable + +public protocol AgentOutputParsable { + static func parse(_ string: String) throws -> Self + var botReadableContent: String { get } +} + +extension String: AgentOutputParsable { + public static func parse(_ string: String) throws -> String { string } + public var botReadableContent: String { self } +} + +extension Int: AgentOutputParsable { + public static func parse(_ string: String) throws -> Int { + guard let int = Int(string) else { return 0 } + return int + } + + public var botReadableContent: String { String(self) } +} + +extension Double: AgentOutputParsable { + public static func parse(_ string: String) throws -> Double { + guard let double = Double(string) else { return 0 } + return double + } + + public var botReadableContent: String { String(self) } +} + diff --git a/Tool/Sources/LangChain/AgentTool.swift b/Tool/Sources/LangChain/AgentTool.swift new file mode 100644 index 00000000..652bbfac --- /dev/null +++ b/Tool/Sources/LangChain/AgentTool.swift @@ -0,0 +1,99 @@ +import ChatBasic +import Foundation +import OpenAIService + +public protocol AgentTool { + var name: String { get } + var description: String { get } + var returnDirectly: Bool { get } + func run(input: String) async throws -> String +} + +public struct SimpleAgentTool: AgentTool { + public let name: String + public let description: String + public let returnDirectly: Bool + public let run: (String) async throws -> String + + public init( + name: String, + description: String, + returnDirectly: Bool = false, + run: @escaping (String) async throws -> String + ) { + self.name = name + self.description = description + self.returnDirectly = returnDirectly + self.run = run + } + + public func run(input: String) async throws -> String { + try await run(input) + } +} + +public class FunctionCallingAgentTool: AgentTool, ChatGPTFunction { + public func call(arguments: F.Arguments) async throws -> F.Result { + try await function.call(arguments: arguments, reportProgress: reportProgress) + } + + public var argumentSchema: ChatBasic.JSONSchemaValue { function.argumentSchema } + + public typealias Arguments = F.Arguments + public typealias Result = F.Result + + public var function: F + public var name: String + public var description: String + public var returnDirectly: Bool + + let callbackManagers: [CallbackManager] + + public init( + function: F, + returnDirectly: Bool = false, + callbackManagers: [CallbackManager] = [] + ) { + self.function = function + self.callbackManagers = callbackManagers + name = function.name + description = function.description + self.returnDirectly = returnDirectly + } + + func reportProgress(_ progress: String) { + callbackManagers.send( + CallbackEvents.AgentFunctionCallingToolReportProgress(info: .init( + functionName: name, + progress: progress + )) + ) + } + + public func run(input: String) async throws -> String { + await prepare(reportProgress: { [weak self] p in + self?.reportProgress(p) + }) + return try await call( + argumentsJsonString: input, + reportProgress: { [weak self] p in + self?.reportProgress(p) + } + ) + .botReadableContent + } + + public func prepare(reportProgress: @escaping ReportProgress) async { + await function.prepare(reportProgress: { [weak self] p in + self?.reportProgress(p) + }) + } + + public func call( + arguments: F.Arguments, + reportProgress: @escaping ReportProgress + ) async throws -> F.Result { + try await function.call(arguments: arguments, reportProgress: reportProgress) + } +} + diff --git a/Tool/Sources/LangChain/Agents/ChatAgent.swift b/Tool/Sources/LangChain/Agents/ChatAgent.swift new file mode 100644 index 00000000..b78ff113 --- /dev/null +++ b/Tool/Sources/LangChain/Agents/ChatAgent.swift @@ -0,0 +1,194 @@ +import Foundation +import Logger +import Parsing + +private func formatInstruction(toolsNames: String, preferredLanguage: String) -> String { + """ + The way you use the tools is by specifying a json blob. + Specifically, this json should have a `action` key (with the name of the tool to use) and a `action_input` key (with the input to the tool going here). + + The only values that should be in the "action" field are: \(toolsNames) + + The $JSON_BLOB should only contain a SINGLE action, do NOT return a list of multiple actions. Here is an example of a valid $JSON_BLOB: + + ``` + { + "action": $TOOL_NAME, + "action_input": $INPUT + } + ``` + + ALWAYS use the following format: + + Question: the input question you must answer + Thought: you should always think about what to do + Action: + ``` + $JSON_BLOB + ``` + Observation: the result of the action + ... (this Thought/Action/Observation can repeat N times) + Thought: I now know the final answer + Final Answer: the final answer to the original input question \(preferredLanguage) + """ +} + +public class ChatAgent: Agent { + public typealias Input = String + public typealias Output = String + public typealias ScratchPadContent = String + public var observationPrefix: String { "Observation: " } + public var llmPrefix: String { "Thought: " } + public let chatModelChain: ChatModelChain> + let tools: [AgentTool] + + public init(chatModel: ChatModel, tools: [AgentTool], preferredLanguage: String) { + self.tools = tools + chatModelChain = .init( + chatModel: chatModel, + stops: ["Observation:"], + promptTemplate: { agentInput in + [ + .init( + role: .system, + content: """ + Respond to the human as helpfully and accurately as possible. \ + Wrap any code block in thought in . \ + Format final answer to be more readable, in a ordered list if possible. \ + You have access to the following tools: + + \(tools.map { "\($0.name): \($0.description)" }.joined(separator: "\n")) + + \(formatInstruction( + toolsNames: tools.map(\.name).joined(separator: ","), + preferredLanguage: preferredLanguage.isEmpty + ? "" + : "(in \(preferredLanguage)" + )) + + Begin! Reminder to always use the exact characters `Final Answer` when responding. + """ + ), + agentInput.thoughts.content.isEmpty + ? .init(role: .user, content: agentInput.input) + : .init( + role: .user, + content: """ + \(agentInput.input) + + \(agentInput.thoughts.content) + """ + ), + ] + } + ) + } + + func constructBaseScratchpad(intermediateSteps: [AgentAction]) -> String { + var thoughts = "" + for step in intermediateSteps { + thoughts += """ + \(step.log) + \(observationPrefix)\(step.observation ?? "") + """ + } + return thoughts + } + + public func constructScratchpad(intermediateSteps: [AgentAction]) -> AgentScratchPad { + let baseScratchpad = constructBaseScratchpad(intermediateSteps: intermediateSteps) + if baseScratchpad.isEmpty { return .init(content: "") } + return .init(content: """ + This was your previous work (but I haven't seen any of it! I only see what you return as `Final Answer`): + \(baseScratchpad) + (Please continue with `Thought:` or `Final Answer:`) + """) + } + + public func constructFinalScratchpad(intermediateSteps: [AgentAction]) -> AgentScratchPad { + let baseScratchpad = constructBaseScratchpad(intermediateSteps: intermediateSteps) + if baseScratchpad.isEmpty { return .init(content: "") } + return .init(content: """ + This was your previous work (but I haven't seen any of it! I only see what you return as `Final Answer`): + \(baseScratchpad) + \(llmPrefix)I now need to return a final answer based on the previous steps: + "(Please continue with `Final Answer:`)" + """) + } + + public func validateTools(tools: [AgentTool]) throws { + // no validation + } + + public func extraPlan(input: AgentInput) { + // do nothing + } + + public func parseOutput(_ output: ChatMessage) async -> AgentNextStep { + let text = output.content ?? "" + + func parseFinalAnswerIfPossible() -> AgentNextStep? { + let throughAnswerParser = PrefixThrough("Final Answer:") + var parsableContent = text[...] + do { + _ = try throughAnswerParser.parse(&parsableContent) + let answer = String(parsableContent) + let output = answer.trimmingCharacters(in: .whitespacesAndNewlines) + return .finish(AgentFinish(returnValue: .structured(output), log: text)) + } catch { + Logger.langchain.info("Could not parse LLM output final answer: \(error)") + return nil + } + } + + func parseNextActionIfPossible() -> AgentNextStep? { + let throughActionBlockParser = PrefixThrough(""" + Action: + ``` + """) + let throughActionBlockSimplifiedParser = PrefixThrough("```") + let jsonBlobParser = PrefixUpTo("```") + var parsableContent = text[...] + do { + let actionBlockPrefix = try? throughActionBlockParser.parse(&parsableContent) + if actionBlockPrefix == nil { + _ = try throughActionBlockSimplifiedParser.parse(&parsableContent) + } + let jsonBlob = try jsonBlobParser.parse(&parsableContent) + + struct Action: Codable { + let action: String + let action_input: String + } + let response = try JSONDecoder() + .decode(Action.self, from: jsonBlob.data(using: .utf8) ?? Data()) + return .actions([ + AgentAction( + toolName: response.action, + toolInput: response.action_input, + log: text + ), + ]) + } catch { + Logger.langchain.info("Could not parse LLM output next action: \(error)") + return nil + } + } + + if let step = parseFinalAnswerIfPossible() { return step } + if let step = parseNextActionIfPossible() { return step } + + let forceParser = PrefixUpTo("Action:") + var parsableContent = text[...] + let finalAnswer = try? forceParser.parse(&parsableContent) + .trimmingCharacters(in: .whitespacesAndNewlines) + + var answer = finalAnswer ?? text + if answer.isEmpty { + answer = "Sorry, I don't know." + } + + return .finish(AgentFinish(returnValue: .structured(String(answer)), log: text)) + } +} + diff --git a/Tool/Sources/LangChain/Callback.swift b/Tool/Sources/LangChain/Callback.swift new file mode 100644 index 00000000..57a1708b --- /dev/null +++ b/Tool/Sources/LangChain/Callback.swift @@ -0,0 +1,88 @@ +import Foundation + +public protocol CallbackEvent { + associatedtype Info + var info: Info { get } +} + +public struct CallbackEvents { + public struct UnTypedEvent: CallbackEvent { + public var info: String + public init(info: String) { + self.info = info + } + } + + public var untyped: UnTypedEvent.Type { UnTypedEvent.self } + + private init() {} +} + +public struct CallbackManager { + struct Observer { + let handler: (Event.Info) -> Void + } + + fileprivate var observers = [Any]() + + public init() {} + + public init(observers: (inout CallbackManager) -> Void) { + var manager = CallbackManager() + observers(&manager) + self = manager + } + + public mutating func on( + _: Event.Type = Event.self, + _ handler: @escaping (Event.Info) -> Void + ) { + observers.append(Observer(handler: handler)) + } + + public mutating func on( + _: KeyPath, + _ handler: @escaping (Event.Info) -> Void + ) { + observers.append(Observer(handler: handler)) + } + + public func send(_ event: Event) { + for case let observer as Observer in observers { + observer.handler(event.info) + } + } + + func send( + _: KeyPath, + _ info: Event.Info + ) { + for case let observer as Observer in observers { + observer.handler(info) + } + } + + public func send(_ string: String) { + for case let observer as Observer in observers { + observer.handler(string) + } + } +} + +public extension [CallbackManager] { + func send(_ event: Event) { + for cb in self { cb.send(event) } + } + + func send( + _ keyPath: KeyPath, + _ info: Event.Info + ) { + for cb in self { cb.send(keyPath, info) } + } + + func send(_ event: String) { + for cb in self { cb.send(event) } + } +} + diff --git a/Tool/Sources/LangChain/Chain.swift b/Tool/Sources/LangChain/Chain.swift new file mode 100644 index 00000000..9533bcfd --- /dev/null +++ b/Tool/Sources/LangChain/Chain.swift @@ -0,0 +1,139 @@ +import Foundation + +public protocol Chain { + associatedtype Input + associatedtype Output + func callLogic(_ input: Input, callbackManagers: [CallbackManager]) async throws -> Output + func parseOutput(_ output: Output) -> String +} + +public extension Chain { + typealias ChainDidStart = CallbackEvents.ChainDidStart + typealias ChainDidEnd = CallbackEvents.ChainDidEnd + + func run(_ input: Input, callbackManagers: [CallbackManager] = []) async throws -> String { + let output = try await call(input, callbackManagers: callbackManagers) + return parseOutput(output) + } + + func call(_ input: Input, callbackManagers: [CallbackManager] = []) async throws -> Output { + callbackManagers + .send(CallbackEvents.ChainDidStart(info: (type: Self.self, input: input))) + defer { + callbackManagers + .send(CallbackEvents.ChainDidEnd(info: (type: Self.self, input: input))) + } + return try await callLogic(input, callbackManagers: callbackManagers) + } +} + +public extension CallbackEvents { + struct ChainDidStart: CallbackEvent { + public let info: (type: T.Type, input: T.Input) + } + + struct ChainDidEnd: CallbackEvent { + public let info: (type: T.Type, input: T.Input) + } +} + +public struct SimpleChain: Chain { + let block: (Input) async throws -> Output + let parseOutputBlock: (Output) -> String + + public init( + block: @escaping (Input) async throws -> Output, + parseOutput: @escaping (Output) -> String = { String(describing: $0) } + ) { + self.block = block + parseOutputBlock = parseOutput + } + + public func callLogic( + _ input: Input, + callbackManagers: [CallbackManager] + ) async throws -> Output { + return try await block(input) + } + + public func parseOutput(_ output: Output) -> String { + return parseOutputBlock(output) + } +} + +public struct ConnectedChain: Chain where B.Input == A.Output { + public typealias Input = A.Input + public typealias Output = (B.Output, A.Output) + + public let chainA: A + public let chainB: B + + public func callLogic( + _ input: Input, + callbackManagers: [CallbackManager] = [] + ) async throws -> Output { + let a = try await chainA.call(input, callbackManagers: callbackManagers) + let b = try await chainB.call(a, callbackManagers: callbackManagers) + return (b, a) + } + + public func parseOutput(_ output: Output) -> String { + chainB.parseOutput(output.0) + } +} + +public struct PairedChain: Chain { + public typealias Input = (A.Input, B.Input) + public typealias Output = (A.Output, B.Output) + + public let chainA: A + public let chainB: B + + public func callLogic( + _ input: Input, + callbackManagers: [CallbackManager] = [] + ) async throws -> Output { + async let a = chainA.call(input.0, callbackManagers: callbackManagers) + async let b = chainB.call(input.1, callbackManagers: callbackManagers) + return try await (a, b) + } + + public func parseOutput(_ output: (A.Output, B.Output)) -> String { + [chainA.parseOutput(output.0), chainB.parseOutput(output.1)].joined(separator: "\n") + } +} + +public struct MappedChain: Chain { + public typealias Input = A.Input + public typealias Output = NewOutput + + public let chain: A + public let map: (A.Output) -> NewOutput + + public func callLogic( + _ input: Input, + callbackManagers: [CallbackManager] + ) async throws -> Output { + let output = try await chain.call(input, callbackManagers: callbackManagers) + return map(output) + } + + public func parseOutput(_ output: Output) -> String { + String(describing: output) + } +} + +public extension Chain { + func pair(with another: C) -> PairedChain { + PairedChain(chainA: self, chainB: another) + } + + func chain(to another: C) -> ConnectedChain { + ConnectedChain(chainA: self, chainB: another) + } + + func map(_ map: @escaping (Output) -> NewOutput) -> MappedChain { + MappedChain(chain: self, map: map) + } +} + diff --git a/Tool/Sources/LangChain/Chains/CombineAnswersChain.swift b/Tool/Sources/LangChain/Chains/CombineAnswersChain.swift new file mode 100644 index 00000000..3838713c --- /dev/null +++ b/Tool/Sources/LangChain/Chains/CombineAnswersChain.swift @@ -0,0 +1,72 @@ +import Foundation +import Logger +import OpenAIService +import Preferences + +public class CombineAnswersChain: Chain { + public struct Input: Decodable { + public var question: String + public var answers: [String] + public init(question: String, answers: [String]) { + self.question = question + self.answers = answers + } + } + + public typealias Output = String + public let chatModelChain: ChatModelChain + + public init( + configuration: ChatGPTConfiguration = + UserPreferenceChatGPTConfiguration(chatModelKey: \.preferredChatModelIdForUtilities), + extraInstructions: String = "" + ) { + chatModelChain = .init( + chatModel: OpenAIChat( + configuration: configuration.overriding { + $0.runFunctionsAutomatically = false + }, + memory: nil, + stream: false + ), + stops: ["Observation:"], + promptTemplate: { input in + [ + .init( + role: .system, + content: """ + You are a helpful assistant. + Your job is to combine multiple answers from different sources to one question. + \(extraInstructions) + """ + ), + .init(role: .user, content: """ + Question: \(input.question) + + Answers: + \(input.answers.joined(separator: "\n\(String(repeating: "-", count: 32))\n")) + + What is the combined answer? + """), + ] + } + ) + } + + public func callLogic( + _ input: Input, + callbackManagers: [CallbackManager] + ) async throws -> String { + let output = try await chatModelChain.call(input, callbackManagers: callbackManagers) + return await parseOutput(output) + } + + public func parseOutput(_ message: ChatMessage) async -> String { + return message.content ?? "No answer." + } + + public func parseOutput(_ output: String) -> String { + output + } +} + diff --git a/Tool/Sources/LangChain/Chains/LLMChain.swift b/Tool/Sources/LangChain/Chains/LLMChain.swift new file mode 100644 index 00000000..2ba4aef4 --- /dev/null +++ b/Tool/Sources/LangChain/Chains/LLMChain.swift @@ -0,0 +1,44 @@ +import Foundation + +public class ChatModelChain: Chain { + public typealias Output = ChatMessage + + public internal(set) var chatModel: ChatModel + public internal(set) var promptTemplate: (Input) -> [ChatMessage] + public internal(set) var stops: [String] + + public init( + chatModel: ChatModel, + stops: [String] = [], + promptTemplate: @escaping (Input) -> [ChatMessage] + ) { + self.chatModel = chatModel + self.promptTemplate = promptTemplate + self.stops = stops + } + + public func callLogic( + _ input: Input, + callbackManagers: [CallbackManager] + ) async throws -> Output { + let prompt = promptTemplate(input) + let output = try await chatModel.generate( + prompt: prompt, + stops: stops, + callbackManagers: callbackManagers + ) + return output + } + + public func parseOutput(_ output: Output) -> String { + if let content = output.content { + return content + } else if let toolCalls = output.toolCalls { + return toolCalls.map { "[\($0.id)] \($0.function.name): \($0.function.arguments)" } + .joined(separator: "\n") + } + + return "" + } +} + diff --git a/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift b/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift new file mode 100644 index 00000000..8b889ff7 --- /dev/null +++ b/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift @@ -0,0 +1,104 @@ +import Foundation +import OpenAIService + +public final class QAInformationRetrievalChain: Chain { + let vectorStores: [VectorStore] + let embedding: Embeddings + let maxCount: Int + let filterMetadata: (String) -> Bool + let hint: String + + public struct Output { + public var information: String + public var sourceDocuments: [Document] + public var distance: [Float] + } + + public init( + vectorStore: VectorStore, + embedding: Embeddings, + maxCount: Int = 5, + filterMetadata: @escaping (String) -> Bool = { _ in true }, + hint: String = "" + ) { + vectorStores = [vectorStore] + self.embedding = embedding + self.maxCount = maxCount + self.filterMetadata = filterMetadata + self.hint = hint + } + + public init( + vectorStores: [VectorStore], + embedding: Embeddings, + maxCount: Int = 5, + filterMetadata: @escaping (String) -> Bool = { _ in true }, + hint: String = "" + ) { + self.vectorStores = vectorStores + self.embedding = embedding + self.maxCount = maxCount + self.filterMetadata = filterMetadata + self.hint = hint + } + + public func callLogic( + _ input: String, + callbackManagers: [CallbackManager] + ) async throws -> Output { + let embeddedQuestion = try await embedding.embed(query: input) + let documentsSlice = await withTaskGroup( + of: [(document: Document, distance: Float)].self + ) { group in + for vectorStore in vectorStores { + group.addTask { + (try? await vectorStore.searchWithDistance( + embeddings: embeddedQuestion, + count: 5 + ).filter { item in + item.distance < 0.31 + }) ?? [] + } + } + var result = [(document: Document, distance: Float)]() + for await items in group { + result.append(contentsOf: items) + } + return result + }.sorted { $0.distance < $1.distance }.prefix(maxCount) + + let documents = Array(documentsSlice) + + callbackManagers.send(CallbackEvents.RetrievalQADidExtractRelevantContent(info: documents)) + + let relevantInformationChain = RelevantInformationExtractionChain( + filterMetadata: filterMetadata, + hint: hint + ) + let relevantInformation = try await relevantInformationChain.run( + .init(question: input, documents: documents), + callbackManagers: callbackManagers + ) + + return .init( + information: relevantInformation, + sourceDocuments: documents.map(\.document), + distance: documents.map(\.distance) + ) + } + + public func parseOutput(_ output: Output) -> String { + return output.information + } +} + +public extension CallbackEvents { + struct RetrievalQADidExtractRelevantContent: CallbackEvent { + public let info: [(document: Document, distance: Float)] + } + + var retrievalQADidExtractRelevantContent: RetrievalQADidExtractRelevantContent.Type { + RetrievalQADidExtractRelevantContent.self + } +} + diff --git a/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift b/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift new file mode 100644 index 00000000..81d060e0 --- /dev/null +++ b/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift @@ -0,0 +1,192 @@ +import ChatBasic +import Foundation +import OpenAIService +import Preferences + +public final class RefineDocumentChain: Chain { + public struct Input { + var question: String + var documents: [(document: Document, distance: Float)] + } + + struct RefinementInput { + var index: Int + var totalCount: Int + var question: String + var previousAnswer: String? + var document: String + var distance: Float + } + + public struct IntermediateAnswer: Decodable { + public var answer: String + public var usefulness: Double + public var more: Bool + + public enum CodingKeys: String, CodingKey { + case answer + case usefulness + case more + } + + init(answer: String, usefulness: Double, more: Bool) { + self.answer = answer + self.usefulness = usefulness + self.more = more + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + answer = try container.decode(String.self, forKey: .answer) + usefulness = (try? container.decode(Double.self, forKey: .usefulness)) ?? 0 + more = (try? container.decode(Bool.self, forKey: .more)) ?? true + } + } + + class FunctionProvider: ChatGPTFunctionProvider { + var functionCallStrategy: FunctionCallStrategy? = .function(name: "respond") + var functions: [any ChatGPTFunction] = [RespondFunction()] + } + + struct RespondFunction: ChatGPTArgumentsCollectingFunction { + typealias Arguments = IntermediateAnswer + var name: String = "respond" + var description: String = "Respond with the refined answer" + var argumentSchema: JSONSchemaValue { + return [ + .type: "object", + .properties: [ + "answer": [ + .type: "string", + .description: "The refined answer", + ], + "usefulness": [ + .type: "number", + .description: "How useful the page of document is in generating the answer, the higher the better. 0 to 10", + ], + "more": [ + .type: "boolean", + .description: "Whether you want to read the next page. The next page maybe less relevant to the question", + ], + ], + .required: ["answer", "more", "usefulness"], + ] + } + } + + func buildChatModel() -> ChatModelChain { + .init( + chatModel: OpenAIChat( + configuration: UserPreferenceChatGPTConfiguration( + chatModelKey: \.preferredChatModelIdForUtilities + ) + .overriding { + $0.temperature = 0 + $0.runFunctionsAutomatically = false + }, + memory: EmptyChatGPTMemory(), + functionProvider: FunctionProvider(), + stream: false + ), + promptTemplate: { input in [ + .init( + role: .system, + content: { + if let previousAnswer = input.previousAnswer { + return """ + I will send you a question about a document, you must refine your previous answer to it only according to the document. + Previous answer:### + \(previousAnswer) + ### + Page \(input.index) of \(input.totalCount) of the document:### + \(input.document) + ### + """ + } else { + return """ + I will send you a question about a document, you must answer it only according to the document. + Page \(input.index) of \(input.totalCount) of the document:### + \(input.document) + ### + """ + } + }() + + ), + .init(role: .user, content: input.question), + ] } + ) + } + + public init() {} + + public func callLogic( + _ input: Input, + callbackManagers: [CallbackManager] + ) async throws -> String { + var intermediateAnswer: IntermediateAnswer? + + for (index, document) in input.documents.enumerated() { + if let intermediateAnswer, !intermediateAnswer.more { break } + + let output = try await buildChatModel().call( + .init( + index: index, + totalCount: input.documents.count, + question: input.question, + previousAnswer: intermediateAnswer?.answer, + document: document.document.pageContent, + distance: document.distance + ), + callbackManagers: callbackManagers + ) + intermediateAnswer = extractAnswer(output) + + if let intermediateAnswer { + callbackManagers.send( + \.refineDocumentChainDidGenerateIntermediateAnswer, + intermediateAnswer + ) + } + } + + return intermediateAnswer?.answer ?? "None" + } + + public func parseOutput(_ output: String) -> String { + return output + } + + func extractAnswer(_ chatMessage: ChatMessage) -> IntermediateAnswer { + for functionCall in chatMessage.toolCalls?.map(\.function) ?? [] { + do { + let intermediateAnswer = try JSONDecoder().decode( + IntermediateAnswer.self, + from: functionCall.arguments.data(using: .utf8) ?? Data() + ) + return intermediateAnswer + } catch { + let intermediateAnswer = IntermediateAnswer( + answer: functionCall.arguments, + usefulness: 0, + more: true + ) + return intermediateAnswer + } + } + return .init(answer: chatMessage.content ?? "", usefulness: 0, more: true) + } +} + +public extension CallbackEvents { + struct RefineDocumentChainDidGenerateIntermediateAnswer: CallbackEvent { + public let info: RefineDocumentChain.IntermediateAnswer + } + + var refineDocumentChainDidGenerateIntermediateAnswer: + RefineDocumentChainDidGenerateIntermediateAnswer.Type + { + RefineDocumentChainDidGenerateIntermediateAnswer.self + } +} + diff --git a/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift b/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift new file mode 100644 index 00000000..29f8b73e --- /dev/null +++ b/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift @@ -0,0 +1,175 @@ +import ChatBasic +import Foundation +import OpenAIService +import Preferences + +public final class RelevantInformationExtractionChain: Chain { + public struct Input { + var question: String + var documents: [(document: Document, distance: Float)] + } + + struct TaskInput { + var question: String + var document: Document + } + + public typealias Output = String + + class FunctionProvider: ChatGPTFunctionProvider { + var functionCallStrategy: FunctionCallStrategy? = .function(name: "saveFinalAnswer") + var functions: [any ChatGPTFunction] = [FinalAnswer()] + } + + struct FinalAnswer: ChatGPTArgumentsCollectingFunction { + struct Arguments: Decodable { + var relevantInformation: String + var noRelevantInformationFound: Bool? + } + + var name: String = "saveFinalAnswer" + var description: String = + "save the relevant information" + var argumentSchema: JSONSchemaValue { + [ + .type: "object", + .properties: [ + "relevantInformation": [.type: "string"], + "noRelevantInformationFound": [.type: "boolean"], + ], + .required: ["relevantInformation", "noRelevantInformationFound"], + ] + } + } + + let filterMetadata: (String) -> Bool + let hint: String + + init(filterMetadata: @escaping (String) -> Bool = { _ in true }, hint: String) { + self.filterMetadata = filterMetadata + self.hint = hint + } + + func buildChatModel() -> ChatModelChain { + .init( + chatModel: OpenAIChat( + configuration: UserPreferenceChatGPTConfiguration( + chatModelKey: \.preferredChatModelIdForUtilities + ) + .overriding { + $0.temperature = 0.5 + $0.runFunctionsAutomatically = false + }, + memory: EmptyChatGPTMemory(), + functionProvider: FunctionProvider(), + stream: false + ) + ) { [filterMetadata, hint] input in [ + .init( + role: .system, + content: """ + Extract the relevant information from the Document according to the Question. + The information may not directly answer the question, but it should be relevant to the question, \ + please think carefully and make you decision. + Make the information clear, concise and short. + If found code, wrap it in markdown code block. + \(hint) + """ + ), + .init( + role: .user, + content: """ + Question:### + (how, when, what or why) + \(input.question) + ### + Document:### + \(input.document.metadata.filter { key, _ in + filterMetadata(key) + }) + \(input.document.pageContent) + ### + """ + ), + ] } + } + + public func callLogic( + _ input: Input, + callbackManagers: [CallbackManager] + ) async throws -> Output { + await withTaskGroup(of: String.self) { group in + for document in input.documents { + let taskInput = TaskInput(question: input.question, document: document.document) + group.addTask { + func run() async throws -> String { + let model = self.buildChatModel() + let output = try await model.call( + taskInput, + callbackManagers: callbackManagers + ) + + if let functionCall = output.toolCalls? + .first(where: { $0.function.name == FinalAnswer().name })?.function + { + do { + let arguments = try JSONDecoder().decode( + FinalAnswer.Arguments.self, + from: functionCall.arguments.data(using: .utf8) ?? Data() + ) + if arguments.noRelevantInformationFound ?? false { + return "" + } + return arguments.relevantInformation + } catch { + return output.content ?? "" + } + } + + return output.content ?? "" + } + + var repeatCount = 0 + while repeatCount < 3 { + do { + return try await run() + } catch { + repeatCount += 1 + } + } + return "" + } + } + + var results = [String]() + for await output in group where !output.isEmpty { + callbackManagers.send( + \.relevantInformationExtractionChainDidExtractPartialRelevantContent, + output + ) + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + if results.contains(trimmed) { continue } + results.append(trimmed) + } + if results.isEmpty { return "No information found." } + return results.joined(separator: "") + } + } + + public func parseOutput(_ output: Output) -> String { + return output + } +} + +public extension CallbackEvents { + struct RelevantInformationExtractionChainDidExtractPartialRelevantContent: CallbackEvent { + public let info: String + } + + var relevantInformationExtractionChainDidExtractPartialRelevantContent: + RelevantInformationExtractionChainDidExtractPartialRelevantContent.Type + { + RelevantInformationExtractionChainDidExtractPartialRelevantContent.self + } +} + diff --git a/Tool/Sources/LangChain/Chains/StructuredOutputChatModelChain.swift b/Tool/Sources/LangChain/Chains/StructuredOutputChatModelChain.swift new file mode 100644 index 00000000..b362bbce --- /dev/null +++ b/Tool/Sources/LangChain/Chains/StructuredOutputChatModelChain.swift @@ -0,0 +1,127 @@ +import ChatBasic +import Foundation +import Logger +import OpenAIService + +/// This is an agent used to get a structured output. +public class StructuredOutputChatModelChain: Chain { + public struct EndFunction: ChatGPTArgumentsCollectingFunction { + public struct Arguments: Decodable { + var finalAnswer: Output + } + + public var name: String { "FinalAnswer" } + public var description: String { "Save the final answer when it's ready" } + public var argumentSchema: JSONSchemaValue { + return [ + .type: "object", + .properties: [ + "finalAnswer": .hash(finalAnswerSchema), + ], + .required: ["finalAnswer"], + ] + } + + public let finalAnswerSchema: [String: JSONSchemaValue] + + public init(argumentSchema: [String: JSONSchemaValue]) { + finalAnswerSchema = argumentSchema + } + + public init() where Output == String { + finalAnswerSchema = [ + JSONSchemaKey.type.key: "string", + ] + } + + public init() where Output == Int { + finalAnswerSchema = [ + JSONSchemaKey.type.key: "number", + ] + } + + public init() where Output == Double { + finalAnswerSchema = [ + JSONSchemaKey.type.key: "number", + ] + } + } + + struct FunctionProvider: ChatGPTFunctionProvider { + var endFunction: EndFunction + var functions: [any ChatGPTFunction] { + [endFunction] + } + + var functionCallStrategy: FunctionCallStrategy? { + .function(name: endFunction.name) + } + } + + public typealias Input = String + public let chatModelChain: ChatModelChain + var functionProvider: FunctionProvider + + public init( + configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(), + endFunction: EndFunction, + promptTemplate: ((String) -> [ChatMessage])? = nil + ) { + functionProvider = .init( + endFunction: endFunction + ) + chatModelChain = .init( + chatModel: OpenAIChat( + configuration: configuration.overriding { + $0.runFunctionsAutomatically = false + }, + memory: nil, + functionProvider: functionProvider, + stream: false + ), + stops: ["Observation:"], + promptTemplate: promptTemplate ?? { input in + [ + .init( + role: .system, + content: """ + You are a helpful assistant + Generate a final answer to my query as concisely, helpfully and accurately as possible. + You don't ask me for additional information. + """ + ), + .init(role: .user, content: input), + ] + } + ) + } + + public func callLogic( + _ input: String, + callbackManagers: [CallbackManager] + ) async throws -> Output? { + let output = try await chatModelChain.call(input, callbackManagers: callbackManagers) + return await parseOutput(output) + } + + public func parseOutput(_ output: Output?) -> String { + return String(describing: output) + } + + public func parseOutput(_ message: ChatMessage) async -> Output? { + if let functionCall = message.toolCalls?.first?.function { + do { + let result = try JSONDecoder().decode( + EndFunction.Arguments.self, + from: functionCall.arguments.data(using: .utf8) ?? Data() + ) + return result.finalAnswer + } catch { + return nil + } + } + + return nil + } +} + diff --git a/Tool/Sources/LangChain/ChatModel/ChatModel.swift b/Tool/Sources/LangChain/ChatModel/ChatModel.swift new file mode 100644 index 00000000..75ba0233 --- /dev/null +++ b/Tool/Sources/LangChain/ChatModel/ChatModel.swift @@ -0,0 +1,22 @@ +import Foundation +import OpenAIService + +public protocol ChatModel { + func generate( + prompt: [ChatMessage], + stops: [String], + callbackManagers: [CallbackManager] + ) async throws -> ChatMessage +} + +public typealias ChatMessage = OpenAIService.ChatMessage + +public extension CallbackEvents { + struct LLMDidProduceNewToken: CallbackEvent { + public let info: String + } + + var llmDidProduceNewToken: LLMDidProduceNewToken.Type { + LLMDidProduceNewToken.self + } +} diff --git a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift new file mode 100644 index 00000000..2023e3c9 --- /dev/null +++ b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift @@ -0,0 +1,52 @@ +import Foundation +import OpenAIService + +public struct OpenAIChat: ChatModel { + public var configuration: ChatGPTConfiguration + public var memory: ChatGPTMemory? + public var functionProvider: ChatGPTFunctionProvider + public var stream: Bool + + public init( + configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(), + memory: ChatGPTMemory? = ConversationChatGPTMemory(systemPrompt: ""), + functionProvider: ChatGPTFunctionProvider = NoChatGPTFunctionProvider(), + stream: Bool + ) { + self.configuration = configuration + self.memory = memory + self.functionProvider = functionProvider + self.stream = stream + } + + public func generate( + prompt: [ChatMessage], + stops: [String], + callbackManagers: [CallbackManager] + ) async throws -> ChatMessage { + let memory = memory ?? EmptyChatGPTMemory() + + let service = LegacyChatGPTService( + memory: memory, + configuration: configuration, + functionProvider: functionProvider + ) + for message in prompt { + await memory.appendMessage(message) + } + + if stream { + let stream = try await service.send(content: "") + var message = "" + for try await chunk in stream { + message.append(chunk) + callbackManagers.send(CallbackEvents.LLMDidProduceNewToken(info: chunk)) + } + return await memory.history.last ?? .init(role: .assistant, content: "") + } else { + let _ = try await service.sendAndWait(content: "") + return await memory.history.last ?? .init(role: .assistant, content: "") + } + } +} + diff --git a/Tool/Sources/LangChain/DocumentLoader/DocumentLoader.swift b/Tool/Sources/LangChain/DocumentLoader/DocumentLoader.swift new file mode 100644 index 00000000..020f5993 --- /dev/null +++ b/Tool/Sources/LangChain/DocumentLoader/DocumentLoader.swift @@ -0,0 +1,31 @@ +import Foundation +import JSONRPC + +public struct Document: Codable { + public typealias Metadata = [String: JSONValue] + public var pageContent: String + public var metadata: Metadata + public init(pageContent: String, metadata: Metadata) { + self.pageContent = pageContent + self.metadata = metadata + } + + public func metadata(_ keyPath: KeyPath) -> JSONValue? { + let key = Key.self[keyPath: keyPath] + return metadata[key] + } +} + +public protocol DocumentLoader { + func load() async throws -> [Document] +} + +extension DocumentLoader { + func loadAndSplit( + with textSplitter: TextSplitter = RecursiveCharacterTextSplitter() + ) async throws -> [Document] { + let docs = try await load() + return try await textSplitter.splitDocuments(docs) + } +} + diff --git a/Tool/Sources/LangChain/DocumentLoader/TextLoader.swift b/Tool/Sources/LangChain/DocumentLoader/TextLoader.swift new file mode 100644 index 00000000..1c588fe6 --- /dev/null +++ b/Tool/Sources/LangChain/DocumentLoader/TextLoader.swift @@ -0,0 +1,46 @@ +import AppKit +import Foundation + +/// Load a text document from local file. +public struct TextLoader: DocumentLoader { + public enum MetadataKeys { + public static let filename = "filename" + public static let `extension` = "extension" + public static let contentModificationDate = "contentModificationDate" + public static let filePath = "filePath" + } + + let url: URL + let encoding: String.Encoding + let options: [NSAttributedString.DocumentReadingOptionKey: Any] + + public init( + url: URL, + encoding: String.Encoding = .utf8, + options: [NSAttributedString.DocumentReadingOptionKey: Any] = [:] + ) { + self.url = url + self.encoding = encoding + self.options = options + } + + public func load() async throws -> [Document] { + let data = try Data(contentsOf: url) + let attributedString = try NSAttributedString( + data: data, + options: options, + documentAttributes: nil + ) + let modificationDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate + return [Document(pageContent: attributedString.string, metadata: [ + MetadataKeys.filename: .string(url.lastPathComponent), + MetadataKeys.extension: .string(url.pathExtension), + MetadataKeys.contentModificationDate: .number( + (modificationDate ?? Date()).timeIntervalSince1970 + ), + MetadataKeys.filePath: .string(url.path), + ])] + } +} + diff --git a/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift b/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift new file mode 100644 index 00000000..c484ab4a --- /dev/null +++ b/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift @@ -0,0 +1,211 @@ +import Foundation +import Logger +import SwiftSoup +import WebKit + +/// Load the body of a web page. +public struct WebLoader: DocumentLoader { + enum MetadataKeys { + static let title = "title" + static let url = "url" + static let date = "date" + } + + var downloadHTML: (_ url: URL, _ strategy: LoadWebPageMainContentStrategy) async throws + -> (url: URL, html: String, strategy: LoadWebPageMainContentStrategy) = { url, strategy in + let html = try await WebScrapper(strategy: strategy).fetch(url: url) + return (url, html, strategy) + } + + public var urls: [URL] + + public init(urls: [URL]) { + self.urls = urls + } + + public init(url: URL) { + urls = [url] + } + + public func load() async throws -> [Document] { + try await withThrowingTaskGroup(of: ( + url: URL, + html: String, + strategy: LoadWebPageMainContentStrategy + ).self) { group in + for url in urls { + let strategy: LoadWebPageMainContentStrategy = { + switch url { + default: + return DefaultLoadContentStrategy() + } + }() + group.addTask { + try await downloadHTML(url, strategy) + } + } + var documents: [Document] = [] + for try await result in group { + do { + let parsed = try SwiftSoup.parse(result.html, result.url.path) + + let title = (try? parsed.title()) ?? "Untitled" + let parsedDocuments = try result.strategy.load( + parsed, + metadata: [ + MetadataKeys.title: .string(title), + MetadataKeys.url: .string(result.url.absoluteString), + MetadataKeys.date: .number(Date().timeIntervalSince1970), + ] + ) + documents.append(contentsOf: parsedDocuments) + } catch let Exception.Error(_, message) { + Logger.langchain.error(message) + } catch { + Logger.langchain.error(error.localizedDescription) + } + } + return documents + } + } +} + +// MARK: - WebScrapper + +@MainActor +public final class WebScrapper: NSObject, WKNavigationDelegate { + public var webView: WKWebView + + let strategy: LoadWebPageMainContentStrategy + let retryLimit: Int + var webViewDidFinishLoading = false + var navigationError: (any Error)? + + enum WebScrapperError: Error { + case retry + } + + init( + retryLimit: Int = 10, + strategy: LoadWebPageMainContentStrategy + ) { + self.retryLimit = retryLimit + self.strategy = strategy + let configuration = WKWebViewConfiguration() + configuration.defaultWebpagePreferences.preferredContentMode = .desktop + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + configuration + .applicationNameForUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15" + // The web page need the web view to have a size to load correctly. + let webView = WKWebView( + frame: .init(x: 0, y: 0, width: 500, height: 500), + configuration: configuration + ) + self.webView = webView + super.init() + webView.navigationDelegate = self + } + + func fetch(url: URL) async throws -> String { + webViewDidFinishLoading = false + navigationError = nil + var retryCount = 0 + _ = webView.load(.init(url: url)) + while !webViewDidFinishLoading { + try await Task.sleep(nanoseconds: 10_000_000) + } + if let navigationError { throw navigationError } + while retryCount < retryLimit { + if let html = try? await getHTML(), !html.isEmpty, + let document = try? SwiftSoup.parse(html, url.path), + strategy.validate(document) + { + return html + } + retryCount += 1 + try await Task.sleep(nanoseconds: 100_000_000) + } + + throw CancellationError() + } + + public nonisolated func webView(_: WKWebView, didFinish _: WKNavigation!) { + Task { @MainActor in + self.webViewDidFinishLoading = true + } + } + + public nonisolated func webView( + _: WKWebView, + didFail _: WKNavigation!, + withError error: Error + ) { + Task { @MainActor in + self.navigationError = error + self.webViewDidFinishLoading = true + } + } + + func getHTML() async throws -> String { + do { + let isReady = try await webView.evaluateJavaScript(checkIfReady) as? Bool ?? false + if !isReady { throw WebScrapperError.retry } + return try await webView.evaluateJavaScript(getHTMLText) as? String ?? "" + } catch { + throw WebScrapperError.retry + } + } +} + +private let getHTMLText = """ +document.documentElement.outerHTML; +""" + +private let checkIfReady = """ +document.readyState === "ready" || document.readyState === "complete"; +""" + +// MARK: - LoadWebPageMainContentStrategy + +protocol LoadWebPageMainContentStrategy { + /// Load the web content into several documents. + func load(_ document: SwiftSoup.Document, metadata: Document.Metadata) throws -> [Document] + /// Validate if the web page is fully loaded. + func validate(_ document: SwiftSoup.Document) -> Bool +} + +extension LoadWebPageMainContentStrategy { + func text(inFirstTag tagName: String, from document: SwiftSoup.Document) -> String? { + if let tag = try? document.getElementsByTag(tagName).first(), + let text = try? tag.text() + { + return text + } + return nil + } +} + +extension WebLoader { + struct DefaultLoadContentStrategy: LoadWebPageMainContentStrategy { + func load( + _ document: SwiftSoup.Document, + metadata: Document.Metadata + ) throws -> [Document] { + if let mainContent = try? { + if let article = text(inFirstTag: "article", from: document) { return article } + if let main = text(inFirstTag: "main", from: document) { return main } + let body = try document.body()?.text() + return body + }() { + return [.init(pageContent: mainContent, metadata: metadata)] + } + return [] + } + + func validate(_: SwiftSoup.Document) -> Bool { + return true + } + } +} + diff --git a/Tool/Sources/LangChain/DocumentTransformer/DocumentTransformer.swift b/Tool/Sources/LangChain/DocumentTransformer/DocumentTransformer.swift new file mode 100644 index 00000000..ac81334d --- /dev/null +++ b/Tool/Sources/LangChain/DocumentTransformer/DocumentTransformer.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol DocumentTransformer { + func transformDocuments(_ documents: [Document]) async throws -> [Document] +} diff --git a/Tool/Sources/LangChain/DocumentTransformer/RecursiveCharacterTextSplitter.swift b/Tool/Sources/LangChain/DocumentTransformer/RecursiveCharacterTextSplitter.swift new file mode 100644 index 00000000..00edc4fc --- /dev/null +++ b/Tool/Sources/LangChain/DocumentTransformer/RecursiveCharacterTextSplitter.swift @@ -0,0 +1,118 @@ +import Foundation + +/// Implementation of splitting text that looks at characters. +/// Recursively tries to split by different characters to find one that works. +public class RecursiveCharacterTextSplitter: TextSplitter { + public var chunkSize: Int + public var chunkOverlap: Int + public var lengthFunction: (String) -> Int + + /// A list of separators to try. They will be used in order. Supports regular expressions. + public var separators: [String] + + /// Create a new splitter + /// - Parameters: + /// - separators: A list of separators to try. They will be used in order. Supports regular + /// expressions. + /// - chunkSize: The maximum size of chunks. Don't use chunk size larger than 8191, because + /// length safe embedding is not implemented. + /// - chunkOverlap: The maximum overlap between chunks. + /// - lengthFunction: A function to compute the length of text. + public init( + separators: [String], + chunkSize: Int = 4000, + chunkOverlap: Int = 200, + lengthFunction: @escaping (String) -> Int = { $0.count } + ) { + assert(chunkOverlap <= chunkSize) + self.chunkSize = chunkSize + self.chunkOverlap = chunkOverlap + self.lengthFunction = lengthFunction + self.separators = separators + } + + // Create a new splitter + /// - Parameters: + /// - separatorSet: A set of separators to try. + /// - chunkSize: The maximum size of chunks. Don't use chunk size larger than 8191, because + /// length safe embedding is not implemented. + /// - chunkOverlap: The maximum overlap between chunks. + /// - lengthFunction: A function to compute the length of text. + public init( + separatorSet: TextSplitterSeparatorSet = .default, + chunkSize: Int = 4000, + chunkOverlap: Int = 200, + lengthFunction: @escaping (String) -> Int = { $0.count } + ) { + assert(chunkOverlap <= chunkSize) + self.chunkSize = chunkSize + self.chunkOverlap = chunkOverlap + self.lengthFunction = lengthFunction + separators = separatorSet.separators + } + + public func split(text: String) async throws -> [TextChunk] { + return split(text: text, separators: separators, startIndex: 0) + } + + private func split(text: String, separators: [String], startIndex: Int) -> [TextChunk] { + var finalChunks = [TextChunk]() + + // Get appropriate separator to use + let firstSeparatorIndex = separators.firstIndex { + let pattern = "(\($0))" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return false } + return regex.firstMatch( + in: text, + options: [], + range: NSRange(text.startIndex..., in: text) + ) != nil + } + var separator: String + var nextSeparators: [String] + + if let index = firstSeparatorIndex { + separator = separators[index] + if index < separators.endIndex - 1 { + nextSeparators = Array(separators[(index + 1)...]) + } else { + nextSeparators = [] + } + } else { + separator = "" + nextSeparators = [] + } + + let splits = split(text: text, separator: separator, startIndex: startIndex) + + // Now go merging things, recursively splitting longer texts. + var goodSplits = [TextChunk]() + for s in splits { + if lengthFunction(s.text) < chunkSize { + goodSplits.append(s) + } else { + if !goodSplits.isEmpty { + let mergedText = mergeSplits(goodSplits) + finalChunks.append(contentsOf: mergedText) + goodSplits.removeAll() + } + if nextSeparators.isEmpty { + finalChunks.append(s) + } else { + let other_info = split( + text: s.text, + separators: nextSeparators, + startIndex: s.startUTF16Offset + ) + finalChunks.append(contentsOf: other_info) + } + } + } + if !goodSplits.isEmpty { + let merged_text = mergeSplits(goodSplits) + finalChunks.append(contentsOf: merged_text) + } + return finalChunks + } +} + diff --git a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift new file mode 100644 index 00000000..5880616c --- /dev/null +++ b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift @@ -0,0 +1,215 @@ +import Foundation +import JSONRPC + +/// Split text into multiple components. +public protocol TextSplitter: DocumentTransformer { + /// The maximum size of chunks. + var chunkSize: Int { get } + /// The maximum overlap between chunks. + var chunkOverlap: Int { get } + /// A function to compute the length of text. + var lengthFunction: (String) -> Int { get } + + /// Split text into multiple components. + func split(text: String) async throws -> [TextChunk] +} + +public extension TextSplitter { + /// Create documents from a list of texts. + func createDocuments( + texts: [String], + metadata: [Document.Metadata] = [] + ) async throws -> [Document] { + var documents = [Document]() + let paddingLength = texts.count - metadata.count + let metadata = metadata + .init(repeating: [:], count: paddingLength) + for (text, metadata) in zip(texts, metadata) { + let chunks = try await split(text: text) + for chunk in chunks { + var metadata = metadata + metadata["startUTF16Offset"] = .number(Double(chunk.startUTF16Offset)) + metadata["endUTF16Offset"] = .number(Double(chunk.endUTF16Offset)) + let document = Document(pageContent: chunk.text, metadata: metadata) + documents.append(document) + } + } + return documents + } + + /// Split documents. + func splitDocuments(_ documents: [Document]) async throws -> [Document] { + var texts = [String]() + var metadata = [Document.Metadata]() + for document in documents { + texts.append(document.pageContent) + metadata.append(document.metadata) + } + return try await createDocuments(texts: texts, metadata: metadata) + } + + /// Transform sequence of documents by splitting them. + func transformDocuments(_ documents: [Document]) async throws -> [Document] { + return try await splitDocuments(documents) + } + + func joinDocuments(_ documents: [Document]) -> Document { + let textChunks: [TextChunk] = documents.compactMap { document in + func extract(_ key: String) -> Int? { + if case let .number(d) = document.metadata[key] { + return Int(d) + } + return nil + } + guard let start = extract("startUTF16Offset"), + let end = extract("endUTF16Offset") + else { return nil } + return TextChunk( + text: document.pageContent, + startUTF16Offset: start, + endUTF16Offset: end + ) + }.sorted(by: { $0.startUTF16Offset < $1.startUTF16Offset }) + var sumChunk: TextChunk? + for chunk in textChunks { + if let current = sumChunk { + if let merged = current.merged(with: chunk, force: true) { + sumChunk = merged + } + } else { + sumChunk = chunk + } + } + let pageContent = sumChunk?.text ?? "" + var metadata = documents.first?.metadata ?? [String: JSONValue]() + metadata["startUTF16Offset"] = nil + metadata["endUTF16Offset"] = nil + + return Document(pageContent: pageContent, metadata: metadata) + } +} + +public struct TextChunk: Equatable { + public var text: String + public var startUTF16Offset: Int + public var endUTF16Offset: Int + + /// Merge the current chunk with another chunk if the 2 chunks are overlapping or adjacent. + public func merged(with chunk: TextChunk, force: Bool = false) -> TextChunk? { + let frontChunk = startUTF16Offset < chunk.startUTF16Offset ? self : chunk + let backChunk = startUTF16Offset < chunk.startUTF16Offset ? chunk : self + let overlap = frontChunk.endUTF16Offset - backChunk.startUTF16Offset + guard overlap >= 0 || force else { return nil } + + let text = frontChunk.text + backChunk.text.dropFirst(max(0, overlap)) + let start = frontChunk.startUTF16Offset + let end = backChunk.endUTF16Offset + return TextChunk(text: text, startUTF16Offset: start, endUTF16Offset: end) + } +} + +public extension TextSplitter { + /// Merge small splits to just fit in the chunk size. + func mergeSplits(_ splits: [TextChunk]) -> [TextChunk] { + let chunkOverlap = chunkOverlap < chunkSize ? chunkOverlap : 0 + + var chunks = [TextChunk]() + var currentChunk = [TextChunk]() + var overlappingChunks = [TextChunk]() + var currentChunkSize = 0 + + func join(_ a: [TextChunk], _ b: [TextChunk]) -> TextChunk? { + let text = (a + b).map(\.text).joined() + var l = Int.max + var u = 0 + + for chunk in a + b { + l = min(l, chunk.startUTF16Offset) + u = max(u, chunk.endUTF16Offset) + } + + guard l < u else { return nil } + + return .init(text: text, startUTF16Offset: l, endUTF16Offset: u) + } + + for chunk in splits { + let textLength = lengthFunction(chunk.text) + if currentChunkSize + textLength > chunkSize { + guard let currentChunkText = join(overlappingChunks, currentChunk) else { continue } + chunks.append(currentChunkText) + + overlappingChunks = [] + var overlappingSize = 0 + // use small chunks as overlap if possible + for chunk in currentChunk.reversed() { + let length = lengthFunction(chunk.text) + if overlappingSize + length > chunkOverlap { break } + if overlappingSize + length + textLength > chunkSize { break } + overlappingSize += length + overlappingChunks.insert(chunk, at: 0) + } +// // fallback to use suffix if no small chunk found +// if overlappingChunks.isEmpty { +// let suffix = String( +// currentChunkText.suffix(min(chunkOverlap, chunkSize - textLength)) +// ) +// overlappingChunks.append(suffix) +// overlappingSize = lengthFunction(suffix) +// } + + currentChunkSize = overlappingSize + textLength + currentChunk = [chunk] + } else { + currentChunkSize += textLength + currentChunk.append(chunk) + } + } + + if !currentChunk.isEmpty, let joinedChunks = join(overlappingChunks, currentChunk) { + chunks.append(joinedChunks) + } else { + chunks.append(contentsOf: overlappingChunks) + chunks.append(contentsOf: currentChunk) + } + + return chunks + } + + /// Split the text by separator. + func split(text: String, separator: String, startIndex: Int = 0) -> [TextChunk] { + let pattern = "(\(separator))" + if !separator.isEmpty, let regex = try? NSRegularExpression(pattern: pattern) { + let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) + var all = [TextChunk]() + var start = text.startIndex + for match in matches { + guard let range = Range(match.range, in: text) else { break } + guard range.lowerBound > start else { break } + let result = text[start..", + "
", + "

", + "
", + "

  • ", + "

    ", + "

    ", + "

    ", + "

    ", + "

    ", + "
    ", + "", + "", + "", + "
    ", + "", + "
      ", + "
        ", + "
        ", + "