diff --git a/.gitignore b/.gitignore index eb99039a..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 diff --git a/.gitmodules b/.gitmodules index a091985c..e69de29b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "Pro"] - path = Pro - url = git@github.com:intitni/CopilotForXcodePro.git diff --git a/AppIcon.png b/AppIcon.png index 160db273..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/ChatPlugins/.swiftpm/xcode/xcshareddata/xcschemes/ChatPlugins.xcscheme b/ChatPlugins/.swiftpm/xcode/xcshareddata/xcschemes/ChatPlugins.xcscheme new file mode 100644 index 00000000..53df9491 --- /dev/null +++ b/ChatPlugins/.swiftpm/xcode/xcshareddata/xcschemes/ChatPlugins.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 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 ad49af50..056e5761 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -31,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 */; }; @@ -42,9 +53,8 @@ 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 */; }; - C8F1032B2A7A39D700D28F4F /* launchAgent.plist in Copy Launch Agent */ = {isa = PBXBuildFile; fileRef = C8F103292A7A365000D28F4F /* launchAgent.plist */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -69,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 */ @@ -109,12 +126,22 @@ 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; @@ -142,7 +169,7 @@ dstPath = Contents/Library/LaunchAgents; dstSubfolderSpec = 1; files = ( - C8F1032B2A7A39D700D28F4F /* launchAgent.plist in Copy Launch Agent */, + C8738B8A2BE540D000609E7F /* bridgeLaunchAgent.plist in Copy Launch Agent */, ); name = "Copy Launch Agent"; runOnlyForDeploymentPostprocessing = 0; @@ -178,7 +205,7 @@ 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 = ""; }; - C83E5DED2A38CD8C0071506D /* Makefile */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.make; path = Makefile; 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 = ""; }; @@ -187,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; }; @@ -196,8 +235,9 @@ 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 */ @@ -237,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 */ @@ -265,7 +321,7 @@ C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */, C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */, C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */, - C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */, + C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */, C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */, C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */, C81458972939EFDC00135263 /* Info.plist */, @@ -280,19 +336,23 @@ C887BC832965D96000931567 /* DEVELOPMENT.md */, C8520308293D805800460097 /* README.md */, C82E38492A1F025F00D4EADF /* LICENSE */, - C83E5DED2A38CD8C0071506D /* Makefile */, 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 */, ); @@ -305,6 +365,8 @@ C814588C2939EFDC00135263 /* Copilot.appex */, C8216B70298036EC00AD38C7 /* Helper */, C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */, + C8738B632BE4D4B900609E7F /* CommunicationBridge */, + C8738B782BE5363800609E7F /* SandboxedClientTester.app */, ); name = Products; sourceTree = ""; @@ -345,6 +407,7 @@ C861E61F2994F6390056CB02 /* ServiceDelegate.swift */, C861E6102994F6070056CB02 /* AppDelegate.swift */, C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */, + C8738B702BE4F8B700609E7F /* XPCController.swift */, C81291D52994FE6900196E12 /* Main.storyboard */, C861E6142994F6080056CB02 /* Assets.xcassets */, C861E6192994F6080056CB02 /* ExtensionService.entitlements */, @@ -352,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 */ @@ -391,6 +484,7 @@ buildRules = ( ); dependencies = ( + C8738B8D2BE540F900609E7F /* PBXTargetDependency */, C81291B02994F92700196E12 /* PBXTargetDependency */, C8216B7F2980377E00AD38C7 /* PBXTargetDependency */, C814589A2939EFDC00135263 /* PBXTargetDependency */, @@ -444,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 */ @@ -466,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" */; @@ -489,6 +629,8 @@ C814588B2939EFDC00135263 /* EditorExtension */, C8216B6F298036EC00AD38C7 /* Helper */, C861E60D2994F6070056CB02 /* ExtensionService */, + C8738B622BE4D4B900609E7F /* CommunicationBridge */, + C8738B772BE5363800609E7F /* SandboxedClientTester */, ); }; /* End PBXProject section */ @@ -519,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 */ @@ -526,7 +677,7 @@ 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 */, @@ -567,11 +718,30 @@ 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 */ @@ -590,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 */ @@ -600,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; @@ -627,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; @@ -681,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; @@ -742,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; @@ -772,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; @@ -804,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)"; @@ -829,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; @@ -842,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; @@ -860,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; @@ -873,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)"; @@ -892,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; @@ -905,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)"; @@ -915,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 */ @@ -963,6 +1256,24 @@ 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 */ @@ -998,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/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/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index c5870abd..87fd4d4e 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,23 @@ { "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", @@ -14,8 +32,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "0625932976b3ae23949f6b816d13bd97f3b40b7c", - "version" : "0.10.0" + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" + } + }, + { + "identity" : "copilotforxcodekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/CopilotForXcodeKit", + "state" : { + "branch" : "feature/custom-chat-tab", + "revision" : "63915ee1f8aba5375bc0f0166c8645fe81fe5b88" } }, { @@ -27,6 +54,15 @@ "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", @@ -41,8 +77,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/intitni/Highlightr", "state" : { - "branch" : "bump-highlight-js-version", - "revision" : "4ffbb1b0b721378263297cafea6f2838044eb1eb" + "branch" : "master", + "revision" : "81d8c8b3733939bf5d9e52cd6318f944cc033bd2" + } + }, + { + "identity" : "indexstore-db", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/indexstore-db.git", + "state" : { + "branch" : "release/6.1", + "revision" : "54212fce1aecb199070808bdb265e7f17e396015" } }, { @@ -54,6 +99,15 @@ "version" : "0.6.0" } }, + { + "identity" : "keyboardshortcuts", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/KeyboardShortcuts", + "state" : { + "branch" : "main", + "revision" : "65fb410b0c6d3ed96623b460bab31ffce5f48b4d" + } + }, { "identity" : "languageclient", "kind" : "remoteSourceControl", @@ -72,6 +126,24 @@ "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", @@ -81,6 +153,15 @@ "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", @@ -90,13 +171,31 @@ "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" : "87e4fcbac39912f9cdb9a9acf205cad60e1ca3bc", - "version" : "2.4.2" + "revision" : "0ca3004e98712ea2b39dd881d28448630cce1c99", + "version" : "2.7.0" + } + }, + { + "identity" : "spectre", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/Spectre.git", + "state" : { + "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", + "version" : "0.10.1" } }, { @@ -113,8 +212,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms", "state" : { - "revision" : "9cfed92b026c524674ed869a4ff2dcfdeedf8a2a", - "version" : "0.1.0" + "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", + "version" : "1.0.4" } }, { @@ -122,8 +221,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984", - "version" : "0.14.1" + "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", + "version" : "1.7.0" } }, { @@ -131,8 +230,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "f9acfa1a45f4483fe0f2c434a74e6f68f865d12d", - "version" : "0.3.0" + "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" } }, { @@ -140,8 +248,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", - "version" : "1.0.4" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -149,8 +257,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "89f80fe2400d21a853abc9556a060a2fa50eb2cb", - "version" : "0.55.0" + "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" } }, { @@ -158,8 +275,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "3a35f7892e7cf6ba28a78cd46a703c0be4e0c6dc", - "version" : "0.11.0" + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" } }, { @@ -167,8 +284,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "de1a984a71e51f6e488e98ce3652035563eb8acb", - "version" : "0.5.1" + "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596", + "version" : "1.9.2" } }, { @@ -176,8 +293,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "d01446a78fb768adc9a78cbb6df07767c8ccfc29", - "version" : "0.8.0" + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" } }, { @@ -185,8 +302,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/swift-markdown-ui", "state" : { - "revision" : "12b351a75201a8124c2f2e1f9fc6ef5cd812c0b9", - "version" : "2.1.0" + "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" } }, { @@ -194,8 +320,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-parsing", "state" : { - "revision" : "27c941bbd22a4bbc53005a15a0440443fd892f70", - "version" : "0.12.1" + "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" } }, { @@ -203,8 +338,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax.git", "state" : { - "branch" : "main", - "revision" : "aa3b1e187c9cc568f9d1abc47feb11f6b044d284" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } }, { @@ -216,22 +351,40 @@ "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/ChimeHQ/SwiftTreeSitter", + "location" : "https://github.com/intitni/SwiftTreeSitter.git", "state" : { - "revision" : "df25a52f72ebc5b50ae20d26d1363793408bb28b", - "version" : "0.7.1" + "branch" : "main", + "revision" : "fd499bfafcccfae12a1a579dc922d8418025a35d" } }, { - "identity" : "swiftui-navigation", + "identity" : "swiftui-introspect", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swiftui-navigation", + "location" : "https://github.com/siteline/swiftui-introspect", "state" : { - "revision" : "2aa885e719087ee19df251c08a5980ad3e787f12", - "version" : "0.8.0" + "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", + "version" : "1.3.0" + } + }, + { + "identity" : "swxmlhash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/drmohundro/SWXMLHash.git", + "state" : { + "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", + "version" : "7.0.2" } }, { @@ -253,21 +406,21 @@ } }, { - "identity" : "tree-sitter-swift", + "identity" : "usearch", "kind" : "remoteSourceControl", - "location" : "https://github.com/alex-pinkus/tree-sitter-swift", + "location" : "https://github.com/unum-cloud/usearch", "state" : { - "branch" : "with-generated-files", - "revision" : "eda05af7ac41adb4eb19c346883c0fa32fe3bdd8" + "revision" : "f2ab884d50902c3ad63f07a3a20bc34008a17449", + "version" : "0.19.3" } }, { - "identity" : "usearch", + "identity" : "xcodeproj", "kind" : "remoteSourceControl", - "location" : "https://github.com/unum-cloud/usearch", + "location" : "https://github.com/tuist/XcodeProj.git", "state" : { - "revision" : "f2ab884d50902c3ad63f07a3a20bc34008a17449", - "version" : "0.19.3" + "revision" : "b1caa062d4aaab3e3d2bed5fe0ac5f8ce9bf84f4", + "version" : "8.27.7" } }, { @@ -275,8 +428,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "50843cbb8551db836adec2290bb4bc6bac5c1865", - "version" : "0.9.0" + "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", + "version" : "1.5.2" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "b4b8042411dc7bbb696300a34a4bf3ba1b7ad19b", + "version" : "5.3.1" } } ], diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift index 094e32d5..99a0a044 100644 --- a/Copilot for Xcode/App.swift +++ b/Copilot for Xcode/App.swift @@ -6,10 +6,22 @@ import UpdateChecker import XPCShared struct VisualEffect: NSViewRepresentable { - func makeNSView(context: Self.Context) -> NSView { return NSVisualEffectView() } - func updateNSView(_ nsView: NSView, context: Context) { } + 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 { @@ -20,7 +32,17 @@ struct CopilotForXcodeApp: App { .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 291eaac7..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 160db273..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 4fcd6278..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 e31a8d3b..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 e31a8d3b..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 ec264755..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 ec264755..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 4b760bc1..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 4b760bc1..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 8d777985..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/Core-Package.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme similarity index 84% rename from Core/.swiftpm/xcode/xcshareddata/xcschemes/Core-Package.xcscheme rename to Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme index b5513aeb..0deca224 100644 --- a/Core/.swiftpm/xcode/xcshareddata/xcschemes/Core-Package.xcscheme +++ b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme @@ -1,10 +1,11 @@ + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> @@ -49,9 +50,9 @@ diff --git a/Tool/.swiftpm/xcode/xcshareddata/xcschemes/Tool-Package.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme similarity index 84% rename from Tool/.swiftpm/xcode/xcshareddata/xcschemes/Tool-Package.xcscheme rename to Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme index 3bb0323b..25654d7d 100644 --- a/Tool/.swiftpm/xcode/xcshareddata/xcschemes/Tool-Package.xcscheme +++ b/Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme @@ -1,10 +1,11 @@ + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> @@ -49,9 +50,9 @@ 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 { - // include the pro package - return self + targetNames.map { Target.Dependency.product(name: $0, package: "Pro") } - } - return self - } -} - -extension [Package.Dependency] { - var pro: [Package.Dependency] { - if isProIncluded { - // include the pro package - return self + [.package(path: "../Pro/Pro")] - } - return self - } -} - -let isProIncluded: Bool = { - func isProIncluded(file: StaticString = #file) -> Bool { - let filePath = "\(file)" - let fileURL = URL(fileURLWithPath: filePath) - let rootURL = fileURL - .deletingLastPathComponent() - .deletingLastPathComponent() - let confURL = rootURL.appendingPathComponent("PLUS") - if !FileManager.default.fileExists(atPath: confURL.path) { - return false - } - do { - if let content = try String( - data: Data(contentsOf: confURL), - encoding: .utf8 - ) { - if content.hasPrefix("YES") { - return true - } - } - return false - } catch { - return false - } - } - - return isProIncluded() -}() - // MARK: - Package let package = Package( name: "Core", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], products: [ .library( name: "Service", targets: [ "Service", - "SuggestionInjector", "FileChangeChecker", "LaunchAgentManager", "UpdateChecker", @@ -89,18 +37,22 @@ let package = Package( ], dependencies: [ .package(path: "../Tool"), + .package(path: "../ChatPlugins"), + .package(path: "../OverlayWindow"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), - .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.1.0"), - .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0"), + .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"), + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), - .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.5.1"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", - from: "0.55.0" + exact: "1.16.1" ), // quick hack to support custom UserDefaults // https://github.com/sindresorhus/KeyboardShortcuts .package(url: "https://github.com/intitni/KeyboardShortcuts", branch: "main"), + .package(url: "https://github.com/intitni/CGEventOverride", from: "1.2.1"), + .package(url: "https://github.com/intitni/Highlightr", branch: "master"), ].pro, targets: [ // MARK: - Main @@ -110,11 +62,11 @@ let package = Package( dependencies: [ .product(name: "XPCShared", package: "Tool"), .product(name: "SuggestionProvider", package: "Tool"), - .product(name: "SuggestionModel", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "Preferences", package: "Tool"), - ].pro([ - "ProClient", + ].proCore([ + "LicenseManagement", ]) ), .target( @@ -126,20 +78,28 @@ let package = Package( "PromptToCodeService", "ServiceUpdateMigration", "ChatGPTChatTab", + "PlusFeatureFlag", + "KeyBindingManager", + "XcodeThemeController", .product(name: "XPCShared", package: "Tool"), .product(name: "SuggestionProvider", package: "Tool"), .product(name: "Workspace", package: "Tool"), + .product(name: "WorkspaceSuggestionService", package: "Tool"), .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), - .product(name: "SuggestionModel", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "PromptToCode", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), + .product(name: "CommandHandler", package: "Tool"), + .product(name: "OverlayWindow", package: "OverlayWindow"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), + .product(name: "CustomCommandTemplateProcessor", package: "Tool"), ].pro([ "ProService", ]) @@ -149,10 +109,9 @@ let package = Package( dependencies: [ "Service", "Client", - "SuggestionInjector", .product(name: "XPCShared", package: "Tool"), .product(name: "SuggestionProvider", package: "Tool"), - .product(name: "SuggestionModel", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), .product(name: "Preferences", package: "Tool"), ] ), @@ -168,7 +127,8 @@ let package = Package( .product(name: "SuggestionProvider", package: "Tool"), .product(name: "Toast", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), - .product(name: "SuggestionModel", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "WebSearchService", package: "Tool"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), @@ -184,28 +144,23 @@ let package = Package( .target( name: "SuggestionService", dependencies: [ - .product(name: "SuggestionModel", package: "Tool"), - .product(name: "SuggestionProvider", package: "Tool") + .product(name: "UserDefaultsObserver", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "SuggestionProvider", package: "Tool"), ].pro([ "ProExtension", ]) ), - .target( - name: "SuggestionInjector", - dependencies: [.product(name: "SuggestionModel", package: "Tool")] - ), - .testTarget( - name: "SuggestionInjectorTests", - dependencies: ["SuggestionInjector"] - ), // MARK: - Prompt To Code .target( name: "PromptToCodeService", dependencies: [ + .product(name: "PromptToCode", package: "Tool"), .product(name: "FocusedCodeFinder", package: "Tool"), - .product(name: "SuggestionModel", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), @@ -220,29 +175,27 @@ let package = Package( .target( name: "ChatService", dependencies: [ - "ChatPlugin", - - // plugins - "MathChatPlugin", - "SearchChatPlugin", - "ShortcutChatPlugin", + "LegacyChatPlugin", // context collectors "WebChatContextCollector", "SystemInfoChatContextCollector", .product(name: "ChatContextCollector", package: "Tool"), + .product(name: "PromptToCode", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), - .product(name: "Parsing", package: "swift-parsing"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), + .product(name: "CustomCommandTemplateProcessor", package: "Tool"), + .product(name: "ChatPlugins", package: "ChatPlugins"), + .product(name: "Parsing", package: "swift-parsing"), ].pro([ "ProService", ]) ), .testTarget(name: "ChatServiceTests", dependencies: ["ChatService"]), .target( - name: "ChatPlugin", + name: "LegacyChatPlugin", dependencies: [ .product(name: "AppMonitoring", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), @@ -271,6 +224,7 @@ let package = Package( dependencies: [ "PromptToCodeService", "ChatGPTChatTab", + .product(name: "PromptToCode", package: "Tool"), .product(name: "Toast", package: "Tool"), .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), @@ -278,6 +232,7 @@ let package = Package( .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "CustomAsyncAlgorithms", package: "Tool"), + .product(name: "CodeDiff", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), @@ -319,61 +274,100 @@ let package = Package( ]) ), - // MARK: - Chat Plugins + // MAKR: - Chat Context Collector .target( - name: "MathChatPlugin", + name: "WebChatContextCollector", dependencies: [ - "ChatPlugin", - .product(name: "OpenAIService", package: "Tool"), + .product(name: "ChatContextCollector", package: "Tool"), .product(name: "LangChain", package: "Tool"), - ], - path: "Sources/ChatPlugins/MathChatPlugin" - ), - - .target( - name: "SearchChatPlugin", - dependencies: [ - "ChatPlugin", .product(name: "OpenAIService", package: "Tool"), - .product(name: "LangChain", package: "Tool"), .product(name: "ExternalServices", package: "Tool"), + .product(name: "Preferences", package: "Tool"), ], - path: "Sources/ChatPlugins/SearchChatPlugin" + path: "Sources/ChatContextCollectors/WebChatContextCollector" ), .target( - name: "ShortcutChatPlugin", + name: "SystemInfoChatContextCollector", dependencies: [ - "ChatPlugin", - .product(name: "Parsing", package: "swift-parsing"), - .product(name: "Terminal", package: "Tool"), + .product(name: "ChatContextCollector", package: "Tool"), + .product(name: "OpenAIService", package: "Tool"), ], - path: "Sources/ChatPlugins/ShortcutChatPlugin" + path: "Sources/ChatContextCollectors/SystemInfoChatContextCollector" ), - // MAKR: - Chat Context Collector + // MARK: Key Binding .target( - name: "WebChatContextCollector", + name: "KeyBindingManager", dependencies: [ - .product(name: "ChatContextCollector", package: "Tool"), - .product(name: "LangChain", package: "Tool"), - .product(name: "OpenAIService", package: "Tool"), - .product(name: "ExternalServices", package: "Tool"), + .product(name: "CommandHandler", package: "Tool"), + .product(name: "Workspace", package: "Tool"), .product(name: "Preferences", package: "Tool"), - ], - path: "Sources/ChatContextCollectors/WebChatContextCollector" + .product(name: "Logger", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "UserDefaultsObserver", package: "Tool"), + .product(name: "CGEventOverride", package: "CGEventOverride"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] ), + .testTarget( + name: "KeyBindingManagerTests", + dependencies: ["KeyBindingManager"] + ), + + // MARK: Theming .target( - name: "SystemInfoChatContextCollector", + name: "XcodeThemeController", dependencies: [ - .product(name: "ChatContextCollector", package: "Tool"), - .product(name: "OpenAIService", package: "Tool"), - ], - path: "Sources/ChatContextCollectors/SystemInfoChatContextCollector" + .product(name: "Preferences", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "Highlightr", package: "Highlightr"), + ] ), ] ) +extension [Target.Dependency] { + func pro(_ targetNames: [String]) -> [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/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift index e4a22903..0620123c 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift @@ -1,3 +1,4 @@ +import ChatBasic import Foundation import LangChain import OpenAIService @@ -15,6 +16,10 @@ struct QueryWebsiteFunction: ChatGPTFunction { var botReadableContent: String { return answers.joined(separator: "\n") } + + var userReadableContent: ChatGPTFunctionResultUserReadableContent { + .text(botReadableContent) + } } var name: String { @@ -54,16 +59,23 @@ struct QueryWebsiteFunction: ChatGPTFunction { reportProgress: @escaping (String) async -> Void ) async throws -> Result { do { - let embedding = OpenAIEmbedding(configuration: UserPreferenceEmbeddingConfiguration()) + 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: urlString) { + if let database = await TemporaryUSearch.view( + identifier: storeIdentifier, + dimensions: dimensions + ) { await reportProgress("Getting relevant information..") let qa = QAInformationRetrievalChain( vectorStore: database, @@ -76,14 +88,17 @@ struct QueryWebsiteFunction: ChatGPTFunction { await reportProgress("Processing \(url)..") // 2. split the content let splitter = RecursiveCharacterTextSplitter( - chunkSize: 1000, + 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: urlString) + let database = TemporaryUSearch( + identifier: storeIdentifier, + dimensions: dimensions + ) try await database.set(embeddedDocuments) // 4. generate answer await reportProgress("Getting relevant information..") diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift index 99c88312..60a5504e 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift @@ -1,7 +1,8 @@ -import BingSearchService +import ChatBasic import Foundation import OpenAIService import Preferences +import WebSearchService struct SearchFunction: ChatGPTFunction { static let dateFormatter = { @@ -16,17 +17,21 @@ struct SearchFunction: ChatGPTFunction { } struct Result: ChatGPTFunctionResult { - var result: BingSearchResult + var result: WebSearchResult var botReadableContent: String { - result.webPages.value.enumerated().map { + result.webPages.enumerated().map { let (index, page) = $0 return """ - \(index + 1). \(page.name) \(page.url) + \(index + 1). \(page.title) \(page.urlString) \(page.snippet) """ }.joined(separator: "\n") } + + var userReadableContent: ChatGPTFunctionResultUserReadableContent { + .text(botReadableContent) + } } let maxTokens: Int @@ -71,22 +76,15 @@ struct SearchFunction: ChatGPTFunction { await reportProgress("Searching \(arguments.query)") do { - let bingSearch = BingSearchService( - subscriptionKey: UserDefaults.shared.value(for: \.bingSearchSubscriptionKey), - searchURL: UserDefaults.shared.value(for: \.bingSearchEndpoint) - ) + let search = WebSearchService(provider: .userPreferred) - let result = try await bingSearch.search( - query: arguments.query, - numberOfResult: maxTokens > 5000 ? 5 : 3, - freshness: arguments.freshness - ) + let result = try await search.search(query: arguments.query) await reportProgress(""" Finish searching \(arguments.query) \( - result.webPages.value - .map { "- [\($0.name)](\($0.url))" } + result.webPages + .map { "- [\($0.title)](\($0.urlString))" } .joined(separator: "\n") ) """) diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift index 851fdcf7..848ca0fa 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift @@ -1,3 +1,4 @@ +import ChatBasic import ChatContextCollector import Foundation import OpenAIService diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift index 9b7f2ca8..28443876 100644 --- a/Core/Sources/ChatGPTChatTab/Chat.swift +++ b/Core/Sources/ChatGPTChatTab/Chat.swift @@ -1,6 +1,9 @@ +import AppKit +import ChatBasic import ChatService import ComposableArchitecture import Foundation +import MarkdownUI import OpenAIService import Preferences import Terminal @@ -40,12 +43,14 @@ public struct DisplayedChatMessage: Equatable { 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 } } @@ -54,16 +59,20 @@ private var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } -struct Chat: ReducerProtocol { +@Reducer +struct Chat { public typealias MessageID = String + @ObservableState struct State: Equatable { var title: String = "Chat" - @BindingState var typedMessage = "" + var typedMessage = "" var history: [DisplayedChatMessage] = [] - @BindingState var isReceivingMessage = false + var isReceivingMessage = false var chatMenu = ChatMenu.State() - @BindingState var focusedField: Field? + var focusedField: Field? + var isEnabled = true + var isPinnedToBottom = true enum Field: String, Hashable { case textField @@ -75,6 +84,7 @@ struct Chat: ReducerProtocol { case appear case refresh + case setIsEnabled(Bool) case sendButtonTapped case returnButtonTapped case stopRespondingButtonTapped @@ -82,6 +92,8 @@ struct Chat: ReducerProtocol { case deleteMessageButtonTapped(MessageID) case resendMessageButtonTapped(MessageID) case setAsExtraPromptButtonTapped(MessageID) + case manuallyScrolledUp + case scrollToBottomButtonTapped case focusOnTextField case referenceClicked(DisplayedChatMessage.Reference) @@ -113,12 +125,10 @@ struct Chat: ReducerProtocol { case sendMessage(UUID) } - @Dependency(\.openURL) var openURL - - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() - Scope(state: \.chatMenu, action: /Action.chatMenu) { + Scope(state: \.chatMenu, action: \.chatMenu) { ChatMenu(service: service) } @@ -141,6 +151,10 @@ struct Chat: ReducerProtocol { 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 @@ -191,18 +205,26 @@ struct Chat: ReducerProtocol { "/bin/bash", arguments: [ "-c", - "xed -l \(reference.startLine ?? 0) \"\(reference.uri)\"", + "xed -l \(reference.startLine ?? 0) ${TARGET_FILE}", ], - environment: [:] + environment: ["TARGET_FILE": reference.uri] ) } catch { print(error) } } else if let url = URL(string: reference.uri), url.scheme != nil { - await openURL(url) + 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 @@ -229,7 +251,7 @@ struct Chat: ReducerProtocol { let debouncedHistoryChange = TimedDebounceFunction(duration: 0.2) { await send(.historyChanged) } - + for await _ in stream { await debouncedHistoryChange() } @@ -320,15 +342,7 @@ struct Chat: ReducerProtocol { } }(), text: message.summary ?? message.content ?? "", - references: message.references.map { - .init( - title: $0.title, - subtitle: $0.subTitle, - uri: $0.uri, - startLine: $0.startLine, - kind: $0.kind - ) - } + references: message.references.map(convertReference) )) for call in message.toolCalls ?? [] { @@ -363,6 +377,9 @@ struct Chat: ReducerProtocol { case .isReceivingMessageChanged: state.isReceivingMessage = service.isReceivingMessage + if service.isReceivingMessage { + state.isPinnedToBottom = true + } return .none case .systemPromptChanged: @@ -387,7 +404,9 @@ struct Chat: ReducerProtocol { } } -struct ChatMenu: ReducerProtocol { +@Reducer +struct ChatMenu { + @ObservableState struct State: Equatable { var systemPrompt: String = "" var extraSystemPrompt: String = "" @@ -409,7 +428,7 @@ struct ChatMenu: ReducerProtocol { let service: ChatService - var body: some ReducerProtocol { + var body: some ReducerOf { Reduce { state, action in switch action { case .appear: @@ -478,9 +497,59 @@ private actor TimedDebounceFunction { } } } - + 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 index e6a3b2c4..9114a5dd 100644 --- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift +++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift @@ -8,8 +8,8 @@ struct ChatTabItemView: View { let chat: StoreOf var body: some View { - WithViewStore(chat, observe: \.title) { viewStore in - Text(viewStore.state) + WithPerceptionTracking { + Text(chat.title) } } } @@ -22,46 +22,44 @@ struct ChatContextMenu: View { @AppStorage(\.chatGPTTemperature) var defaultTemperature var body: some View { - currentSystemPrompt - .onAppear { store.send(.appear) } - currentExtraSystemPrompt - resetPrompt + WithPerceptionTracking { + currentSystemPrompt + .onAppear { store.send(.appear) } + currentExtraSystemPrompt + resetPrompt - Divider() + Divider() - chatModel - temperature - defaultScopes + chatModel + temperature + defaultScopes - Divider() + Divider() - customCommandMenu + customCommandMenu + } } @ViewBuilder var currentSystemPrompt: some View { Text("System Prompt:") - WithViewStore(store, observe: \.systemPrompt) { viewStore in - Text({ - var text = viewStore.state - if text.isEmpty { text = "N/A" } - if text.count > 30 { text = String(text.prefix(30)) + "..." } - return text - }() as String) - } + 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:") - WithViewStore(store, observe: \.extraSystemPrompt) { viewStore in - Text({ - var text = viewStore.state - if text.isEmpty { text = "N/A" } - if text.count > 30 { text = String(text.prefix(30)) + "..." } - return text - }() as String) - } + 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 { @@ -72,47 +70,52 @@ struct ChatContextMenu: View { @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") { - WithViewStore(store, observe: \.chatModelIdOverride) { viewStore in - Button(action: { - viewStore.send(.chatModelIdOverrideSelected(nil)) - }) { - HStack { - if let defaultModel = chatModels - .first(where: { $0.id == defaultChatModelId }) - { - Text("Default (\(defaultModel.name))") - if viewStore.state == nil { - Image(systemName: "checkmark") - } - } else { - Text("No Model Available") + 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 = viewStore.state, !chatModels.map(\.id).contains(id) { - Button(action: { - viewStore.send(.chatModelIdOverrideSelected(nil)) - }) { - HStack { - Text("Default (Selected Model Not Found)") - Image(systemName: "checkmark") - } + 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(chatModels, id: \.id) { model in - Button(action: { - viewStore.send(.chatModelIdOverrideSelected(model.id)) - }) { - HStack { - Text(model.name) - if model.id == viewStore.state { - 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") } } } @@ -123,34 +126,32 @@ struct ChatContextMenu: View { @ViewBuilder var temperature: some View { Menu("Temperature") { - WithViewStore(store, observe: \.temperatureOverride) { viewStore in + 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: { - viewStore.send(.temperatureOverrideSelected(nil)) + store.send(.temperatureOverrideSelected(value)) }) { HStack { - Text( - "Default (\(defaultTemperature.formatted(.number.precision(.fractionLength(1)))))" - ) - if viewStore.state == nil { + Text("\(value.formatted(.number.precision(.fractionLength(1))))") + if value == store.temperatureOverride { Image(systemName: "checkmark") } } } - - Divider() - - ForEach(Array(stride(from: 0.0, through: 2.0, by: 0.1)), id: \.self) { value in - Button(action: { - viewStore.send(.temperatureOverrideSelected(value)) - }) { - HStack { - Text("\(value.formatted(.number.precision(.fractionLength(1))))") - if value == viewStore.state { - Image(systemName: "checkmark") - } - } - } - } } } } @@ -158,24 +159,22 @@ struct ChatContextMenu: View { @ViewBuilder var defaultScopes: some View { Menu("Default Scopes") { - WithViewStore(store, observe: \.defaultScopes) { viewStore in + Button(action: { + store.send(.resetDefaultScopesButtonTapped) + }) { + Text("Reset Default Scopes") + } + + Divider() + + ForEach(ChatService.Scope.allCases, id: \.rawValue) { value in Button(action: { - store.send(.resetDefaultScopesButtonTapped) + store.send(.toggleScope(value)) }) { - Text("Reset Default Scopes") - } - - Divider() - - ForEach(ChatService.Scope.allCases, id: \.rawValue) { value in - Button(action: { - viewStore.send(.toggleScope(value)) - }) { - HStack { - Text("@" + value.rawValue) - if viewStore.state.contains(value) { - Image(systemName: "checkmark") - } + HStack { + Text("@" + value.rawValue) + if store.defaultScopes.contains(value) { + Image(systemName: "checkmark") } } } diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index db14b5d3..ad2c6887 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -4,6 +4,7 @@ import ChatTab import CodableWrappers import Combine import ComposableArchitecture +import DebounceFunction import Foundation import OpenAIService import Preferences @@ -15,8 +16,9 @@ public class ChatGPTChatTab: ChatTab { public let service: ChatService let chat: StoreOf - let viewStore: ViewStoreOf private var cancellable = Set() + private var observer = NSObject() + private let updateContentDebounce = DebounceRunner(duration: 0.5) struct RestorableState: Codable { var history: [OpenAIService.ChatMessage] @@ -50,8 +52,8 @@ public class ChatGPTChatTab: ChatTab { } public func buildIcon() -> any View { - WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in - if viewStore.state { + WithPerceptionTracking { + if self.chat.isReceivingMessage { Image(systemName: "ellipsis.message") } else { Image(systemName: "message") @@ -60,7 +62,7 @@ public class ChatGPTChatTab: ChatTab { } public func buildMenu() -> any View { - ChatContextMenu(store: chat.scope(state: \.chatMenu, action: Chat.Action.chatMenu)) + ChatContextMenu(store: chat.scope(state: \.chatMenu, action: \.chatMenu)) } public func restorableState() async -> Data { @@ -74,10 +76,7 @@ public class ChatGPTChatTab: ChatTab { return (try? JSONEncoder().encode(state)) ?? Data() } - public static func restore( - from data: Data, - externalDependency: Void - ) async throws -> any ChatTabBuilder { + 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 @@ -89,12 +88,12 @@ public class ChatGPTChatTab: ChatTab { await tab.service.memory.mutateHistory { history in history = state.history } - tab.viewStore.send(.refresh) + tab.chat.send(.refresh) } return builder } - public static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] { + public static func chatBuilders() -> [ChatTabBuilder] { let customCommands = UserDefaults.shared.value(for: \.customCommands).compactMap { command in if case .customChat = command.feature { @@ -103,52 +102,87 @@ public class ChatGPTChatTab: ChatTab { return nil } - return [Builder(title: "New Chat", customCommand: nil)] + customCommands + 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)) - viewStore = .init(chat) + chat = .init(initialState: .init(), reducer: { Chat(service: service) }) super.init(store: store) } public func start() { - chatTabViewStore.send(.updateTitle("Chat")) + observer = .init() + cancellable = [] - chatTabViewStore.publisher.focusTrigger.removeDuplicates().sink { [weak self] _ in - Task { @MainActor [weak self] in - self?.viewStore.send(.focusOnTextField) - } - }.store(in: &cancellable) + chatTabStore.send(.updateTitle("Chat")) service.$systemPrompt.removeDuplicates().sink { [weak self] _ in Task { @MainActor [weak self] in - self?.chatTabViewStore.send(.tabContentUpdated) + self?.chatTabStore.send(.tabContentUpdated) } }.store(in: &cancellable) service.$extraSystemPrompt.removeDuplicates().sink { [weak self] _ in Task { @MainActor [weak self] in - self?.chatTabViewStore.send(.tabContentUpdated) + self?.chatTabStore.send(.tabContentUpdated) } }.store(in: &cancellable) - viewStore.publisher.map(\.title).removeDuplicates().sink { [weak self] title in - Task { @MainActor [weak self] in - self?.chatTabViewStore.send(.updateTitle(title)) + 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) + } } - }.store(in: &cancellable) + } - viewStore.publisher.removeDuplicates().debounce( - for: .milliseconds(500), - scheduler: DispatchQueue.main - ).sink { [weak self] _ in - Task { @MainActor [weak self] in - self?.chatTabViewStore.send(.tabContentUpdated) + 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)) + } } - }.store(in: &cancellable) + } + + 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 index db080dac..9210a05d 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -18,7 +18,7 @@ public struct ChatPanel: View { Divider() ChatPanelInputArea(chat: chat) } - .background(.clear) + .background(Color(nsColor: .windowBackgroundColor)) .onAppear { chat.send(.appear) } } } @@ -43,120 +43,129 @@ struct ChatPanelMessages: View { let chat: StoreOf @State var cancellable = Set() @State var isScrollToBottomButtonDisplayed = true - @State var isPinnedToBottom = 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 { - ScrollViewReader { proxy in - GeometryReader { listGeo in - List { - Group { - Spacer(minLength: 12) - - Instruction(chat: chat) - - ChatHistory(chat: chat) - .listItemTint(.clear) - - WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in - if viewStore.state { - Spacer(minLength: 12) - } + 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 + ) + }) } - - Spacer(minLength: 12) - .id(bottomID) - .onAppear { - proxy.scrollTo(bottomID, anchor: .bottom) - } - .task { - proxy.scrollTo(bottomID, anchor: .bottom) + .modify { view in + if #available(macOS 13.0, *) { + view + .listRowSeparator(.hidden) + .listSectionSeparator(.hidden) + } else { + view } - .background(GeometryReader { geo in - let offset = geo.frame(in: .named(scrollSpace)).minY - Color.clear.preference( - key: ScrollViewOffsetPreferenceKey.self, - value: offset - ) - }) + } } + .listStyle(.plain) + .listRowBackground(EmptyView()) .modify { view in if #available(macOS 13.0, *) { - view - .listRowSeparator(.hidden) - .listSectionSeparator(.hidden) + view.scrollContentBackground(.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() } - } - .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) { - WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in + .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in + scrollOffset = value + updatePinningState() + } + .overlay(alignment: .bottom) { StopRespondingButton(chat: chat) - .padding(.bottom, 8) - .opacity(viewStore.state ? 1 : 0) - .disabled(!viewStore.state) - .transformEffect(.init(translationX: 0, y: viewStore.state ? 0 : 20)) } - } - .overlay(alignment: .bottomTrailing) { - scrollToBottomButton(proxy: proxy) - } - .background { - PinToBottomHandler(chat: chat, pinnedToBottom: $isPinnedToBottom) { + .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 = [] + .onAppear { + trackScrollWheel() + } + .onDisappear { + cancellable.forEach { $0.cancel() } + cancellable = [] + } + .onChange(of: isEnabled) { isEnabled in + chat.send(.setIsEnabled(isEnabled)) + } } } func trackScrollWheel() { NSApplication.shared.publisher(for: \.currentEvent) - .filter { - if !isEnabled { return false } + .receive(on: DispatchQueue.main) + .filter { [chat] in + guard chat.withState(\.isEnabled) else { return false } return $0?.type == .scrollWheel } .compactMap { $0 } .sink { event in - guard isPinnedToBottom else { return } + guard chat.withState(\.isPinnedToBottom) else { return } let delta = event.deltaY let scrollUp = delta > 0 if scrollUp { - isPinnedToBottom = false + chat.send(.manuallyScrolledUp) } } .store(in: &cancellable) @@ -174,7 +183,7 @@ struct ChatPanelMessages: View { @ViewBuilder func scrollToBottomButton(proxy: ScrollViewProxy) -> some View { Button(action: { - isPinnedToBottom = true + chat.send(.scrollToBottomButtonTapped) withAnimation(.easeInOut(duration: 0.1)) { proxy.scrollTo(bottomID, anchor: .bottom) } @@ -197,43 +206,57 @@ struct ChatPanelMessages: View { .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 - @Binding var pinnedToBottom: Bool + let isBottomHidden: Bool let scrollToBottom: () -> Void @State var isInitialLoad = true - struct PinToBottomRelatedState: Equatable { - var isReceivingMessage: Bool - var lastMessage: DisplayedChatMessage? - } - var body: some View { - WithViewStore(chat, observe: { - PinToBottomRelatedState( - isReceivingMessage: $0.isReceivingMessage, - lastMessage: $0.history.last - ) - }) { viewStore in + WithPerceptionTracking { EmptyView() - .onChange(of: viewStore.state.isReceivingMessage) { isReceiving in + .onChange(of: chat.isReceivingMessage) { isReceiving in if isReceiving { - pinnedToBottom = true - scrollToBottom() + Task { + await Task.yield() + withAnimation(.easeInOut(duration: 0.1)) { + scrollToBottom() + } + } } } - .onChange(of: viewStore.state.lastMessage) { _ in - if pinnedToBottom || isInitialLoad { + .onChange(of: chat.history.last) { _ in + if chat.withState(\.isPinnedToBottom) || isInitialLoad { if isInitialLoad { isInitialLoad = false } Task { await Task.yield() - scrollToBottom() + 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() + } + } } } } @@ -243,67 +266,97 @@ struct ChatHistory: View { let chat: StoreOf var body: some View { - WithViewStore(chat, observe: \.history) { viewStore in - ForEach(viewStore.state, id: \.id) { message in - let text = message.text - - switch message.role { - case .user: - UserMessage(id: message.id, text: text, chat: chat) - .listRowInsets(EdgeInsets( - top: 0, - leading: -8, - bottom: 0, - trailing: -8 - )) - .padding(.vertical, 4) - case .assistant: - BotMessage( - id: message.id, - text: text, - 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() + WithPerceptionTracking { + ForEach(chat.history, id: \.id) { message in + WithPerceptionTracking { + ChatHistoryItem(chat: chat, message: message).id(message.id) } } } } } -private struct StopRespondingButton: View { +struct ChatHistoryItem: View { let chat: StoreOf + let message: DisplayedChatMessage var body: some View { - Button(action: { - chat.send(.stopRespondingButtonTapped) - }) { - HStack(spacing: 4) { - Image(systemName: "stop.fill") - Text("Stop Responding") + 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() } - .padding(8) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: r, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: r, style: .continuous) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } +} + +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 + )) } } - .buttonStyle(.borderless) - .frame(maxWidth: .infinity, alignment: .center) } } @@ -314,7 +367,7 @@ struct ChatPanelInputArea: View { var body: some View { HStack { clearButton - textEditor + InputAreaTextEditor(chat: chat, focusedField: $focusedField) } .padding(8) .background(.ultraThickMaterial) @@ -343,89 +396,86 @@ struct ChatPanelInputArea: View { .buttonStyle(.plain) } - @MainActor - var textEditor: some View { - HStack(spacing: 0) { - WithViewStore( - chat, - removeDuplicates: { - $0.typedMessage == $1.typedMessage && $0.focusedField == $1.focusedField + 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: []) } - ) { viewStore in - AutoresizingCustomTextEditor( - text: viewStore.$typedMessage, - font: .systemFont(ofSize: 14), - isEditable: true, - maxHeight: 400, - onSubmit: { viewStore.send(.sendButtonTapped) }, - completions: chatAutoCompletion - ) - .focused($focusedField, equals: .textField) - .bind(viewStore.$focusedField, to: $focusedField) - .padding(8) - .fixedSize(horizontal: false, vertical: true) - } + .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]) - WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in - Button(action: { - viewStore.send(.sendButtonTapped) - }) { - Image(systemName: "paperplane.fill") - .padding(8) + Button(action: { + focusedField.wrappedValue = .textField + }) { + EmptyView() + } + .keyboardShortcut("l", modifiers: [.command]) } - .buttonStyle(.plain) - .disabled(viewStore.state) - .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 = .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", + ] - 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 + 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 + } } } @@ -454,7 +504,7 @@ struct ChatPanel_Preview: PreviewProvider { subtitle: "Hi Hi Hi Hi", uri: "https://google.com", startLine: nil, - kind: .class + kind: .symbol(.class, uri: "https://google.com", startLine: nil, endLine: nil) ), ] ), @@ -512,7 +562,7 @@ struct ChatPanel_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true), - reducer: Chat(service: .init()) + reducer: { Chat(service: .init()) } )) .frame(width: 450, height: 1200) .colorScheme(.dark) @@ -522,8 +572,8 @@ struct ChatPanel_Preview: PreviewProvider { struct ChatPanel_EmptyChat_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( - initialState: .init(history: [], isReceivingMessage: false), - reducer: Chat(service: .init()) + initialState: .init(history: [DisplayedChatMessage](), isReceivingMessage: false), + reducer: { Chat(service: .init()) } )) .padding() .frame(width: 450, height: 600) @@ -531,34 +581,11 @@ struct ChatPanel_EmptyChat_Preview: PreviewProvider { } } -struct ChatCodeSyntaxHighlighter: CodeSyntaxHighlighter { - let brightMode: Bool - let font: NSFont - let colorChange: Color? - - init(brightMode: Bool, font: NSFont, colorChange: Color?) { - self.brightMode = brightMode - self.font = font - self.colorChange = colorChange - } - - func highlightCode(_ content: String, language: String?) -> Text { - let content = highlightedCodeBlock( - code: content, - language: language ?? "", - scenario: "chat", - brightMode: brightMode, - font: font - ) - return Text(AttributedString(content)) - } -} - 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()) + reducer: { Chat(service: .init()) } )) .padding() .frame(width: 450, height: 600) @@ -576,7 +603,7 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider { history: ChatPanel_Preview.history, isReceivingMessage: false ), - reducer: Chat(service: .init()) + reducer: { Chat(service: .init()) } ) ) .padding() @@ -589,7 +616,7 @@ 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()) + reducer: { Chat(service: .init()) } )) .padding() .frame(width: 450, height: 600) 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 index e654b72e..6c117c9a 100644 --- a/Core/Sources/ChatGPTChatTab/Styles.swift +++ b/Core/Sources/ChatGPTChatTab/Styles.swift @@ -57,7 +57,7 @@ extension View { HStack(alignment: .center) { Text(configuration.language ?? "code") .foregroundStyle(labelColor) - .font(.callout) + .font(.callout.bold()) .padding(.leading, 8) .lineLimit(1) Spacer() @@ -74,191 +74,6 @@ extension View { } } -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 text: String - - init(_ text: String) { - self.text = text - } - - var body: some View { - Markdown(text) - .textSelection(.enabled) - .markdownTheme(.custom( - fontSize: chatFontSize, - 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) - }() - )) - .markdownCodeSyntaxHighlighter( - ChatCodeSyntaxHighlighter( - brightMode: colorScheme != .dark, - font: chatCodeFont.value.nsFont, - colorChange: colorScheme == .dark - ? codeForegroundColorDark.value?.swiftUIColor - : codeForegroundColorLight.value?.swiftUIColor - ) - ) - } -} - -extension MarkdownUI.Theme { - static func custom( - fontSize: Double, - 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) - - if wrapCode { - configuration.label - .codeBlockLabelStyle() - .codeBlockStyle( - configuration, - backgroundColor: codeBlockBackgroundColor, - labelColor: codeBlockLabelColor - ) - } else { - ScrollView(.horizontal) { - configuration.label - .codeBlockLabelStyle() - } - .workaroundForVerticalScrollingBugInMacOS() - .codeBlockStyle( - configuration, - backgroundColor: codeBlockBackgroundColor, - labelColor: codeBlockLabelColor - ) - } - } - } - - 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)) - } - } - - 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) - } - } -} - final class VerticalScrollingFixHostingView: NSHostingView where Content: View { override func wantsForwardedScrollEvents(for axis: NSEvent.GestureAxis) -> Bool { return axis == .vertical diff --git a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift index 5d202678..bcd9a455 100644 --- a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift +++ b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift @@ -8,6 +8,7 @@ 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 @@ -43,7 +44,7 @@ struct BotMessage: View { } } - ThemedMarkdownText(text) + ThemedMarkdownText(markdownContent) } .frame(alignment: .trailing) .padding() @@ -89,41 +90,45 @@ struct ReferenceList: View { let chat: StoreOf var body: some View { - 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/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 index 35097d08..dba6bfbf 100644 --- a/Core/Sources/ChatGPTChatTab/Views/Instructions.swift +++ b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift @@ -7,28 +7,26 @@ struct Instruction: View { let chat: StoreOf var body: some View { - Group { - Markdown( + WithPerceptionTracking { + Group { + Markdown( """ You can use plugins to perform various tasks. - + | Plugin Name | Description | | --- | --- | - | `/run` | Runs a command under the project root | - | `/math` | Solves a math problem in natural language | - | `/search` | Searches on Bing and summarizes the results | + | `/shell` | Runs a command under the project root | | `/shortcut(name)` | Runs a shortcut from the Shortcuts.app, with the previous message as input | - | `/shortcutInput(name)` | Runs a shortcut and uses its result as a new message | - + To use plugins, you can prefix a message with `/pluginName`. """ - ) - .modifier(InstructionModifier()) - - Markdown( + ) + .modifier(InstructionModifier()) + + Markdown( """ You can use scopes to give the bot extra abilities. - + | Scope Name | Abilities | | --- | --- | | `@file` | Read the metadata of the editing file | @@ -36,29 +34,29 @@ struct Instruction: View { | `@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()) - - WithViewStore(chat, observe: \.chatMenu.defaultScopes) { viewStore in + ) + .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 viewStore.state.isEmpty { - return "No scope is enabled by default" - } else { - let scopes = viewStore.state.map(\.rawValue).sorted() - .joined(separator: ", ") - return "Default scopes: `\(scopes)`" - } - }()) - """ + """ + 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()) } 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 index f27e3ed4..edac231a 100644 --- a/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift +++ b/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift @@ -7,11 +7,12 @@ 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(text) + ThemedMarkdownText(markdownContent) .frame(alignment: .leading) .padding() .background { @@ -50,27 +51,30 @@ struct UserMessage: View { } #Preview { - UserMessage( + let text = #""" + Please buy me a coffee! + | Coffee | Milk | + |--------|------| + | Espresso | No | + | Latte | Yes | + ```swift + func foo() {} + ``` + ```objectivec + - (void)bar {} + ``` + """# + + return UserMessage( id: "A", - text: #""" - Please buy me a coffee! - | Coffee | Milk | - |--------|------| - | Espresso | No | - | Latte | Yes | - ```swift - func foo() {} - ``` - ```objectivec - - (void)bar {} - ``` - """#, + text: text, + markdownContent: .init(text), chat: .init( - initialState: .init(history: [], isReceivingMessage: false), - reducer: Chat(service: .init()) + initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false), + reducer: { Chat(service: .init()) } ) ) .padding() - .fixedSize(horizontal: /*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/, vertical: /*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/) + .fixedSize(horizontal: true, vertical: true) } diff --git a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift b/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift deleted file mode 100644 index 6e95f29d..00000000 --- a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift +++ /dev/null @@ -1,196 +0,0 @@ -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.memory.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.memory.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.memory.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.memory.mutateHistory { history in - history.append(.init( - role: .assistant, - content: "Sorry, I don't understand. Do you want me to run it?" - )) - } - } - } else { - await chatGPTService.memory.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.memory.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.memory.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 { - guard let reply = try await askChatGPT( - systemPrompt: systemPrompt, - question: "Question: \(question)", - temperature: 0 - ) else { return "No answer." } - - // parse inside text code block - let codeBlockRegex = try NSRegularExpression(pattern: "```text\n(.*?)\n```", options: []) - let codeBlockMatches = codeBlockRegex.matches( - in: reply, - options: [], - range: NSRange(reply.startIndex.. String? { - let mathExpression = NSExpression(format: expression) - let value = mathExpression.expressionValue(with: nil, context: nil) - Logger.service.debug(String(describing: value)) - return (value as? Int).flatMap(String.init) -} - diff --git a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift deleted file mode 100644 index 99cf6028..00000000 --- a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift +++ /dev/null @@ -1,98 +0,0 @@ -import ChatPlugin -import Foundation -import OpenAIService - -public actor SearchChatPlugin: ChatPlugin { - public static var command: String { "search" } - public nonisolated var name: String { "Search" } - - let chatGPTService: any ChatGPTServiceType - var isCancelled = false - weak var delegate: ChatPluginDelegate? - - public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) { - self.chatGPTService = chatGPTService - self.delegate = delegate - } - - public 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.appendMessage(.init(role: .user, content: originalMessage, summary: content)) - - do { - let (eventStream, cancelAgent) = try await search(content) - - var actions = [String]() - var finishedActions = Set() - var message = "" - - for try await event in eventStream { - guard !isCancelled else { - await cancelAgent() - break - } - switch event { - case let .startAction(content): - actions.append(content) - case let .endAction(content): - finishedActions.insert(content) - case let .answerToken(token): - message.append(token) - case let .finishAnswer(answer, links): - message = """ - \(answer) - - \(links.map { "- [\($0.title)](\($0.link))" }.joined(separator: "\n")) - """ - } - - await chatGPTService.memory.mutateHistory { history in - if history.last?.id == id { - history.removeLast() - } - - let actionString = actions.map { - "> \(finishedActions.contains($0) ? "✅" : "🔍") \($0)" - }.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) - } - - public func cancel() async { - isCancelled = true - } - - public func stopResponding() async { - isCancelled = true - } -} - diff --git a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchQuery.swift b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchQuery.swift deleted file mode 100644 index 18df59da..00000000 --- a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchQuery.swift +++ /dev/null @@ -1,103 +0,0 @@ -import BingSearchService -import Foundation -import LangChain -import OpenAIService - -enum SearchEvent { - case startAction(String) - case endAction(String) - case answerToken(String) - case finishAnswer(String, [(title: String, link: String)]) -} - -func search(_ query: String) async throws - -> (stream: AsyncThrowingStream, cancel: () async -> Void) -{ - let bingSearch = BingSearchService( - subscriptionKey: UserDefaults.shared.value(for: \.bingSearchSubscriptionKey), - searchURL: UserDefaults.shared.value(for: \.bingSearchEndpoint) - ) - - final class LinkStorage { - var links = [(title: String, link: String)]() - } - - let linkStorage = LinkStorage() - - let tools = [ - SimpleAgentTool( - name: "Search", - description: "useful for when you need to answer questions about current events. Don't search for the same thing twice", - run: { - linkStorage.links = [] - let result = try await bingSearch.search(query: $0, numberOfResult: 5) - let websites = result.webPages.value - - var string = "" - for (index, website) in websites.enumerated() { - string.append("[\(index)]:###\(website.snippet)###\n") - linkStorage.links.append((website.name, website.url)) - } - return string - } - ), - ] - - let chatModel = OpenAIChat( - configuration: UserPreferenceChatGPTConfiguration().overriding { $0.temperature = 0 }, - stream: true - ) - - let agentExecutor = AgentExecutor( - agent: ChatAgent( - chatModel: chatModel, - tools: tools, - preferredLanguage: UserDefaults.shared.value(for: \.chatGPTLanguage) - ), - tools: tools, - maxIteration: UserDefaults.shared.value(for: \.chatSearchPluginMaxIterations), - earlyStopHandleType: .generate - ) - - return (AsyncThrowingStream { continuation in - var accumulation: String = "" - var isGeneratingFinalAnswer = false - - let callbackManager = CallbackManager { manager in - manager.on(CallbackEvents.AgentActionDidStart.self) { - continuation.yield(.startAction("\($0.toolName): \($0.toolInput)")) - } - - manager.on(CallbackEvents.AgentActionDidEnd.self) { - continuation.yield(.endAction("\($0.toolName): \($0.toolInput)")) - } - - manager.on(CallbackEvents.LLMDidProduceNewToken.self) { - if isGeneratingFinalAnswer { - continuation.yield(.answerToken($0)) - return - } - accumulation.append($0) - if accumulation.hasSuffix("Final Answer: ") { - isGeneratingFinalAnswer = true - accumulation = "" - } - } - } - Task { - do { - let finalAnswer = try await agentExecutor.run( - query, - callbackManagers: [callbackManager] - ) - continuation.yield(.finishAnswer(finalAnswer, linkStorage.links)) - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - }, { - await agentExecutor.cancel() - }) -} - diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift deleted file mode 100644 index c6a9bddf..00000000 --- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift +++ /dev/null @@ -1,124 +0,0 @@ -import ChatPlugin -import Foundation -import OpenAIService -import Parsing -import Terminal - -public actor ShortcutChatPlugin: ChatPlugin { - public static var command: String { "shortcut" } - public nonisolated var name: String { "Shortcut" } - - let chatGPTService: any ChatGPTServiceType - var terminal: TerminalType = Terminal() - var isCancelled = false - weak var delegate: ChatPluginDelegate? - - public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) { - self.chatGPTService = chatGPTService - self.delegate = delegate - } - - public func send(content: String, originalMessage: String) async { - delegate?.pluginDidStart(self) - delegate?.pluginDidStartResponding(self) - - defer { - delegate?.pluginDidEndResponding(self) - delegate?.pluginDidEnd(self) - } - - let id = "\(Self.command)-\(UUID().uuidString)" - var message = ChatMessage(id: id, role: .assistant, content: "") - - var content = content[...] - let firstParenthesisParser = PrefixThrough("(") - let shortcutNameParser = PrefixUpTo(")") - - _ = try? firstParenthesisParser.parse(&content) - let shortcutName = try? shortcutNameParser.parse(&content) - _ = try? PrefixThrough(")").parse(&content) - - guard let shortcutName, !shortcutName.isEmpty else { - message.content = - "Please provide the shortcut name in format: `/\(Self.command)(shortcut name)`." - await chatGPTService.memory.appendMessage(message) - return - } - - var input = String(content).trimmingCharacters(in: .whitespacesAndNewlines) - if input.isEmpty { - // if no input detected, use the previous message as input - input = await chatGPTService.memory.history.last?.content ?? "" - await chatGPTService.memory.appendMessage(.init(role: .user, content: originalMessage)) - } else { - await chatGPTService.memory.appendMessage(.init(role: .user, content: originalMessage)) - } - - do { - if isCancelled { throw CancellationError() } - - 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)" - """ - - _ = try await terminal.runCommand( - shell, - arguments: ["-i", "-l", "-c", command], - currentDirectoryURL: nil, - environment: [:] - ) - - await Task.yield() - - if FileManager.default.fileExists(atPath: temporaryOutputFileURL.path) { - let data = try Data(contentsOf: temporaryOutputFileURL) - if let text = String(data: data, encoding: .utf8) { - message.content = text - if text.isEmpty { - message.content = "Finished" - } - await chatGPTService.memory.appendMessage(message) - } else { - message.content = """ - [View File](\(temporaryOutputFileURL)) - """ - await chatGPTService.memory.appendMessage(message) - } - - return - } - - message.content = "Finished" - await chatGPTService.memory.appendMessage(message) - } catch { - message.content = error.localizedDescription - if error.localizedDescription.isEmpty { - message.content = "Error" - } - await chatGPTService.memory.appendMessage(message) - } - } - - public func cancel() async { - isCancelled = true - await terminal.terminate() - } - - public func stopResponding() async { - isCancelled = true - await terminal.terminate() - } -} - diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift deleted file mode 100644 index 5616f072..00000000 --- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift +++ /dev/null @@ -1,126 +0,0 @@ -import ChatPlugin -import Foundation -import OpenAIService -import Parsing -import Terminal - -public actor ShortcutInputChatPlugin: ChatPlugin { - public static var command: String { "shortcutInput" } - public nonisolated var name: String { "Shortcut Input" } - - let chatGPTService: any ChatGPTServiceType - var terminal: TerminalType = Terminal() - var isCancelled = false - weak var delegate: ChatPluginDelegate? - - public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) { - self.chatGPTService = chatGPTService - self.delegate = delegate - } - - public func send(content: String, originalMessage: String) async { - delegate?.pluginDidStart(self) - delegate?.pluginDidStartResponding(self) - - defer { - delegate?.pluginDidEndResponding(self) - delegate?.pluginDidEnd(self) - } - - let id = "\(Self.command)-\(UUID().uuidString)" - - var content = content[...] - let firstParenthesisParser = PrefixThrough("(") - let shortcutNameParser = PrefixUpTo(")") - - _ = try? firstParenthesisParser.parse(&content) - let shortcutName = try? shortcutNameParser.parse(&content) - _ = try? PrefixThrough(")").parse(&content) - - guard let shortcutName, !shortcutName.isEmpty else { - let id = "\(Self.command)-\(UUID().uuidString)" - let reply = ChatMessage( - id: id, - role: .assistant, - content: "Please provide the shortcut name in format: `/\(Self.command)(shortcut name)`." - ) - await chatGPTService.memory.appendMessage(reply) - return - } - - var input = String(content).trimmingCharacters(in: .whitespacesAndNewlines) - if input.isEmpty { - // if no input detected, use the previous message as input - input = await chatGPTService.memory.history.last?.content ?? "" - } - - do { - if isCancelled { throw CancellationError() } - - 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)" - """ - - _ = try await terminal.runCommand( - shell, - arguments: ["-i", "-l", "-c", command], - currentDirectoryURL: nil, - environment: [:] - ) - - await Task.yield() - - if FileManager.default.fileExists(atPath: temporaryOutputFileURL.path) { - let data = try Data(contentsOf: temporaryOutputFileURL) - if let text = String(data: data, encoding: .utf8) { - if text.isEmpty { return } - let stream = try await chatGPTService.send(content: text, summary: nil) - do { - for try await _ in stream {} - } catch {} - } else { - let text = """ - [View File](\(temporaryOutputFileURL)) - """ - let stream = try await chatGPTService.send(content: text, summary: nil) - do { - for try await _ in stream {} - } catch {} - } - - return - } - } catch { - let id = "\(Self.command)-\(UUID().uuidString)" - let reply = ChatMessage( - id: id, - role: .assistant, - content: error.localizedDescription - ) - await chatGPTService.memory.appendMessage(reply) - } - } - - public func cancel() async { - isCancelled = true - await terminal.terminate() - } - - public func stopResponding() async { - isCancelled = true - await terminal.terminate() - } -} - diff --git a/Core/Sources/ChatService/AllPlugins.swift b/Core/Sources/ChatService/AllPlugins.swift index 108200a2..3f0b9de7 100644 --- a/Core/Sources/ChatService/AllPlugins.swift +++ b/Core/Sources/ChatService/AllPlugins.swift @@ -1,14 +1,144 @@ -import ChatPlugin -import MathChatPlugin -import SearchChatPlugin +import ChatBasic +import Foundation +import OpenAIService import ShortcutChatPlugin +import TerminalChatPlugin -let allPlugins: [ChatPlugin.Type] = [ - TerminalChatPlugin.self, - AITerminalChatPlugin.self, - MathChatPlugin.self, - SearchChatPlugin.self, - ShortcutChatPlugin.self, - ShortcutInputChatPlugin.self, +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 index dffab8f2..de8ca5bf 100644 --- a/Core/Sources/ChatService/ChatFunctionProvider.swift +++ b/Core/Sources/ChatService/ChatFunctionProvider.swift @@ -1,3 +1,4 @@ +import ChatBasic import Foundation import OpenAIService diff --git a/Core/Sources/ChatService/ChatPluginController.swift b/Core/Sources/ChatService/ChatPluginController.swift index 99a7c629..c1a6d973 100644 --- a/Core/Sources/ChatService/ChatPluginController.swift +++ b/Core/Sources/ChatService/ChatPluginController.swift @@ -1,24 +1,27 @@ -import ChatPlugin 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.lowercased()] = plugin } self.plugins = all } - convenience init(chatGPTService: any ChatGPTServiceType, plugins: ChatPlugin.Type...) { + convenience init( + chatGPTService: any LegacyChatGPTServiceType, + plugins: LegacyChatPlugin.Type... + ) { self.init(chatGPTService: chatGPTService, plugins: plugins) } @@ -94,11 +97,11 @@ final class ChatPluginController { return false } } - + func stopResponding() async { await runningPlugin?.stopResponding() } - + func cancel() async { await runningPlugin?.cancel() } @@ -106,26 +109,29 @@ final class ChatPluginController { // MARK: - ChatPluginDelegate -extension ChatPluginController: ChatPluginDelegate { - public func pluginDidStartResponding(_: ChatPlugin) { +extension ChatPluginController: LegacyChatPluginDelegate { + public func pluginDidStartResponding(_: LegacyChatPlugin) { chatService?.isReceivingMessage = true } - public func pluginDidEndResponding(_: ChatPlugin) { + 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 4bb74639..e1b0eb54 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -1,16 +1,17 @@ import ChatContextCollector -import ChatPlugin +import LegacyChatPlugin import Combine +import CustomCommandTemplateProcessor import Foundation import OpenAIService import Preferences public final class ChatService: ObservableObject { public typealias Scope = ChatContext.Scope - + public let memory: ContextAwareAutoManagedChatGPTMemory public let configuration: OverridingChatGPTConfiguration - public let chatGPTService: any ChatGPTServiceType + 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 @@ -22,7 +23,7 @@ public final class ChatService: ObservableObject { let pluginController: ChatPluginController var cancellable = Set() - init( + init( memory: ContextAwareAutoManagedChatGPTMemory, configuration: OverridingChatGPTConfiguration, chatGPTService: T @@ -53,7 +54,7 @@ public final class ChatService: ObservableObject { self.init( memory: memory, configuration: configuration, - chatGPTService: ChatGPTService( + chatGPTService: LegacyChatGPTService( memory: memory, configuration: extraConfiguration, functionProvider: memory.functionProvider @@ -91,7 +92,7 @@ public final class ChatService: ObservableObject { if UserDefaults.shared.value(for: \.enableWebScopeByDefaultInChatContext) { scopes.insert(.web) } - + defaultScopes = scopes } diff --git a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift index ac44d87c..32d65694 100644 --- a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift +++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift @@ -22,7 +22,8 @@ public final class ContextAwareAutoManagedChatGPTMemory: ChatGPTMemory { memory = AutoManagedChatGPTMemory( systemPrompt: "", configuration: configuration, - functionProvider: functionProvider + functionProvider: functionProvider, + maxNumberOfMessages: UserDefaults.shared.value(for: \.chatGPTMaxMessageCount) ) contextController = DynamicContextController( memory: memory, diff --git a/Core/Sources/ChatService/DynamicContextController.swift b/Core/Sources/ChatService/DynamicContextController.swift index c6adb9a4..11ae9753 100644 --- a/Core/Sources/ChatService/DynamicContextController.swift +++ b/Core/Sources/ChatService/DynamicContextController.swift @@ -60,7 +60,7 @@ final class DynamicContextController { let idIndexMap = chatModels.enumerated().reduce(into: [String: Int]()) { $0[$1.element.id] = $1.offset } - return ids.sorted(by: { + return ids.filter { !$0.isEmpty }.sorted(by: { let lhs = idIndexMap[$0] ?? Int.max let rhs = idIndexMap[$1] ?? Int.max return lhs < rhs diff --git a/Core/Sources/Client/AsyncXPCService.swift b/Core/Sources/Client/AsyncXPCService.swift deleted file mode 100644 index 36729d5f..00000000 --- a/Core/Sources/Client/AsyncXPCService.swift +++ /dev/null @@ -1,248 +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 getPromptToCodeAcceptedCode(editorContent: EditorContent) async throws - -> UpdatedContent? - { - try await suggestionRequest( - connection, - editorContent, - { $0.getPromptToCodeAcceptedCode } - ) - } - - 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) } } - ) - } - - public func postNotification(name: String) async throws { - try await withXPCServiceConnected(connection: connection) { - service, continuation in - service.postNotification(name: name) { - continuation.resume(()) - } - } - } - - public func send( - requestBody: M - ) async throws -> M.ResponseBody { - try await withXPCServiceConnected(connection: connection) { service, continuation in - do { - let requestBodyData = try JSONEncoder().encode(requestBody) - service.send(endpoint: M.endpoint, requestBody: requestBodyData) { data, error in - if let error { - continuation.reject(error) - } else { - do { - guard let data = data else { - continuation.reject(NoDataError()) - return - } - let responseBody = try JSONDecoder().decode( - M.ResponseBody.self, - from: data - ) - continuation.resume(responseBody) - } catch { - continuation.reject(error) - } - } - } - } catch { - continuation.reject(error) - } - } - } -} - -struct NoDataError: Error {} - -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/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift index 48bd8632..bc6c910e 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift @@ -3,51 +3,53 @@ import SharedUIComponents import SwiftUI struct APIKeyManagementView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - 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() + 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) } - .buttonStyle(.plain) - } - .background(Color(nsColor: .separatorColor)) + .background(Color(nsColor: .separatorColor)) - List { - WithViewStore(store, observe: { $0.availableAPIKeyNames }) { viewStore in - ForEach(viewStore.state, id: \.self) { name in - HStack { - Text(name) - .contextMenu { - Button("Remove") { - viewStore.send(.deleteButtonClicked(name: name)) + List { + ForEach(store.availableAPIKeyNames, id: \.self) { name in + WithPerceptionTracking { + HStack { + Text(name) + .contextMenu { + Button("Remove") { + store.send(.deleteButtonClicked(name: name)) + } } - } - Spacer() + Spacer() - Button(action: { - viewStore.send(.deleteButtonClicked(name: name)) - }) { - Image(systemName: "trash.fill") - .foregroundStyle(.secondary) + Button(action: { + store.send(.deleteButtonClicked(name: name)) + }) { + Image(systemName: "trash.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } } .modify { view in @@ -58,11 +60,9 @@ struct APIKeyManagementView: View { } } } - } - .removeBackground() - .overlay { - WithViewStore(store, observe: { $0.availableAPIKeyNames }) { viewStore in - if viewStore.state.isEmpty { + .removeBackground() + .overlay { + if store.availableAPIKeyNames.isEmpty { Text(""" Empty Add a new key by clicking the add button @@ -72,52 +72,53 @@ struct APIKeyManagementView: View { } } } - } - .focusable(false) - .frame(width: 300, height: 400) - .background(.thickMaterial) - .onAppear { - store.send(.appear) - } - .sheet(store: store.scope( - state: \.$apiKeySubmission, - action: APIKeyManagement.Action.apiKeySubmission - )) { store in - APIKeySubmissionView(store: store) - .frame(minWidth: 400) + .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 { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - ScrollView { - VStack(spacing: 0) { - Form { - WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in - TextField("Name", text: viewStore.$name) - } - WithViewStore(store, removeDuplicates: { $0.key == $1.key }) { viewStore in - SecureField("Key", text: viewStore.$key) + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + TextField("Name", text: $store.name) + SecureField("Key", text: $store.key) } - }.padding() + .padding() - Divider() + Divider() - HStack { - Spacer() + HStack { + Spacer() - Button("Cancel") { store.send(.cancelButtonClicked) } - .keyboardShortcut(.cancelAction) + Button("Cancel") { store.send(.cancelButtonClicked) } + .keyboardShortcut(.cancelAction) - Button("Save", action: { store.send(.saveButtonClicked) }) - .keyboardShortcut(.defaultAction) - }.padding() + Button("Save", action: { store.send(.saveButtonClicked) }) + .keyboardShortcut(.defaultAction) + }.padding() + } } + .textFieldStyle(.roundedBorder) } - .textFieldStyle(.roundedBorder) } } @@ -128,7 +129,7 @@ class APIKeyManagementView_Preview: PreviewProvider { initialState: .init( availableAPIKeyNames: ["test1", "test2"] ), - reducer: APIKeyManagement() + reducer: { APIKeyManagement() } ) ) } @@ -139,7 +140,7 @@ class APIKeySubmissionView_Preview: PreviewProvider { APIKeySubmissionView( store: .init( initialState: .init(), - reducer: APIKeySubmission() + reducer: { APIKeySubmission() } ) ) } diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift index 3ff3188e..2756ce1e 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift @@ -1,10 +1,12 @@ import ComposableArchitecture import Foundation -struct APIKeyManagement: ReducerProtocol { +@Reducer +struct APIKeyManagement { + @ObservableState struct State: Equatable { var availableAPIKeyNames: [String] = [] - @PresentationState var apiKeySubmission: APIKeySubmission.State? + @Presents var apiKeySubmission: APIKeySubmission.State? } enum Action: Equatable { @@ -20,7 +22,7 @@ struct APIKeyManagement: ReducerProtocol { @Dependency(\.toast) var toast @Dependency(\.apiKeyKeychain) var keychain - var body: some ReducerProtocol { + var body: some ReducerOf { Reduce { state, action in switch action { case .appear: @@ -72,7 +74,7 @@ struct APIKeyManagement: ReducerProtocol { return .none } } - .ifLet(\.$apiKeySubmission, action: /Action.apiKeySubmission) { + .ifLet(\.$apiKeySubmission, action: \.apiKeySubmission) { APIKeySubmission() } } diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift index a18e0a4c..57e853d4 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift @@ -2,26 +2,27 @@ import ComposableArchitecture import SwiftUI struct APIKeyPicker: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - WithViewStore(store) { viewStore in + WithPerceptionTracking { HStack { Picker( - selection: viewStore.$apiKeyName, + selection: $store.apiKeyName, content: { Text("No API Key").tag("") - if viewStore.state.availableAPIKeyNames.isEmpty { + if store.availableAPIKeyNames.isEmpty { Text("No API key found, please add a new one →") } - - if !viewStore.state.availableAPIKeyNames.contains(viewStore.state.apiKeyName), - !viewStore.state.apiKeyName.isEmpty { - Text("Key not found: \(viewStore.state.apiKeyName)") - .tag(viewStore.state.apiKeyName) + + if !store.availableAPIKeyNames.contains(store.apiKeyName), + !store.apiKeyName.isEmpty + { + Text("Key not found: \(store.apiKeyName)") + .tag(store.apiKeyName) } - - ForEach(viewStore.state.availableAPIKeyNames, id: \.self) { name in + + ForEach(store.availableAPIKeyNames, id: \.self) { name in Text(name).tag(name) } @@ -32,15 +33,17 @@ struct APIKeyPicker: View { Button(action: { store.send(.manageAPIKeysButtonClicked) }) { Text(Image(systemName: "key")) } - }.sheet(isPresented: viewStore.$isAPIKeyManagementPresented) { - APIKeyManagementView(store: store.scope( - state: \.apiKeyManagement, - action: APIKeySelection.Action.apiKeyManagement - )) + }.sheet(isPresented: $store.isAPIKeyManagementPresented) { + WithPerceptionTracking { + APIKeyManagementView(store: store.scope( + state: \.apiKeyManagement, + action: \.apiKeyManagement + )) + } + } + .onAppear { + store.send(.appear) } - } - .onAppear { - store.send(.appear) } } } diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift index 75e2d77c..47e8b33b 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift @@ -2,14 +2,16 @@ import Foundation import SwiftUI import ComposableArchitecture -struct APIKeySelection: ReducerProtocol { +@Reducer +struct APIKeySelection { + @ObservableState struct State: Equatable { - @BindingState var apiKeyName: String = "" + var apiKeyName: String = "" var availableAPIKeyNames: [String] { apiKeyManagement.availableAPIKeyNames } var apiKeyManagement: APIKeyManagement.State = .init() - @BindingState var isAPIKeyManagementPresented: Bool = false + var isAPIKeyManagementPresented: Bool = false } enum Action: Equatable, BindableAction { @@ -23,10 +25,10 @@ struct APIKeySelection: ReducerProtocol { @Dependency(\.toast) var toast @Dependency(\.apiKeyKeychain) var keychain - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() - Scope(state: \.apiKeyManagement, action: /Action.apiKeyManagement) { + Scope(state: \.apiKeyManagement, action: \.apiKeyManagement) { APIKeyManagement() } diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift index 64f16b7d..8fe390ee 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift @@ -1,10 +1,12 @@ import ComposableArchitecture import Foundation -struct APIKeySubmission: ReducerProtocol { +@Reducer +struct APIKeySubmission { + @ObservableState struct State: Equatable { - @BindingState var name: String = "" - @BindingState var key: String = "" + var name: String = "" + var key: String = "" } enum Action: Equatable, BindableAction { @@ -22,7 +24,7 @@ struct APIKeySubmission: ReducerProtocol { case keyIsEmpty } - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() Reduce { state, action in diff --git a/Core/Sources/HostApp/AccountSettings/BingSearchView.swift b/Core/Sources/HostApp/AccountSettings/BingSearchView.swift deleted file mode 100644 index 408dbd55..00000000 --- a/Core/Sources/HostApp/AccountSettings/BingSearchView.swift +++ /dev/null @@ -1,51 +0,0 @@ -import AppKit -import Client -import OpenAIService -import Preferences -import SuggestionModel -import SwiftUI - -final class BingSearchViewSettings: ObservableObject { - @AppStorage(\.bingSearchSubscriptionKey) var bingSearchSubscriptionKey: String - @AppStorage(\.bingSearchEndpoint) var bingSearchEndpoint: String - init() {} -} - -struct BingSearchView: View { - @Environment(\.openURL) var openURL - @StateObject var settings = BingSearchViewSettings() - - var body: some View { - Form { - Button(action: { - let url = URL(string: "https://www.microsoft.com/bing/apis/bing-web-search-api")! - openURL(url) - }) { - Text("Apply for Subscription Key for Free") - } - - SecureField(text: $settings.bingSearchSubscriptionKey, prompt: Text("")) { - Text("Bing Search Subscription Key") - } - .textFieldStyle(.roundedBorder) - - TextField( - text: $settings.bingSearchEndpoint, - prompt: Text("https://api.bing.microsoft.com/***") - ) { - Text("Bing Search Endpoint") - }.textFieldStyle(.roundedBorder) - } - } -} - -struct BingSearchView_Previews: PreviewProvider { - static var previews: some View { - VStack(alignment: .leading, spacing: 8) { - BingSearchView() - } - .frame(height: 800) - .padding(.all, 8) - } -} - diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index 62eec368..f0c673e5 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -7,16 +7,18 @@ import Preferences import SwiftUI import Toast -struct ChatModelEdit: ReducerProtocol { +@Reducer +struct ChatModelEdit { + @ObservableState struct State: Equatable, Identifiable { var id: String - @BindingState var name: String - @BindingState var format: ChatModel.Format - @BindingState var maxTokens: Int = 4000 - @BindingState var supportsFunctionCalling: Bool = true - @BindingState var modelName: String = "" - @BindingState var ollamaKeepAlive: String = "" - @BindingState var apiVersion: 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 } @@ -26,6 +28,14 @@ struct ChatModelEdit: ReducerProtocol { 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 { @@ -38,10 +48,44 @@ struct ChatModelEdit: ReducerProtocol { 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 { @@ -51,14 +95,14 @@ struct ChatModelEdit: ReducerProtocol { @Dependency(\.apiKeyKeychain) var keychain - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() - Scope(state: \.apiKeySelection, action: /Action.apiKeySelection) { + Scope(state: \.apiKeySelection, action: \.apiKeySelection) { APIKeySelection() } - Scope(state: \.baseURLSelection, action: /Action.baseURLSelection) { + Scope(state: \.baseURLSelection, action: \.baseURLSelection) { BaseURLSelection() } @@ -82,21 +126,33 @@ struct ChatModelEdit: ReducerProtocol { let model = ChatModel(state: state) return .run { send in do { - let service = ChatGPTService( - configuration: UserPreferenceChatGPTConfiguration() - .overriding { - $0.model = model - } - ) - let reply = try await service - .sendAndWait(content: "Respond with \"Test succeeded\"") - await send(.testSucceeded(reply ?? "No Message")) - let stream = try await service - .send(content: "Respond with \"Stream response is working\"") - var streamReply = "" - for try await chunk in stream { - streamReply += chunk + 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)) @@ -145,24 +201,66 @@ struct ChatModelEdit: ReducerProtocol { 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): + case .binding(\.format): return .run { send in await send(.refreshAvailableModelNames) await send(.checkSuggestedMaxTokens) } - case .binding(\.$modelName): + case .binding(\.modelName): return .run { send in await send(.checkSuggestedMaxTokens) } @@ -174,26 +272,6 @@ struct ChatModelEdit: ReducerProtocol { } } -extension ChatModelEdit.State { - init(model: ChatModel) { - self.init( - id: model.id, - name: model.name, - format: model.format, - maxTokens: model.info.maxTokens, - supportsFunctionCalling: model.info.supportsFunctionCalling, - modelName: model.info.modelName, - ollamaKeepAlive: model.info.ollamaInfo.keepAlive, - apiVersion: model.info.googleGenerativeAIInfo.apiVersion, - apiKeySelection: .init( - apiKeyName: model.info.apiKeyName, - apiKeyManagement: .init(availableAPIKeyNames: [model.info.apiKeyName]) - ), - baseURLSelection: .init(baseURL: model.info.baseURL, isFullURL: model.info.isFullURL) - ) - } -} - extension ChatModel { init(state: ChatModelEdit.State) { self.init( @@ -209,15 +287,56 @@ extension ChatModel { switch state.format { case .googleAI, .ollama, .claude: return false - case .azureOpenAI, .openAI, .openAICompatible: + case .azureOpenAI, .openAI, .openAICompatible, .gitHubCopilot: return state.supportsFunctionCalling } }(), - modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines), + 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) + 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 index 2fe21715..d16b7556 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -6,445 +6,621 @@ import SwiftUI @MainActor struct ChatModelEditView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - ScrollView { - VStack(spacing: 0) { - Form { - nameTextField - formatPicker - - WithViewStore(store, observe: { $0.format }) { viewStore in - switch viewStore.state { + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + NameTextField(store: store) + FormatPicker(store: store) + + switch store.format { case .openAI: - openAI + OpenAIForm(store: store) case .azureOpenAI: - azureOpenAI + AzureOpenAIForm(store: store) case .openAICompatible: - openAICompatible + OpenAICompatibleForm(store: store) case .googleAI: - googleAI + GoogleAIForm(store: store) case .ollama: - ollama + OllamaForm(store: store) case .claude: - claude + ClaudeForm(store: store) + case .gitHubCopilot: + GitHubCopilotForm(store: store) } } - } - .padding() + .padding() - Divider() + Divider() - HStack { - WithViewStore(store, observe: { $0.isTesting }) { viewStore in + HStack { HStack(spacing: 8) { Button("Test") { store.send(.testButtonClicked) } - .disabled(viewStore.state) + .disabled(store.isTesting) - if viewStore.state { + if store.isTesting { ProgressView() .controlSize(.small) } } - } - Spacer() - - Button("Cancel") { - store.send(.cancelButtonClicked) - } - .keyboardShortcut(.cancelAction) + 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") + Button(action: { store.send(.saveButtonClicked) }) { + Text("Save") + } + .keyboardShortcut(.defaultAction) } - .keyboardShortcut(.defaultAction) + .padding() } - .padding() } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + .fixedSize(horizontal: false, vertical: true) + .handleToast(namespace: "ChatModelEdit") } - .textFieldStyle(.roundedBorder) - .onAppear { - store.send(.appear) - } - .fixedSize(horizontal: false, vertical: true) - .handleToast(namespace: "ChatModelEdit") } - var nameTextField: some View { - WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in - TextField("Name", text: viewStore.$name) + struct NameTextField: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + TextField("Name", text: $store.name) + } } } - var formatPicker: some View { - WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in - Picker( - selection: viewStore.$format, - content: { - ForEach( - ChatModel.Format.allCases, - id: \.rawValue - ) { format in - switch format { - case .openAI: - Text("OpenAI").tag(format) - case .azureOpenAI: - Text("Azure OpenAI").tag(format) - case .openAICompatible: - Text("OpenAI Compatible").tag(format) - case .googleAI: - Text("Google Generative AI").tag(format) - case .ollama: - Text("Ollama").tag(format) - case .claude: - Text("Claude").tag(format) + 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(.segmented) + }, + label: { Text("Format") } + ) + .pickerStyle(.menu) + } } } - func baseURLTextField( - title: String = "Base URL", - prompt: Text?, - @ViewBuilder trailingContent: @escaping () -> V - ) -> some View { - BaseURLPicker( - title: title, - prompt: prompt, - store: store.scope( - state: \.baseURLSelection, - action: ChatModelEdit.Action.baseURLSelection - ), - trailingContent: trailingContent - ) + 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 + ) + } + } } - func baseURLTextField( - title: String = "Base URL", - prompt: Text? - ) -> some View { - baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() }) - } + struct SupportsFunctionCallingToggle: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + Toggle( + "Supports Function Calling", + isOn: $store.supportsFunctionCalling + ) - var supportsFunctionCallingToggle: some View { - WithViewStore( - store, - removeDuplicates: { $0.supportsFunctionCalling == $1.supportsFunctionCalling } - ) { viewStore in - Toggle( - "Supports Function Calling", - isOn: viewStore.$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() + 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: Equatable { - @BindingViewState var maxTokens: Int - var suggestedMaxTokens: Int? - } + struct MaxTokensTextField: View { + @Perception.Bindable var store: StoreOf - var maxTokensTextField: some View { - WithViewStore( - store, - observe: { - MaxTokensTextField( - maxTokens: $0.$maxTokens, - suggestedMaxTokens: $0.suggestedMaxTokens - ) - } - ) { viewStore in - HStack { - let textFieldBinding = Binding( - get: { String(viewStore.state.maxTokens) }, - set: { - if let selectionMaxToken = Int($0) { - viewStore.$maxTokens.wrappedValue = selectionMaxToken - } else { - viewStore.$maxTokens.wrappedValue = 0 + 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: viewStore.$maxTokens, - in: 0...Int.max, - step: 100 - ) { - EmptyView() - } - } - .foregroundColor({ - guard let max = viewStore.state.suggestedMaxTokens else { - return .primary + TextField(text: textFieldBinding) { + Text("Context Window") + .multilineTextAlignment(.trailing) } - if viewStore.state.maxTokens > max { - return .red + .overlay(alignment: .trailing) { + Stepper( + value: $store.maxTokens, + in: 0...Int.max, + step: 100 + ) { + EmptyView() + } } - return .primary - }() as Color) + .foregroundColor({ + guard let max = store.suggestedMaxTokens else { + return .primary + } + if store.maxTokens > max { + return .red + } + return .primary + }() as Color) - if let max = viewStore.state.suggestedMaxTokens { - Text("Max: \(max)") + if let max = store.suggestedMaxTokens { + Text("Max: \(max)") + } } } } } - struct APIKeyState: Equatable { - @BindingViewState var apiKeyName: String - var availableAPIKeys: [String] + struct ApiKeyNamePicker: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + } + } } - @ViewBuilder - var apiKeyNamePicker: some View { - APIKeyPicker(store: store.scope( - state: \.apiKeySelection, - action: ChatModelEdit.Action.apiKeySelection - )) - } + struct CustomBodyEdit: View { + @Perception.Bindable var store: StoreOf + @State private var isEditing = false + @Dependency(\.namespacedToast) var toast - @ViewBuilder - var openAI: some View { - baseURLTextField(prompt: Text("https://api.openai.com")) { - Text("/v1/chat/completions") - } - apiKeyNamePicker - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - .overlay(alignment: .trailing) { - Picker( - "", - selection: viewStore.$modelName, - content: { - if ChatGPTModel(rawValue: viewStore.state.modelName) == nil { - Text("Custom Model").tag(viewStore.state.modelName) + 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 } - ForEach(ChatGPTModel.allCases, id: \.self) { model in - Text(model.rawValue).tag(model.rawValue) + guard let _ = try? JSONSerialization + .jsonObject(with: store.customBody.data(using: .utf8) ?? Data()) + else { + toast("Invalid JSON object", .error, "CustomBodyEdit") + return } + isEditing = false + }) { + Text("Done") } - ) - .frame(width: 20) + .keyboardShortcut(.defaultAction) + } + .padding() + .frame(width: 600, height: 500) + .background(Color(nsColor: .windowBackgroundColor)) } + } } + } - maxTokensTextField - supportsFunctionCallingToggle - - 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)" - ) + struct CustomHeaderEdit: View { + @Perception.Bindable var store: StoreOf + @State private var isEditing = false - 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." - ) + var body: some View { + Button("Custom Headers") { + isEditing = true + } + .sheet(isPresented: $isEditing) { + WithPerceptionTracking { + CustomHeaderSettingsView(headers: $store.customHeaders) + } + } } - .padding(.vertical) } - @ViewBuilder - var azureOpenAI: some View { - baseURLTextField(prompt: Text("https://xxxx.openai.azure.com")) - apiKeyNamePicker + 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) + } - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Deployment Name", text: viewStore.$modelName) - } + MaxTokensTextField(store: store) + SupportsFunctionCallingToggle(store: store) - maxTokensTextField - supportsFunctionCallingToggle - } + TextField(text: $store.openAIOrganizationID, prompt: Text("Optional")) { + Text("Organization ID") + } - @ViewBuilder - var openAICompatible: some View { - WithViewStore(store.scope( - state: \.baseURLSelection, - action: ChatModelEdit.Action.baseURLSelection - ), removeDuplicates: { $0.isFullURL != $1.isFullURL }) { viewStore in - Picker( - selection: viewStore.$isFullURL, - content: { - Text("Base URL").tag(false) - Text("Full URL").tag(true) - }, - label: { Text("URL") } - ) - .pickerStyle(.segmented) - } + TextField(text: $store.openAIProjectID, prompt: Text("Optional")) { + Text("Project ID") + } + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } - WithViewStore(store, observe: \.isFullURL) { viewStore in - baseURLTextField( - title: "", - prompt: viewStore.state - ? Text("https://api.openai.com/v1/chat/completions") - : Text("https://api.openai.com") - ) { - if !viewStore.state { - Text("/v1/chat/completions") + 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) } } + } - apiKeyNamePicker + 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) - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - } + TextField("Deployment Name", text: $store.modelName) - maxTokensTextField - supportsFunctionCallingToggle + MaxTokensTextField(store: store) + SupportsFunctionCallingToggle(store: store) + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } + } + } } - @ViewBuilder - var googleAI: some View { - baseURLTextField(prompt: Text("https://generativelanguage.googleapis.com")) { - Text("/v1") - } + 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 - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - .overlay(alignment: .trailing) { - Picker( - "", - selection: viewStore.$modelName, - content: { - if GoogleGenerativeAIModel(rawValue: viewStore.state.modelName) == nil { - Text("Custom Model").tag(viewStore.state.modelName) - } - ForEach(GoogleGenerativeAIModel.allCases, id: \.self) { model in - Text(model.rawValue).tag(model.rawValue) - } - } - ) - .frame(width: 20) + 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") } - } - maxTokensTextField + Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) { + Text("Support multi-part message content") + } - WithViewStore(store, removeDuplicates: { $0.apiVersion == $1.apiVersion }) { viewStore in - TextField("API Version", text: viewStore.$apiVersion, prompt: Text("v1")) + Toggle(isOn: $store.requiresBeginWithUserMessage) { + Text("Requires the first message to be from the user") + } + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } + } } } - @ViewBuilder - var ollama: some View { - baseURLTextField(prompt: Text("http://127.0.0.1:11434")) { - Text("/api/chat") - } + struct GoogleAIForm: View { + @Perception.Bindable var store: StoreOf - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - } + 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 + MaxTokensTextField(store: store) - WithViewStore( - store, - removeDuplicates: { $0.ollamaKeepAlive == $1.ollamaKeepAlive } - ) { viewStore in - TextField(text: viewStore.$ollamaKeepAlive, prompt: Text("Default Value")) { - Text("Keep Alive") + TextField("API Version", text: $store.apiVersion, prompt: Text("v1")) + + 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)." - ) + 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) + } } - .padding(.vertical) } - @ViewBuilder - var claude: some View { - baseURLTextField(prompt: Text("https://api.anthropic.com")) { - Text("/v1/messages") - } + 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 - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - .overlay(alignment: .trailing) { - Picker( - "", - selection: viewStore.$modelName, - content: { - if ClaudeChatCompletionsService - .KnownModel(rawValue: viewStore.state.modelName) == nil - { - Text("Custom Model").tag(viewStore.state.modelName) + 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) + } } - 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)." ) - .frame(width: 20) } + .padding(.vertical) + } } + } + + struct GitHubCopilotForm: View { + @Perception.Bindable var store: StoreOf - maxTokensTextField + 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") + } - VStack(alignment: .leading, spacing: 8) { - Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( - " For more details, please visit [https://anthropic.com](https://anthropic.com)." - ) + 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) + } } - .padding(.vertical) } } #Preview("OpenAI") { ChatModelEditView( store: .init( - initialState: .init(model: ChatModel( + initialState: ChatModel( id: "3", name: "Test Model 3", format: .openAI, @@ -455,8 +631,8 @@ struct ChatModelEditView: View { supportsFunctionCalling: false, modelName: "gpt-3.5-turbo" ) - )), - reducer: ChatModelEdit() + ).toState(), + reducer: { ChatModelEdit() } ) ) } @@ -464,7 +640,7 @@ struct ChatModelEditView: View { #Preview("OpenAI Compatible") { ChatModelEditView( store: .init( - initialState: .init(model: ChatModel( + initialState: ChatModel( id: "3", name: "Test Model 3", format: .openAICompatible, @@ -476,8 +652,8 @@ struct ChatModelEditView: View { supportsFunctionCalling: false, modelName: "gpt-3.5-turbo" ) - )), - reducer: ChatModelEdit() + ).toState(), + reducer: { ChatModelEdit() } ) ) } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift index 8fbe3a52..64eadd57 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift @@ -13,6 +13,7 @@ extension ChatModel: ManageableAIModel { case .googleAI: return "Google Generative AI" case .ollama: return "Ollama" case .claude: return "Claude" + case .gitHubCopilot: return "GitHub Copilot" } } @@ -37,13 +38,15 @@ extension ChatModel: ManageableAIModel { } } +@Reducer struct ChatModelManagement: AIModelManagement { typealias Model = ChatModel + @ObservableState struct State: Equatable, AIModelManagementState { typealias Model = ChatModel var models: IdentifiedArrayOf = [] - @PresentationState var editingModel: ChatModelEdit.State? + @Presents var editingModel: ChatModelEdit.State? var selectedModelId: String? { editingModel?.id } } @@ -61,7 +64,7 @@ struct ChatModelManagement: AIModelManagement { @Dependency(\.toast) var toast @Dependency(\.userDefaults) var userDefaults - var body: some ReducerProtocol { + var body: some ReducerOf { Reduce { state, action in switch action { case .appear: @@ -89,7 +92,7 @@ struct ChatModelManagement: AIModelManagement { case let .selectModel(id): guard let model = state.models[id: id] else { return .none } - state.editingModel = .init(model: model) + state.editingModel = model.toState() return .none case let .duplicateModel(id): @@ -134,7 +137,7 @@ struct ChatModelManagement: AIModelManagement { case .chatModelItem: return .none } - }.ifLet(\.$editingModel, action: /Action.chatModelItem) { + }.ifLet(\.$editingModel, action: \.chatModelItem) { ChatModelEdit() } } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift index 6101de58..e81b4a97 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift @@ -3,17 +3,19 @@ import ComposableArchitecture import SwiftUI struct ChatModelManagementView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - AIModelManagementView(store: store) - .sheet(store: store.scope( - state: \.$editingModel, - action: ChatModelManagement.Action.chatModelItem - )) { store in - ChatModelEditView(store: store) - .frame(width: 800) - } + WithPerceptionTracking { + AIModelManagementView(store: store) + .sheet(item: $store.scope( + state: \.editingModel, + action: \.chatModelItem + )) { store in + ChatModelEditView(store: store) + .frame(width: 800) + } + } } } @@ -62,23 +64,22 @@ class ChatModelManagementView_Previews: PreviewProvider { ) ), ]), - editingModel: .init( - model: 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() + reducer: { ChatModelManagement() } ) ) } } + diff --git a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift index f6e8ee26..e60af2a8 100644 --- a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift +++ b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift @@ -14,10 +14,14 @@ struct CodeiumView: View { @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( @@ -56,7 +60,7 @@ struct CodeiumView: View { func refreshInstallationStatus() { Task { @MainActor in - installationStatus = installationManager.checkInstallation() + installationStatus = await installationManager.checkInstallation() } } @@ -147,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 @@ -202,6 +206,12 @@ struct CodeiumView: View { } } } + + SubSection(title: Text("Indexing")) { + Form { + Toggle("Enable Indexing", isOn: $viewModel.indexEnabled) + } + } SubSection(title: Text("Enterprise")) { Form { @@ -313,7 +323,7 @@ struct CodeiumView_Previews: PreviewProvider { CodeiumView(viewModel: TestViewModel( isSignedIn: true, - installationStatus: .outdated(current: "1.2.9", latest: "1.3.0"), + installationStatus: .outdated(current: "1.2.9", latest: "1.3.0", mandatory: true), installationStep: .downloading )) 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/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift index 45ae25fd..f057be21 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift @@ -1,20 +1,23 @@ import AIModel -import Toast import ComposableArchitecture import Dependencies import Keychain import OpenAIService import Preferences import SwiftUI +import Toast -struct EmbeddingModelEdit: ReducerProtocol { +@Reducer +struct EmbeddingModelEdit { + @ObservableState struct State: Equatable, Identifiable { var id: String - @BindingState var name: String - @BindingState var format: EmbeddingModel.Format - @BindingState var maxTokens: Int = 8191 - @BindingState var modelName: String = "" - @BindingState var ollamaKeepAlive: 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 } @@ -24,6 +27,7 @@ struct EmbeddingModelEdit: ReducerProtocol { var suggestedMaxTokens: Int? var apiKeySelection: APIKeySelection.State = .init() var baseURLSelection: BaseURLSelection.State = .init() + var customHeaders: [ChatModel.Info.CustomHeaderInfo.HeaderField] = [] } enum Action: Equatable, BindableAction { @@ -35,10 +39,37 @@ struct EmbeddingModelEdit: ReducerProtocol { 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 @@ -46,16 +77,17 @@ struct EmbeddingModelEdit: ReducerProtocol { toast($0, $1, "EmbeddingModelEdit") } } + @Dependency(\.apiKeyKeychain) var keychain - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() - Scope(state: \.apiKeySelection, action: /Action.apiKeySelection) { + Scope(state: \.apiKeySelection, action: \.apiKeySelection) { APIKeySelection() } - Scope(state: \.baseURLSelection, action: /Action.baseURLSelection) { + Scope(state: \.baseURLSelection, action: \.baseURLSelection) { BaseURLSelection() } @@ -76,6 +108,7 @@ struct EmbeddingModelEdit: ReducerProtocol { case .testButtonClicked: guard !state.isTesting else { return .none } state.isTesting = true + let dimensions = state.dimensions let model = EmbeddingModel( id: state.id, name: state.name, @@ -85,18 +118,33 @@ struct EmbeddingModelEdit: ReducerProtocol { baseURL: state.baseURL, isFullURL: state.isFullURL, maxTokens: state.maxTokens, + dimensions: dimensions, modelName: state.modelName ) ) return .run { send in do { - _ = try await EmbeddingService( + let result = try await EmbeddingService( configuration: UserPreferenceEmbeddingConfiguration() .overriding { $0.model = model } ).embed(text: "Hello") - await send(.testSucceeded("Succeeded!")) + 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)) } @@ -127,6 +175,34 @@ struct EmbeddingModelEdit: ReducerProtocol { 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: @@ -135,13 +211,13 @@ struct EmbeddingModelEdit: ReducerProtocol { case .baseURLSelection: return .none - case .binding(\.$format): + case .binding(\.format): return .run { send in await send(.refreshAvailableModelNames) await send(.checkSuggestedMaxTokens) } - case .binding(\.$modelName): + case .binding(\.modelName): return .run { send in await send(.checkSuggestedMaxTokens) } @@ -153,24 +229,6 @@ struct EmbeddingModelEdit: ReducerProtocol { } } -extension EmbeddingModelEdit.State { - init(model: EmbeddingModel) { - self.init( - id: model.id, - name: model.name, - format: model.format, - maxTokens: model.info.maxTokens, - modelName: model.info.modelName, - ollamaKeepAlive: model.info.ollamaInfo.keepAlive, - apiKeySelection: .init( - apiKeyName: model.info.apiKeyName, - apiKeyManagement: .init(availableAPIKeyNames: [model.info.apiKeyName]) - ), - baseURLSelection: .init(baseURL: model.info.baseURL, isFullURL: model.info.isFullURL) - ) - } -} - extension EmbeddingModel { init(state: EmbeddingModelEdit.State) { self.init( @@ -182,10 +240,33 @@ extension EmbeddingModel { 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) + 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 index ca7037e2..46f4effd 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -5,326 +5,434 @@ import SwiftUI @MainActor struct EmbeddingModelEditView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - ScrollView { - VStack(spacing: 0) { - Form { - nameTextField - formatPicker - - WithViewStore(store, observe: { $0.format }) { viewStore in - switch viewStore.state { + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + NameTextField(store: store) + FormatPicker(store: store) + + switch store.format { case .openAI: - openAI + OpenAIForm(store: store) case .azureOpenAI: - azureOpenAI + AzureOpenAIForm(store: store) case .openAICompatible: - openAICompatible + OpenAICompatibleForm(store: store) case .ollama: - ollama + OllamaForm(store: store) + case .gitHubCopilot: + GitHubCopilotForm(store: store) } } - } - .padding() + .padding() - Divider() + Divider() - HStack { - WithViewStore(store, observe: { $0.isTesting }) { viewStore in + HStack { HStack(spacing: 8) { Button("Test") { store.send(.testButtonClicked) } - .disabled(viewStore.state) + .disabled(store.isTesting) - if viewStore.state { + if store.isTesting { ProgressView() .controlSize(.small) } } - } - Spacer() + Spacer() - Button("Cancel") { - store.send(.cancelButtonClicked) - } - .keyboardShortcut(.cancelAction) + Button("Cancel") { + store.send(.cancelButtonClicked) + } + .keyboardShortcut(.cancelAction) - Button(action: { store.send(.saveButtonClicked) }) { - Text("Save") + Button(action: { store.send(.saveButtonClicked) }) { + Text("Save") + } + .keyboardShortcut(.defaultAction) } - .keyboardShortcut(.defaultAction) + .padding() } - .padding() } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + .fixedSize(horizontal: false, vertical: true) + .handleToast(namespace: "EmbeddingModelEdit") } - .textFieldStyle(.roundedBorder) - .onAppear { - store.send(.appear) - } - .fixedSize(horizontal: false, vertical: true) - .handleToast(namespace: "EmbeddingModelEdit") } - var nameTextField: some View { - WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in - TextField("Name", text: viewStore.$name) + struct NameTextField: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + TextField("Name", text: $store.name) + } } } - var formatPicker: some View { - WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in - Picker( - selection: viewStore.$format, - content: { - ForEach( - EmbeddingModel.Format.allCases, - id: \.rawValue - ) { format in - switch format { - case .openAI: - Text("OpenAI").tag(format) - case .azureOpenAI: - Text("Azure OpenAI").tag(format) - case .openAICompatible: - Text("OpenAI Compatible").tag(format) - case .ollama: - Text("Ollama").tag(format) + 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(.segmented) + }, + label: { Text("Format") } + ) + .pickerStyle(.menu) + } } } - func baseURLTextField( - title: String = "Base URL", - prompt: Text?, - @ViewBuilder trailingContent: @escaping () -> V - ) -> some View { - BaseURLPicker( - title: title, - prompt: prompt, - store: store.scope( - state: \.baseURLSelection, - action: EmbeddingModelEdit.Action.baseURLSelection - ), - trailingContent: trailingContent - ) + 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 + ) + } + } } - func baseURLTextField( - title: String = "Base URL", - prompt: Text? - ) -> some View { - baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() }) - } + struct MaxTokensTextField: View { + @Perception.Bindable var store: StoreOf - struct MaxTokensTextField: Equatable { - @BindingViewState var maxTokens: Int - var suggestedMaxTokens: Int? - } + 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 + } + } + ) - var maxTokensTextField: some View { - WithViewStore( - store, - observe: { - MaxTokensTextField( - maxTokens: $0.$maxTokens, - suggestedMaxTokens: $0.suggestedMaxTokens - ) - } - ) { viewStore in - HStack { - let textFieldBinding = Binding( - get: { String(viewStore.state.maxTokens) }, - set: { - if let selectionMaxToken = Int($0) { - viewStore.$maxTokens.wrappedValue = selectionMaxToken - } else { - viewStore.$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) - TextField(text: textFieldBinding) { - Text("Max Input Tokens") - .multilineTextAlignment(.trailing) - } - .overlay(alignment: .trailing) { - Stepper( - value: viewStore.$maxTokens, - in: 0...Int.max, - step: 100 - ) { - EmptyView() + if let max = store.suggestedMaxTokens { + Text("Max: \(max)") } } - .foregroundColor({ - guard let max = viewStore.state.suggestedMaxTokens else { - return .primary + } + } + } + + 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) } - if viewStore.state.maxTokens > max { - return .red + .overlay(alignment: .trailing) { + Stepper( + value: $store.dimensions, + in: 0...Int.max, + step: 100 + ) { + EmptyView() + } } - return .primary - }() as Color) - - if let max = viewStore.state.suggestedMaxTokens { - Text("Max: \(max)") + .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 APIKeyState: Equatable { - @BindingViewState var apiKeyName: String - var availableAPIKeys: [String] + struct ApiKeyNamePicker: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + } + } } - @ViewBuilder - var apiKeyNamePicker: some View { - APIKeyPicker(store: store.scope( - state: \.apiKeySelection, - action: EmbeddingModelEdit.Action.apiKeySelection - )) - } + struct OpenAIForm: View { + @Perception.Bindable var store: StoreOf - @ViewBuilder - var openAI: some View { - baseURLTextField(prompt: Text("https://api.openai.com")) { - Text("/v1/embeddings") - } - apiKeyNamePicker - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - .overlay(alignment: .trailing) { - Picker( - "", - selection: viewStore.$modelName, - content: { - if OpenAIEmbeddingModel(rawValue: viewStore.state.modelName) == nil { - Text("Custom Model").tag(viewStore.state.modelName) - } - ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in - Text(model.rawValue).tag(model.rawValue) + 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." ) - .frame(width: 20) } + .padding(.vertical) + } } + } - maxTokensTextField - - 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)" - ) + 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) - 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." - ) + TextField("Deployment Name", text: $store.modelName) + + MaxTokensTextField(store: store) + DimensionsTextField(store: store) + } } - .padding(.vertical) } - @ViewBuilder - var azureOpenAI: some View { - baseURLTextField(prompt: Text("https://xxxx.openai.azure.com")) - apiKeyNamePicker + 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") + } + } - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Deployment Name", text: viewStore.$modelName) - } + ApiKeyNamePicker(store: store) - maxTokensTextField - } + TextField("Model Name", text: $store.modelName) - @ViewBuilder - var openAICompatible: some View { - WithViewStore(store.scope( - state: \.baseURLSelection, - action: EmbeddingModelEdit.Action.baseURLSelection - ), removeDuplicates: { $0.isFullURL != $1.isFullURL }) { viewStore in - Picker( - selection: viewStore.$isFullURL, - content: { - Text("Base URL").tag(false) - Text("Full URL").tag(true) - }, - label: { Text("URL") } - ) - .pickerStyle(.segmented) - } + MaxTokensTextField(store: store) + DimensionsTextField(store: store) - WithViewStore(store, observe: \.isFullURL) { viewStore in - baseURLTextField( - title: "", - prompt: viewStore.state - ? Text("https://api.openai.com/v1/embeddings") - : Text("https://api.openai.com") - ) { - if !viewStore.state { - Text("/v1/embeddings") + Button("Custom Headers") { + isEditingCustomHeader.toggle() } + }.sheet(isPresented: $isEditingCustomHeader) { + CustomHeaderSettingsView(headers: $store.customHeaders) } } + } - apiKeyNamePicker + struct OllamaForm: View { + @Perception.Bindable var store: StoreOf + @State var isEditingCustomHeader = false - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - } + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) { + Text("/api/embeddings") + } - maxTokensTextField - } - - @ViewBuilder - var ollama: some View { - baseURLTextField(prompt: Text("http://127.0.0.1:11434")) { - Text("/api/embeddings") - } + ApiKeyNamePicker(store: store) - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - } + 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() + } - maxTokensTextField - - WithViewStore( - store, - removeDuplicates: { $0.ollamaKeepAlive == $1.ollamaKeepAlive } - ) { viewStore in - TextField(text: viewStore.$ollamaKeepAlive, prompt: Text("Default Value")) { - Text("Keep Alive") + 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) } } - - VStack(alignment: .leading, spacing: 8) { - Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( - " For more details, please visit [https://ollama.com](https://ollama.com)." - ) + } + + 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) + } } - .padding(.vertical) } } @@ -332,7 +440,7 @@ class EmbeddingModelManagementView_Editing_Previews: PreviewProvider { static var previews: some View { EmbeddingModelEditView( store: .init( - initialState: .init(model: EmbeddingModel( + initialState: EmbeddingModel( id: "3", name: "Test Model 3", format: .openAICompatible, @@ -342,8 +450,8 @@ class EmbeddingModelManagementView_Editing_Previews: PreviewProvider { maxTokens: 3000, modelName: "gpt-3.5-turbo" ) - )), - reducer: EmbeddingModelEdit() + ).toState(), + reducer: { EmbeddingModelEdit() } ) ) } diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift index 71b0d4a5..156f58ac 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift @@ -11,6 +11,7 @@ extension EmbeddingModel: ManageableAIModel { case .azureOpenAI: return "Azure OpenAI" case .openAICompatible: return "OpenAI Compatible" case .ollama: return "Ollama" + case .gitHubCopilot: return "GitHub Copilot" } } @@ -29,13 +30,15 @@ extension EmbeddingModel: ManageableAIModel { } } +@Reducer struct EmbeddingModelManagement: AIModelManagement { typealias Model = EmbeddingModel + @ObservableState struct State: Equatable, AIModelManagementState { typealias Model = EmbeddingModel var models: IdentifiedArrayOf = [] - @PresentationState var editingModel: EmbeddingModelEdit.State? + @Presents var editingModel: EmbeddingModelEdit.State? var selectedModelId: Model.ID? { editingModel?.id } } @@ -53,7 +56,7 @@ struct EmbeddingModelManagement: AIModelManagement { @Dependency(\.toast) var toast @Dependency(\.userDefaults) var userDefaults - var body: some ReducerProtocol { + var body: some ReducerOf { Reduce { state, action in switch action { case .appear: @@ -81,7 +84,7 @@ struct EmbeddingModelManagement: AIModelManagement { case let .selectModel(id): guard let model = state.models[id: id] else { return .none } - state.editingModel = .init(model: model) + state.editingModel = model.toState() return .none case let .duplicateModel(id): @@ -126,7 +129,7 @@ struct EmbeddingModelManagement: AIModelManagement { case .embeddingModelItem: return .none } - }.ifLet(\.$editingModel, action: /Action.embeddingModelItem) { + }.ifLet(\.$editingModel, action: \.embeddingModelItem) { EmbeddingModelEdit() } } diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift index a3bfa16c..e251af10 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift @@ -3,17 +3,19 @@ import ComposableArchitecture import SwiftUI struct EmbeddingModelManagementView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - AIModelManagementView(store: store) - .sheet(store: store.scope( - state: \.$editingModel, - action: EmbeddingModelManagement.Action.embeddingModelItem - )) { store in - EmbeddingModelEditView(store: store) - .frame(width: 800) - } + WithPerceptionTracking { + AIModelManagementView(store: store) + .sheet(item: $store.scope( + state: \.editingModel, + action: \.embeddingModelItem + )) { store in + EmbeddingModelEditView(store: store) + .frame(width: 800) + } + } } } @@ -59,22 +61,21 @@ class EmbeddingModelManagementView_Previews: PreviewProvider { ) ), ]), - editingModel: .init( - model: 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() + 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/GitHubCopilotView.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift index 90a8bdf5..ec627113 100644 --- a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift @@ -3,7 +3,7 @@ import Client import GitHubCopilotService import Preferences import SharedUIComponents -import SuggestionModel +import SuggestionBasic import SwiftUI struct GitHubCopilotView: View { @@ -20,10 +20,12 @@ struct GitHubCopilotView: View { @AppStorage(\.gitHubCopilotProxyPassword) var gitHubCopilotProxyPassword @AppStorage(\.gitHubCopilotUseStrictSSL) var gitHubCopilotUseStrictSSL @AppStorage(\.gitHubCopilotEnterpriseURI) var gitHubCopilotEnterpriseURI - @AppStorage(\.gitHubCopilotIgnoreTrailingNewLines) - var gitHubCopilotIgnoreTrailingNewLines + @AppStorage(\.gitHubCopilotPretendIDEToBeVSCode) var pretendIDEToBeVSCode @AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear) var disableGitHubCopilotSettingsAutoRefreshOnAppear + @AppStorage(\.gitHubCopilotLoadKeyChainCertificates) + var gitHubCopilotLoadKeyChainCertificates + @AppStorage(\.gitHubCopilotModelId) var gitHubCopilotModelId init() {} } @@ -156,7 +158,7 @@ struct GitHubCopilotView: View { "node" ) ) { - Text("Path to Node (v18+)") + Text("Path to Node (v22.0+)") } Text( @@ -198,6 +200,10 @@ struct GitHubCopilotView: View { .foregroundColor(.secondary) .font(.callout) .dynamicHeightTextInFormWorkaround() + + Toggle(isOn: $settings.gitHubCopilotLoadKeyChainCertificates) { + Text("Load certificates in keychain") + } } } @@ -214,7 +220,7 @@ struct GitHubCopilotView: View { 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 @@ -255,30 +261,27 @@ struct GitHubCopilotView: View { if isRunningAction { ActivityIndicatorView() } - } + } .opacity(isRunningAction ? 0.8 : 1) .disabled(isRunningAction) - Button("Refresh Configuration for Enterprise and Proxy") { + Button("Refresh configurations") { refreshConfiguration() } + + Form { + GitHubCopilotModelPicker( + title: "Chat Model Name", + gitHubCopilotModelId: $settings.gitHubCopilotModelId + ) + } } SettingsDivider("Advanced") Form { - Toggle( - "Remove Extra New Lines Generated by GitHub Copilot", - isOn: $settings.gitHubCopilotIgnoreTrailingNewLines - ) - Text( - "Sometimes GitHub Copilot may generate extra unwanted new lines at the end of a suggestion. If you don't like that, you can turn this toggle on." - ) - .lineLimit(10) - .foregroundColor(.secondary) - .font(.callout) - .dynamicHeightTextInFormWorkaround() - Toggle("Verbose Log", isOn: $settings.gitHubCopilotVerboseLog) + Toggle("Verbose log", isOn: $settings.gitHubCopilotVerboseLog) + Toggle("Pretend IDE to be VSCode", isOn: $settings.pretendIDEToBeVSCode) } SettingsDivider("Enterprise") @@ -288,7 +291,7 @@ struct GitHubCopilotView: View { text: $settings.gitHubCopilotEnterpriseURI, prompt: Text("Leave it blank if non is available.") ) { - Text("Auth Provider URL") + Text("Auth provider URL") } } @@ -299,18 +302,18 @@ struct GitHubCopilotView: View { text: $settings.gitHubCopilotProxyHost, prompt: Text("xxx.xxx.xxx.xxx, leave it blank to disable proxy.") ) { - Text("Proxy Host") + Text("Proxy host") } TextField(text: $settings.gitHubCopilotProxyPort, prompt: Text("80")) { - Text("Proxy Port") + Text("Proxy port") } TextField(text: $settings.gitHubCopilotProxyUsername) { - Text("Proxy Username") + Text("Proxy username") } SecureField(text: $settings.gitHubCopilotProxyPassword) { - Text("Proxy Password") + Text("Proxy password") } - Toggle("Proxy Strict SSL", isOn: $settings.gitHubCopilotUseStrictSSL) + Toggle("Proxy strict SSL", isOn: $settings.gitHubCopilotUseStrictSSL) } } Spacer() @@ -354,7 +357,6 @@ struct GitHubCopilotView: View { if status != .ok, status != .notSignedIn { toast( "GitHub Copilot status is not \"ok\". Please check if you have a valid GitHub Copilot subscription.", - .error ) } 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 index 621ed75d..2c1fd2d7 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift @@ -20,9 +20,9 @@ protocol AIModelManagementState: Equatable { var selectedModelId: Model.ID? { get } } -protocol AIModelManagement: ReducerProtocol where +protocol AIModelManagement: Reducer where Action: AIModelManagementAction, - State: AIModelManagementState, + State: AIModelManagementState & ObservableState, Action.Model == Self.Model, State.Model == Self.Model { @@ -39,69 +39,71 @@ protocol ManageableAIModel: Identifiable { struct AIModelManagementView: View where Management.Model == Model { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - VStack(spacing: 0) { - HStack { - Spacer() - if isFeatureAvailable(\.unlimitedChatAndEmbeddingModels) { - Button("Add Model") { - store.send(.createModel) - } - } else { - WithViewStore(store, observe: { $0.models.count }) { viewStore in - Text("\(viewStore.state) / 2") + 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 = viewStore.state >= 2 + let disabled = store.models.count >= 2 Button(disabled ? "Add More Model (Plus)" : "Add Model") { store.send(.createModel) }.disabled(disabled) } - } - }.padding(4) + }.padding(4) - Divider() + Divider() - ModelList(store: store) - } - .onAppear { - store.send(.appear) + ModelList(store: store) + } + .onAppear { + store.send(.appear) + } } } struct ModelList: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - WithViewStore(store) { viewStore in + WithPerceptionTracking { List { - ForEach(viewStore.state.models) { model in - let isSelected = viewStore.state.selectedModelId == model.id - HStack(spacing: 4) { - Image(systemName: "line.3.horizontal") + ForEach(store.models) { model in + WithPerceptionTracking { + let isSelected = store.selectedModelId == model.id + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal") - Button(action: { - viewStore.send(.selectModel(id: model.id)) - }) { - Cell(model: model, isSelected: isSelected) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .contextMenu { - Button("Duplicate") { - store.send(.duplicateModel(id: model.id)) + Button(action: { + store.send(.selectModel(id: model.id)) + }) { + Cell(model: model, isSelected: isSelected) + .contentShape(Rectangle()) } - Button("Remove") { - store.send(.removeModel(id: model.id)) + .buttonStyle(.plain) + .contextMenu { + Button("Duplicate") { + store.send(.duplicateModel(id: model.id)) + } + Button("Remove") { + store.send(.removeModel(id: model.id)) + } } } } } .onMove(perform: { indices, newOffset in - viewStore.send(.moveModel(from: indices, to: newOffset)) + store.send(.moveModel(from: indices, to: newOffset)) }) .modify { view in if #available(macOS 13.0, *) { @@ -115,7 +117,7 @@ struct AIModelManagementView( store: .init( - initialState: .init(models: []), - reducer: ChatModelManagement() + initialState: .init(models: [] as IdentifiedArrayOf), + reducer: { ChatModelManagement() } ) ) } diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift index 066983e7..9456946e 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift @@ -4,40 +4,40 @@ import SwiftUI struct BaseURLPicker: View { let title: String let prompt: Text? - let store: StoreOf + @Perception.Bindable var store: StoreOf @ViewBuilder let trailingContent: () -> TrailingContent - + var body: some View { - WithViewStore(store) { viewStore in + WithPerceptionTracking { HStack { - TextField(title, text: viewStore.$baseURL, prompt: prompt) + TextField(title, text: $store.baseURL, prompt: prompt) .overlay(alignment: .trailing) { Picker( "", - selection: viewStore.$baseURL, + selection: $store.baseURL, content: { - if !viewStore.state.availableBaseURLs - .contains(viewStore.state.baseURL), - !viewStore.state.baseURL.isEmpty + if !store.availableBaseURLs + .contains(store.baseURL), + !store.baseURL.isEmpty { - Text("Custom Value").tag(viewStore.state.baseURL) + Text("Custom Value").tag(store.baseURL) } - + Text("Empty (Default Value)").tag("") - - ForEach(viewStore.state.availableBaseURLs, id: \.self) { baseURL in + + ForEach(store.availableBaseURLs, id: \.self) { baseURL in Text(baseURL).tag(baseURL) } } ) .frame(width: 20) } - + trailingContent() .foregroundStyle(.secondary) } .onAppear { - viewStore.send(.appear) + store.send(.appear) } } } @@ -57,3 +57,4 @@ extension BaseURLPicker where TrailingContent == EmptyView { ) } } + diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift index daff8e21..502d79a7 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift @@ -3,10 +3,12 @@ import Foundation import Preferences import SwiftUI -struct BaseURLSelection: ReducerProtocol { +@Reducer +struct BaseURLSelection { + @ObservableState struct State: Equatable { - @BindingState var baseURL: String = "" - @BindingState var isFullURL: Bool = false + var baseURL: String = "" + var isFullURL: Bool = false var availableBaseURLs: [String] = [] } @@ -19,7 +21,7 @@ struct BaseURLSelection: ReducerProtocol { @Dependency(\.toast) var toast @Dependency(\.userDefaults) var userDefaults - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() Reduce { state, action in 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 index 212b8313..884c58f0 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift @@ -5,7 +5,9 @@ import Preferences import SwiftUI import Toast -struct CustomCommandFeature: ReducerProtocol { +@Reducer +struct CustomCommandFeature { + @ObservableState struct State: Equatable { var editCustomCommand: EditCustomCommand.State? } @@ -24,7 +26,7 @@ struct CustomCommandFeature: ReducerProtocol { @Dependency(\.toast) var toast - var body: some ReducerProtocol { + var body: some ReducerOf { Reduce { state, action in switch action { case .createNewCommand: @@ -122,7 +124,7 @@ struct CustomCommandFeature: ReducerProtocol { } } } - }.ifLet(\.editCustomCommand, action: /Action.editCustomCommand) { + }.ifLet(\.editCustomCommand, action: \.editCustomCommand) { EditCustomCommand(settings: settings) } } diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift index 22594715..033b9850 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -21,9 +21,7 @@ extension List { let customCommandStore = StoreOf( initialState: .init(), - reducer: CustomCommandFeature( - settings: .init() - ) + reducer: { CustomCommandFeature(settings: .init()) } ) struct CustomCommandView: View { @@ -43,63 +41,70 @@ struct CustomCommandView: View { var body: some View { HStack(spacing: 0) { - leftPane + LeftPanel(store: store, settings: settings) Divider() - rightPane + RightPanel(store: store) } } - @ViewBuilder - var leftPane: some View { - 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 + 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)") + .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) + } } - } - .buttonStyle(.plain) - .padding() - .contextMenu { - Button("Import") { - store.send(.importCommandClicked) + .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)) } } - .onDrop(of: [.json], delegate: FileDropDelegate(store: store, toast: toast)) } struct FileDropDelegate: DropDelegate { @@ -108,15 +113,16 @@ struct CustomCommandView: View { 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) + 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 @@ -124,92 +130,96 @@ struct CustomCommandView: View { } struct CommandButton: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf let command: CustomCommand var body: some View { - HStack(spacing: 4) { - Image(systemName: "line.3.horizontal") + WithPerceptionTracking { + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal") - VStack(alignment: .leading) { - Text(command.name) - .foregroundStyle(.primary) + 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("Prompt to Code") - case .singleRoundDialog: - Text("Single Round Dialog") + 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)) } - .font(.caption) - .foregroundStyle(.tertiary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { - store.send(.editCommand(command)) } - } - .padding(4) - .background { - WithViewStore(store, observe: { $0.editCustomCommand?.commandId }) { viewStore in + .padding(4) + .background { RoundedRectangle(cornerRadius: 4) .fill( - viewStore.state == command.id + store.editCustomCommand?.commandId == command.id ? Color.primary.opacity(0.05) : Color.clear ) } - } - .contextMenu { - Button("Remove") { - store.send(.deleteCommand(command)) - } + .contextMenu { + Button("Remove") { + store.send(.deleteCommand(command)) + } - Button("Export") { - store.send(.exportCommand(command)) + Button("Export") { + store.send(.exportCommand(command)) + } } } } } - @ViewBuilder - var rightPane: some View { - IfLetStore(store.scope( - state: \.editCustomCommand, - action: CustomCommandFeature.Action.editCustomCommand - )) { store in - 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 through the \"Extra System Prompt\" as well." - ) - } - SubSection(title: Text("Prompt to Code")) { - 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 system prompt to let the bot behave differently." - ) - } - 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 `/run`. For example, you can set the prompt to `/run open .` to open the project in Finder." - ) + 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() } } - .padding() } } } @@ -265,7 +275,9 @@ struct CustomCommandView_Preview: PreviewProvider { extraSystemPrompt: nil, prompt: "Hello", useExtraSystemPrompt: false - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), .init( commandId: "2", @@ -275,7 +287,9 @@ struct CustomCommandView_Preview: PreviewProvider { prompt: "Refactor", continuousMode: false, generateDescription: true - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), ], "CustomCommandView_Preview")) @@ -289,10 +303,12 @@ struct CustomCommandView_Preview: PreviewProvider { extraSystemPrompt: nil, prompt: "Hello", useExtraSystemPrompt: false - ) + ), + ignoreExistingAttachments: false, + attachments: [] as [CustomCommand.Attachment] ))) ), - reducer: CustomCommandFeature(settings: settings) + reducer: { CustomCommandFeature(settings: settings) } ), settings: settings ) @@ -309,7 +325,9 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider { extraSystemPrompt: nil, prompt: "Hello", useExtraSystemPrompt: false - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), .init( commandId: "2", @@ -319,7 +337,9 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider { prompt: "Refactor", continuousMode: false, generateDescription: true - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), ], "CustomCommandView_Preview")) @@ -328,7 +348,7 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider { initialState: .init( editCustomCommand: nil ), - reducer: CustomCommandFeature(settings: settings) + reducer: { CustomCommandFeature(settings: settings) } ), settings: settings ) diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift index 03d8ddf9..e927a9ff 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift @@ -3,7 +3,8 @@ import Foundation import Preferences import SwiftUI -struct EditCustomCommand: ReducerProtocol { +@Reducer +struct EditCustomCommand { enum CommandType: Int, CaseIterable, Equatable { case sendMessage case promptToCode @@ -11,9 +12,10 @@ struct EditCustomCommand: ReducerProtocol { case singleRoundDialog } + @ObservableState struct State: Equatable { - @BindingState var name: String = "" - @BindingState var commandType: CommandType = .sendMessage + var name: String = "" + var commandType: CommandType = .sendMessage var isNewCommand: Bool = false let commandId: String @@ -21,11 +23,16 @@ struct EditCustomCommand: ReducerProtocol { 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): @@ -81,29 +88,34 @@ struct EditCustomCommand: ReducerProtocol { 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 ReducerProtocol { - Scope(state: \.sendMessage, action: /Action.sendMessage) { + var body: some ReducerOf { + Scope(state: \.sendMessage, action: \.sendMessage) { EditSendMessageCommand() } - Scope(state: \.promptToCode, action: /Action.promptToCode) { + Scope(state: \.promptToCode, action: \.promptToCode) { EditPromptToCodeCommand() } - Scope(state: \.customChat, action: /Action.customChat) { + Scope(state: \.customChat, action: \.customChat) { EditCustomChatCommand() } - Scope(state: \.singleRoundDialog, action: /Action.singleRoundDialog) { + Scope(state: \.singleRoundDialog, action: \.singleRoundDialog) { EditSingleRoundDialogCommand() } + Scope(state: \.attachments, action: \.attachments) { + EditCustomCommandAttachment() + } + BindingReducer() Reduce { state, action in @@ -149,7 +161,9 @@ struct EditCustomCommand: ReducerProtocol { receiveReplyInNotification: state.receiveReplyInNotification ) } - }() + }(), + ignoreExistingAttachments: state.attachments.ignoreExistingAttachments, + attachments: state.attachments.attachments ) if state.isNewCommand { @@ -182,23 +196,51 @@ struct EditCustomCommand: ReducerProtocol { 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 } } } } -struct EditSendMessageCommand: ReducerProtocol { +@Reducer +struct EditSendMessageCommand { + @ObservableState struct State: Equatable { - @BindingState var extraSystemPrompt: String = "" - @BindingState var useExtraSystemPrompt: Bool = false - @BindingState var prompt: String = "" + var extraSystemPrompt: String = "" + var useExtraSystemPrompt: Bool = false + var prompt: String = "" } enum Action: BindableAction, Equatable { case binding(BindingAction) } - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() Reduce { _, action in @@ -210,51 +252,57 @@ struct EditSendMessageCommand: ReducerProtocol { } } -struct EditPromptToCodeCommand: ReducerProtocol { +@Reducer +struct EditPromptToCodeCommand { + @ObservableState struct State: Equatable { - @BindingState var extraSystemPrompt: String = "" - @BindingState var prompt: String = "" - @BindingState var continuousMode: Bool = false - @BindingState var generateDescription: Bool = false + var extraSystemPrompt: String = "" + var prompt: String = "" + var continuousMode: Bool = false + var generateDescription: Bool = false } enum Action: BindableAction, Equatable { case binding(BindingAction) } - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() } } -struct EditCustomChatCommand: ReducerProtocol { +@Reducer +struct EditCustomChatCommand { + @ObservableState struct State: Equatable { - @BindingState var systemPrompt: String = "" - @BindingState var prompt: String = "" + var systemPrompt: String = "" + var prompt: String = "" } enum Action: BindableAction, Equatable { case binding(BindingAction) } - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() } } -struct EditSingleRoundDialogCommand: ReducerProtocol { +@Reducer +struct EditSingleRoundDialogCommand { + @ObservableState struct State: Equatable { - @BindingState var systemPrompt: String = "" - @BindingState var overwriteSystemPrompt: Bool = false - @BindingState var prompt: String = "" - @BindingState var receiveReplyInNotification: Bool = false + var systemPrompt: String = "" + var overwriteSystemPrompt: Bool = false + var prompt: String = "" + var receiveReplyInNotification: Bool = false } enum Action: BindableAction, Equatable { case binding(BindingAction) } - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() } } diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift index 74b61585..e2304f8b 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift @@ -6,7 +6,7 @@ import SwiftUI @MainActor struct EditCustomCommandView: View { @Environment(\.toast) var toast - let store: StoreOf + @Perception.Bindable var store: StoreOf init(store: StoreOf) { self.store = store @@ -24,10 +24,10 @@ struct EditCustomCommandView: View { } @ViewBuilder var sharedForm: some View { - WithViewStore(store, observe: { $0 }) { viewStore in - TextField("Name", text: viewStore.$name) + WithPerceptionTracking { + TextField("Name", text: $store.name) - Picker("Command Type", selection: viewStore.$commandType) { + Picker("Command Type", selection: $store.commandType) { ForEach( EditCustomCommand.CommandType.allCases, id: \.rawValue @@ -37,7 +37,7 @@ struct EditCustomCommandView: View { case .sendMessage: return "Send Message" case .promptToCode: - return "Prompt to Code" + return "Modification" case .customChat: return "Custom Chat" case .singleRoundDialog: @@ -50,37 +50,42 @@ struct EditCustomCommandView: View { } @ViewBuilder var featureSpecificForm: some View { - WithViewStore( - store, - observe: { $0.commandType } - ) { viewStore in - switch viewStore.state { + WithPerceptionTracking { + switch store.commandType { case .sendMessage: EditSendMessageCommandView( store: store.scope( state: \.sendMessage, - action: EditCustomCommand.Action.sendMessage + action: \.sendMessage + ), + attachmentStore: store.scope( + state: \.attachments, + action: \.attachments ) ) case .promptToCode: EditPromptToCodeCommandView( store: store.scope( state: \.promptToCode, - action: EditCustomCommand.Action.promptToCode + action: \.promptToCode ) ) case .customChat: EditCustomChatCommandView( store: store.scope( state: \.customChat, - action: EditCustomCommand.Action.customChat + action: \.customChat + ), + attachmentStore: store.scope( + state: \.attachments, + action: \.attachments ) ) case .singleRoundDialog: EditSingleRoundDialogCommandView( store: store.scope( state: \.singleRoundDialog, - action: EditCustomCommand.Action.singleRoundDialog + action: \.singleRoundDialog ) ) } @@ -88,23 +93,23 @@ struct EditCustomCommandView: View { } @ViewBuilder var bottomBar: some View { - VStack { - Divider() + WithPerceptionTracking { + VStack { + Divider() - VStack(alignment: .trailing) { - Text( - "After renaming or adding a custom command, please restart Xcode to refresh the menu." - ) - .foregroundStyle(.secondary) + 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) - } + HStack { + Spacer() + Button("Close") { + store.send(.close) + } - WithViewStore(store, observe: { $0.isNewCommand }) { viewStore in - if viewStore.state { + if store.isNewCommand { Button("Add") { store.send(.saveCommand) } @@ -115,51 +120,196 @@ struct EditCustomCommandView: View { } } } + .padding(.horizontal) } - .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" } - .padding(.bottom) - .background(.regularMaterial) } } struct EditSendMessageCommandView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf + var attachmentStore: StoreOf var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in + WithPerceptionTracking { VStack(alignment: .leading, spacing: 4) { - Toggle("Extra System Prompt", isOn: viewStore.$useExtraSystemPrompt) - EditableText(text: viewStore.$extraSystemPrompt) + Toggle("Extra Context", isOn: $store.useExtraSystemPrompt) + EditableText(text: $store.extraSystemPrompt) } .padding(.vertical, 4) VStack(alignment: .leading, spacing: 4) { - Text("Prompt") - EditableText(text: viewStore.$prompt) + Text("Send immediately") + EditableText(text: $store.prompt) } .padding(.vertical, 4) + + CustomCommandAttachmentPickerView(store: attachmentStore) + .padding(.vertical, 4) } } } struct EditPromptToCodeCommandView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in - Toggle("Continuous Mode", isOn: viewStore.$continuousMode) - Toggle("Generate Description", isOn: viewStore.$generateDescription) + WithPerceptionTracking { + Toggle("Continuous Mode", isOn: $store.continuousMode) VStack(alignment: .leading, spacing: 4) { Text("Extra Context") - EditableText(text: viewStore.$extraSystemPrompt) + EditableText(text: $store.extraSystemPrompt) } .padding(.vertical, 4) VStack(alignment: .leading, spacing: 4) { - Text("Prompt") - EditableText(text: viewStore.$prompt) + Text("Instruction") + EditableText(text: $store.prompt) } .padding(.vertical, 4) } @@ -167,39 +317,43 @@ struct EditPromptToCodeCommandView: View { } struct EditCustomChatCommandView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf + var attachmentStore: StoreOf var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in + WithPerceptionTracking { VStack(alignment: .leading, spacing: 4) { - Text("System Prompt") - EditableText(text: viewStore.$systemPrompt) + Text("Topic") + EditableText(text: $store.systemPrompt) } .padding(.vertical, 4) VStack(alignment: .leading, spacing: 4) { - Text("Prompt") - EditableText(text: viewStore.$prompt) + Text("Send immediately") + EditableText(text: $store.prompt) } .padding(.vertical, 4) + + CustomCommandAttachmentPickerView(store: attachmentStore) + .padding(.vertical, 4) } } } struct EditSingleRoundDialogCommandView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in + WithPerceptionTracking { VStack(alignment: .leading, spacing: 4) { Text("System Prompt") - EditableText(text: viewStore.$systemPrompt) + EditableText(text: $store.systemPrompt) } .padding(.vertical, 4) - Picker(selection: viewStore.$overwriteSystemPrompt) { - Text("Append to Default System Prompt").tag(false) - Text("Overwrite Default System Prompt").tag(true) + Picker(selection: $store.overwriteSystemPrompt) { + Text("Append to default system prompt").tag(false) + Text("Overwrite default system prompt").tag(true) } label: { Text("Mode") } @@ -207,11 +361,11 @@ struct EditSingleRoundDialogCommandView: View { VStack(alignment: .leading, spacing: 4) { Text("Prompt") - EditableText(text: viewStore.$prompt) + EditableText(text: $store.prompt) } .padding(.vertical, 4) - Toggle("Receive Reply in Notification", isOn: viewStore.$receiveReplyInNotification) + 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." ) @@ -221,8 +375,6 @@ struct EditSingleRoundDialogCommandView: View { } } - - // MARK: - Preview struct EditCustomCommandView_Preview: PreviewProvider { @@ -237,14 +389,18 @@ struct EditCustomCommandView_Preview: PreviewProvider { prompt: "Hello", continuousMode: false, generateDescription: true - ) + ), + ignoreExistingAttachments: false, + attachments: [] as [CustomCommand.Attachment] )), - reducer: EditCustomCommand( - settings: .init(customCommands: .init( - wrappedValue: [], - "CustomCommandView_Preview" - )) - ) + reducer: { + EditCustomCommand( + settings: .init(customCommands: .init( + wrappedValue: [], + "CustomCommandView_Preview" + )) + ) + } ) ) .frame(width: 800) @@ -255,7 +411,7 @@ struct EditSingleRoundDialogCommandView_Preview: PreviewProvider { static var previews: some View { EditSingleRoundDialogCommandView(store: .init( initialState: .init(), - reducer: EditSingleRoundDialogCommand() + reducer: { EditSingleRoundDialogCommand() } )) .frame(width: 800, height: 600) } diff --git a/Core/Sources/HostApp/DebugView.swift b/Core/Sources/HostApp/DebugView.swift index 1f210e96..527d5b7b 100644 --- a/Core/Sources/HostApp/DebugView.swift +++ b/Core/Sources/HostApp/DebugView.swift @@ -29,11 +29,14 @@ final class DebugSettings: ObservableObject { 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 { @@ -134,6 +137,16 @@ struct DebugSettingsView: View { ) { 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) 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 7683ebf2..00000000 --- a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift +++ /dev/null @@ -1,439 +0,0 @@ -import Preferences -import SharedUIComponents -import SwiftUI - -#if canImport(ProHostApp) -import ProHostApp -#endif - -struct ChatSettingsView: 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(\.defaultChatSystemPrompt) var defaultChatSystemPrompt - @AppStorage(\.chatSearchPluginMaxIterations) var chatSearchPluginMaxIterations - @AppStorage(\.defaultChatFeatureEmbeddingModelId) var defaultChatFeatureEmbeddingModelId - @AppStorage(\.chatModels) var chatModels - @AppStorage(\.embeddingModels) var embeddingModels - @AppStorage(\.wrapCodeInChatCodeBlock) var wrapCodeInCodeBlock - - init() {} - } - - @Environment(\.openURL) var openURL - @Environment(\.toast) var toast - @StateObject var settings = Settings() - @State var maxTokenOverLimit = false - - var body: some View { - VStack { - chatSettingsForm - SettingsDivider("UI") - uiForm - SettingsDivider("Plugin") - pluginForm - ScopeForm() - } - } - - @ViewBuilder - var chatSettingsForm: some View { - Form { - Picker( - "Chat Model", - selection: $settings.defaultChatFeatureChatModelId - ) { - if !settings.chatModels - .contains(where: { $0.id == settings.defaultChatFeatureChatModelId }) - { - Text( - (settings.chatModels.first?.name).map { "\($0) (Default)" } - ?? "No Model Found" - ) - .tag(settings.defaultChatFeatureChatModelId) - } - - 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) - } - - VStack(alignment: .leading, spacing: 4) { - Text("Default 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 code in code block") - } - - #if canImport(ProHostApp) - - CodeHighlightThemePicker(scenario: .chat) - - #endif - } - } - - @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 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 - ) - } - } - - struct ScopeForm: View { - class Settings: ObservableObject { - @AppStorage(\.enableFileScopeByDefaultInChatContext) - var enableFileScopeByDefaultInChatContext: Bool - @AppStorage(\.enableCodeScopeByDefaultInChatContext) - var enableCodeScopeByDefaultInChatContext: Bool - @AppStorage(\.enableSenseScopeByDefaultInChatContext) - var enableSenseScopeByDefaultInChatContext: Bool - @AppStorage(\.enableProjectScopeByDefaultInChatContext) - var enableProjectScopeByDefaultInChatContext: Bool - @AppStorage(\.enableWebScopeByDefaultInChatContext) - var enableWebScopeByDefaultInChatContext: Bool - @AppStorage(\.preferredChatModelIdForSenseScope) - var preferredChatModelIdForSenseScope: String - @AppStorage(\.preferredChatModelIdForProjectScope) - var preferredChatModelIdForProjectScope: String - @AppStorage(\.preferredChatModelIdForWebScope) - var preferredChatModelIdForWebScope: String - @AppStorage(\.chatModels) var chatModels - @AppStorage(\.maxFocusedCodeLineCount) - var maxFocusedCodeLineCount - - init() {} - } - - @StateObject var settings = Settings() - - var body: some View { - SettingsDivider("Scopes") - - VStack { - SubSection( - title: Text("File Scope"), - description: "Enable the bot to read the metadata of the editing file." - ) { - Form { - Toggle(isOn: $settings.enableFileScopeByDefaultInChatContext) { - Text("Enable by default") - } - } - } - - SubSection( - title: Text("Code Scope"), - description: "Enable the bot to read the code and metadata of the editing file." - ) { - Form { - Toggle(isOn: $settings.enableCodeScopeByDefaultInChatContext) { - Text("Enable by default") - } - - HStack { - TextField(text: .init(get: { - "\(Int(settings.maxFocusedCodeLineCount))" - }, set: { - settings.maxFocusedCodeLineCount = Int($0) ?? 0 - })) { - Text("Max focused code") - } - .textFieldStyle(.roundedBorder) - - Text("lines") - } - } - } - - #if canImport(ProHostApp) - - SubSection( - title: Text("Sense Scope (Experimental)"), - description: IfFeatureEnabled(\.senseScopeInChat) { - Text(""" - Enable the bot to access the relevant code \ - of the editing document in the project, third party packages and the SDK. - """) - } else: { - VStack(alignment: .leading) { - Text(""" - Enable the bot to read the relevant code \ - of the editing document in the SDK, and - """) - - WithFeatureEnabled(\.senseScopeInChat, alignment: .inlineLeading) { - Text("the project and third party packages.") - } - } - } - ) { - Form { - Toggle(isOn: $settings.enableSenseScopeByDefaultInChatContext) { - Text("Enable by default") - } - - Picker( - "Preferred Chat Model", - selection: $settings.preferredChatModelIdForSenseScope - ) { - Text("Use the default model").tag("") - - if !settings.chatModels - .contains(where: { - $0.id == settings.preferredChatModelIdForSenseScope - }), - !settings.preferredChatModelIdForSenseScope.isEmpty - { - Text( - (settings.chatModels.first?.name).map { "\($0) (Default)" } - ?? "No Model Found" - ) - .tag(settings.preferredChatModelIdForSenseScope) - } - - ForEach(settings.chatModels, id: \.id) { chatModel in - Text(chatModel.name).tag(chatModel.id) - } - } - } - } - - SubSection( - title: Text("Project Scope (Experimental)"), - description: IfFeatureEnabled(\.projectScopeInChat) { - Text(""" - Enable the bot to search code and texts \ - in the project, third party packages and the SDK. - """) - } else: { - VStack(alignment: .leading) { - Text(""" - Enable the bot to search code and texts \ - in the neighboring files of the editing document, and - """) - - WithFeatureEnabled(\.senseScopeInChat, alignment: .inlineLeading) { - Text("the project, third party packages and the SDK.") - } - } - } - ) { - Form { - Toggle(isOn: $settings.enableProjectScopeByDefaultInChatContext) { - Text("Enable by default") - } - - Picker( - "Preferred Chat Model", - selection: $settings.preferredChatModelIdForProjectScope - ) { - Text("Use the default model").tag("") - - if !settings.chatModels - .contains(where: { - $0.id == settings.preferredChatModelIdForProjectScope - }), - !settings.preferredChatModelIdForProjectScope.isEmpty - { - Text( - (settings.chatModels.first?.name).map { "\($0) (Default)" } - ?? "No Model Found" - ) - .tag(settings.preferredChatModelIdForProjectScope) - } - - ForEach(settings.chatModels, id: \.id) { chatModel in - Text(chatModel.name).tag(chatModel.id) - } - } - } - } - - #endif - - SubSection( - title: Text("Web Scope"), - description: "Allow the bot to search on Bing or read a web page. The current implementation requires function calling." - ) { - Form { - Toggle(isOn: $settings.enableWebScopeByDefaultInChatContext) { - Text("Enable @web scope by default in chat context.") - } - - Picker( - "Preferred Chat Model", - selection: $settings.preferredChatModelIdForWebScope - ) { - Text("Use the default model").tag("") - - if !settings.chatModels - .contains(where: { - $0.id == settings.preferredChatModelIdForWebScope - }), - !settings.preferredChatModelIdForWebScope.isEmpty - { - Text( - (settings.chatModels.first?.name).map { "\($0) (Default)" } - ?? "No Model Found" - ) - .tag(settings.preferredChatModelIdForWebScope) - } - - ForEach(settings.chatModels, id: \.id) { chatModel in - Text(chatModel.name).tag(chatModel.id) - } - } - } - } - } - } - } -} - -// MARK: - Preview - -// -// #Preview { -// ScrollView { -// ChatSettingsView() -// .padding() -// } -// .frame(height: 800) -// .environment(\.overrideFeatureFlag, \.never) -// } -// - diff --git a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift index 630a9bba..f9c7f545 100644 --- a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift @@ -2,10 +2,6 @@ import Preferences import SharedUIComponents import SwiftUI -#if canImport(ProHostApp) -import ProHostApp -#endif - struct PromptToCodeSettingsView: View { final class Settings: ObservableObject { @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) @@ -20,7 +16,7 @@ struct PromptToCodeSettingsView: View { var promptToCodeChatModelId @AppStorage(\.promptToCodeEmbeddingModelId) var promptToCodeEmbeddingModelId - + @AppStorage(\.wrapCodeInPromptToCode) var wrapCode @AppStorage(\.chatModels) var chatModels @AppStorage(\.embeddingModels) var embeddingModels init() {} @@ -32,10 +28,10 @@ struct PromptToCodeSettingsView: View { VStack(alignment: .center) { Form { Picker( - "Chat Model", + "Chat model", selection: $settings.promptToCodeChatModelId ) { - Text("Same as Chat Feature").tag("") + Text("Same as chat feature").tag("") if !settings.chatModels .contains(where: { $0.id == settings.promptToCodeChatModelId }), @@ -43,7 +39,7 @@ struct PromptToCodeSettingsView: View { { Text( (settings.chatModels.first?.name).map { "\($0) (Default)" } - ?? "No Model Found" + ?? "No model found" ) .tag(settings.promptToCodeChatModelId) } @@ -52,104 +48,25 @@ struct PromptToCodeSettingsView: View { Text(chatModel.name).tag(chatModel.id) } } - - Picker( - "Embedding Model", - selection: $settings.promptToCodeEmbeddingModelId - ) { - Text("Same as Chat Feature").tag("") - - if !settings.embeddingModels - .contains(where: { $0.id == settings.promptToCodeEmbeddingModelId }), - !settings.promptToCodeEmbeddingModelId.isEmpty - { - Text( - (settings.embeddingModels.first?.name).map { "\($0) (Default)" } - ?? "No Model Found" - ) - .tag(settings.promptToCodeEmbeddingModelId) - } - - ForEach(settings.embeddingModels, id: \.id) { embeddingModel in - Text(embeddingModel.name).tag(embeddingModel.id) - } - } - - Toggle(isOn: $settings.promptToCodeGenerateDescription) { - Text("Generate Description") - } - - Toggle(isOn: $settings.promptToCodeGenerateDescriptionInUserPreferredLanguage) { - Text("Generate Description in user preferred language") - } } SettingsDivider("UI") Form { Toggle(isOn: $settings.hideCommonPrecedingSpaces) { - Text("Hide Common Preceding Spaces") + Text("Hide common preceding spaces") } - #if canImport(ProHostApp) + Toggle(isOn: $settings.wrapCode) { + Text("Wrap code") + } CodeHighlightThemePicker(scenario: .promptToCode) - #endif - FontPicker(font: $settings.font) { Text("Font") } } - - ScopeForm() - } - } - - struct ScopeForm: View { - class Settings: ObservableObject { - @AppStorage(\.enableSenseScopeByDefaultInPromptToCode) - var enableSenseScopeByDefaultInPromptToCode: Bool - init() {} - } - - @StateObject var settings = Settings() - - var body: some View { - SettingsDivider("Scopes") - - VStack { - #if canImport(ProHostApp) - - SubSection( - title: Text("Sense Scope (Experimental)"), - description: IfFeatureEnabled(\.senseScopeInChat) { - Text(""" - Enable the bot to access the relevant code \ - of the editing document in the project, third party packages and the SDK. - """) - } else: { - VStack(alignment: .leading) { - Text(""" - Enable the bot to read the relevant code \ - of the editing document in the SDK, and - """) - - WithFeatureEnabled(\.senseScopeInChat, alignment: .inlineLeading) { - Text("the project and third party packages.") - } - } - } - ) { - Form { - Toggle(isOn: $settings.enableSenseScopeByDefaultInPromptToCode) { - Text("Enable by default") - } - } - } - - #endif - } } } } diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionFeatureDisabledLanguageListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift similarity index 89% rename from Core/Sources/HostApp/FeatureSettings/SuggestionFeatureDisabledLanguageListView.swift rename to Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift index e011751b..6d894cfd 100644 --- a/Core/Sources/HostApp/FeatureSettings/SuggestionFeatureDisabledLanguageListView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift @@ -1,4 +1,4 @@ -import SuggestionModel +import SuggestionBasic import SwiftUI import SharedUIComponents @@ -29,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)) @@ -82,9 +74,10 @@ struct SuggestionFeatureDisabledLanguageListView: View { 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 98% rename from Core/Sources/HostApp/FeatureSettings/SuggestionFeatureEnabledProjectListView.swift rename to Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift index f57cd5e4..0cf66ca6 100644 --- a/Core/Sources/HostApp/FeatureSettings/SuggestionFeatureEnabledProjectListView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift @@ -82,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/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift similarity index 68% rename from Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift rename to Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift index 02c31e54..390c7f98 100644 --- a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift @@ -8,7 +8,7 @@ import XPCShared import ProHostApp #endif -struct SuggestionSettingsView: View { +struct SuggestionSettingsGeneralSectionView: View { struct SuggestionFeatureProviderOption: Identifiable, Hashable { var id: String { (builtInProvider?.rawValue).map(String.init) ?? bundleIdentifier ?? "n/A" @@ -56,8 +56,6 @@ struct SuggestionSettingsView: View { var acceptSuggestionWithTab @AppStorage(\.dismissSuggestionWithEsc) var dismissSuggestionWithEsc - @AppStorage(\.isSuggestionSenseEnabled) - var isSuggestionSenseEnabled var refreshExtensionSuggestionFeatureProvidersTask: Task? @@ -70,7 +68,7 @@ struct SuggestionSettingsView: View { refreshExtensionSuggestionFeatureProviders() } refreshExtensionSuggestionFeatureProvidersTask = Task { [weak self] in - let sequence = await NotificationCenter.default + let sequence = NotificationCenter.default .notifications(named: NSApplication.didBecomeActiveNotification) for await _ in sequence { guard let self else { return } @@ -90,8 +88,6 @@ struct SuggestionSettingsView: View { extensionSuggestionFeatureProviderOptions = services.map { .init(name: $0.name, bundleIdentifier: $0.bundleIdentifier) } - print(services.map(\.bundleIdentifier)) - print(suggestionFeatureProvider) } } } @@ -99,6 +95,7 @@ struct SuggestionSettingsView: View { @StateObject var settings = Settings() @State var isSuggestionFeatureEnabledListPickerOpen = false @State var isSuggestionFeatureDisabledLanguageListViewOpen = false + @State var isTabToAcceptSuggestionModifierViewOpen = false var body: some View { Form { @@ -106,9 +103,9 @@ struct SuggestionSettingsView: View { ForEach(PresentationMode.allCases, id: \.rawValue) { switch $0 { case .nearbyTextCursor: - Text("Nearby Text Cursor").tag($0) + Text("Nearby text cursor").tag($0) case .floatingWidget: - Text("Floating Widget").tag($0) + Text("Floating widget").tag($0) } } } label: { @@ -157,7 +154,7 @@ struct SuggestionSettingsView: View { if !settings.extensionSuggestionFeatureProviderOptions.contains(where: { $0.bundleIdentifier == identifier }) { - Text("\(name) (Not Found)").tag( + Text("\(name) (Not found)").tag( SuggestionFeatureProviderOption( name: name, bundleIdentifier: identifier @@ -166,39 +163,38 @@ struct SuggestionSettingsView: View { } } } label: { - Text("Feature Provider") + Text("Feature provider") } Toggle(isOn: $settings.realtimeSuggestionToggle) { - Text("Real-time Suggestion") + Text("Real-time suggestion") } - #if canImport(ProHostApp) - WithFeatureEnabled(\.suggestionSense) { - Toggle(isOn: $settings.isSuggestionSenseEnabled) { - Text("Suggestion Cheatsheet (Experimental)") - } - } - #endif + Toggle(isOn: $settings.acceptSuggestionWithTab) { + HStack { + Text("Accept suggestion with Tab") - #if canImport(ProHostApp) - WithFeatureEnabled(\.tabToAcceptSuggestion) { - Toggle(isOn: $settings.acceptSuggestionWithTab) { - 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") + Text("Dismiss suggestion with ESC") } - #endif HStack { Toggle(isOn: $settings.disableSuggestionFeatureGlobally) { - Text("Disable Suggestion Feature Globally") + Text("Disable suggestion feature globally") } - Button("Exception List") { + Button("Exception list") { isSuggestionFeatureEnabledListPickerOpen = true } }.sheet(isPresented: $isSuggestionFeatureEnabledListPickerOpen) { @@ -208,7 +204,7 @@ struct SuggestionSettingsView: View { } HStack { - Button("Disabled Language List") { + Button("Disabled language list") { isSuggestionFeatureDisabledLanguageListViewOpen = true } }.sheet(isPresented: $isSuggestionFeatureDisabledLanguageListViewOpen) { @@ -219,7 +215,7 @@ struct SuggestionSettingsView: View { HStack { Slider(value: $settings.realtimeSuggestionDebounce, in: 0.1...2, step: 0.1) { - Text("Real-time Suggestion Debounce") + Text("Real-time suggestion debounce") } Text( @@ -240,29 +236,92 @@ struct SuggestionSettingsView: View { Form { Toggle(isOn: $settings.suggestionDisplayCompactMode) { - Text("Hide Buttons") + Text("Hide buttons") } Toggle(isOn: $settings.hideCommonPrecedingSpacesInSuggestion) { - Text("Hide Common Preceding Spaces") + Text("Hide common preceding spaces") } - #if canImport(ProHostApp) - CodeHighlightThemePicker(scenario: .suggestion) - #endif - FontPicker(font: $settings.font) { Text("Font") } } } -} -struct SuggestionSettingsView_Previews: PreviewProvider { - static var previews: some View { - SuggestionSettingsView() + 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/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift index cd5683f7..e8c1e38f 100644 --- a/Core/Sources/HostApp/FeatureSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettingsView.swift @@ -1,40 +1,41 @@ 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" + ) - ScrollView { - ChatSettingsView().padding() - } - .sidebarItem( - tag: 1, - title: "Chat", - subtitle: "Chat about your code", - image: "character.bubble" - ) + ChatSettingsView() + .sidebarItem( + tag: 1, + title: "Chat", + subtitle: "Chat about your code", + image: "character.bubble" + ) ScrollView { PromptToCodeSettingsView().padding() } .sidebarItem( tag: 2, - title: "Prompt to Code", - subtitle: "Write code with natural language", + title: "Modification", + subtitle: "Write or modify code with natural language", image: "paintbrush" ) - + ScrollView { XcodeSettingsView().padding() } @@ -42,20 +43,20 @@ struct FeatureSettingsView: View { tag: 3, title: "Xcode", subtitle: "Xcode related features", - image: "app" + image: "hammer.circle" ) -// #if canImport(ProHostApp) -// ScrollView { -// TerminalSettingsView().padding() -// } -// .sidebarItem( -// tag: 3, -// title: "Terminal", -// subtitle: "Terminal chat tab", -// image: "terminal" -// ) -// #endif + 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 + ) + } } } } @@ -66,4 +67,3 @@ struct FeatureSettingsView_Previews: PreviewProvider { .frame(width: 800) } } - diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index a0c1ea88..96ade16c 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -5,49 +5,203 @@ import LaunchAgentManager import SwiftUI import XPCShared -struct General: ReducerProtocol { +@Reducer +struct General { + @ObservableState struct State: Equatable { var xpcServiceVersion: String? var isAccessibilityPermissionGranted: Bool? var isReloading = false + @Presents var alert: AlertState? } - enum Action: Equatable { + 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 - var body: some ReducerProtocol { + 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: - return .run { send in - await send(.setupLaunchAgentIfNeeded) + 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 - Task { - do { - try await LaunchAgentManager() - .setupLaunchAgentForTheFirstTimeIfNeeded() - } catch { - toast(error.localizedDescription, .error) - } + 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() @@ -65,18 +219,31 @@ struct General: ReducerProtocol { return .run { send in let service = try getService() do { - let xpcServiceVersion = try await service.getXPCServiceVersion().version - let isAccessibilityPermissionGranted = try await service - .getXPCServiceAccessibilityPermission() - await send(.finishReloading( - xpcServiceVersion: xpcServiceVersion, - permissionGranted: isAccessibilityPermissionGranted - )) + 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 @@ -87,6 +254,38 @@ struct General: ReducerProtocol { 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 f25a7fb8..b69c0127 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -16,7 +16,7 @@ struct GeneralView: View { SettingsDivider() ExtensionServiceView(store: store) SettingsDivider() - LaunchAgentView() + LaunchAgentView(store: store) SettingsDivider() GeneralSettingsView() } @@ -30,120 +30,121 @@ struct GeneralView: View { struct AppInfoView: View { @State var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String @Environment(\.updateChecker) var updateChecker - let store: StoreOf + @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() - - Button(action: { - store.send(.openExtensionManager) - }) { - HStack(spacing: 2) { - Image(systemName: "puzzlepiece.extension.fill") - Text("Extensions") + 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) + + Spacer() + + Button(action: { + store.send(.openExtensionManager) + }) { + HStack(spacing: 2) { + Image(systemName: "puzzlepiece.extension.fill") + Text("Extensions") + } } - } - Button(action: { - updateChecker.checkForUpdates() - }) { - HStack(spacing: 2) { - Image(systemName: "arrow.up.right.circle.fill") - Text("Check for Updates") + Button(action: { + updateChecker.checkForUpdates() + }) { + HStack(spacing: 2) { + Image(systemName: "arrow.up.right.circle.fill") + Text("Check for Updates") + } } } - } - HStack(spacing: 16) { - Link( - destination: URL(string: "https://github.com/intitni/CopilotForXcode")! - ) { - HStack(spacing: 2) { - Image(systemName: "link") - Text("GitHub") + HStack(spacing: 16) { + Link( + destination: URL(string: "https://github.com/intitni/CopilotForXcode")! + ) { + HStack(spacing: 2) { + Image(systemName: "link") + Text("GitHub") + } } - } - .focusable(false) - .foregroundColor(.accentColor) + .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") + 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 { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - VStack(alignment: .leading) { - WithViewStore(store, observe: { $0.xpcServiceVersion }) { viewStore in - Text("Extension Service Version: \(viewStore.state ?? "Loading..")") - } + WithPerceptionTracking { + VStack(alignment: .leading) { + Text("Extension Service Version: \(store.xpcServiceVersion ?? "Loading..")") - WithViewStore(store, observe: { $0.isAccessibilityPermissionGranted }) { viewStore in let grantedStatus: String = { - guard let granted = viewStore.state else { return "Loading.." } + guard let granted = store.isAccessibilityPermissionGranted + else { return "Loading.." } return granted ? "Granted" : "Not Granted" }() Text("Accessibility Permission: \(grantedStatus)") - } - HStack { - WithViewStore(store, observe: { $0.isReloading }) { viewStore in - Button(action: { viewStore.send(.reloadStatus) }) { + HStack { + Button(action: { store.send(.reloadStatus) }) { Text("Refresh") - }.disabled(viewStore.state) - } - - Button(action: { - Task { - let workspace = NSWorkspace.shared - let url = Bundle.main.bundleURL - .appendingPathComponent("Contents") - .appendingPathComponent("Applications") - .appendingPathComponent("CopilotForXcodeExtensionService.app") - workspace.activateFileViewerSelecting([url]) + }.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") + } } } } @@ -152,75 +153,34 @@ struct ExtensionServiceView: View { } 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(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(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(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() } } @@ -245,7 +205,7 @@ struct GeneralSettingsView: View { @StateObject var settings = Settings() @Environment(\.updateChecker) var updateChecker @State var automaticallyCheckForUpdate: Bool? - + var body: some View { Form { Toggle(isOn: $settings.quitXPCServiceOnXcodeAndAppQuit) { @@ -261,7 +221,7 @@ struct GeneralSettingsView: View { )) { Text("Automatically Check for Update") } - + Toggle(isOn: $settings.installBetaBuilds) { Text("Install beta builds") } @@ -320,7 +280,7 @@ struct GeneralSettingsView: View { } Toggle(isOn: $settings.hideCircularWidget) { - Text("Hide circular widget") + Text("Hide indicator widget") } }.padding() } @@ -394,7 +354,7 @@ struct LargeIconPicker< struct GeneralView_Previews: PreviewProvider { static var previews: some View { - GeneralView(store: .init(initialState: .init(), reducer: General())) + GeneralView(store: .init(initialState: .init(), reducer: { General() })) .frame(height: 800) } } diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index e5379319..f2b90303 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -4,26 +4,29 @@ import Foundation import KeyboardShortcuts #if canImport(LicenseManagement) -import LicenseManagement +import ProHostApp #endif extension KeyboardShortcuts.Name { static let showHideWidget = Self("ShowHideWidget") } -struct HostApp: ReducerProtocol { +@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: Equatable { + enum Action { case appear - case informExtensionServiceAboutLicenseKeyChange case general(General.Action) case chatModelManagement(ChatModelManagement.Action) case embeddingModelManagement(EmbeddingModelManagement.Action) + case webSearchSettings(WebSearchSettings.Action) } @Dependency(\.toast) var toast @@ -32,38 +35,30 @@ struct HostApp: ReducerProtocol { KeyboardShortcuts.userDefaults = .shared } - var body: some ReducerProtocol { - Scope(state: \.general, action: /Action.general) { + var body: some ReducerOf { + Scope(state: \.general, action: \.general) { General() } - Scope(state: \.chatModelManagement, action: /Action.chatModelManagement) { + Scope(state: \.chatModelManagement, action: \.chatModelManagement) { ChatModelManagement() } - Scope(state: \.embeddingModelManagement, action: /Action.embeddingModelManagement) { + Scope(state: \.embeddingModelManagement, action: \.embeddingModelManagement) { EmbeddingModelManagement() } + + Scope(state: \.webSearchSettings, action: \.webSearchSettings) { + WebSearchSettings() + } Reduce { _, action in switch action { case .appear: - return .none - - case .informExtensionServiceAboutLicenseKeyChange: - #if canImport(LicenseManagement) - return .run { _ in - let service = try getService() - do { - try await service - .postNotification(name: Notification.Name.licenseKeyChanged.rawValue) - } catch { - toast(error.localizedDescription, .error) - } - } - #else - return .none + #if canImport(ProHostApp) + ProHostApp.start() #endif + return .none case .general: return .none @@ -73,6 +68,9 @@ struct HostApp: ReducerProtocol { case .embeddingModelManagement: return .none + + case .webSearchSettings: + return .none } } } diff --git a/Core/Sources/HostApp/LaunchAgentManager.swift b/Core/Sources/HostApp/LaunchAgentManager.swift index db4d48b9..44937bb1 100644 --- a/Core/Sources/HostApp/LaunchAgentManager.swift +++ b/Core/Sources/HostApp/LaunchAgentManager.swift @@ -6,16 +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 b0e6bf59..bf81eb51 100644 --- a/Core/Sources/HostApp/ServiceView.swift +++ b/Core/Sources/HostApp/ServiceView.swift @@ -1,64 +1,79 @@ -import SwiftUI import ComposableArchitecture +import SwiftUI struct ServiceView: View { let store: StoreOf @State var tag = 0 - + var body: some View { - SidebarTabView(tag: $tag) { - 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: HostApp.Action.chatModelManagement - )).sidebarItem( - tag: 2, - title: "Chat Models", - subtitle: "Chat, Prompt to Code", - image: "globe" - ) - - EmbeddingModelManagementView(store: store.scope( - state: \.embeddingModelManagement, - action: HostApp.Action.embeddingModelManagement - )).sidebarItem( - tag: 3, - title: "Embedding Models", - subtitle: "Chat, Prompt to Code", - image: "globe" - ) - - ScrollView { - BingSearchView().padding() - }.sidebarItem( - tag: 4, - title: "Bing Search", - subtitle: "Search Chat Plugin", - 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(store: .init(initialState: .init(), reducer: HostApp())) + 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/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index ac4bbb40..8616b5af 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -2,16 +2,13 @@ import ComposableArchitecture import Dependencies import Foundation import LaunchAgentManager +import SharedUIComponents import SwiftUI import Toast import UpdateChecker -#if canImport(ProHostApp) -import ProHostApp -#endif - @MainActor -let hostAppStore: StoreOf = .init(initialState: .init(), reducer: HostApp()) +let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() }) public struct TabContainer: View { let store: StoreOf @@ -19,6 +16,10 @@ public struct TabContainer: View { @State private var tabBarItems = [TabBarItem]() @State var tag: Int = 0 + var externalTabContainer: ExternalTabContainer { + ExternalTabContainer.tabContainer(for: "TabContainer") + } + public init() { toastController = ToastControllerDependencyKey.liveValue store = hostAppStore @@ -30,62 +31,65 @@ public struct TabContainer: View { } public var body: some View { - VStack(spacing: 0) { - TabBar(tag: $tag, tabBarItems: tabBarItems) - .padding(.bottom, 8) - - Divider() - - ZStack(alignment: .center) { - GeneralView(store: store.scope(state: \.general, action: HostApp.Action.general)) - .tabBarItem( - tag: 0, - title: "General", - image: "app.gift" + 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" ) - 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" - ) - #if canImport(ProHostApp) - PlusView(onLicenseKeyChanged: { - store.send(.informExtensionServiceAboutLicenseKeyChange) - }).tabBarItem( - tag: 5, - title: "Plus", - image: "plus.diamond" - ) - #endif - DebugSettingsView().tabBarItem( - tag: 4, - title: "Advanced", - image: "gearshape.2" - ) + FeatureSettingsView().tabBarItem( + tag: 2, + title: "Feature", + image: "star.square" + ) + CustomCommandView(store: customCommandStore).tabBarItem( + tag: 3, + title: "Custom Command", + image: "command.square" + ) + + ForEach(0..? + 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][.. - - - - Label - \(serviceIdentifier) - Program - \(executablePath) - MachServices - - \(serviceIdentifier) - - - AssociatedBundleIdentifiers - - \(bundleIdentifier) - \(serviceIdentifier) - - - - """ - if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) { - try FileManager.default.createDirectory( - at: launchAgentDirURL, - withIntermediateDirectories: false - ) + if executableURL.path.hasPrefix("/Applications") { + try setupLaunchAgentWithPredefinedPlist() + } else { + try await setupLaunchAgentWithDynamicPlist() } - FileManager.default.createFile( - atPath: launchAgentPath, - contents: content.data(using: .utf8) - ) - try await launchctl("load", launchAgentPath) + } else { + try await setupLaunchAgentWithDynamicPlist() } let buildNumber = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) @@ -84,8 +53,12 @@ public struct LaunchAgentManager { public func removeLaunchAgent() async throws { if #available(macOS 13, *) { - let launchAgent = SMAppService.agent(plistName: "launchAgent.plist") - try await launchAgent.unregister() + let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") + try? await bridgeLaunchAgent.unregister() + if FileManager.default.fileExists(atPath: launchAgentPath) { + try? await launchctl("unload", launchAgentPath) + try? FileManager.default.removeItem(atPath: launchAgentPath) + } } else { try await launchctl("unload", launchAgentPath) try FileManager.default.removeItem(atPath: launchAgentPath) @@ -97,23 +70,56 @@ public struct LaunchAgentManager { try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier) } } +} - public func removeObsoleteLaunchAgent() async { - if #available(macOS 13, *) { - let path = launchAgentPath - if FileManager.default.fileExists(atPath: path) { - try? await launchctl("unload", path) - try? FileManager.default.removeItem(atPath: path) - } - } else { - let path = launchAgentPath.replacingOccurrences( - of: "ExtensionService", - with: "XPCService" +extension LaunchAgentManager { + @available(macOS 13, *) + func setupLaunchAgentWithPredefinedPlist() throws { + let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") + try bridgeLaunchAgent.register() + } + + func setupLaunchAgentWithDynamicPlist() async throws { + if FileManager.default.fileExists(atPath: launchAgentPath) { + throw E(errorDescription: "Launch agent already exists.") + } + + let content = """ + + + + + Label + \(serviceIdentifier) + Program + \(executableURL.path) + MachServices + + \(serviceIdentifier) + + + AssociatedBundleIdentifiers + + \(bundleIdentifier) + \(serviceIdentifier) + + + + """ + if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) { + try FileManager.default.createDirectory( + at: launchAgentDirURL, + withIntermediateDirectories: false ) - if FileManager.default.fileExists(atPath: path) { - try? FileManager.default.removeItem(atPath: path) - } } + FileManager.default.createFile( + atPath: launchAgentPath, + contents: content.data(using: .utf8) + ) + #if DEBUG + #else + try await launchctl("load", launchAgentPath) + #endif } } @@ -170,7 +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/ChatPlugin/AskChatGPT.swift b/Core/Sources/LegacyChatPlugin/AskChatGPT.swift similarity index 81% rename from Core/Sources/ChatPlugin/AskChatGPT.swift rename to Core/Sources/LegacyChatPlugin/AskChatGPT.swift index e95deac9..b942a7de 100644 --- a/Core/Sources/ChatPlugin/AskChatGPT.swift +++ b/Core/Sources/LegacyChatPlugin/AskChatGPT.swift @@ -12,9 +12,10 @@ public func askChatGPT( let memory = AutoManagedChatGPTMemory( systemPrompt: systemPrompt, configuration: configuration, - functionProvider: NoChatGPTFunctionProvider() + functionProvider: NoChatGPTFunctionProvider(), + maxNumberOfMessages: .max ) - let service = ChatGPTService( + let service = LegacyChatGPTService( memory: memory, configuration: configuration ) diff --git a/Core/Sources/ChatPlugin/CallAIFunction.swift b/Core/Sources/LegacyChatPlugin/CallAIFunction.swift similarity index 87% rename from Core/Sources/ChatPlugin/CallAIFunction.swift rename to Core/Sources/LegacyChatPlugin/CallAIFunction.swift index e29a4d31..20f7a01d 100644 --- a/Core/Sources/ChatPlugin/CallAIFunction.swift +++ b/Core/Sources/LegacyChatPlugin/CallAIFunction.swift @@ -18,11 +18,12 @@ func callAIFunction( let argsString = args.joined(separator: ", ") let configuration = UserPreferenceChatGPTConfiguration() .overriding(.init(temperature: 0)) - let service = ChatGPTService( + 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() + functionProvider: NoChatGPTFunctionProvider(), + maxNumberOfMessages: .max ), configuration: configuration ) 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/ChatPlugin/TerminalChatPlugin.swift b/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift similarity index 89% rename from Core/Sources/ChatPlugin/TerminalChatPlugin.swift rename to Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift index 285b2947..3ac8bd74 100644 --- a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift +++ b/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift @@ -3,16 +3,16 @@ 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,8 +34,8 @@ public actor TerminalChatPlugin: ChatPlugin { } do { - let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL - let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL + let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL var environment = [String: String]() if let fileURL { diff --git a/Core/Sources/ChatPlugin/Translate.swift b/Core/Sources/LegacyChatPlugin/Translate.swift similarity index 100% rename from Core/Sources/ChatPlugin/Translate.swift rename to Core/Sources/LegacyChatPlugin/Translate.swift diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index 6804ecf8..25db646f 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -1,26 +1,57 @@ import Foundation +import ModificationBasic import OpenAIService import Preferences -import SuggestionModel +import SuggestionBasic import XcodeInspector -public final class OpenAIPromptToCodeService: PromptToCodeServiceType { - var service: (any ChatGPTServiceType)? +public final class SimpleModificationAgent: 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 + ) - public init() {} + for try await response in stream { + continuation.yield(response) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } - public func stopResponding() { - Task { await service?.stopReceivingMessage() } + continuation.onTermination = { _ in + task.cancel() + } + } } - public func modifyCode( + public init() {} + + func modifyCode( code: String, requirement: String, source: PromptToCodeSource, isDetached: Bool, extraSystemPrompt: String?, generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { + ) async throws -> AsyncThrowingStream { let userPreferredLanguage = UserDefaults.shared.value(for: \.chatGPTLanguage) let textLanguage = { if !UserDefaults.shared @@ -38,6 +69,7 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { lines: source.lines, selections: [source.range], cursorPosition: .outOfScope, + cursorOffset: -1, lineAnnotations: [] ), selectedContent: code, @@ -175,61 +207,80 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { let configuration = UserPreferenceChatGPTConfiguration(chatModelKey: \.promptToCodeChatModelId) .overriding(.init(temperature: 0)) + let memory = AutoManagedChatGPTMemory( systemPrompt: systemPrompt, configuration: configuration, - functionProvider: NoChatGPTFunctionProvider() + functionProvider: NoChatGPTFunctionProvider(), + maxNumberOfMessages: .max ) let chatGPTService = ChatGPTService( - memory: memory, - configuration: configuration + configuration: configuration, + functionProvider: NoChatGPTFunctionProvider() ) - service = chatGPTService + 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 = try await chatGPTService.send(content: requirement) + let stream = chatGPTService.send(memory) + return .init { continuation in - Task { - var content = "" - var extracted = extractCodeAndDescription(from: content) + let task = Task { + let parser = ExplanationThenCodeStreamParser() 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) + 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 OpenAIPromptToCodeService { +extension SimpleModificationAgent { func extractCodeAndDescription(from content: String) -> (code: String, description: String) { - func extractCodeFromMarkdown(_ markdown: String) -> (code: String, endIndex: Int)? { + 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 { - let startIndex = markdown.index(markdown.startIndex, offsetBy: startIndex) + func extractDescriptionFromMarkdown( + _ markdown: String, + startIndex: String.Index + ) -> String { 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 (code, description) in stream { + continuation.yield(.code(code)) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } -public final class PreviewPromptToCodeService: PromptToCodeServiceType { public init() {} - public func modifyCode( + func modifyCode( code: String, requirement: String, source: PromptToCodeSource, diff --git a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift index 25d3cc6a..3e0cd400 100644 --- a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift +++ b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift @@ -1,19 +1,6 @@ import Dependencies import Foundation -import SuggestionModel - -public protocol PromptToCodeServiceType { - func modifyCode( - code: String, - requirement: String, - source: PromptToCodeSource, - isDetached: Bool, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> - - func stopResponding() -} +import SuggestionBasic public struct PromptToCodeSource { public var language: CodeLanguage @@ -39,76 +26,3 @@ public struct PromptToCodeSource { self.range = range } } - -public struct PromptToCodeServiceDependencyKey: DependencyKey { - public static let liveValue: PromptToCodeServiceType = PreviewPromptToCodeService() - public static let previewValue: PromptToCodeServiceType = PreviewPromptToCodeService() -} - -public extension DependencyValues { - var promptToCodeService: PromptToCodeServiceType { - get { self[PromptToCodeServiceDependencyKey.self] } - set { self[PromptToCodeServiceDependencyKey.self] = newValue } - } - - var promptToCodeServiceFactory: () -> PromptToCodeServiceType { - get { self[PromptToCodeServiceFactoryDependencyKey.self] } - set { self[PromptToCodeServiceFactoryDependencyKey.self] = newValue } - } -} - -#if canImport(ContextAwarePromptToCodeService) - -import ContextAwarePromptToCodeService - -extension ContextAwarePromptToCodeService: PromptToCodeServiceType { - public func modifyCode( - code: String, - requirement: String, - source: PromptToCodeSource, - isDetached: Bool, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { - try await modifyCode( - code: code, - requirement: requirement, - source: ContextAwarePromptToCodeService.Source( - language: source.language, - documentURL: source.documentURL, - projectRootURL: source.projectRootURL, - content: source.content, - lines: source.lines, - range: source.range - ), - isDetached: isDetached, - extraSystemPrompt: extraSystemPrompt, - generateDescriptionRequirement: generateDescriptionRequirement - ) - } -} - -public struct PromptToCodeServiceFactoryDependencyKey: DependencyKey { - public static let liveValue: () -> PromptToCodeServiceType = { - ContextAwarePromptToCodeService() - } - - public static let previewValue: () -> PromptToCodeServiceType = { - PreviewPromptToCodeService() - } -} - -#else - -public struct PromptToCodeServiceFactoryDependencyKey: DependencyKey { - public static let liveValue: () -> PromptToCodeServiceType = { - OpenAIPromptToCodeService() - } - - public static let previewValue: () -> PromptToCodeServiceType = { - PreviewPromptToCodeService() - } -} - -#endif - diff --git a/Core/Sources/Service/DependencyUpdater.swift b/Core/Sources/Service/DependencyUpdater.swift index 31ab8b7f..6eb4124a 100644 --- a/Core/Sources/Service/DependencyUpdater.swift +++ b/Core/Sources/Service/DependencyUpdater.swift @@ -1,4 +1,5 @@ import CodeiumService +import Foundation import GitHubCopilotService import Logger @@ -39,8 +40,10 @@ 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/ChatTabFactory.swift b/Core/Sources/Service/GUI/ChatTabFactory.swift index 18af0372..fae16330 100644 --- a/Core/Sources/Service/GUI/ChatTabFactory.swift +++ b/Core/Sources/Service/GUI/ChatTabFactory.swift @@ -1,136 +1,57 @@ +import BuiltinExtension import ChatGPTChatTab import ChatService import ChatTab import Foundation import PromptToCodeService -import SuggestionModel +import SuggestionBasic import SuggestionWidget import XcodeInspector -#if canImport(ProChatTabs) -import ProChatTabs - enum ChatTabFactory { static func chatTabBuilderCollection() -> [ChatTabBuilderCollection] { - 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 + 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 } - - let collection = [ - folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name), - folderIfNeeded( - BrowserChatTab.chatBuilders( - externalDependency: externalDependenciesForBrowserChatTab() - ), - title: BrowserChatTab.name - ), - folderIfNeeded(TerminalChatTab.chatBuilders(), title: TerminalChatTab.name), - ].compactMap { $0 } - - return collection } - static func externalDependenciesForBrowserChatTab() -> BrowserChatTab.ExternalDependency { - .init( - getEditorContent: { - guard let editor = XcodeInspector.shared.focusedEditor else { - return .init(selectedText: "", language: "", fileContent: "") - } - let content = editor.getContent() - return .init( - selectedText: content.selectedContent, - language: ( - XcodeInspector.shared.activeDocumentURL - .map(languageIdentifierFromFileURL) ?? .plaintext - ).rawValue, - fileContent: content.content - ) - }, - handleCustomCommand: { command, prompt in - switch command.feature { - case let .chatWithSelection(extraSystemPrompt, _, useExtraSystemPrompt): - let service = ChatService() - return try await service.processMessage( - systemPrompt: nil, - extraSystemPrompt: (useExtraSystemPrompt ?? false) ? extraSystemPrompt : - nil, - prompt: prompt - ) - case let .customChat(systemPrompt, _): - let service = ChatService() - return try await service.processMessage( - systemPrompt: systemPrompt, - extraSystemPrompt: nil, - prompt: prompt - ) - case let .singleRoundDialog( - systemPrompt, - overwriteSystemPrompt, - _, - _ - ): - let service = ChatService() - return try await service.handleSingleRoundDialogCommand( - systemPrompt: systemPrompt, - overwriteSystemPrompt: overwriteSystemPrompt ?? false, - prompt: prompt - ) - case let .promptToCode(extraSystemPrompt, instruction, _, _): - let service = OpenAIPromptToCodeService() - - let result = try await service.modifyCode( - code: prompt, - requirement: instruction ?? "Modify content.", - source: .init( - language: .plaintext, - documentURL: .init(fileURLWithPath: "/"), - projectRootURL: .init(fileURLWithPath: "/"), - content: prompt, - lines: prompt.breakLines(), - range: .outOfScope - ), - isDetached: true, - extraSystemPrompt: extraSystemPrompt, - generateDescriptionRequirement: false - ) - var code = "" - for try await (newCode, _) in result { - code = newCode - } - return code - } - } - ) + 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 } -} -#else - -enum ChatTabFactory { - static func chatTabBuilderCollection() -> [ChatTabBuilderCollection] { - func folderIfNeeded( - _ builders: [any ChatTabBuilder], - title: String - ) -> ChatTabBuilderCollection? { - if builders.count > 1 { - return .folder(title: title, kinds: builders.map(ChatTabKind.init)) + 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) } - if let first = builders.first { return .kind(ChatTabKind(first)) } - return nil } - - return [ - folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name), - ].compactMap { $0 } + return (defaultChatTab, otherChatTabs) } } - -#endif - diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 4f17fc81..dfbd719a 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -1,27 +1,27 @@ import ActiveApplicationMonitor import AppActivator import AppKit +import BuiltinExtension import ChatGPTChatTab import ChatTab import ComposableArchitecture import Dependencies +import Logger import Preferences -import SuggestionModel +import SuggestionBasic import SuggestionWidget -#if canImport(ProChatTabs) -import ProChatTabs -#endif - #if canImport(ChatTabPersistent) import ChatTabPersistent #endif -struct GUI: ReducerProtocol { - struct State: Equatable { - var suggestionWidgetState = WidgetFeature.State() +@Reducer +struct GUI { + @ObservableState + struct State { + var suggestionWidgetState = Widget.State() - var chatTabGroup: ChatPanelFeature.ChatTabGroup { + var chatTabGroup: SuggestionWidget.ChatPanel.ChatTabGroup { get { suggestionWidgetState.chatPanelState.chatTabGroup } set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue } } @@ -52,12 +52,16 @@ struct GUI: ReducerProtocol { enum Action { case start - case openChatPanel(forceDetach: Bool) - case createChatGPTChatTabIfNeeded + case openChatPanel(forceDetach: Bool, activateThisApp: Bool) + case createAndSwitchToChatGPTChatTabIfNeeded + case createAndSwitchToChatTabIfNeededMatching( + check: (any ChatTab) -> Bool, + kind: ChatTabKind? + ) case sendCustomCommandToActiveChat(CustomCommand) case toggleWidgetsHotkeyPressed - case suggestionWidget(WidgetFeature.Action) + case suggestionWidget(Widget.Action) static func promptToCodeGroup(_ action: PromptToCodeGroup.Action) -> Self { .suggestionWidget(.panel(.sharedPanel(.promptToCodeGroup(action)))) @@ -75,15 +79,15 @@ struct GUI: ReducerProtocol { case updateChatTabOrder } - var body: some ReducerProtocol { + var body: some ReducerOf { CombineReducers { - Scope(state: \.suggestionWidgetState, action: /Action.suggestionWidget) { - WidgetFeature() + Scope(state: \.suggestionWidgetState, action: \.suggestionWidget) { + Widget() } Scope( state: \.chatTabGroup, - action: /Action.suggestionWidget .. /WidgetFeature.Action.chatPanel + action: \.suggestionWidget.chatPanel ) { Reduce { _, action in switch action { @@ -99,7 +103,7 @@ struct GUI: ReducerProtocol { chatTabPool.removeTab(of: id) } - case let .chatTab(_, .openNewTab(builder)): + case let .chatTab(.element(_, .openNewTab(builder))): return .run { send in if let (_, chatTabInfo) = await chatTabPool .createTab(from: builder.chatTabBuilder) @@ -115,7 +119,7 @@ struct GUI: ReducerProtocol { } #if canImport(ChatTabPersistent) - Scope(state: \.persistentState, action: /Action.persistent) { + Scope(state: \.persistentState, action: \.persistent) { ChatTabPersistent() } #endif @@ -131,7 +135,7 @@ struct GUI: ReducerProtocol { return .none #endif - case let .openChatPanel(forceDetach): + case let .openChatPanel(forceDetach, activate): return .run { send in await send( .suggestionWidget( @@ -140,17 +144,42 @@ struct GUI: ReducerProtocol { ) await send(.suggestionWidget(.updateKeyWindow(.chatPanel))) - activateThisApp() + if activate { + activateThisApp() + } } - case .createChatGPTChatTabIfNeeded: - if state.chatTabGroup.tabInfo.contains(where: { - chatTabPool.getTab(of: $0.id) is ChatGPTChatTab - }) { + 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: nil) { + if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) { await send( .suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo))) ) @@ -158,44 +187,35 @@ struct GUI: ReducerProtocol { } case let .sendCustomCommandToActiveChat(command): - @Sendable func stopAndHandleCommand(_ tab: ChatGPTChatTab) async { - if tab.service.isReceivingMessage { - await tab.service.stopReceivingMessage() - } - try? await tab.service.handleCustomCommand(command) - } - if let info = state.chatTabGroup.selectedTabInfo, - let activeTab = chatTabPool.getTab(of: info.id) as? ChatGPTChatTab + let tab = chatTabPool.getTab(of: info.id), + tab.handleCustomCommand(command) { return .run { send in - await send(.openChatPanel(forceDetach: false)) - await stopAndHandleCommand(activeTab) + await send(.openChatPanel(forceDetach: false, activateThisApp: false)) } } - if let info = state.chatTabGroup.tabInfo.first(where: { - chatTabPool.getTab(of: $0.id) is ChatGPTChatTab - }), - let chatTab = chatTabPool.getTab(of: info.id) as? ChatGPTChatTab - { - state.chatTabGroup.selectedTabId = chatTab.id - return .run { send in - await send(.openChatPanel(forceDetach: false)) - await stopAndHandleCommand(chatTab) + 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 - } + else { return } await send(.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))) - await send(.openChatPanel(forceDetach: false)) - if let chatTab = chatTab as? ChatGPTChatTab { - await stopAndHandleCommand(chatTab) - } + await send(.openChatPanel(forceDetach: false, activateThisApp: false)) + _ = chatTab.handleCustomCommand(command) } case .toggleWidgetsHotkeyPressed: @@ -203,7 +223,7 @@ struct GUI: ReducerProtocol { await send(.suggestionWidget(.circularWidget(.widgetClicked))) } - case let .suggestionWidget(.chatPanel(.chatTab(id, .tabContentUpdated))): + case let .suggestionWidget(.chatPanel(.chatTab(.element(id, .tabContentUpdated)))): #if canImport(ChatTabPersistent) // when a tab is updated, persist it. return .run { send in @@ -251,10 +271,9 @@ struct GUI: ReducerProtocol { @MainActor public final class GraphicalUserInterfaceController { - private let store: StoreOf + let store: StoreOf let widgetController: SuggestionWidgetController let widgetDataSource: WidgetDataSource - let viewStore: ViewStoreOf let chatTabPool: ChatTabPool class WeakStoreHolder { @@ -269,17 +288,6 @@ public final class GraphicalUserInterfaceController { dependencies.suggestionWidgetUserDefaultsObservers = .init() dependencies.chatTabPool = chatTabPool dependencies.chatTabBuilderCollection = ChatTabFactory.chatTabBuilderCollection - dependencies.promptToCodeAcceptHandler = { promptToCode in - Task { - let handler = PseudoCommandHandler() - await handler.acceptPromptToCode() - if !promptToCode.isContinuous { - NSWorkspace.activatePreviousActiveXcode() - } else { - NSWorkspace.activateThisApp() - } - } - } #if canImport(ChatTabPersistent) && canImport(ProChatTabs) dependencies.restoreChatTabInPool = { @@ -289,18 +297,17 @@ public final class GraphicalUserInterfaceController { } let store = StoreOf( initialState: .init(), - reducer: GUI(), - prepareDependencies: setupDependency + reducer: { GUI() }, + withDependencies: setupDependency ) self.store = store self.chatTabPool = chatTabPool - viewStore = ViewStore(store) widgetDataSource = .init() widgetController = SuggestionWidgetController( store: store.scope( state: \.suggestionWidgetState, - action: GUI.Action.suggestionWidget + action: \.suggestionWidget ), chatTabPool: chatTabPool, dependency: suggestionDependency @@ -309,20 +316,28 @@ public final class GraphicalUserInterfaceController { chatTabPool.createStore = { id in store.scope( state: { state in - state.chatTabGroup.tabInfo[id: id] - ?? .init(id: id, title: "") + state.chatTabGroup.tabInfo[id: id] ?? .init(id: id, title: "") }, action: { childAction in - .suggestionWidget(.chatPanel(.chatTab(id: id, action: childAction))) + .suggestionWidget(.chatPanel(.chatTab(.element( + id: id, + action: childAction + )))) } ) } suggestionDependency.suggestionWidgetDataSource = widgetDataSource - suggestionDependency.onOpenChatClicked = { [weak self] in - Task { [weak self] in - await self?.viewStore.send(.createChatGPTChatTabIfNeeded).finish() - self?.viewStore.send(.openChatPanel(forceDetach: false)) + 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 @@ -338,10 +353,7 @@ public final class GraphicalUserInterfaceController { } public func openGlobalChat() { - Task { - await self.viewStore.send(.createChatGPTChatTabIfNeeded).finish() - viewStore.send(.openChatPanel(forceDetach: true)) - } + PseudoCommandHandler().openChat(forceDetach: true) } } @@ -354,7 +366,7 @@ extension ChatTabPool { let id = id let info = ChatTabInfo(id: id, title: "") guard let chatTap = await builder.build(store: createStore(id)) else { return nil } - setTab(chatTap) + setTab(chatTap, forId: id) return (chatTap, info) } @@ -364,56 +376,45 @@ extension ChatTabPool { ) async -> (any ChatTab, ChatTabInfo)? { let id = UUID().uuidString let info = ChatTabInfo(id: id, title: "") - guard let builder = kind?.builder else { - let chatTap = ChatGPTChatTab(store: createStore(id)) - setTab(chatTap) - return (chatTap, info) - } - + 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) + setTab(chatTap, forId: id) return (chatTap, info) } - #if canImport(ChatTabPersistent) && canImport(ProChatTabs) + #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, - externalDependency: () - ) else { break } - return await createTab(id: data.id, from: builder) - case EmptyChatTab.name: - guard let builder = try? await EmptyChatTab.restore( - from: data.data, - externalDependency: () - ) else { break } - return await createTab(id: data.id, from: builder) - case BrowserChatTab.name: - guard let builder = try? BrowserChatTab.restore( - from: data.data, - externalDependency: ChatTabFactory.externalDependenciesForBrowserChatTab() - ) else { break } - return await createTab(id: data.id, from: builder) - case TerminalChatTab.name: - guard let builder = try? await TerminalChatTab.restore( - from: data.data, - externalDependency: () - ) else { break } + guard let builder = try? await ChatGPTChatTab.restore(from: data.data) + else { fallthrough } return await createTab(id: data.id, from: builder) default: - break + 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, externalDependency: () - ) else { - return nil - } + 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/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index 6233a6e5..ae1b6371 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -7,14 +7,14 @@ import Foundation import GitHubCopilotService import OpenAIService import PromptToCodeService -import SuggestionModel +import SuggestionBasic import SuggestionWidget @MainActor final class WidgetDataSource {} extension WidgetDataSource: SuggestionWidgetDataSource { - func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? { + 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 @@ -25,39 +25,9 @@ extension WidgetDataSource: SuggestionWidgetDataSource { startLineIndex: suggestion.position.line, suggestionCount: filespace.suggestions.count, currentSuggestionIndex: filespace.suggestionIndex, - onSelectPreviousSuggestionTapped: { - Task { - let handler = PseudoCommandHandler() - await handler.presentPreviousSuggestion() - } - }, - onSelectNextSuggestionTapped: { - Task { - let handler = PseudoCommandHandler() - await handler.presentNextSuggestion() - } - }, - onRejectSuggestionTapped: { - Task { - let handler = PseudoCommandHandler() - await handler.rejectSuggestions() - NSWorkspace.activatePreviousActiveXcode() - } - }, - onAcceptSuggestionTapped: { - Task { - let handler = PseudoCommandHandler() - await handler.acceptSuggestion() - NSWorkspace.activatePreviousActiveXcode() - } - }, - onDismissSuggestionTapped: { - Task { - let handler = PseudoCommandHandler() - await handler.dismissSuggestion() - NSWorkspace.activatePreviousActiveXcode() - } - } + replacingRange: suggestion.range, + replacingLines: suggestion.replacingLines, + descriptions: suggestion.descriptions ) } } diff --git a/Core/Sources/Service/GlobalShortcutManager.swift b/Core/Sources/Service/GlobalShortcutManager.swift index 3ed6a69c..a3bb32b4 100644 --- a/Core/Sources/Service/GlobalShortcutManager.swift +++ b/Core/Sources/Service/GlobalShortcutManager.swift @@ -11,7 +11,7 @@ extension KeyboardShortcuts.Name { @MainActor final class GlobalShortcutManager { let guiController: GraphicalUserInterfaceController - private var cancellable = Set() + private var activeAppChangeTask: Task? nonisolated init(guiController: GraphicalUserInterfaceController) { self.guiController = guiController @@ -25,31 +25,39 @@ final class GlobalShortcutManager { let isXCodeActive = XcodeInspector.shared.activeXcode != nil if !isXCodeActive, - !guiController.viewStore.state.suggestionWidgetState.chatPanelState.isPanelDisplayed, + !guiController.store.state.suggestionWidgetState.chatPanelState.isPanelDisplayed, UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) { - guiController.viewStore.send(.openChatPanel(forceDetach: true)) + guiController.store.send(.openChatPanel(forceDetach: true, activateThisApp: true)) } else { - guiController.viewStore.send(.toggleWidgetsHotkeyPressed) + guiController.store.send(.toggleWidgetsHotkeyPressed) } } - XcodeInspector.shared.$activeApplication.sink { app in - if !UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) { - let shouldBeEnabled = if let app, app.isXcode || app.isExtensionService { - true + 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 { - false + await self.setupShortcutIfNeeded() } - if shouldBeEnabled { - self.setupShortcutIfNeeded() - } else { - self.removeShortcutIfNeeded() - } - } else { - self.setupShortcutIfNeeded() } - }.store(in: &cancellable) + } } func setupShortcutIfNeeded() { diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 27965c7a..39770260 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -2,7 +2,6 @@ import ActiveApplicationMonitor import AppKit import AsyncAlgorithms import AXExtension -import Combine import Foundation import Logger import Preferences @@ -11,7 +10,7 @@ import Workspace import XcodeInspector public actor RealtimeSuggestionController { - private var cancellable: Set = [] + private var xcodeChangeObservationTask: Task? private var inflightPrefetchTask: Task? private var editorObservationTask: Task? private var sourceEditor: SourceEditor? @@ -19,7 +18,6 @@ public actor RealtimeSuggestionController { init() {} deinit { - cancellable.forEach { $0.cancel() } inflightPrefetchTask?.cancel() editorObservationTask?.cancel() } @@ -30,16 +28,18 @@ public actor RealtimeSuggestionController { } private func observeXcodeChange() { - cancellable.forEach { $0.cancel() } + xcodeChangeObservationTask?.cancel() - XcodeInspector.shared.$focusedEditor - .sink { [weak self] editor in + xcodeChangeObservationTask = Task { [weak self] in + for await _ in NotificationCenter.default + .notifications(named: .focusedEditorDidChange) + { guard let self else { return } - Task { - guard let editor else { return } - await self.handleFocusElementChange(editor) - } - }.store(in: &cancellable) + try Task.checkCancellation() + guard let editor = await XcodeInspector.shared.focusedEditor else { continue } + await self.handleFocusElementChange(editor) + } + } } private func handleFocusElementChange(_ sourceEditor: SourceEditor) { @@ -51,7 +51,7 @@ public actor RealtimeSuggestionController { editorObservationTask = nil editorObservationTask = Task { [weak self] in - if let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL { + if let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL { await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( fileURL: fileURL, sourceEditor: sourceEditor @@ -86,7 +86,7 @@ public actor RealtimeSuggestionController { } group.addTask { let handler = { - guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL + guard let fileURL = await XcodeInspector.shared.activeDocumentURL else { return } await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( fileURL: fileURL, @@ -113,7 +113,8 @@ public actor RealtimeSuggestionController { Task { @WorkspaceActor in // Get cache ready for real-time suggestions. guard UserDefaults.shared.value(for: \.preCacheOnFileOpen) else { return } - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + 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) @@ -123,8 +124,8 @@ public actor RealtimeSuggestionController { // avoid the command get called twice filespace.codeMetadata.uti = "" do { - try await XcodeInspector.shared.safe.latestActiveXcode? - .triggerCopilotCommand(name: "Real-time Suggestions") + try await XcodeInspector.shared.latestActiveXcode? + .triggerCopilotCommand(name: "Prepare for Real-time Suggestions") } catch { if filespace.codeMetadata.uti?.isEmpty ?? true { filespace.codeMetadata.uti = nil @@ -147,7 +148,7 @@ public actor RealtimeSuggestionController { else { return } if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally), - let fileURL = await XcodeInspector.shared.safe.activeDocumentURL, + let fileURL = await XcodeInspector.shared.activeDocumentURL, let (workspace, _) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) { @@ -163,10 +164,10 @@ public actor 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 Service.shared.workspacePool.workspaces { + for (_, workspace) in workspaces { group.addTask { await workspace.cancelInFlightRealtimeSuggestionRequests() } @@ -184,7 +185,7 @@ public actor RealtimeSuggestionController { } func notifyEditingFileChange(editor: AXUIElement) async { - guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL, + guard let fileURL = await XcodeInspector.shared.activeDocumentURL, let (workspace, _) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 7e96e113..b011bd78 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -1,6 +1,7 @@ import ActiveApplicationMonitor import AppKit import AXExtension +import BuiltinExtension import Foundation import Logger import Workspace @@ -32,8 +33,8 @@ public final class ScheduledCleaner { @ServiceActor func cleanUp() async { guard let service else { return } - - let workspaceInfos = XcodeInspector.shared.xcodes.reduce( + + let workspaceInfos = await XcodeInspector.shared.xcodes.reduce( into: [ XcodeAppInstanceInspector.WorkspaceIdentifier: XcodeAppInstanceInspector.WorkspaceInfo @@ -52,7 +53,7 @@ public final class ScheduledCleaner { if workspace.isExpired, workspaceInfos[.url(url)] == nil { Logger.service.info("Remove idle workspace") _ = await Task { @MainActor in - service.guiController.viewStore.send( + service.guiController.store.send( .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: Array( workspace.filespaces.keys ))) @@ -72,7 +73,7 @@ public final class ScheduledCleaner { ) { Logger.service.info("Remove idle filespace") _ = await Task { @MainActor in - service.guiController.viewStore.send( + service.guiController.store.send( .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: [url])) ) }.result @@ -82,7 +83,7 @@ public final class ScheduledCleaner { await workspace.cleanUp(availableTabs: tabs) } } - + #if canImport(ProService) await service.proService.cleanUp(workspaceInfos: workspaceInfos) #endif @@ -90,10 +91,7 @@ public final class ScheduledCleaner { @ServiceActor public func closeAllChildProcesses() async { - guard let service else { return } - for (_, workspace) in service.workspacePool.workspaces { - await workspace.terminateSuggestionService() - } + BuiltinExtensionManager.shared.terminate() } } diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index d02d8ef3..b1702924 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -1,13 +1,20 @@ +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 @@ -19,15 +26,19 @@ import ProService /// The running extension service. public final class Service { + @MainActor public static let shared = Service() - @WorkspaceActor - let workspacePool: WorkspacePool + @Dependency(\.workspacePool) var workspacePool @MainActor - public let guiController = GraphicalUserInterfaceController() - public let realtimeSuggestionController = RealtimeSuggestionController() + 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 @@ -36,29 +47,44 @@ public final class Service { @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() - workspacePool.registerPlugin { - SuggestionServiceWorkspacePlugin(workspace: $0) { projectRootURL, onLaunched in - SuggestionService(projectRootURL: projectRootURL, onServiceLaunched: onLaunched) - } - } - self.workspacePool = workspacePool - globalShortcutManager = .init(guiController: guiController) + overlayWindowController = .init() #if canImport(ProService) - proService = ProService( - acceptSuggestion: { - Task { await PseudoCommandHandler().acceptSuggestion() } - }, - dismissSuggestion: { - Task { await PseudoCommandHandler().dismissSuggestion() } - } - ) + 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 } @@ -67,25 +93,41 @@ public final class Service { scheduledCleaner.start() realtimeSuggestionController.start() guiController.start() + xcodeThemeController.start() #if canImport(ProService) proService.start() #endif + overlayWindowController.start() DependencyUpdater().update() globalShortcutManager.start() + keyBindingManager.start() - Task { - await XcodeInspector.shared.safe.$activeDocumentURL - .removeDuplicates() - .filter { $0 != .init(fileURLWithPath: "/") } - .compactMap { $0 } - .sink { [weak self] fileURL in - Task { - try await self?.workspacePool - .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) - } - }.store(in: &cancellable) + 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 { @@ -96,12 +138,44 @@ public extension Service { ) { do { #if canImport(ProService) - try Service.shared.proService.handleXPCServiceRequests( + 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 { diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index d6de7239..c1d38d78 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -1,18 +1,33 @@ import ActiveApplicationMonitor import AppKit +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 } @@ -23,6 +38,7 @@ struct PseudoCommandHandler { lines: [], uti: "", cursorPosition: .outOfScope, + cursorOffset: -1, selections: [], tabSize: 0, indentSize: 0, @@ -37,6 +53,7 @@ struct PseudoCommandHandler { lines: [], uti: "", cursorPosition: .outOfScope, + cursorOffset: -1, selections: [], tabSize: 0, indentSize: 0, @@ -75,7 +92,7 @@ struct PseudoCommandHandler { } let snapshot = FilespaceSuggestionSnapshot( - linesHash: editor.lines.hashValue, + lines: editor.lines, cursorPosition: editor.cursorPosition ) @@ -98,7 +115,16 @@ struct PseudoCommandHandler { } 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 } } @@ -128,6 +154,7 @@ struct PseudoCommandHandler { lines: [], uti: "", cursorPosition: .outOfScope, + cursorOffset: -1, selections: [], tabSize: 0, indentSize: 0, @@ -148,6 +175,7 @@ struct PseudoCommandHandler { lines: [], uti: "", cursorPosition: .outOfScope, + cursorOffset: -1, selections: [], tabSize: 0, indentSize: 0, @@ -159,7 +187,7 @@ struct PseudoCommandHandler { } }() else { do { - try await XcodeInspector.shared.safe.latestActiveXcode? + try await XcodeInspector.shared.latestActiveXcode? .triggerCopilotCommand(name: command.name) } catch { let presenter = PresentInWindowSuggestionPresenter() @@ -177,14 +205,91 @@ struct PseudoCommandHandler { } } - func acceptPromptToCode() async { + 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, + cursorOffset + ) = await getFileContent(sourceEditor: nil) + else { + PresentInWindowSuggestionPresenter() + .presentErrorMessage("Unable to get file content.") + return + } + let handler = WindowBaseCommandHandler() + do { + 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 } + + try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) + } catch { + PresentInWindowSuggestionPresenter().presentError(error) + } + } + } + + 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))) + } + + func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async { do { if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { throw CancellationError() } do { - try await XcodeInspector.shared.safe.latestActiveXcode? - .triggerCopilotCommand(name: "Accept Prompt to Code") + try await XcodeInspector.shared.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Suggestion Line") } catch { let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI let now = Date() @@ -194,7 +299,7 @@ struct PseudoCommandHandler { 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) + """, type: .warning, duration: 10) } throw error @@ -206,7 +311,13 @@ struct PseudoCommandHandler { 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.") @@ -214,11 +325,12 @@ struct PseudoCommandHandler { } let handler = WindowBaseCommandHandler() do { - guard let result = try await handler.acceptPromptToCode(editor: .init( + guard let result = try await handler.acceptSuggestion(editor: .init( content: content, lines: lines, uti: "", cursorPosition: cursorPosition, + cursorOffset: cursorOffset, selections: [], tabSize: 0, indentSize: 0, @@ -238,7 +350,7 @@ struct PseudoCommandHandler { throw CancellationError() } do { - try await XcodeInspector.shared.safe.latestActiveXcode? + try await XcodeInspector.shared.latestActiveXcode? .triggerCopilotCommand(name: "Accept Suggestion") } catch { let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI @@ -249,7 +361,7 @@ struct PseudoCommandHandler { 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) + """, type: .warning, duration: 10) } throw error @@ -261,7 +373,13 @@ struct PseudoCommandHandler { 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.") @@ -274,6 +392,7 @@ struct PseudoCommandHandler { lines: lines, uti: "", cursorPosition: cursorPosition, + cursorOffset: cursorOffset, selections: [], tabSize: 0, indentSize: 0, @@ -288,12 +407,183 @@ struct PseudoCommandHandler { } func dismissSuggestion() async { - guard let documentURL = await XcodeInspector.shared.safe.activeDocumentURL else { return } + 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() - PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL) + } + + 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) + } } } @@ -320,8 +610,8 @@ extension PseudoCommandHandler { // recover selection range - if let selection = result.newSelection { - var range = convertCursorRangeToRange(selection, in: result.content) + if let selection = result.newSelections.first { + var range = SourceEditor.convertCursorRangeToRange(selection, in: result.content) if let value = AXValueCreate(.cfRange, &range) { AXUIElementSetAttributeValue( focusElement, @@ -361,7 +651,8 @@ extension PseudoCommandHandler { content: String, lines: [String], selections: [CursorRange], - cursorPosition: CursorPosition + cursorPosition: CursorPosition, + cursorOffset: Int )? { guard let xcode = ActiveApplicationMonitor.shared.activeXcode @@ -373,12 +664,12 @@ extension PseudoCommandHandler { guard let selectionRange = focusElement.selectedTextRange else { return nil } let content = focusElement.value let split = content.breakLines(appendLineBreakToLastLine: false) - let range = convertRangeToCursorRange(selectionRange, in: content) - return (content, split, [range], range.start) + let range = SourceEditor.convertRangeToCursorRange(selectionRange, in: content) + return (content, split, [range], range.start, selectionRange.lowerBound) } func getFileURL() async -> URL? { - await XcodeInspector.shared.safe.realtimeActiveDocumentURL + XcodeInspector.shared.realtimeActiveDocumentURL } @WorkspaceActor @@ -396,7 +687,7 @@ extension PseudoCommandHandler { guard let filespace = await getFilespace(), let sourceEditor = await { if let sourceEditor { sourceEditor } - else { await XcodeInspector.shared.safe.focusedEditor } + else { await XcodeInspector.shared.latestFocusedEditor } }() else { return nil } if Task.isCancelled { return nil } @@ -410,6 +701,7 @@ extension PseudoCommandHandler { lines: content.lines, uti: uti, cursorPosition: content.cursorPosition, + cursorOffset: content.cursorOffset, selections: content.selections.map { .init(start: $0.start, end: $0.end) }, @@ -419,54 +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 } + + return try await acceptSuggestionLineInGroup( + atIndex: 0, + editor: editor + ) } - 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 + 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 35eec4fb..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,6 +11,8 @@ 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? @@ -19,9 +21,8 @@ protocol SuggestionCommandHandler { @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 d4f0d52e..53b0c833 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -1,12 +1,15 @@ import AppKit import ChatService +import ComposableArchitecture +import CustomCommandTemplateProcessor import Foundation import GitHubCopilotService import LanguageServerProtocol import Logger +import ModificationBasic import OpenAIService +import SuggestionBasic import SuggestionInjector -import SuggestionModel import SuggestionWidget import UserNotifications import Workspace @@ -39,7 +42,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { defer { presenter.markAsProcessing(false) } - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -73,7 +76,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor private func _presentNextSuggestion(editor: EditorContent) async throws { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -99,7 +102,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor private func _presentPreviousSuggestion(editor: EditorContent) async throws { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -125,7 +128,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor private func _rejectSuggestion(editor: EditorContent) async throws { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, _) = try await Service.shared.workspacePool @@ -136,7 +139,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let (workspace, _) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -169,8 +172,34 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { 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, + completion: acceptedSuggestion, + extraInfo: &extraInfo + ) + + return .init( + content: String(lines.joined(separator: "")), + newSelection: .cursor(cursorPosition), + modifications: extraInfo.modifications + ) + } + + return nil + } + func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let injector = SuggestionInjector() @@ -178,58 +207,69 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { var cursorPosition = editor.cursorPosition var extraInfo = SuggestionInjector.ExtraInfo() - let viewStore = Service.shared.guiController.viewStore + let store = await Service.shared.guiController.store - if let promptToCode = viewStore.state.promptToCodeGroup.activePromptToCode { - if promptToCode.isAttachedToSelectionRange, promptToCode.documentURL != fileURL { + if let promptToCode = await MainActor + .run(body: { store.state.promptToCodeGroup.activePromptToCode }) + { + if promptToCode.promptToCodeState.isAttachedToTarget, + promptToCode.promptToCodeState.source.documentURL != fileURL + { return nil } - let range = { - if promptToCode.isAttachedToSelectionRange, - let range = promptToCode.selectionRange - { - return range + 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 + ) } - return editor.selections.first.map { - CursorRange(start: $0.start, end: $0.end) - } ?? CursorRange( - start: editor.cursorPosition, - end: editor.cursorPosition - ) - }() - - let suggestion = CodeSuggestion( - id: UUID().uuidString, - text: promptToCode.code, - position: range.start, - range: range - ) - injector.acceptSuggestion( + injector.acceptSuggestions( intoContentWithoutSuggestion: &lines, cursorPosition: &cursorPosition, - completion: suggestion, + completions: suggestions, extraInfo: &extraInfo ) - _ = await Task { @MainActor [cursorPosition] in - viewStore.send( - .promptToCodeGroup(.updatePromptToCodeRange( - id: promptToCode.id, - range: .init(start: range.start, end: cursorPosition) - )) - ) - viewStore.send( + 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 )) ) - }.result + } return .init( content: String(lines.joined(separator: "")), - newSelection: .init(start: range.start, end: cursorPosition), + newSelections: extraInfo.modificationRanges.values + .sorted(by: { $0.start.line <= $1.start.line }), modifications: extraInfo.modifications ) } @@ -246,7 +286,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor func prepareCache(editor: EditorContent) async throws -> UpdatedContent? { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let (_, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -262,15 +302,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return try await presentSuggestions(editor: editor) } - func chatWithSelection(editor: EditorContent) async throws -> UpdatedContent? { - Task { @MainActor in - let viewStore = Service.shared.guiController.viewStore - viewStore.send(.createChatGPTChatTabIfNeeded) - viewStore.send(.openChatPanel(forceDetach: false)) - } - return nil - } - func promptToCode(editor: EditorContent) async throws -> UpdatedContent? { Task { do { @@ -314,7 +345,7 @@ extension WindowBaseCommandHandler { switch command.feature { case .chatWithSelection, .customChat: Task { @MainActor in - Service.shared.guiController.viewStore + Service.shared.guiController.store .send(.sendCustomCommandToActiveChat(command)) } case let .promptToCode(extraSystemPrompt, prompt, continuousMode, generateDescription): @@ -350,9 +381,9 @@ extension WindowBaseCommandHandler { generateDescription: Bool?, name: String? ) async throws { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } - let (workspace, filespace) = try await Service.shared.workspacePool + 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") @@ -361,23 +392,45 @@ extension WindowBaseCommandHandler { 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 } }() @@ -386,50 +439,55 @@ 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 viewStore = Service.shared.guiController.viewStore + let store = await Service.shared.guiController.store let customCommandTemplateProcessor = CustomCommandTemplateProcessor() - + let newExtraSystemPrompt: String? = if let extraSystemPrompt { await customCommandTemplateProcessor.process(extraSystemPrompt) } else { nil } - + let newPrompt: String? = if let prompt { await customCommandTemplateProcessor.process(prompt) } else { nil } - _ = await Task { @MainActor in - // if there is already a prompt to code presenting, we should not present another one - viewStore.send(.promptToCodeGroup(.activateOrCreatePromptToCode(.init( - code: code, - selectionRange: selection, - language: codeLanguage, - identSize: filespace.codeMetadata.indentSize ?? 4, - usesTabsForIndentation: filespace.codeMetadata.usesTabsForIndentation ?? false, - documentURL: fileURL, - projectRootURL: workspace.projectRootURL, - allCode: editor.content, - allLines: editor.lines, - isContinuous: isContinuous, + _ = 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, - defaultPrompt: newPrompt ?? "", - extraSystemPrompt: newExtraSystemPrompt, - generateDescriptionRequirement: generateDescription + isContinuous: isContinuous )))) - }.result + } } func executeSingleRoundDialog( diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift index e2568f9f..7069422b 100644 --- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -1,7 +1,7 @@ import ChatService import Foundation import OpenAIService -import SuggestionModel +import SuggestionBasic import SuggestionWidget struct PresentInWindowSuggestionPresenter { @@ -42,17 +42,10 @@ struct PresentInWindowSuggestionPresenter { } } - func closeChatRoom(fileURL: URL) { - Task { @MainActor in - let controller = Service.shared.guiController.widgetController - controller.closeChatRoom() - } - } - func presentChatRoom(fileURL: URL) { Task { @MainActor in - let controller = Service.shared.guiController.widgetController - controller.presentChatRoom() + let controller = Service.shared.guiController + controller.store.send(.openChatPanel(forceDetach: false, activateThisApp: true)) } } } diff --git a/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift index 6841a24c..d154aade 100644 --- a/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift +++ b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift @@ -1,6 +1,6 @@ import Foundation -import Workspace import SuggestionProvider +import Workspace import WorkspaceSuggestionService extension Workspace { @@ -8,9 +8,6 @@ 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) closeFilespace(fileURL: fileURL) } @@ -26,10 +23,10 @@ extension Workspace { func cancelInFlightRealtimeSuggestionRequests() async { guard let suggestionService else { return } - await suggestionService.cancelRequest() - } - - func terminateSuggestionService() async { - await suggestionPlugin?.terminateSuggestionService() + await suggestionService.cancelRequest(workspaceInfo: .init( + workspaceURL: workspaceURL, + projectURL: projectRootURL + )) } } + diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 3c2cf47b..b8c70126 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -140,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( @@ -177,10 +177,16 @@ public class XPCService: NSObject, XPCServiceProtocol { } Task { @ServiceActor in await Service.shared.realtimeSuggestionController.cancelInFlightTasks() - UserDefaults.shared.set( - !UserDefaults.shared.value(for: \.realtimeSuggestionToggle), - for: \.realtimeSuggestionToggle - ) + 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) } } @@ -189,6 +195,13 @@ public class XPCService: NSObject, XPCServiceProtocol { reply() NotificationCenter.default.post(name: .init(name), object: nil) } + + public func quit(reply: @escaping () -> Void) { + Task { + await Service.shared.prepareForExit() + reply() + } + } // MARK: - Requests @@ -197,11 +210,13 @@ public class XPCService: NSObject, XPCServiceProtocol { requestBody: Data, reply: @escaping (Data?, Error?) -> Void ) { - Service.shared.handleXPCServiceRequests( - endpoint: endpoint, - requestBody: requestBody, - reply: reply - ) + Task { + await Service.shared.handleXPCServiceRequests( + endpoint: endpoint, + requestBody: requestBody, + reply: reply + ) + } } } diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index d20b3f1b..335f0c83 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -1,8 +1,14 @@ +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 #if canImport(ProExtension) import ProExtension @@ -11,119 +17,99 @@ import ProExtension public protocol SuggestionServiceType: SuggestionServiceProvider {} public actor SuggestionService: SuggestionServiceType { - public var configuration: SuggestionServiceConfiguration { + public typealias Middleware = SuggestionServiceMiddleware + public typealias EventHandler = SuggestionServiceEventHandler + public var configuration: SuggestionProvider.SuggestionServiceConfiguration { get async { await suggestionProvider.configuration } } - var middlewares: [SuggestionServiceMiddleware] { - SuggestionServiceMiddlewareContainer.middlewares - } - - let projectRootURL: URL - let onServiceLaunched: (SuggestionServiceProvider) -> Void - let providerChangeObserver = UserDefaultsObserver( - object: UserDefaults.shared, - forKeyPaths: [UserDefaultPreferenceKeys().suggestionFeatureProvider.key], - context: nil - ) - - 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 (SuggestionServiceProvider) -> Void + provider: any SuggestionServiceProvider, + middlewares: [Middleware] = SuggestionServiceMiddlewareContainer.middlewares, + eventHandlers: [EventHandler] = SuggestionServiceEventHandlerContainer.handlers ) { - self.projectRootURL = projectRootURL - self.onServiceLaunched = onServiceLaunched - - providerChangeObserver.onChange = { [weak self] in - Task { [weak self] in - guard let self else { return } - await rebuildService() - } - } + suggestionProvider = provider + self.middlewares = middlewares + self.eventHandlers = eventHandlers } - func buildService() -> SuggestionServiceProvider { + public static func service( + for serviceType: SuggestionFeatureProvider = UserDefaults.shared + .value(for: \.suggestionFeatureProvider) + ) -> SuggestionService { #if canImport(ProExtension) if let provider = ProExtension.suggestionProviderFactory(serviceType) { - return provider + return SuggestionService(provider: provider) } #endif switch serviceType { case .builtIn(.codeium): - return CodeiumSuggestionProvider( - projectRootURL: projectRootURL, - onServiceLaunched: onServiceLaunched + let provider = BuiltinExtensionSuggestionServiceProvider( + extension: CodeiumExtension.self ) + return SuggestionService(provider: provider) case .builtIn(.gitHubCopilot), .extension: - return GitHubCopilotSuggestionProvider( - projectRootURL: projectRootURL, - onServiceLaunched: onServiceLaunched + let provider = BuiltinExtensionSuggestionServiceProvider( + extension: GitHubCopilotExtension.self ) + return SuggestionService(provider: provider) } } - - func rebuildService() { - suggestionProvider = buildService() - } } public extension SuggestionService { func getSuggestions( - _ request: SuggestionRequest - ) async throws -> [SuggestionModel.CodeSuggestion] { - var getSuggestion = suggestionProvider.getSuggestions - let configuration = await configuration - - for middleware in middlewares.reversed() { - getSuggestion = { [getSuggestion] request in - try await middleware.getSuggestion( - request, - configuration: configuration, - next: getSuggestion - ) + _ 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) + } + ) + } } - } - return try await getSuggestion(request) - } - - 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) - } - - 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/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 2855f142..58c6f4d7 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -4,30 +4,17 @@ import ChatGPTChatTab import ChatTab import ComposableArchitecture import SwiftUI +import SharedUIComponents private let r: Double = 8 struct ChatWindowView: View { - let store: StoreOf + let store: StoreOf let toggleVisibility: (Bool) -> Void - struct OverallState: Equatable { - var isPanelDisplayed: Bool - var colorScheme: ColorScheme - var selectedTabId: String? - } - var body: some View { - WithViewStore( - store, - observe: { - OverallState( - isPanelDisplayed: $0.isPanelDisplayed, - colorScheme: $0.colorScheme, - selectedTabId: $0.chatTabGroup.selectedTabId - ) - } - ) { viewStore in + WithPerceptionTracking { + let _ = store.chatTabGroup.selectedTabId // force re-evaluation VStack(spacing: 0) { Rectangle().fill(.regularMaterial).frame(height: 28) @@ -35,54 +22,55 @@ struct ChatWindowView: View { ChatTabBar(store: store) .frame(height: 26) + .clipped() Divider() ChatTabContainer(store: store) .frame(maxWidth: .infinity, maxHeight: .infinity) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) - .onChange(of: viewStore.state.isPanelDisplayed) { isDisplayed in + .onChange(of: store.isPanelDisplayed) { isDisplayed in toggleVisibility(isDisplayed) } - .preferredColorScheme(viewStore.state.colorScheme) + .preferredColorScheme(store.colorScheme) } } } struct ChatTitleBar: View { - let store: StoreOf + let store: StoreOf @State var isHovering = false var body: some View { - HStack(spacing: 6) { - Button(action: { - store.send(.closeActiveTabClicked) - }) { - EmptyView() - } - .opacity(0) - .keyboardShortcut("w", modifiers: [.command]) + WithPerceptionTracking { + HStack(spacing: 6) { + Button(action: { + store.send(.closeActiveTabClicked) + }) { + EmptyView() + } + .opacity(0) + .keyboardShortcut("w", modifiers: [.command]) - Button( - action: { - store.send(.hideButtonClicked) + Button( + action: { + store.send(.hideButtonClicked) + } + ) { + Image(systemName: "minus") + .foregroundStyle(.black.opacity(0.5)) + .font(Font.system(size: 8).weight(.heavy)) } - ) { - Image(systemName: "minus") - .foregroundStyle(.black.opacity(0.5)) - .font(Font.system(size: 8).weight(.heavy)) - } - .opacity(0) - .keyboardShortcut("m", modifiers: [.command]) + .opacity(0) + .keyboardShortcut("m", modifiers: [.command]) - Spacer() + Spacer() - WithViewStore(store, observe: { $0.chatPanelInASeparateWindow }) { viewStore in TrafficLightButton( isHovering: isHovering, - isActive: viewStore.state, + isActive: store.isDetached, color: Color(nsColor: .systemCyan), action: { store.send(.toggleChatPanelDetachedButtonClicked) @@ -94,12 +82,12 @@ struct ChatTitleBar: View { .transformEffect(.init(translationX: 0, y: 0.5)) } } + .buttonStyle(.plain) + .padding(.trailing, 8) + .onHover(perform: { hovering in + isHovering = hovering + }) } - .buttonStyle(.plain) - .padding(.trailing, 8) - .onHover(perform: { hovering in - isHovering = hovering - }) } struct TrafficLightButton: View { @@ -139,7 +127,7 @@ struct ChatTitleBar: View { } } -private extension View { +extension View { func hideScrollIndicator() -> some View { if #available(macOS 13.0, *) { return scrollIndicators(.hidden) @@ -150,136 +138,149 @@ private extension View { } struct ChatTabBar: View { - let store: StoreOf + let store: StoreOf struct TabBarState: Equatable { var tabInfo: IdentifiedArray var selectedTabId: String } - @Environment(\.chatTabPool) var chatTabPool - @State var draggingTabId: String? - var body: some View { - WithViewStore( - store, - observe: { TabBarState( - tabInfo: $0.chatTabGroup.tabInfo, - selectedTabId: $0.chatTabGroup.selectedTabId - ?? $0.chatTabGroup.tabInfo.first?.id ?? "" - ) } - ) { viewStore in - HStack(spacing: 0) { + 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(viewStore.state.tabInfo, id: \.id) { info in - if let tab = chatTabPool.getTab(of: info.id) { - ChatTabBarButton( - store: store, - info: info, - content: { tab.tabItem }, - icon: { tab.icon }, - isSelected: info.id == viewStore.state.selectedTabId - ) - .contextMenu { - tab.menu - } - .id(info.id) - .onDrag { - draggingTabId = info.id - return NSItemProvider(object: info.id as NSString) - } - .onDrop( - of: [.text], - delegate: ChatTabBarDropDelegate( + ForEach(tabInfo, id: \.id) { info in + WithPerceptionTracking { + if let tab = chatTabPool.getTab(of: info.id) { + ChatTabBarButton( store: store, - tabs: viewStore.state.tabInfo, - itemId: info.id, - draggingTabId: $draggingTabId + 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 { - EmptyView() + } else { + ChatTabBarButton( + store: store, + info: info, + content: { Text("Not Found") }, + icon: { Image(systemName: "questionmark.diamond") }, + isSelected: info.id == selectedTabId + ) + } } } } } .hideScrollIndicator() - .onChange(of: viewStore.selectedTabId) { id in + .onChange(of: selectedTabId) { id in withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo(id) } } } - - Divider() - - createButton } } - .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]) - } } - @ViewBuilder - var createButton: some View { - Menu { - WithViewStore(store, observe: { $0.chatTabGroup.tabCollection }) { viewStore in - ForEach(0.. + + var body: some View { + WithPerceptionTracking { + let collection = store.chatTabGroup.tabCollection + Menu { + ForEach(0.. + let store: StoreOf let tabs: IdentifiedArray let itemId: String @Binding var draggingTabId: String? @@ -303,7 +304,7 @@ struct ChatTabBarDropDelegate: DropDelegate { } struct ChatTabBarButton: View { - let store: StoreOf + let store: StoreOf let info: ChatTabInfo let content: () -> Content let icon: () -> Icon @@ -348,33 +349,23 @@ struct ChatTabBarButton: View { } struct ChatTabContainer: View { - let store: StoreOf - - struct TabContainerState: Equatable { - var tabInfo: IdentifiedArray - var selectedTabId: String? - } - + let store: StoreOf @Environment(\.chatTabPool) var chatTabPool var body: some View { - WithViewStore( - store, - observe: { - TabContainerState( - tabInfo: $0.chatTabGroup.tabInfo, - selectedTabId: $0.chatTabGroup.selectedTabId - ?? $0.chatTabGroup.tabInfo.first?.id ?? "" - ) - } - ) { viewStore in + WithPerceptionTracking { + let tabInfo = store.chatTabGroup.tabInfo + let selectedTabId = store.chatTabGroup.selectedTabId + ?? store.chatTabGroup.tabInfo.first?.id + ?? "" + ZStack { - if viewStore.state.tabInfo.isEmpty { + if tabInfo.isEmpty { Text("Empty") } else { - ForEach(viewStore.state.tabInfo) { tabInfo in + ForEach(tabInfo) { tabInfo in if let tab = chatTabPool.getTab(of: tabInfo.id) { - let isActive = tab.id == viewStore.state.selectedTabId + let isActive = tab.id == selectedTabId tab.body .opacity(isActive ? 1 : 0) .disabled(!isActive) @@ -386,7 +377,7 @@ struct ChatTabContainer: View { anchor: .topLeading ) } else { - EmptyView() + Text("404 Not Found") } } } @@ -417,8 +408,8 @@ struct ChatWindowView_Previews: PreviewProvider { "7": EmptyChatTab(id: "7"), ]) - static func createStore() -> StoreOf { - StoreOf( + static func createStore() -> StoreOf { + StoreOf( initialState: .init( chatTabGroup: .init( tabInfo: [ @@ -428,12 +419,12 @@ struct ChatWindowView_Previews: PreviewProvider { .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: ChatPanelFeature() + reducer: { ChatPanel() } ) } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift similarity index 91% rename from Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift rename to Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift index fae36a70..28bf5bfc 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift @@ -22,7 +22,8 @@ public struct ChatTabKind: Equatable { } } -public struct ChatPanelFeature: ReducerProtocol { +@Reducer +public struct ChatPanel { public struct ChatTabGroup: Equatable { public var tabInfo: IdentifiedArray public var tabCollection: [ChatTabBuilderCollection] @@ -44,11 +45,12 @@ public struct ChatPanelFeature: ReducerProtocol { } } + @ObservableState public struct State: Equatable { public var chatTabGroup = ChatTabGroup() var colorScheme: ColorScheme = .light public internal(set) var isPanelDisplayed = false - var chatPanelInASeparateWindow = false + var isDetached = false var isFullScreen = false } @@ -75,9 +77,10 @@ public struct ChatPanelFeature: ReducerProtocol { case moveChatTab(from: Int, to: Int) case focusActiveChatTab - case chatTab(id: String, action: ChatTabItem.Action) + case chatTab(IdentifiedActionOf) } + @Dependency(\.chatTabPool) var chatTabPool @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @Dependency(\.xcodeInspector) var xcodeInspector @Dependency(\.activatePreviousActiveXcode) var activatePreviouslyActiveXcode @@ -90,7 +93,7 @@ public struct ChatPanelFeature: ReducerProtocol { window?.toggleFullScreen(nil) } - public var body: some ReducerProtocol { + public var body: some ReducerOf { Reduce { state, action in switch action { case .hideButtonClicked: @@ -118,17 +121,17 @@ public struct ChatPanelFeature: ReducerProtocol { return .none case .toggleChatPanelDetachedButtonClicked: - if state.isFullScreen, state.chatPanelInASeparateWindow { + if state.isFullScreen, state.isDetached { return .run { send in await send(.attachChatPanel) } } - - state.chatPanelInASeparateWindow.toggle() + + state.isDetached.toggle() return .none case .detachChatPanel: - state.chatPanelInASeparateWindow = true + state.isDetached = true return .none case .attachChatPanel: @@ -140,7 +143,7 @@ public struct ChatPanelFeature: ReducerProtocol { } } - state.chatPanelInASeparateWindow = false + state.isDetached = false return .none case .enterFullScreen: @@ -155,11 +158,15 @@ public struct ChatPanelFeature: ReducerProtocol { case let .presentChatPanel(forceDetach): if forceDetach { - state.chatPanelInASeparateWindow = true + state.isDetached = true } state.isPanelDisplayed = true return .run { send in - activateExtensionService() + if forceDetach { + await suggestionWidgetControllerDependency.windowsController?.windows + .chatPanelWindow + .centerInActiveSpaceIfNeeded() + } await send(.focusActiveChatTab) } @@ -191,6 +198,7 @@ public struct ChatPanelFeature: ReducerProtocol { return max(nextIndex, 0) }() state.chatTabGroup.tabInfo.removeAll { $0.id == id } + chatTabPool.getTab(of: id)?.close() if state.chatTabGroup.tabInfo.isEmpty { state.isPanelDisplayed = false } @@ -272,10 +280,10 @@ public struct ChatPanelFeature: ReducerProtocol { let id = state.chatTabGroup.selectedTabInfo?.id guard let id else { return .none } return .run { send in - await send(.chatTab(id: id, action: .focus)) + await send(.chatTab(.element(id: id, action: .focus))) } - case let .chatTab(id, .close): + case let .chatTab(.element(id, .close)): return .run { send in await send(.closeTabButtonClicked(id: id)) } @@ -283,7 +291,7 @@ public struct ChatPanelFeature: ReducerProtocol { case .chatTab: return .none } - }.forEach(\.chatTabGroup.tabInfo, action: /Action.chatTab) { + }.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/CircularWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift deleted file mode 100644 index 8c09a769..00000000 --- a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift +++ /dev/null @@ -1,79 +0,0 @@ -import ActiveApplicationMonitor -import ComposableArchitecture -import Preferences -import SuggestionModel -import SwiftUI - -public struct CircularWidgetFeature: ReducerProtocol { - public struct IsProcessingCounter: Equatable { - var expirationDate: TimeInterval - } - - 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 runCustomCommandButtonClicked(CustomCommand) - case markIsProcessing - case endIsProcessing - case _forceEndIsProcessing - } - - struct CancelAutoEndIsProcessKey: Hashable {} - - @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency - - public func reduce(into state: inout State, action: Action) -> EffectTask { - switch action { - case .detachChatPanelToggleClicked: - return .none // handled elsewhere - - case .openChatButtonClicked: - return .run { _ in - suggestionWidgetControllerDependency.onOpenChatClicked() - } - - 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/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift deleted file mode 100644 index da791871..00000000 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift +++ /dev/null @@ -1,274 +0,0 @@ -import AppKit -import ComposableArchitecture -import CustomAsyncAlgorithms -import Dependencies -import Foundation -import PromptToCodeService -import SuggestionModel - -public struct PromptToCodeAcceptHandlerDependencyKey: DependencyKey { - public static let liveValue: (PromptToCode.State) -> Void = { _ in - assertionFailure("Please provide a handler") - } - - public static let previewValue: (PromptToCode.State) -> Void = { _ in - print("Accept Prompt to Code") - } -} - -public extension DependencyValues { - var promptToCodeAcceptHandler: (PromptToCode.State) -> Void { - get { self[PromptToCodeAcceptHandlerDependencyKey.self] } - set { self[PromptToCodeAcceptHandlerDependencyKey.self] = newValue } - } -} - -public struct PromptToCode: ReducerProtocol { - public struct State: Equatable, Identifiable { - 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) - } - } - } - - public enum FocusField: Equatable { - case textField - } - - public var id: URL { documentURL } - public var history: HistoryNode - public var code: String - public var isResponding: Bool - public var description: String - public var error: String? - public var selectionRange: CursorRange? - public var language: CodeLanguage - public var indentSize: Int - public var usesTabsForIndentation: Bool - public var projectRootURL: URL - public var documentURL: URL - public var allCode: String - public var allLines: [String] - public var extraSystemPrompt: String? - public var generateDescriptionRequirement: Bool? - public var commandName: String? - @BindingState public var prompt: String - @BindingState public var isContinuous: Bool - @BindingState public var isAttachedToSelectionRange: Bool - @BindingState public var focusedField: FocusField? = .textField - - public var filename: String { documentURL.lastPathComponent } - public var canRevert: Bool { history != .empty } - - public init( - code: String, - prompt: String, - language: CodeLanguage, - indentSize: Int, - usesTabsForIndentation: Bool, - projectRootURL: URL, - documentURL: URL, - allCode: String, - allLines: [String], - commandName: String? = nil, - description: String = "", - isResponding: Bool = false, - isAttachedToSelectionRange: Bool = true, - error: String? = nil, - history: HistoryNode = .empty, - isContinuous: Bool = false, - selectionRange: CursorRange? = nil, - extraSystemPrompt: String? = nil, - generateDescriptionRequirement: Bool? = nil - ) { - self.history = history - self.code = code - self.prompt = prompt - self.isResponding = isResponding - self.description = description - self.error = error - self.isContinuous = isContinuous - self.selectionRange = selectionRange - self.language = language - self.indentSize = indentSize - self.usesTabsForIndentation = usesTabsForIndentation - self.projectRootURL = projectRootURL - self.documentURL = documentURL - self.allCode = allCode - self.allLines = allLines - self.extraSystemPrompt = extraSystemPrompt - self.generateDescriptionRequirement = generateDescriptionRequirement - self.isAttachedToSelectionRange = isAttachedToSelectionRange - self.commandName = commandName - - if selectionRange?.isEmpty ?? true { - self.isAttachedToSelectionRange = false - } - } - } - - public enum Action: Equatable, BindableAction { - case binding(BindingAction) - case focusOnTextField - case selectionRangeToggleTapped - case modifyCodeButtonTapped - case revertButtonTapped - case stopRespondingButtonTapped - case modifyCodeFinished - case modifyCodeChunkReceived(code: String, description: String) - case modifyCodeFailed(error: String) - case modifyCodeCancelled - case cancelButtonTapped - case acceptButtonTapped - case copyCodeButtonTapped - case appendNewLineToPromptButtonTapped - } - - @Dependency(\.promptToCodeService) var promptToCodeService - @Dependency(\.promptToCodeAcceptHandler) var promptToCodeAcceptHandler - - enum CancellationKey: Hashable { - case modifyCode(State.ID) - } - - public var body: some ReducerProtocol { - BindingReducer() - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .focusOnTextField: - state.focusedField = .textField - return .none - - case .selectionRangeToggleTapped: - state.isAttachedToSelectionRange.toggle() - return .none - - case .modifyCodeButtonTapped: - guard !state.isResponding else { return .none } - let copiedState = state - state.history.enqueue(code: state.code, description: state.description) - state.isResponding = true - state.code = "" - state.description = "" - state.error = nil - - return .run { send in - do { - let stream = try await promptToCodeService.modifyCode( - code: copiedState.code, - requirement: copiedState.prompt, - source: .init( - language: copiedState.language, - documentURL: copiedState.documentURL, - projectRootURL: copiedState.projectRootURL, - content: copiedState.allCode, - lines: copiedState.allLines, - range: copiedState.selectionRange ?? .outOfScope - ), - isDetached: !copiedState.isAttachedToSelectionRange, - extraSystemPrompt: copiedState.extraSystemPrompt, - generateDescriptionRequirement: copiedState - .generateDescriptionRequirement - ).timedDebounce(for: 0.2) - - for try await fragment in stream { - try Task.checkCancellation() - await send(.modifyCodeChunkReceived( - code: fragment.code, - description: fragment.description - )) - } - try Task.checkCancellation() - await send(.modifyCodeFinished) - } catch is CancellationError { - try Task.checkCancellation() - await send(.modifyCodeCancelled) - } catch { - try Task.checkCancellation() - if (error as NSError).code == NSURLErrorCancelled { - await send(.modifyCodeCancelled) - return - } - - await send(.modifyCodeFailed(error: error.localizedDescription)) - } - }.cancellable(id: CancellationKey.modifyCode(state.id), cancelInFlight: true) - - case .revertButtonTapped: - guard let (code, description) = state.history.pop() else { return .none } - state.code = code - state.description = description - return .none - - case .stopRespondingButtonTapped: - state.isResponding = false - promptToCodeService.stopResponding() - return .cancel(id: CancellationKey.modifyCode(state.id)) - - case let .modifyCodeChunkReceived(code, description): - state.code = code - state.description = description - return .none - - case .modifyCodeFinished: - state.prompt = "" - state.isResponding = false - if state.code.isEmpty, state.description.isEmpty { - // if both code and description are empty, we treat it as failed - return .run { send in - await send(.revertButtonTapped) - } - } - - return .none - - case let .modifyCodeFailed(error): - state.error = error - state.isResponding = false - return .run { send in - await send(.revertButtonTapped) - } - - case .modifyCodeCancelled: - state.isResponding = false - return .none - - case .cancelButtonTapped: - promptToCodeService.stopResponding() - return .none - - case .acceptButtonTapped: - promptToCodeAcceptHandler(state) - return .none - - case .copyCodeButtonTapped: - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(state.code, forType: .string) - return .none - - case .appendNewLineToPromptButtonTapped: - state.prompt += "\n" - return .none - } - } - } -} - diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift index ec43c49c..d844b336 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -1,146 +1,111 @@ import ComposableArchitecture import Foundation import PromptToCodeService -import SuggestionModel +import SuggestionBasic import XcodeInspector -public struct PromptToCodeGroup: ReducerProtocol { - public struct State: Equatable { - public var promptToCodes: IdentifiedArrayOf = [] - public var activeDocumentURL: PromptToCode.State.ID? = XcodeInspector.shared +@Reducer +public struct PromptToCodeGroup { + @ObservableState + public struct State { + public var promptToCodes: IdentifiedArrayOf = [] + public var activeDocumentURL: PromptToCodePanel.State.ID? = XcodeInspector.shared .realtimeActiveDocumentURL - public var activePromptToCode: PromptToCode.State? { + public var selectedTabId: URL? + public var activePromptToCode: PromptToCodePanel.State? { get { - if let detached = promptToCodes.first(where: { !$0.isAttachedToSelectionRange }) { - return detached - } - guard let id = activeDocumentURL else { return nil } - return promptToCodes[id: id] + guard let selectedTabId else { return promptToCodes.first } + return promptToCodes[id: selectedTabId] ?? promptToCodes.first } set { - if let id = newValue?.id { + selectedTabId = newValue?.id + if let id = selectedTabId { promptToCodes[id: id] = newValue } } } } - public struct PromptToCodeInitialState: Equatable { - public var code: String - public var selectionRange: CursorRange? - public var language: CodeLanguage - public var identSize: Int - public var usesTabsForIndentation: Bool - public var documentURL: URL - public var projectRootURL: URL - public var allCode: String - public var allLines: [String] - public var isContinuous: Bool - public var commandName: String? - public var defaultPrompt: String - public var extraSystemPrompt: String? - public var generateDescriptionRequirement: Bool? - - public init( - code: String, - selectionRange: CursorRange?, - language: CodeLanguage, - identSize: Int, - usesTabsForIndentation: Bool, - documentURL: URL, - projectRootURL: URL, - allCode: String, - allLines: [String], - isContinuous: Bool, - commandName: String?, - defaultPrompt: String, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool? - ) { - self.code = code - self.selectionRange = selectionRange - self.language = language - self.identSize = identSize - self.usesTabsForIndentation = usesTabsForIndentation - self.documentURL = documentURL - self.projectRootURL = projectRootURL - self.allCode = allCode - self.allLines = allLines - self.isContinuous = isContinuous - self.commandName = commandName - self.defaultPrompt = defaultPrompt - self.extraSystemPrompt = extraSystemPrompt - self.generateDescriptionRequirement = generateDescriptionRequirement - } - } - - public enum Action: Equatable { + public enum Action { /// Activate the prompt to code if it exists or create it if it doesn't - case activateOrCreatePromptToCode(PromptToCodeInitialState) - case createPromptToCode(PromptToCodeInitialState) - case updatePromptToCodeRange(id: PromptToCode.State.ID, range: CursorRange) - case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCode.State.ID) + 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 promptToCode(PromptToCode.State.ID, PromptToCode.Action) - case activePromptToCode(PromptToCode.Action) + case tabClicked(id: URL) + case closeTabButtonClicked(id: URL) + case switchToNextTab + case switchToPreviousTab + case promptToCode(IdentifiedActionOf) + case activePromptToCode(PromptToCodePanel.Action) } - @Dependency(\.promptToCodeServiceFactory) var promptToCodeServiceFactory @Dependency(\.activatePreviousActiveXcode) var activatePreviousActiveXcode - public var body: some ReducerProtocol { + public var body: some ReducerOf { Reduce { state, action in switch action { case let .activateOrCreatePromptToCode(s): - if let promptToCode = state.activePromptToCode { + if let promptToCode = state.activePromptToCode, s.id == promptToCode.id { + state.selectedTabId = promptToCode.id return .run { send in - await send(.promptToCode(promptToCode.id, .focusOnTextField)) + await send(.promptToCode(.element( + id: promptToCode.id, + action: .focusOnTextField + ))) } } return .run { send in - await send(.createPromptToCode(s)) + await send(.createPromptToCode(s, sendImmediately: false)) } - case let .createPromptToCode(s): - let newPromptToCode = PromptToCode.State( - code: s.code, - prompt: s.defaultPrompt, - language: s.language, - indentSize: s.identSize, - usesTabsForIndentation: s.usesTabsForIndentation, - projectRootURL: s.projectRootURL, - documentURL: s.documentURL, - allCode: s.allCode, - allLines: s.allLines, - commandName: s.commandName, - isContinuous: s.isContinuous, - selectionRange: s.selectionRange, - extraSystemPrompt: s.extraSystemPrompt, - generateDescriptionRequirement: s.generateDescriptionRequirement - ) - // insert at 0 so it has high priority then the other detached prompt to codes - state.promptToCodes.insert(newPromptToCode, at: 0) - return .run { send in - if !newPromptToCode.prompt.isEmpty { - await send(.promptToCode(newPromptToCode.id, .modifyCodeButtonTapped)) + 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: PromptToCode.CancellationKey.modifyCode(newPromptToCode.id), + id: PromptToCodePanel.CancellationKey.modifyCode(newPromptToCode.id), cancelInFlight: true ) - case let .updatePromptToCodeRange(id, range): - if let p = state.promptToCodes[id: id], p.isAttachedToSelectionRange { - state.promptToCodes[id: id]?.selectionRange = range + 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): - state.promptToCodes.removeAll { $0.id == id && !$0.isContinuous } + 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): @@ -149,34 +114,69 @@ public struct PromptToCodeGroup: ReducerProtocol { } 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: /Action.activePromptToCode) { - PromptToCode() - .dependency(\.promptToCodeService, promptToCodeServiceFactory()) + .ifLet(\.activePromptToCode, action: \.activePromptToCode) { + PromptToCodePanel() } - .forEach(\.promptToCodes, action: /Action.promptToCode, element: { - PromptToCode() - .dependency(\.promptToCodeService, promptToCodeServiceFactory()) + .forEach(\.promptToCodes, action: \.promptToCode, element: { + PromptToCodePanel() }) - + Reduce { state, action in switch action { - case let .promptToCode(id, .cancelButtonTapped): + case let .promptToCode(.element(id, .cancelButtonTapped)): state.promptToCodes.remove(id: id) + let isEmpty = state.promptToCodes.isEmpty return .run { _ in - activatePreviousActiveXcode() + if isEmpty { + activatePreviousActiveXcode() + } } case .activePromptToCode(.cancelButtonTapped): - guard let id = state.activePromptToCode?.id else { return .none } + guard let id = state.selectedTabId else { return .none } state.promptToCodes.remove(id: id) + let isEmpty = state.promptToCodes.isEmpty return .run { _ in - activatePreviousActiveXcode() + 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/SharedPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift similarity index 70% rename from Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift rename to Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift index 27602dac..9f38210e 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift @@ -2,22 +2,23 @@ import ComposableArchitecture import Preferences import SwiftUI -public struct SharedPanelFeature: ReducerProtocol { - public struct Content: Equatable { +@Reducer +public struct SharedPanel { + public struct Content { public var promptToCodeGroup = PromptToCodeGroup.State() - var suggestion: CodeSuggestionProvider? - public var promptToCode: PromptToCode.State? { promptToCodeGroup.activePromptToCode } + var suggestion: PresentingCodeSuggestion? var error: String? } - public struct State: Equatable { + @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.promptToCode != nil { return false } + if !content.promptToCodeGroup.promptToCodes.isEmpty { return false } if content.suggestion != nil, UserDefaults.shared .value(for: \.suggestionPresentationMode) == .floatingWidget { return false } @@ -31,13 +32,13 @@ public struct SharedPanelFeature: ReducerProtocol { } } - public enum Action: Equatable { + public enum Action { case errorMessageCloseButtonTapped case promptToCodeGroup(PromptToCodeGroup.Action) } - public var body: some ReducerProtocol { - Scope(state: \.content.promptToCodeGroup, action: /Action.promptToCodeGroup) { + public var body: some ReducerOf { + Scope(state: \.content.promptToCodeGroup, action: \.promptToCodeGroup) { PromptToCodeGroup() } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift similarity index 78% rename from Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift rename to Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift index db6061e8..7baef1df 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift @@ -2,9 +2,11 @@ import ComposableArchitecture import Foundation import SwiftUI -public struct SuggestionPanelFeature: ReducerProtocol { +@Reducer +public struct SuggestionPanel { + @ObservableState public struct State: Equatable { - var content: CodeSuggestionProvider? + var content: PresentingCodeSuggestion? var colorScheme: ColorScheme = .light var alignTopToAnchor = false var isPanelDisplayed: Bool = false @@ -21,7 +23,7 @@ public struct SuggestionPanelFeature: ReducerProtocol { case noAction } - public var body: some ReducerProtocol { + public var body: some ReducerOf { Reduce { _, _ in .none } } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift index 332caf9d..14ac9d4b 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift @@ -3,7 +3,9 @@ import Preferences import SwiftUI import Toast -public struct ToastPanel: ReducerProtocol { +@Reducer +public struct ToastPanel { + @ObservableState public struct State: Equatable { var toast: Toast.State = .init() var colorScheme: ColorScheme = .light @@ -15,8 +17,8 @@ public struct ToastPanel: ReducerProtocol { case toast(Toast.Action) } - public var body: some ReducerProtocol { - Scope(state: \.toast, action: /Action.toast) { + public var body: some ReducerOf { + Scope(state: \.toast, action: \.toast) { Toast() } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift similarity index 80% rename from Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift rename to Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift index cee3d93c..493628fc 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift @@ -9,7 +9,8 @@ import SwiftUI import Toast import XcodeInspector -public struct WidgetFeature: ReducerProtocol { +@Reducer +public struct Widget { public struct WindowState: Equatable { var alphaValue: Double = 0 var frame: CGRect = .zero @@ -20,7 +21,8 @@ public struct WidgetFeature: ReducerProtocol { case chatPanel } - public struct State: Equatable { + @ObservableState + public struct State { var focusingDocumentURL: URL? public var colorScheme: ColorScheme = .light @@ -28,21 +30,21 @@ public struct WidgetFeature: ReducerProtocol { // MARK: Panels - public var panelState = PanelFeature.State() + public var panelState = WidgetPanel.State() // MARK: ChatPanel - public var chatPanelState = ChatPanelFeature.State() + public var chatPanelState = ChatPanel.State() // MARK: CircularWidget public struct CircularWidgetState: Equatable { - var isProcessingCounters = [CircularWidgetFeature.IsProcessingCounter]() + var isProcessingCounters = [CircularWidget.IsProcessingCounter]() var isProcessing: Bool = false } public var circularWidgetState = CircularWidgetState() - var _circularWidgetState: CircularWidgetFeature.State { + var _internalCircularWidgetState: CircularWidget.State { get { .init( isProcessingCounters: circularWidgetState.isProcessingCounters, @@ -65,7 +67,7 @@ public struct WidgetFeature: ReducerProtocol { }(), isContentEmpty: chatPanelState.chatTabGroup.tabInfo.isEmpty && panelState.sharedPanelState.isEmpty, - isChatPanelDetached: chatPanelState.chatPanelInASeparateWindow, + isChatPanelDetached: chatPanelState.isDetached, isChatOpen: chatPanelState.isPanelDisplayed ) } @@ -83,16 +85,14 @@ public struct WidgetFeature: ReducerProtocol { private enum CancelID { case observeActiveApplicationChange case observeCompletionPanelChange - case observeFullscreenChange case observeWindowChange case observeEditorChange case observeUserDefaults } - public enum Action: Equatable { + public enum Action { case startup case observeActiveApplicationChange - case observeFullscreenChange case observeColorSchemeChange case updateActiveApplication @@ -104,9 +104,9 @@ public struct WidgetFeature: ReducerProtocol { case updateKeyWindow(WindowCanBecomeKey) case toastPanel(ToastPanel.Action) - case panel(PanelFeature.Action) - case chatPanel(ChatPanelFeature.Action) - case circularWidget(CircularWidgetFeature.Action) + case panel(WidgetPanel.Action) + case chatPanel(ChatPanel.Action) + case circularWidget(CircularWidget.Action) } var windowsController: WidgetWindowsController? { @@ -126,13 +126,13 @@ public struct WidgetFeature: ReducerProtocol { public init() {} - public var body: some ReducerProtocol { - Scope(state: \.toastPanel, action: /Action.toastPanel) { + public var body: some ReducerOf { + Scope(state: \.toastPanel, action: \.toastPanel) { ToastPanel() } - Scope(state: \._circularWidgetState, action: /Action.circularWidget) { - CircularWidgetFeature() + Scope(state: \._internalCircularWidgetState, action: \.circularWidget) { + CircularWidget() } Reduce { state, action in @@ -143,7 +143,7 @@ public struct WidgetFeature: ReducerProtocol { } case .circularWidget(.widgetClicked): - let wasDisplayingContent = state._circularWidgetState.isDisplayingContent + let wasDisplayingContent = state._internalCircularWidgetState.isDisplayingContent if wasDisplayingContent { state.panelState.sharedPanelState.isPanelDisplayed = false state.panelState.suggestionPanelState.isPanelDisplayed = false @@ -154,7 +154,7 @@ public struct WidgetFeature: ReducerProtocol { state.chatPanelState.isPanelDisplayed = true } - let isDisplayingContent = state._circularWidgetState.isDisplayingContent + let isDisplayingContent = state._internalCircularWidgetState.isDisplayingContent let hasChat = state.chatPanelState.chatTabGroup.selectedTabInfo != nil let hasPromptToCode = state.panelState.sharedPanelState.content .promptToCodeGroup.activePromptToCode != nil @@ -180,18 +180,18 @@ public struct WidgetFeature: ReducerProtocol { } } - Scope(state: \.panelState, action: /Action.panel) { - PanelFeature() + Scope(state: \.panelState, action: \.panel) { + WidgetPanel() } - Scope(state: \.chatPanelState, action: /Action.chatPanel) { - ChatPanelFeature() + Scope(state: \.chatPanelState, action: \.chatPanel) { + ChatPanel() } Reduce { state, action in switch action { case .chatPanel(.presentChatPanel): - let isDetached = state.chatPanelState.chatPanelInASeparateWindow + let isDetached = state.chatPanelState.isDetached return .run { _ in await windowsController?.updateWindowLocation( animated: false, @@ -206,7 +206,7 @@ public struct WidgetFeature: ReducerProtocol { } case .chatPanel(.toggleChatPanelDetachedButtonClicked): - let isDetached = state.chatPanelState.chatPanelInASeparateWindow + let isDetached = state.chatPanelState.isDetached return .run { _ in await windowsController?.updateWindowLocation( animated: !isDetached, @@ -225,7 +225,6 @@ public struct WidgetFeature: ReducerProtocol { .run { send in await send(.toastPanel(.start)) await send(.observeActiveApplicationChange) - await send(.observeFullscreenChange) await send(.observeColorSchemeChange) } ) @@ -233,12 +232,18 @@ public struct WidgetFeature: ReducerProtocol { case .observeActiveApplicationChange: return .run { send in let stream = AsyncStream { continuation in - let cancellable = xcodeInspector.$activeApplication.sink { newValue in - guard let newValue else { return } - continuation.yield(newValue) + 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 - cancellable.cancel() + task.cancel() } } @@ -252,24 +257,6 @@ public struct WidgetFeature: ReducerProtocol { } }.cancellable(id: CancelID.observeActiveApplicationChange, cancelInFlight: true) - case .observeFullscreenChange: - return .run { _ in - let sequence = NSWorkspace.shared.notificationCenter - .notifications(named: NSWorkspace.activeSpaceDidChangeNotification) - for await _ in sequence { - try Task.checkCancellation() - guard let activeXcode = await xcodeInspector.safe.activeXcode - else { continue } - guard let windowsController, - await windowsController.windows.fullscreenDetector.isOnActiveSpace - else { continue } - let app = activeXcode.appElement - if let _ = app.focusedWindow { - await windowsController.windows.orderFront() - } - } - }.cancellable(id: CancelID.observeFullscreenChange, cancelInFlight: true) - case .observeColorSchemeChange: return .run { send in await send(.updateColorScheme) @@ -324,8 +311,7 @@ public struct WidgetFeature: ReducerProtocol { case .updateFocusingDocumentURL: return .run { send in await send(.setFocusingDocumentURL( - to: await xcodeInspector.safe - .realtimeActiveDocumentURL + to: xcodeInspector.realtimeActiveDocumentURL )) } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift similarity index 76% rename from Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift rename to Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift index 954c5743..7d911f75 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift @@ -2,9 +2,11 @@ import AppKit import ComposableArchitecture import Foundation -public struct PanelFeature: ReducerProtocol { - public struct State: Equatable { - public var content: SharedPanelFeature.Content { +@Reducer +public struct WidgetPanel { + @ObservableState + public struct State { + public var content: SharedPanel.Content { get { sharedPanelState.content } set { sharedPanelState.content = newValue @@ -14,25 +16,25 @@ public struct PanelFeature: ReducerProtocol { // MARK: SharedPanel - var sharedPanelState = SharedPanelFeature.State() + var sharedPanelState = SharedPanel.State() // MARK: SuggestionPanel - var suggestionPanelState = SuggestionPanelFeature.State() + var suggestionPanelState = SuggestionPanel.State() } - public enum Action: Equatable { + public enum Action { case presentSuggestion - case presentSuggestionProvider(CodeSuggestionProvider, displayContent: Bool) + case presentSuggestionProvider(PresentingCodeSuggestion, displayContent: Bool) case presentError(String) - case presentPromptToCode(PromptToCodeGroup.PromptToCodeInitialState) + case presentPromptToCode(PromptToCodePanel.State) case displayPanelContent case discardSuggestion case removeDisplayedContent case switchToAnotherEditorAndUpdateContent - case sharedPanel(SharedPanelFeature.Action) - case suggestionPanel(SuggestionPanelFeature.Action) + case sharedPanel(SharedPanel.Action) + case suggestionPanel(SuggestionPanel.Action) } @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @@ -40,20 +42,20 @@ public struct PanelFeature: ReducerProtocol { @Dependency(\.activateThisApp) var activateThisApp var windows: WidgetWindows? { suggestionWidgetControllerDependency.windowsController?.windows } - public var body: some ReducerProtocol { - Scope(state: \.suggestionPanelState, action: /Action.suggestionPanel) { - SuggestionPanelFeature() + public var body: some ReducerOf { + Scope(state: \.suggestionPanelState, action: \.suggestionPanel) { + SuggestionPanel() } - Scope(state: \.sharedPanelState, action: /Action.sharedPanel) { - SharedPanelFeature() + Scope(state: \.sharedPanelState, action: \.sharedPanel) { + SharedPanel() } Reduce { state, action in switch action { case .presentSuggestion: return .run { send in - guard let fileURL = await xcodeInspector.safe.activeDocumentURL, + guard let fileURL = await xcodeInspector.activeDocumentURL, let provider = await fetchSuggestionProvider(fileURL: fileURL) else { return } await send(.presentSuggestionProvider(provider, displayContent: true)) @@ -76,7 +78,10 @@ public struct PanelFeature: ReducerProtocol { case let .presentPromptToCode(initialState): return .run { send in - await send(.sharedPanel(.promptToCodeGroup(.createPromptToCode(initialState)))) + await send(.sharedPanel(.promptToCodeGroup(.createPromptToCode( + initialState, + sendImmediately: true + )))) } case .displayPanelContent: @@ -96,7 +101,7 @@ public struct PanelFeature: ReducerProtocol { case .switchToAnotherEditorAndUpdateContent: return .run { send in - guard let fileURL = await xcodeInspector.safe.realtimeActiveDocumentURL + guard let fileURL = xcodeInspector.realtimeActiveDocumentURL else { return } await send(.sharedPanel( @@ -113,7 +118,7 @@ public struct PanelFeature: ReducerProtocol { case .sharedPanel(.promptToCodeGroup(.activateOrCreatePromptToCode)), .sharedPanel(.promptToCodeGroup(.createPromptToCode)): - let hasPromptToCode = state.content.promptToCode != nil + let hasPromptToCode = !state.content.promptToCodeGroup.promptToCodes.isEmpty return .run { send in await send(.displayPanelContent) @@ -134,7 +139,7 @@ public struct PanelFeature: ReducerProtocol { } } - func fetchSuggestionProvider(fileURL: URL) async -> CodeSuggestionProvider? { + func fetchSuggestionProvider(fileURL: URL) async -> PresentingCodeSuggestion? { guard let provider = await suggestionWidgetControllerDependency .suggestionWidgetDataSource? .suggestionForFile(at: fileURL) else { return nil } diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift index 0e83df6a..5ca16f76 100644 --- a/Core/Sources/SuggestionWidget/ModuleDependency.swift +++ b/Core/Sources/SuggestionWidget/ModuleDependency.swift @@ -12,6 +12,7 @@ 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? 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/Providers/CodeSuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift deleted file mode 100644 index afede0a2..00000000 --- a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation -import SwiftUI - -public final class CodeSuggestionProvider: ObservableObject, Equatable { - public static func == (lhs: CodeSuggestionProvider, rhs: CodeSuggestionProvider) -> Bool { - lhs.code == rhs.code && lhs.language == rhs.language - } - - @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 - @Published public var extraInformation: String = "" - - public var onSelectPreviousSuggestionTapped: () -> Void - public var onSelectNextSuggestionTapped: () -> Void - public var onRejectSuggestionTapped: () -> Void - public var onAcceptSuggestionTapped: () -> Void - public var onDismissSuggestionTapped: () -> 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 = {}, - onDismissSuggestionTapped: @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 - self.onDismissSuggestionTapped = onDismissSuggestionTapped - } - - func selectPreviousSuggestion() { onSelectPreviousSuggestionTapped() } - func selectNextSuggestion() { onSelectNextSuggestionTapped() } - func rejectSuggestion() { onRejectSuggestionTapped() } - func acceptSuggestion() { onAcceptSuggestionTapped() } - func dismissSuggestion() { onDismissSuggestionTapped() } -} - diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift index d9f42dde..a00b2fee 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -19,8 +19,7 @@ extension View { } struct SharedPanelView: View { - var store: StoreOf - @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode + var store: StoreOf struct OverallState: Equatable { var isPanelDisplayed: Bool @@ -30,71 +29,84 @@ struct SharedPanelView: View { } var body: some View { - WithViewStore( - store, - observe: { OverallState( - isPanelDisplayed: $0.isPanelDisplayed, - opacity: $0.opacity, - colorScheme: $0.colorScheme, - alignTopToAnchor: $0.alignTopToAnchor - ) } - ) { viewStore in - VStack(spacing: 0) { - if !viewStore.state.alignTopToAnchor { - Spacer() - .frame(minHeight: 0, maxHeight: .infinity) - .allowsHitTesting(false) - } + GeometryReader { geometry in + WithPerceptionTracking { + VStack(spacing: 0) { + if !store.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } - WithViewStore(store, observe: { $0.content }) { viewStore in - ZStack(alignment: .topLeading) { - if let error = viewStore.state.error { - ErrorPanel(description: error) { - viewStore.send( - .errorMessageCloseButtonTapped, - animation: .easeInOut(duration: 0.2) - ) - } - } else if let _ = viewStore.state.promptToCode { - IfLetStore(store.scope( - state: { $0.content.promptToCodeGroup.activePromptToCode }, - action: { - SharedPanelFeature.Action - .promptToCodeGroup(.activePromptToCode($0)) - } - )) { - PromptToCodePanel(store: $0) - } - - } else if let suggestion = viewStore.state.suggestion { - switch suggestionPresentationMode { - case .nearbyTextCursor: - EmptyView() - case .floatingWidget: - CodeBlockSuggestionPanel(suggestion: suggestion) - } - } + 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) } - .frame(maxWidth: .infinity, maxHeight: Style.panelHeight) - .fixedSize(horizontal: false, vertical: true) } - .allowsHitTesting(viewStore.isPanelDisplayed) - .frame(maxWidth: .infinity) + .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 - if viewStore.alignTopToAnchor { - Spacer() - .frame(minHeight: 0, maxHeight: .infinity) - .allowsHitTesting(false) + @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) + } } } - .preferredColorScheme(viewStore.colorScheme) - .opacity(viewStore.opacity) - .animation( - featureFlag: \.animationBCrashSuggestion, - .easeInOut(duration: 0.2), - value: viewStore.isPanelDisplayed - ) - .frame(maxWidth: Style.panelWidth, maxHeight: Style.panelHeight) + } + + @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) + } } } } @@ -130,7 +142,7 @@ struct SharedPanelView_Error_Preview: PreviewProvider { colorScheme: .light, isPanelDisplayed: true ), - reducer: SharedPanelFeature() + reducer: { SharedPanel() } )) .frame(width: 450, height: 200) } @@ -150,13 +162,15 @@ struct SharedPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { language: "objective-c", startLineIndex: 8, suggestionCount: 2, - currentSuggestionIndex: 0 + currentSuggestionIndex: 0, + replacingRange: .zero, + replacingLines: [""] ) ), colorScheme: .dark, isPanelDisplayed: true ), - reducer: SharedPanelFeature() + reducer: { SharedPanel() } )) .frame(width: 450, height: 200) .background { diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index 011dcaf1..c2f1436b 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -8,9 +8,10 @@ enum Style { static let panelWidth: Double = 454 static let inlineSuggestionMinWidth: Double = 540 static let inlineSuggestionMaxHeight: Double = 400 - static let widgetHeight: Double = 20 - static var widgetWidth: Double { widgetHeight } + 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 } @@ -45,35 +46,6 @@ extension NSAppearance { } } -struct XcodeLikeFrame: View { - @Environment(\.colorScheme) var colorScheme - let content: Content - let cornerRadius: Double - - var body: some View { - content.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - .background( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Material.bar) - ) - .overlay( - RoundedRectangle(cornerRadius: max(0, cornerRadius), style: .continuous) - .stroke(Color.black.opacity(0.1), style: .init(lineWidth: 1)) - ) // Add an extra border just incase the background is not displayed. - .overlay( - RoundedRectangle(cornerRadius: max(0, cornerRadius - 1), style: .continuous) - .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) - .padding(1) - ) - } -} - -extension View { - func xcodeStyleFrame(cornerRadius: Double? = nil) -> some View { - XcodeLikeFrame(content: self, cornerRadius: cornerRadius ?? 10) - } -} - extension MarkdownUI.Theme { static func custom(fontSize: Double) -> MarkdownUI.Theme { .gitHub.text { diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift deleted file mode 100644 index 1b7e715f..00000000 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ /dev/null @@ -1,231 +0,0 @@ -import SharedUIComponents -import SwiftUI - -struct CodeBlockSuggestionPanel: View { - @ObservedObject var suggestion: CodeSuggestionProvider - @Environment(\.colorScheme) var colorScheme - @AppStorage(\.suggestionCodeFont) var codeFont - @AppStorage(\.suggestionDisplayCompactMode) var suggestionDisplayCompactMode - @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode - @AppStorage(\.hideCommonPrecedingSpacesInSuggestion) var hideCommonPrecedingSpaces - @AppStorage(\.syncSuggestionHighlightTheme) var syncHighlightTheme - @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight - @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark - @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight - @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark - - struct ToolBar: View { - @ObservedObject var suggestion: CodeSuggestionProvider - - 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.dismissSuggestion() - }) { - Text("Dismiss").foregroundStyle(.tertiary).padding(.trailing, 4) - }.buttonStyle(.plain) - - Button(action: { - suggestion.rejectSuggestion() - }) { - Text("Reject") - }.buttonStyle(CommandButtonStyle(color: .gray)) - - Button(action: { - suggestion.acceptSuggestion() - }) { - Text("Accept") - }.buttonStyle(CommandButtonStyle(color: .accentColor)) - } - .padding() - .foregroundColor(.secondary) - .background(.regularMaterial) - } - } - - struct CompactToolBar: View { - @ObservedObject var suggestion: CodeSuggestionProvider - - 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.dismissSuggestion() - }) { - Image(systemName: "xmark") - }.buttonStyle(.plain) - } - .padding(4) - .font(.caption) - .foregroundColor(.secondary) - .background(.regularMaterial) - } - } - - var body: some View { - VStack(spacing: 0) { - CustomScrollView { - CodeBlock( - code: suggestion.code, - language: suggestion.language, - startLineIndex: suggestion.startLineIndex, - scenario: "suggestion", - colorScheme: colorScheme, - font: codeFont.value.nsFont, - droppingLeadingSpaces: hideCommonPrecedingSpaces, - proposedForegroundColor: { - if syncHighlightTheme { - if colorScheme == .light, - let color = codeForegroundColorLight.value?.swiftUIColor - { - return color - } else if let color = codeForegroundColorDark.value?.swiftUIColor { - return color - } - } - return nil - }() - ) - .frame(maxWidth: .infinity) - .background({ () -> 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 - }()) - } - - if suggestionDisplayCompactMode { - CompactToolBar(suggestion: suggestion) - } else { - ToolBar(suggestion: suggestion) - } - } - .xcodeStyleFrame(cornerRadius: { - switch suggestionPresentationMode { - case .nearbyTextCursor: 6 - case .floatingWidget: nil - } - }()) - } -} - -// MARK: - Previews - -#Preview("Code Block Suggestion Panel") { - CodeBlockSuggestionPanel(suggestion: CodeSuggestionProvider( - 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 diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift deleted file mode 100644 index 65da23cf..00000000 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ /dev/null @@ -1,541 +0,0 @@ -import ComposableArchitecture -import MarkdownUI -import SharedUIComponents -import SuggestionModel -import SwiftUI - -struct PromptToCodePanel: View { - let store: StoreOf - - var body: some View { - VStack(spacing: 0) { - TopBar(store: store) - - Content(store: store) - .overlay(alignment: .bottom) { - ActionBar(store: store) - .padding(.bottom, 8) - } - - Divider() - - Toolbar(store: store) - } - .background(.ultraThickMaterial) - .xcodeStyleFrame() - } -} - -extension PromptToCodePanel { - struct TopBar: View { - let store: StoreOf - - struct AttachButtonState: Equatable { - var attachedToFilename: String - var isAttachedToSelectionRange: Bool - var selectionRange: CursorRange? - } - - var body: some View { - HStack { - Button(action: { - withAnimation(.linear(duration: 0.1)) { - store.send(.selectionRangeToggleTapped) - } - }) { - WithViewStore( - store, - observe: { AttachButtonState( - attachedToFilename: $0.filename, - isAttachedToSelectionRange: $0.isAttachedToSelectionRange, - selectionRange: $0.selectionRange - ) } - ) { viewStore in - let isAttached = viewStore.state.isAttachedToSelectionRange - 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(viewStore.state.attachedToFilename) - .lineLimit(1) - .truncationMode(.middle) - if let range = viewStore.state.selectionRange { - Text(range.description) - } - }.foregroundColor(.primary) - } else { - Text("current selection").foregroundColor(.secondary) - } - } - .padding(2) - .padding(.trailing, 4) - .overlay { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .stroke(color, lineWidth: 1) - } - .background { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(color.opacity(0.2)) - } - .padding(2) - } - } - .keyboardShortcut("j", modifiers: [.command]) - .buttonStyle(.plain) - - Spacer() - - WithViewStore(store, observe: { $0.code }) { viewStore in - if !viewStore.state.isEmpty { - CopyButton { - viewStore.send(.copyCodeButtonTapped) - } - } - } - } - .padding(2) - } - } - - struct ActionBar: View { - let store: StoreOf - - struct ActionState: Equatable { - var isResponding: Bool - var isCodeEmpty: Bool - var isDescriptionEmpty: Bool - @BindingViewState var isContinuous: Bool - var isRespondingButCodeIsReady: Bool { - isResponding - && !isCodeEmpty - && !isDescriptionEmpty - } - } - - var body: some View { - HStack { - WithViewStore(store, observe: { $0.isResponding }) { viewStore in - if viewStore.state { - Button(action: { - viewStore.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) - } - } - - WithViewStore(store, observe: { - ActionState( - isResponding: $0.isResponding, - isCodeEmpty: $0.code.isEmpty, - isDescriptionEmpty: $0.description.isEmpty, - isContinuous: $0.$isContinuous - ) - }) { viewStore in - if !viewStore.state.isResponding || viewStore.state.isRespondingButCodeIsReady { - HStack { - Toggle("Continuous Mode", isOn: viewStore.$isContinuous) - .toggleStyle(.checkbox) - - Button(action: { - viewStore.send(.cancelButtonTapped) - }) { - Text("Cancel") - } - .buttonStyle(CommandButtonStyle(color: .gray)) - .keyboardShortcut("w", modifiers: [.command]) - - if !viewStore.state.isCodeEmpty { - Button(action: { - viewStore.send(.acceptButtonTapped) - }) { - Text("Accept(⌘ + ⏎)") - } - .buttonStyle(CommandButtonStyle(color: .accentColor)) - .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) - } - } - } - } - } - } - - struct Content: View { - let store: StoreOf - @Environment(\.colorScheme) var colorScheme - @AppStorage(\.promptToCodeCodeFont) var codeFont - @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) var hideCommonPrecedingSpaces - @AppStorage(\.syncPromptToCodeHighlightTheme) var syncHighlightTheme - @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight - @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark - @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight - @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark - - struct CodeContent: Equatable { - var code: String - var language: String - var startLineIndex: Int - var firstLinePrecedingSpaceCount: Int - var isResponding: Bool - } - - 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.clear - } - - var body: some View { - CustomScrollView { - VStack(spacing: 0) { - Spacer(minLength: 60) - - WithViewStore(store, observe: { $0.error }) { viewStore in - if let errorMessage = viewStore.state, !errorMessage.isEmpty { - Text(errorMessage) - .multilineTextAlignment(.leading) - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Color.red, - in: RoundedRectangle(cornerRadius: 4, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .stroke(Color.primary.opacity(0.2), lineWidth: 1) - } - .scaleEffect(x: 1, y: -1, anchor: .center) - } - } - - WithViewStore(store, observe: { $0.description }) { viewStore in - if !viewStore.state.isEmpty { - Markdown(viewStore.state) - .textSelection(.enabled) - .markdownTheme(.gitHub.text { - BackgroundColor(Color.clear) - ForegroundColor(codeForegroundColor) - }) - .padding() - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) - } - } - - WithViewStore(store, observe: { - CodeContent( - code: $0.code, - language: $0.language.rawValue, - startLineIndex: $0.selectionRange?.start.line ?? 0, - firstLinePrecedingSpaceCount: $0.selectionRange?.start - .character ?? 0, - isResponding: $0.isResponding - ) - }) { viewStore in - if viewStore.state.code.isEmpty { - Text( - viewStore.state.isResponding - ? "Thinking..." - : "Enter your requirement to generate code." - ) - .foregroundColor(codeForegroundColor?.opacity(0.7) ?? .secondary) - .padding() - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) - } else { - CodeBlock( - code: viewStore.state.code, - language: viewStore.state.language, - startLineIndex: viewStore.state.startLineIndex, - scenario: "promptToCode", - colorScheme: colorScheme, - firstLinePrecedingSpaceCount: viewStore.state - .firstLinePrecedingSpaceCount, - font: codeFont.value.nsFont, - droppingLeadingSpaces: hideCommonPrecedingSpaces, - proposedForegroundColor:codeForegroundColor - ) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) - } - } - } - .background(codeBackgroundColor) - } - .scaleEffect(x: 1, y: -1, anchor: .center) - } - } - - struct Toolbar: View { - let store: StoreOf - @FocusState var focusField: PromptToCode.State.FocusField? - - struct RevertButtonState: Equatable { - var isResponding: Bool - var canRevert: Bool - } - - struct InputFieldState: Equatable { - @BindingViewState var prompt: String - @BindingViewState var focusField: PromptToCode.State.FocusField? - var isResponding: Bool - } - - var body: some View { - HStack { - revertButton - - HStack(spacing: 0) { - inputField - sendButton - } - .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.send(.appendNewLineToPromptButtonTapped) }) { - EmptyView() - } - .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) - } - .background { - Button(action: { focusField = .textField }) { - EmptyView() - } - .keyboardShortcut("l", modifiers: [.command]) - } - } - .padding(8) - .background(.ultraThickMaterial) - } - - var revertButton: some View { - WithViewStore(store, observe: { - RevertButtonState(isResponding: $0.isResponding, canRevert: $0.canRevert) - }) { viewStore in - Button(action: { - viewStore.send(.revertButtonTapped) - }) { - 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(viewStore.state.isResponding || !viewStore.state.canRevert) - } - } - - var inputField: some View { - WithViewStore( - store, - observe: { - InputFieldState( - prompt: $0.$prompt, - focusField: $0.$focusedField, - isResponding: $0.isResponding - ) - } - ) { viewStore in - AutoresizingCustomTextEditor( - text: viewStore.$prompt, - font: .systemFont(ofSize: 14), - isEditable: !viewStore.state.isResponding, - maxHeight: 400, - onSubmit: { viewStore.send(.modifyCodeButtonTapped) } - ) - .opacity(viewStore.state.isResponding ? 0.5 : 1) - .disabled(viewStore.state.isResponding) - .focused($focusField, equals: .textField) - .bind(viewStore.$focusField, to: $focusField) - } - .padding(8) - .fixedSize(horizontal: false, vertical: true) - } - - var sendButton: some View { - WithViewStore(store, observe: { $0.isResponding }) { viewStore in - Button(action: { - viewStore.send(.modifyCodeButtonTapped) - }) { - Image(systemName: "paperplane.fill") - .padding(8) - } - .buttonStyle(.plain) - .disabled(viewStore.state) - .keyboardShortcut(KeyEquivalent.return, modifiers: []) - } - } - } -} - -// MARK: - Previews - -struct PromptToCodePanel_Preview: PreviewProvider { - static var previews: some View { - PromptToCodePanel(store: .init(initialState: .init( - 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 index d759c298..c7aca342 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift @@ -7,51 +7,97 @@ import Toast struct ToastPanelView: View { let store: StoreOf - struct ViewState: Equatable { - let colorScheme: ColorScheme - let alignTopToAnchor: Bool - } - var body: some View { - WithViewStore(store, observe: { - ViewState( - colorScheme: $0.colorScheme, - alignTopToAnchor: $0.alignTopToAnchor - ) - }) { viewStore in + WithPerceptionTracking { VStack(spacing: 4) { - if !viewStore.alignTopToAnchor { + if !store.alignTopToAnchor { Spacer() + .allowsHitTesting(false) } - - WithViewStore(store, observe: \.toast.messages) { viewStore in - ForEach(viewStore.state) { message in + + ForEach(store.toast.messages) { message in + HStack { message.content .foregroundColor(.white) - .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) + .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) } - }() as Color, in: RoundedRectangle(cornerRadius: 8)) - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color.black.opacity(0.3), lineWidth: 1) } + } + } + .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 viewStore.alignTopToAnchor { + + if store.alignTopToAnchor { Spacer() + .allowsHitTesting(false) } } - .colorScheme(viewStore.colorScheme) + .colorScheme(store.colorScheme) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .allowsHitTesting(false) } } +#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 7d5a6485..b25eb0e9 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift @@ -3,8 +3,7 @@ import Foundation import SwiftUI struct SuggestionPanelView: View { - let store: StoreOf - @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode + let store: StoreOf struct OverallState: Equatable { var isPanelDisplayed: Bool @@ -15,59 +14,37 @@ struct SuggestionPanelView: View { } var body: some View { - WithViewStore( - store, - observe: { OverallState( - isPanelDisplayed: $0.isPanelDisplayed, - opacity: $0.opacity, - colorScheme: $0.colorScheme, - isPanelOutOfFrame: $0.isPanelOutOfFrame, - alignTopToAnchor: $0.alignTopToAnchor - ) } - ) { viewStore in + WithPerceptionTracking { VStack(spacing: 0) { - if !viewStore.alignTopToAnchor { + if !store.alignTopToAnchor { Spacer() .frame(minHeight: 0, maxHeight: .infinity) .allowsHitTesting(false) } - IfLetStore(store.scope(state: \.content, action: { $0 })) { store in - WithViewStore(store) { viewStore in - ZStack(alignment: .topLeading) { - switch suggestionPresentationMode { - case .nearbyTextCursor: - CodeBlockSuggestionPanel(suggestion: viewStore.state) - case .floatingWidget: - EmptyView() - } - } - .frame(maxWidth: .infinity, maxHeight: Style.inlineSuggestionMaxHeight) - .fixedSize(horizontal: false, vertical: true) - } - } - .allowsHitTesting( - viewStore.isPanelDisplayed && !viewStore.isPanelOutOfFrame - ) - .frame(maxWidth: .infinity) + Content(store: store) + .allowsHitTesting( + store.isPanelDisplayed && !store.isPanelOutOfFrame + ) + .frame(maxWidth: .infinity) - if viewStore.alignTopToAnchor { + if store.alignTopToAnchor { Spacer() .frame(minHeight: 0, maxHeight: .infinity) .allowsHitTesting(false) } } - .preferredColorScheme(viewStore.colorScheme) - .opacity(viewStore.opacity) + .preferredColorScheme(store.colorScheme) + .opacity(store.opacity) .animation( featureFlag: \.animationBCrashSuggestion, .easeInOut(duration: 0.2), - value: viewStore.isPanelDisplayed + value: store.isPanelDisplayed ) .animation( featureFlag: \.animationBCrashSuggestion, .easeInOut(duration: 0.2), - value: viewStore.isPanelOutOfFrame + value: store.isPanelOutOfFrame ) .frame( maxWidth: Style.inlineSuggestionMinWidth, @@ -75,5 +52,27 @@ struct SuggestionPanelView: 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) + } + } + } + } } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 618b3682..09a0ae7a 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -11,8 +11,7 @@ import XcodeInspector @MainActor public final class SuggestionWidgetController: NSObject { - let store: StoreOf - let viewStore: ViewStoreOf + let store: StoreOf let chatTabPool: ChatTabPool let windowsController: WidgetWindowsController private var cancellable = Set() @@ -20,14 +19,13 @@ public final class SuggestionWidgetController: NSObject { public let dependency: SuggestionWidgetControllerDependency public init( - store: StoreOf, + store: StoreOf, chatTabPool: ChatTabPool, dependency: SuggestionWidgetControllerDependency ) { self.dependency = dependency self.store = store self.chatTabPool = chatTabPool - viewStore = .init(store, observe: { $0 }) windowsController = .init(store: store, chatTabPool: chatTabPool) super.init() @@ -72,17 +70,5 @@ public extension SuggestionWidgetController { func presentError(_ errorDescription: String) { store.send(.toastPanel(.toast(.toast(errorDescription, .error, nil)))) } - - func presentChatRoom() { - store.send(.chatPanel(.presentChatPanel(forceDetach: false))) - } - - func presentDetachedGlobalChat() { - store.send(.chatPanel(.presentChatPanel(forceDetach: true))) - } - - func closeChatRoom() { -// store.send(.chatPanel(.closeChatPanel)) - } } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift index f7ad662a..2269d095 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift @@ -1,12 +1,12 @@ import Foundation public protocol SuggestionWidgetDataSource { - func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? + func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion? } struct MockWidgetDataSource: SuggestionWidgetDataSource { - func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? { - return CodeSuggestionProvider( + func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion? { + return PresentingCodeSuggestion( code: """ func test() { let x = 1 @@ -17,7 +17,9 @@ struct MockWidgetDataSource: SuggestionWidgetDataSource { language: "swift", startLineIndex: 1, suggestionCount: 3, - currentSuggestionIndex: 0 + currentSuggestionIndex: 0, + replacingRange: .zero, + replacingLines: [] ) } } 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 a53d68ae..5aed84b3 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -9,6 +9,7 @@ public struct WidgetLocation: Equatable { var widgetFrame: CGRect var tabFrame: CGRect + var sharedPanelLocation: PanelLocation var defaultPanelLocation: PanelLocation var suggestionPanelLocation: PanelLocation? } @@ -16,6 +17,7 @@ public struct WidgetLocation: Equatable { enum UpdateLocationStrategy { struct AlignToTextCursor { func framesForWindows( + windowFrame: CGRect, editorFrame: CGRect, mainScreen: NSScreen, activeScreen: NSScreen, @@ -32,6 +34,7 @@ enum UpdateLocationStrategy { ) else { return FixedToBottom().framesForWindows( + windowFrame: windowFrame, editorFrame: editorFrame, mainScreen: mainScreen, activeScreen: activeScreen, @@ -42,6 +45,7 @@ enum UpdateLocationStrategy { let found = AXValueGetValue(rect, .cgRect, &frame) guard found else { return FixedToBottom().framesForWindows( + windowFrame: windowFrame, editorFrame: editorFrame, mainScreen: mainScreen, activeScreen: activeScreen, @@ -50,6 +54,7 @@ enum UpdateLocationStrategy { } return HorizontalMovable().framesForWindows( y: mainScreen.frame.height - frame.maxY, + windowFrame: windowFrame, alignPanelTopToAnchor: nil, editorFrame: editorFrame, mainScreen: mainScreen, @@ -62,34 +67,50 @@ enum UpdateLocationStrategy { 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) + .value(for: \.preferWidgetToStayInsideEditorWhenWidthGreaterThan), + editorFrameExpendedSize: CGSize = .zero ) -> WidgetLocation { - return HorizontalMovable().framesForWindows( + var frames = HorizontalMovable().framesForWindows( y: mainScreen.frame.height - editorFrame.maxY + Style.widgetPadding, + windowFrame: windowFrame, alignPanelTopToAnchor: false, editorFrame: editorFrame, mainScreen: mainScreen, activeScreen: activeScreen, preferredInsideEditorMinWidth: preferredInsideEditorMinWidth, - hideCircularWidget: hideCircularWidget + 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, - hideCircularWidget: Bool = UserDefaults.shared.value(for: \.hideCircularWidget) + hideCircularWidget: Bool = UserDefaults.shared.value(for: \.hideCircularWidget), + editorFrameExpendedSize: CGSize = .zero ) -> WidgetLocation { let maxY = max( y, @@ -116,13 +137,21 @@ enum UpdateLocationStrategy { 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 + ) if !hideCircularWidget { proposedAnchorFrameOnTheRightSide = widgetFrameOnTheRightSide } - let proposedPanelX = proposedAnchorFrameOnTheRightSide.maxX + Style - .widgetPadding * 2 + 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 @@ -133,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 ) @@ -148,8 +178,12 @@ enum UpdateLocationStrategy { ) return .init( - widgetFrame: widgetFrameOnTheRightSide, + widgetFrame: widgetFrame, tabFrame: tabFrame, + sharedPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), defaultPanelLocation: .init( frame: panelFrame, alignPanelTop: alignPanelTopToAnchor @@ -175,8 +209,10 @@ enum UpdateLocationStrategy { proposedAnchorFrameOnTheLeftSide = widgetFrameOnTheLeftSide } - let proposedPanelX = proposedAnchorFrameOnTheLeftSide.minX - Style - .widgetPadding * 2 - Style.panelWidth + let proposedPanelX = proposedAnchorFrameOnTheLeftSide.minX + - Style.widgetPadding * 2 + - Style.panelWidth + + editorFrameExpendedSize.width let putAnchorToTheLeft = { if editorFrame.size.width >= preferredInsideEditorMinWidth { if editorFrame.maxX <= activeScreen.frame.maxX { @@ -190,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 ) @@ -205,8 +241,12 @@ enum UpdateLocationStrategy { height: Style.widgetHeight ) return .init( - widgetFrame: widgetFrameOnTheLeftSide, + widgetFrame: widgetFrame, tabFrame: tabFrame, + sharedPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), defaultPanelLocation: .init( frame: panelFrame, alignPanelTop: alignPanelTopToAnchor @@ -217,9 +257,9 @@ enum UpdateLocationStrategy { 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 ) @@ -230,8 +270,12 @@ enum UpdateLocationStrategy { height: Style.widgetHeight ) return .init( - widgetFrame: widgetFrameOnTheRightSide, + widgetFrame: widgetFrame, tabFrame: tabFrame, + sharedPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), defaultPanelLocation: .init( frame: panelFrame, alignPanelTop: alignPanelTopToAnchor @@ -393,9 +437,7 @@ enum UpdateLocationStrategy { var firstLineRange: CFRange = .init() let foundFirstLine = AXValueGetValue(selectedRange, .cfRange, &firstLineRange) firstLineRange.length = 0 - - #warning("FIXME: When selection is too low and out of the screen, the selection range becomes something else.") - + if foundFirstLine, let firstLineSelectionRange = AXValueCreate(.cfRange, &firstLineRange), let firstLineRect: AXValue = try? editor.copyParameterizedValue( @@ -413,3 +455,4 @@ enum UpdateLocationStrategy { return selectionFrame } } + diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index 2b4b3b8e..f07816bf 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -1,11 +1,12 @@ import ActiveApplicationMonitor import ComposableArchitecture import Preferences -import SuggestionModel +import SharedUIComponents +import SuggestionBasic import SwiftUI struct WidgetView: View { - let store: StoreOf + let store: StoreOf @State var isHovering: Bool = false var onOpenChatClicked: () -> Void = {} var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } @@ -13,126 +14,137 @@ struct WidgetView: View { @AppStorage(\.hideCircularWidget) var hideCircularWidget var body: some View { - WithViewStore(store, observe: { $0.isProcessing }) { viewStore in - Circle() - .fill(isHovering ? .white.opacity(0.5) : .white.opacity(0.15)) + GeometryReader { _ in + WithPerceptionTracking { + ZStack { + WidgetAnimatedCapsule( + store: store, + isHovering: isHovering + ) + } .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - store.send(.widgetClicked) - } + store.send(.widgetClicked, animation: .easeInOut(duration: 0.2)) } - .overlay { WidgetAnimatedCircle(store: store) } .onHover { yes in - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(.easeInOut(duration: 0.14)) { isHovering = yes } - }.contextMenu { + } + .contextMenu { WidgetContextMenu(store: store) } .opacity({ if !hideCircularWidget { return 1 } - return viewStore.state ? 1 : 0 + return store.isProcessing ? 1 : 0 }()) .animation( - featureFlag: \.animationCCrashSuggestion, .easeInOut(duration: 0.2), - value: viewStore.state + value: isHovering ) + .animation( + .easeInOut(duration: 0.4), + value: store.isProcessing + ) + } } } } -struct WidgetAnimatedCircle: View { - let store: StoreOf - @State var processingProgress: Double = 0 +struct WidgetAnimatedCapsule: View { + let store: StoreOf + var isHovering: Bool - struct OverlayCircleState: Equatable { - var isProcessing: Bool - var isContentEmpty: Bool - } + @State private var breathingOpacity: CGFloat = 1.0 + @State private var animationTask: Task? var body: some View { - let minimumLineWidth: Double = 3 - let lineWidth = (1 - processingProgress) * - (Style.widgetWidth - minimumLineWidth / 2) + minimumLineWidth - let scale = max(processingProgress * 1, 0.0001) - ZStack { - Circle() - .stroke( - Color(nsColor: .darkGray), - style: .init(lineWidth: minimumLineWidth) - ) - .padding(minimumLineWidth / 2) + GeometryReader { geo in + WithPerceptionTracking { + let capsuleWidth = geo.size.width + let capsuleHeight = geo.size.height - // how do I stop the repeatForever animation without removing the view? - // I tried many solutions found on stackoverflow but non of them works. - WithViewStore( - store, - observe: { - OverlayCircleState( - isProcessing: $0.isProcessing, - isContentEmpty: $0.isContentEmpty - ) - } - ) { viewStore in - Group { - if viewStore.isProcessing { - Circle() - .stroke( - Color.accentColor, - style: .init(lineWidth: lineWidth) - ) - .padding(minimumLineWidth / 2) - .scaleEffect(x: scale, y: scale) - .opacity( - !viewStore.isContentEmpty || viewStore.isProcessing ? 1 : 0 - ) - .animation( - featureFlag: \.animationCCrashSuggestion, - .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( - !viewStore.isContentEmpty || viewStore - .isProcessing ? 1 : 0 - ) - .animation( - featureFlag: \.animationCCrashSuggestion, - .easeInOut(duration: 1), - value: processingProgress - ) - } + let backgroundWidth = capsuleWidth + let foregroundWidth = max(capsuleWidth - 4, 2) + let padding = (backgroundWidth - foregroundWidth) / 2 + let foregroundHeight = capsuleHeight - padding * 2 + + ZStack { + 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) + + 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: viewStore.isProcessing) { _ in - refreshRing( - isProcessing: viewStore.state.isProcessing, - isContentEmpty: viewStore.state.isContentEmpty - ) + .onAppear { + updateBreathingAnimation(isProcessing: store.isProcessing) } - .onChange(of: viewStore.isContentEmpty) { _ in - refreshRing( - isProcessing: viewStore.state.isProcessing, - isContentEmpty: viewStore.state.isContentEmpty - ) + .onChange(of: store.isProcessing) { newValue in + updateBreathingAnimation(isProcessing: newValue) } } } } - func refreshRing(isProcessing: Bool, isContentEmpty: Bool) { + private func updateBreathingAnimation(isProcessing: Bool) { + animationTask?.cancel() + animationTask = nil + if isProcessing { - processingProgress = 1 - processingProgress + 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 { - processingProgress = isContentEmpty ? 0 : 1 + withAnimation(.easeInOut(duration: 0.2)) { + breathingOpacity = 0 + } } } } @@ -144,21 +156,23 @@ struct WidgetContextMenu: View { @AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList @AppStorage(\.suggestionFeatureDisabledLanguageList) var suggestionFeatureDisabledLanguageList @AppStorage(\.customCommands) var customCommands - let store: StoreOf + let store: StoreOf @Dependency(\.xcodeInspector) var xcodeInspector var body: some View { - Group { + WithPerceptionTracking { Group { // Commands - WithViewStore(store, observe: { $0.isChatOpen }) { viewStore in - if !viewStore.state { - Button(action: { - viewStore.send(.openChatButtonClicked) - }) { - Text("Open Chat") - } - } + Button(action: { + store.send(.openChatButtonClicked) + }) { + Text("Open Chat") + } + + Button(action: { + store.send(.openModificationButtonClicked) + }) { + Text("Write or Edit Code") } customCommandMenu() @@ -175,17 +189,12 @@ struct WidgetContextMenu: View { Divider() Group { // Settings - WithViewStore( - store, - observe: { $0.isChatPanelDetached } - ) { viewStore in - Button(action: { - viewStore.send(.detachChatPanelToggleClicked) - }) { - Text("Detach Chat Panel") - if viewStore.state { - Image(systemName: "checkmark") - } + Button(action: { + store.send(.detachChatPanelToggleClicked) + }) { + Text("Detach Chat Panel") + if store.isChatPanelDetached { + Image(systemName: "checkmark") } } @@ -219,26 +228,24 @@ struct WidgetContextMenu: View { extension WidgetContextMenu { @ViewBuilder var enableSuggestionForProject: some View { - WithViewStore(store) { _ in - if let projectPath = xcodeInspector.activeProjectRootURL?.path, - disableSuggestionFeatureGlobally - { - let matchedPath = suggestionFeatureEnabledProjectList.first { path in - projectPath.hasPrefix(path) + if let projectPath = xcodeInspector.activeProjectRootURL?.path, + disableSuggestionFeatureGlobally + { + let matchedPath = suggestionFeatureEnabledProjectList.first { path in + projectPath.hasPrefix(path) + } + Button(action: { + if matchedPath != nil { + suggestionFeatureEnabledProjectList + .removeAll { path in path == matchedPath } + } else { + suggestionFeatureEnabledProjectList.append(projectPath) } - Button(action: { - if matchedPath != nil { - suggestionFeatureEnabledProjectList - .removeAll { path in path == matchedPath } - } else { - suggestionFeatureEnabledProjectList.append(projectPath) - } - }) { - if matchedPath == nil { - Text("Add to Suggestion-Enabled Project List") - } else { - Text("Remove from Suggestion-Enabled Project List") - } + }) { + if matchedPath == nil { + Text("Add to Suggestion-Enabled Project List") + } else { + Text("Remove from Suggestion-Enabled Project List") } } } @@ -246,24 +253,22 @@ extension WidgetContextMenu { @ViewBuilder var disableSuggestionForLanguage: some View { - WithViewStore(store) { _ in - let fileURL = xcodeInspector.activeDocumentURL - let fileLanguage = fileURL.map(languageIdentifierFromFileURL) ?? .plaintext - 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)\"") } } } @@ -281,10 +286,11 @@ struct WidgetView_Preview: PreviewProvider { isChatPanelDetached: false, isChatOpen: false ), - reducer: CircularWidgetFeature() + reducer: { CircularWidget() } ), isHovering: false ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) WidgetView( store: Store( @@ -295,10 +301,11 @@ struct WidgetView_Preview: PreviewProvider { isChatPanelDetached: false, isChatOpen: false ), - reducer: CircularWidgetFeature() + reducer: { CircularWidget() } ), isHovering: true ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) WidgetView( store: Store( @@ -309,10 +316,11 @@ struct WidgetView_Preview: PreviewProvider { isChatPanelDetached: false, isChatOpen: false ), - reducer: CircularWidgetFeature() + reducer: { CircularWidget() } ), isHovering: false ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) WidgetView( store: Store( @@ -323,12 +331,13 @@ struct WidgetView_Preview: PreviewProvider { isChatPanelDetached: false, isChatOpen: false ), - reducer: CircularWidgetFeature() + reducer: { CircularWidget() } ), 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 index 4ab5c142..2f70e0e3 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -1,24 +1,28 @@ import AppKit import AsyncAlgorithms import ChatTab -import Combine 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 } - let windows: WidgetWindows - let store: StoreOf - let chatTabPool: ChatTabPool + nonisolated let windows: WidgetWindows + nonisolated let store: StoreOf + nonisolated let chatTabPool: ChatTabPool var currentApplicationProcessIdentifier: pid_t? - var cancellable: Set = [] var observeToAppTask: Task? var observeToFocusedEditorTask: Task? @@ -29,14 +33,16 @@ actor WidgetWindowsController: NSObject { 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) { + init(store: StoreOf, chatTabPool: ChatTabPool) { self.store = store self.chatTabPool = chatTabPool windows = .init(store: store, chatTabPool: chatTabPool) @@ -44,28 +50,35 @@ actor WidgetWindowsController: NSObject { windows.controller = self } - @MainActor func send(_ action: WidgetFeature.Action) { + @MainActor func send(_ action: Widget.Action) { store.send(action) } func start() { - cancellable.removeAll() - - xcodeInspector.$activeApplication.sink { [weak self] app in - guard let app else { return } - Task { [weak self] in await self?.activate(app) } - }.store(in: &cancellable) + Task { [xcodeInspector] in + await observe { [weak self] in + if let app = xcodeInspector.activeApplication { + Task { + await self?.activate(app) + } + } + } - xcodeInspector.$focusedEditor.sink { [weak self] editor in - guard let editor else { return } - Task { [weak self] in await self?.observe(toEditor: editor) } - }.store(in: &cancellable) + await observe { [weak self] in + if let editor = xcodeInspector.focusedEditor { + Task { + await self?.observe(toEditor: editor) + } + } + } - xcodeInspector.$completionPanel.sink { [weak self] newValue in - Task { [weak self] in - await self?.handleCompletionPanelChange(isDisplaying: newValue != nil) + await observe { [weak self] in + let isDisplaying = xcodeInspector.completionPanel != nil + Task { + await self?.handleCompletionPanelChange(isDisplaying: isDisplaying) + } } - }.store(in: &cancellable) + } userDefaultsObservers.presentationModeChangeObserver.onChange = { [weak self] in Task { [weak self] in @@ -73,197 +86,32 @@ actor WidgetWindowsController: NSObject { await self?.send(.updateColorScheme) } } - } - - 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.safe.activeApplication - let latestActiveXcode = await xcodeInspector.safe.latestActiveXcode - let previousActiveApplication = xcodeInspector.previousActiveApplication - await MainActor.run { - let state = store.withState { $0 } - let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow - let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty - 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 = noFocus ? 0 : 1 - windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 - windows.widgetWindow.alphaValue = noFocus ? 0 : 1 - windows.toastWindow.alphaValue = noFocus ? 0 : 1 - - if isChatPanelDetached { - windows.chatPanelWindow.isWindowHidden = !hasChat - } else { - windows.chatPanelWindow.isWindowHidden = noFocus - } - } 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 = noFocus ? 0 : 1 - windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 - windows.widgetWindow.alphaValue = if noFocus { - 0 - } else if previousAppIsXcode { - 1 - } else { - 0 - } - windows.toastWindow.alphaValue = noFocus ? 0 : 1 - if isChatPanelDetached { - windows.chatPanelWindow.isWindowHidden = !hasChat - } else { - windows.chatPanelWindow.isWindowHidden = noFocus && !windows - .chatPanelWindow.isKeyWindow - } - } else { - windows.sharedPanelWindow.alphaValue = 0 - windows.suggestionPanelWindow.alphaValue = 0 - windows.widgetWindow.alphaValue = 0 - windows.toastWindow.alphaValue = 0 - if !isChatPanelDetached { - windows.chatPanelWindow.isWindowHidden = true + 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() } } } } - 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.chatPanelInASeparateWindow - 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.defaultPanelLocation.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 - ) - } - } - - let now = Date() - let shouldThrottle = !immediately && - !(now.timeIntervalSince(lastUpdateWindowLocationTime) > 3) - - updateWindowLocationTask?.cancel() - let interval: TimeInterval = 0.1 - - if shouldThrottle { - let delay = max( - 0, - interval - now.timeIntervalSince(lastUpdateWindowLocationTime) - ) - - updateWindowLocationTask = Task { - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - try Task.checkCancellation() - await update() - } - } else { - Task { - await update() - } - } - lastUpdateWindowLocationTime = Date() - } -} - -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 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)) + windows.chatPanelWindow.isPanelDisplayed = false } } } +// MARK: - Observation + private extension WidgetWindowsController { func activate(_ app: AppInstanceInspector) { Task { @@ -275,6 +123,8 @@ private extension WidgetWindowsController { updateWindowLocation(animated: false, immediately: false) await hideSuggestionPanelWindow() } + await adjustChatPanelWindowLevel() + await adjustModificationPanelLevel() } guard currentApplicationProcessIdentifier != app.processIdentifier else { return } currentApplicationProcessIdentifier = app.processIdentifier @@ -288,38 +138,45 @@ private extension WidgetWindowsController { observeToAppTask = Task { await windows.orderFront() - for await notification in await notifications.notifications() { - try Task.checkCancellation() - - /// Hide the widgets before switching to another window/editor - /// so the transition looks better. - func hideWidgetForTransitions() async { - let newDocumentURL = await xcodeInspector.safe.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 { + /// 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 updateWidgetsAndNotifyChangeOfEditor(immediately: Bool) async { - await send(.panel(.switchToAnotherEditorAndUpdateContent)) - updateWindowLocation(animated: false, immediately: immediately) - updateWindowOpacity(immediately: immediately) - } + func removeContent() async { + await send(.panel(.removeDisplayedContent)) + } - func updateWidgets(immediately: Bool) async { - updateWindowLocation(animated: false, immediately: immediately) - updateWindowOpacity(immediately: immediately) - } + 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, .focusedUIElementChanged: + case .focusedWindowChanged: + await handleSpaceChange() + await hideWidgetForTransitions() + await updateWidgetsAndNotifyChangeOfEditor(immediately: true) + case .focusedUIElementChanged: await hideWidgetForTransitions() await updateWidgetsAndNotifyChangeOfEditor(immediately: true) case .applicationActivated: @@ -358,7 +215,7 @@ private extension WidgetWindowsController { selectionRangeChange.debounce(for: Duration.milliseconds(500)), scroll ) { - guard await xcodeInspector.safe.latestActiveXcode != nil else { return } + guard await xcodeInspector.latestActiveXcode != nil else { return } try Task.checkCancellation() // for better looking @@ -371,7 +228,7 @@ private extension WidgetWindowsController { } } else { for await notification in merge(selectionRangeChange, scroll) { - guard await xcodeInspector.safe.latestActiveXcode != nil else { return } + guard await xcodeInspector.latestActiveXcode != nil else { return } try Task.checkCancellation() // for better looking @@ -403,10 +260,12 @@ private extension WidgetWindowsController { } } +// MARK: - Window Updating + extension WidgetWindowsController { @MainActor func hidePanelWindows() { - windows.sharedPanelWindow.alphaValue = 0 +// windows.sharedPanelWindow.alphaValue = 0 windows.suggestionPanelWindow.alphaValue = 0 } @@ -415,13 +274,20 @@ extension WidgetWindowsController { windows.suggestionPanelWindow.alphaValue = 0 } - func generateWidgetLocation() -> WidgetLocation? { - if let application = xcodeInspector.latestActiveXcode?.appElement { - if let focusElement = xcodeInspector.focusedEditor?.element, + 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 }), - let firstScreen = NSScreen.main + 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) @@ -431,19 +297,21 @@ extension WidgetWindowsController { switch positionMode { case .fixedToBottom: var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( + windowFrame: windowFrame, editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen + activeScreen: windowContainingScreen ) switch suggestionMode { case .nearbyTextCursor: result.suggestionPanelLocation = UpdateLocationStrategy .NearbyTextCursor() .framesForSuggestionWindow( - editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen, + editorFrame: frame, + mainScreen: screen, + activeScreen: windowContainingScreen, editor: focusElement, - completionPanel: xcodeInspector.completionPanel + completionPanel: await xcodeInspector.completionPanel ) default: break @@ -451,9 +319,10 @@ extension WidgetWindowsController { return result case .alignToTextCursor: var result = UpdateLocationStrategy.AlignToTextCursor().framesForWindows( + windowFrame: windowFrame, editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen, + activeScreen: windowContainingScreen, editor: focusElement ) switch suggestionMode { @@ -462,9 +331,9 @@ extension WidgetWindowsController { .NearbyTextCursor() .framesForSuggestionWindow( editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen, + activeScreen: windowContainingScreen, editor: focusElement, - completionPanel: xcodeInspector.completionPanel + completionPanel: await xcodeInspector.completionPanel ) default: break @@ -489,37 +358,338 @@ extension WidgetWindowsController { return WidgetLocation( widgetFrame: .zero, tabFrame: .zero, + sharedPanelLocation: .init(frame: .zero, alignPanelTop: false), defaultPanelLocation: .init(frame: .zero, alignPanelTop: false) ) } - + window = workspaceWindow frame = rect } - if ["Xcode.WorkspaceWindow"].contains(window.identifier) { - // extra padding to bottom so buttons won't be covered - frame.size.height -= 40 - } else { - // move a bit away from the window so buttons won't be covered - frame.origin.x -= Style.widgetPadding + Style.widgetWidth / 2 - frame.size.width += Style.widgetPadding * 2 + Style.widgetWidth - } - return UpdateLocationStrategy.FixedToBottom().framesForWindows( + windowFrame: frame, editorFrame: frame, mainScreen: screen, activeScreen: firstScreen, - preferredInsideEditorMinWidth: 9_999_999_999 // never + 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 store: StoreOf let chatTabPool: ChatTabPool weak var controller: WidgetWindowsController? @@ -534,7 +704,6 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = false it.backgroundColor = .clear it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] it.hasShadow = false @@ -545,23 +714,21 @@ public final class WidgetWindows { @MainActor lazy var widgetWindow = { - let it = CanBecomeKeyWindow( + let it = WidgetWindow( contentRect: .zero, styleMask: .borderless, backing: .buffered, defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = false it.backgroundColor = .clear - it.level = .floating - it.collectionBehavior = [.fullScreenAuxiliary, .transient] - it.hasShadow = true + it.level = widgetLevel(0) + it.hasShadow = false it.contentView = NSHostingView( rootView: WidgetView( store: store.scope( - state: \._circularWidgetState, - action: WidgetFeature.Action.circularWidget + state: \._internalCircularWidgetState, + action: \.circularWidget ) ) ) @@ -572,33 +739,32 @@ public final class WidgetWindows { @MainActor lazy var sharedPanelWindow = { - let it = CanBecomeKeyWindow( + 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 = .init(NSWindow.Level.floating.rawValue + 2) - it.collectionBehavior = [.fullScreenAuxiliary, .transient] - it.hasShadow = true + it.level = widgetLevel(2) + it.hoveringLevel = widgetLevel(2) + it.hasShadow = false it.contentView = NSHostingView( rootView: SharedPanelView( store: store.scope( state: \.panelState, - action: WidgetFeature.Action.panel + action: \.panel ).scope( state: \.sharedPanelState, - action: PanelFeature.Action.sharedPanel + action: \.sharedPanel ) - ) + ).modifierFlagsMonitor() ) it.setIsVisible(true) it.canBecomeKeyChecker = { [store] in store.withState { state in - state.panelState.sharedPanelState.content.promptToCode != nil + !state.panelState.sharedPanelState.content.promptToCodeGroup.promptToCodes.isEmpty } } return it @@ -606,26 +772,26 @@ public final class WidgetWindows { @MainActor lazy var suggestionPanelWindow = { - let it = CanBecomeKeyWindow( + 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 = .init(NSWindow.Level.floating.rawValue + 2) - it.collectionBehavior = [.fullScreenAuxiliary, .transient] - it.hasShadow = true + it.level = widgetLevel(2) + it.hasShadow = false + it.menu = nil + it.animationBehavior = .utilityWindow it.contentView = NSHostingView( rootView: SuggestionPanelView( store: store.scope( state: \.panelState, - action: WidgetFeature.Action.panel + action: \.panel ).scope( state: \.suggestionPanelState, - action: PanelFeature.Action.suggestionPanel + action: \.suggestionPanel ) ) ) @@ -636,86 +802,47 @@ public final class WidgetWindows { @MainActor lazy var chatPanelWindow = { - let it = ChatWindow( - contentRect: .zero, - styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView], - backing: .buffered, - defer: false - ) - it.minimizeWindow = { [weak self] in - self?.store.send(.chatPanel(.hideButtonClicked)) - } - it.titleVisibility = .hidden - it.addTitlebarAccessoryViewController({ - let controller = NSTitlebarAccessoryViewController() - let view = NSHostingView(rootView: ChatTitleBar(store: store.scope( + let it = ChatPanelWindow( + store: store.scope( state: \.chatPanelState, - action: WidgetFeature.Action.chatPanel - ))) - controller.view = view - view.frame = .init(x: 0, y: 0, width: 100, height: 40) - controller.layoutAttribute = .right - return controller - }()) - it.titlebarAppearsTransparent = true - it.isReleasedWhenClosed = false - it.isOpaque = false - it.backgroundColor = .clear - it.level = .init(NSWindow.Level.floating.rawValue + 1) - it.collectionBehavior = [ - .fullScreenAuxiliary, - .transient, - .fullScreenPrimary, - .fullScreenAllowsTiling, - ] - it.hasShadow = true - it.contentView = NSHostingView( - rootView: ChatWindowView( - store: store.scope( - state: \.chatPanelState, - action: WidgetFeature.Action.chatPanel - ), - toggleVisibility: { [weak it] isDisplayed in - guard let window = it else { return } - window.isPanelDisplayed = isDisplayed - } - ) - .environment(\.chatTabPool, chatTabPool) + action: \.chatPanel + ), + chatTabPool: chatTabPool, + minimizeWindow: { [weak self] in + self?.store.send(.chatPanel(.hideButtonClicked)) + } ) - it.setIsVisible(true) - it.isPanelDisplayed = false + it.hoveringLevel = widgetLevel(1) it.delegate = controller return it }() @MainActor lazy var toastWindow = { - let it = CanBecomeKeyWindow( - contentRect: .zero, + 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 = true + it.isOpaque = false it.backgroundColor = .clear - it.level = .floating - it.collectionBehavior = [.fullScreenAuxiliary, .transient] + it.level = widgetLevel(2) it.hasShadow = false it.contentView = NSHostingView( rootView: ToastPanelView(store: store.scope( state: \.toastPanel, - action: WidgetFeature.Action.toastPanel + action: \.toastPanel )) ) it.setIsVisible(true) - it.ignoresMouseEvents = true it.canBecomeKeyChecker = { false } return it }() init( - store: StoreOf, + store: StoreOf, chatTabPool: ChatTabPool ) { self.store = store @@ -728,7 +855,11 @@ public final class WidgetWindows { toastWindow.orderFrontRegardless() sharedPanelWindow.orderFrontRegardless() suggestionPanelWindow.orderFrontRegardless() - chatPanelWindow.orderFrontRegardless() + if chatPanelWindow.level.rawValue > NSWindow.Level.normal.rawValue, + store.withState({ !$0.chatPanelState.isDetached }) + { + chatPanelWindow.orderFrontRegardless() + } } } @@ -740,32 +871,84 @@ class CanBecomeKeyWindow: NSWindow { override var canBecomeMain: Bool { canBecomeKeyChecker() } } -class ChatWindow: NSWindow { - override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { true } +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 minimizeWindow: () -> Void = {} + var isFullscreen: Bool { + styleMask.contains(.fullScreen) + } - var isWindowHidden: Bool = false { + private var state: State? { didSet { - alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 + guard state != oldValue else { return } + switch state { + case .none: + collectionBehavior = defaultCollectionBehavior + case .switchingSpace: + collectionBehavior = defaultCollectionBehavior.union(.moveToActiveSpace) + case .normal: + collectionBehavior = defaultCollectionBehavior + } } } - var isPanelDisplayed: Bool = false { - didSet { - alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 + func moveToActiveSpace() { + let previousState = state + state = .switchingSpace + Task { @MainActor in + try await Task.sleep(nanoseconds: 50_000_000) + self.state = previousState } } - override var alphaValue: CGFloat { - didSet { - ignoresMouseEvents = alphaValue <= 0 + 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) +} - override func miniaturize(_: Any?) { - minimizeWindow() +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 a9d7b88b..7619d579 100644 --- a/Core/Sources/UpdateChecker/UpdateChecker.swift +++ b/Core/Sources/UpdateChecker/UpdateChecker.swift @@ -5,15 +5,25 @@ import Sparkle public final class UpdateChecker { let updater: SPUUpdater let hostBundleFound: Bool - let delegate = UpdaterDelegate() + 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, @@ -30,6 +40,10 @@ public final class UpdateChecker { public func checkForUpdates() { updater.checkForUpdates() } + + public func resetUpdateCycle() { + updater.resetUpdateCycleAfterShortDelay() + } public var automaticallyChecksForUpdates: Bool { get { updater.automaticallyChecksForUpdates } @@ -37,7 +51,45 @@ 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"]) 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/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/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift b/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift index 27bb7075..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 = OpenAIPromptToCodeService() + 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 = OpenAIPromptToCodeService() + 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 = OpenAIPromptToCodeService() + 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 = OpenAIPromptToCodeService() + 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 = OpenAIPromptToCodeService() + 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 b8260c31..13a66210 100644 --- a/Core/Tests/ServiceTests/Environment.swift +++ b/Core/Tests/ServiceTests/Environment.swift @@ -2,7 +2,7 @@ import AppKit import Client import Foundation import GitHubCopilotService -import SuggestionModel +import SuggestionBasic import Workspace import XCTest import XPCShared @@ -14,19 +14,23 @@ func completion(text: String, range: CursorRange, uuid: String = "") -> CodeSugg } class MockSuggestionService: GitHubCopilotSuggestionServiceType { - func terminate() async { + func cancelOngoingTask(workDoneToken: String) async { + fatalError() + } + + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws { fatalError() } - func cancelRequest() async { + func terminate() async { fatalError() } - func notifyOpenTextDocument(fileURL: URL, content: String) async throws { + func cancelRequest() async { fatalError() } - func notifyChangeTextDocument(fileURL: URL, content: String) async throws { + func notifyOpenTextDocument(fileURL: URL, content: String) async throws { fatalError() } @@ -49,13 +53,12 @@ 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, - ignoreTrailingNewLinesAndSpaces: Bool - ) async throws -> [SuggestionModel.CodeSuggestion] { + usesTabsForIndentation: Bool + ) async throws -> [SuggestionBasic.CodeSuggestion] { completions } 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 index 1bf2ee73..44ae7129 100644 --- a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift +++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift @@ -1,5 +1,5 @@ import Foundation -import SuggestionModel +import SuggestionBasic import XCTest @testable import Service @@ -8,13 +8,16 @@ import XCTest 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")) + let (_, filespace) = try await pool.fetchOrCreateWorkspaceAndFilespace( + fileURL: URL(fileURLWithPath: "file/path/to.swift"), + checkIfFileExists: false + ) filespace.suggestions = [ .init( id: "", @@ -23,18 +26,22 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { 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: ["\n", "hell\n", "\n"], - cursorPosition: .init(line: 1, character: 4) + lines: lines, + cursorPosition: .init(line: 1, character: 4), + alwaysTrueIfCursorNotMoved: false // TODO: What ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion @@ -42,29 +49,116 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { } 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: ["\n", "hell man\n", "\n"], - cursorPosition: .init(line: 1, character: 4) + 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: ["\n", "hell\n", "\n"], - cursorPosition: .init(line: 2, character: 0) + lines: lines, + cursorPosition: .init(line: 2, character: 0), + alwaysTrueIfCursorNotMoved: false ) XCTAssertFalse(isValid) let suggestion = filespace.presentingSuggestion @@ -72,14 +166,17 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { } 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: ["\n", "hell\n", "\n"], - cursorPosition: .init(line: 100, character: 4) + lines: lines, + cursorPosition: .init(line: 100, character: 4), + alwaysTrueIfCursorNotMoved: false ) XCTAssertFalse(isValid) let suggestion = filespace.presentingSuggestion @@ -88,13 +185,15 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { 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: 0), - range: .init(startPair: (1, 0), endPair: (1, 0)) + 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) + cursorPosition: .init(line: 1, character: 4), + alwaysTrueIfCursorNotMoved: false ) XCTAssertFalse(isValid) let suggestion = filespace.presentingSuggestion @@ -103,13 +202,15 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { 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: 0), - range: .init(startPair: (1, 0), endPair: (1, 0)) + 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) + cursorPosition: .init(line: 1, character: 100), + alwaysTrueIfCursorNotMoved: false ) XCTAssertFalse(isValid) let suggestion = filespace.presentingSuggestion @@ -117,15 +218,49 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { } 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 man\n", "\n"], - cursorPosition: .init(line: 1, character: 9) + 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) @@ -133,29 +268,60 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { 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) + 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: ["\n", "hello man\n", "\n"], - cursorPosition: .init(line: 1, character: 9) + 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 @@ -164,18 +330,57 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { 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: ["\n", "hell\n", "\n"], - cursorPosition: .init(line: 1, character: 4) + 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 index 52f0e3be..5cc0743f 100644 --- a/Core/Tests/ServiceUpdateMigrationTests/MigrateTo240Tests.swift +++ b/Core/Tests/ServiceUpdateMigrationTests/MigrateTo240Tests.swift @@ -28,7 +28,7 @@ final class MigrateTo240Tests: XCTestCase { XCTAssertEqual(chatModel.info, .init( apiKeyName: "OpenAI", baseURL: "", - maxTokens: 4096, + maxTokens: 16385, supportsFunctionCalling: true, modelName: "gpt-3.5-turbo" )) diff --git a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift deleted file mode 100644 index c7faef1d..00000000 --- a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift +++ /dev/null @@ -1,572 +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( - id: "", - text: text, - position: .init(line: 0, character: 1), - range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 1, character: 0) - ) - ) - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakIntoEditorStyleLines() - var cursor = CursorPosition(line: 0, character: 1) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 2, character: 19)) - XCTAssertEqual( - lines.joined(separator: ""), - """ - struct Cat { - var name: String - var age: String - } - - """, - "There is always a new line at the end of each line! When you join them, it will look like this" - ) - } - - 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( - id: "", - text: text, - position: .init(line: 0, character: 12), - range: .init( - start: .init(line: 0, character: 0), - end: .init(line: 0, character: 12) - ) - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakIntoEditorStyleLines() - var cursor = CursorPosition(line: 0, character: 12) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakIntoEditorStyleLines().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( - id: "", - text: text, - position: .init(line: 1, character: 12), - range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 1, character: 12) - ) - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakIntoEditorStyleLines() - var cursor = CursorPosition(line: 1, character: 12) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakIntoEditorStyleLines().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( - id: "", - text: text, - position: .init(line: 1, character: 12), - range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 1, character: 12) - ) - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakIntoEditorStyleLines() - var cursor = CursorPosition(line: 1, character: 12) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakIntoEditorStyleLines().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_has_suffix_typed() async throws { - let content = """ - print("") - """ - let text = """ - print("Hello World!") - """ - let suggestion = CodeSuggestion( - id: "", - text: text, - position: .init(line: 0, character: 6), - range: .init( - start: .init(line: 0, character: 0), - end: .init(line: 0, character: 6) - ) - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakIntoEditorStyleLines() - var cursor = CursorPosition(line: 0, character: 7) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 0, character: 21)) - XCTAssertEqual(lines.joined(separator: ""), """ - print("Hello World!") - - """) - } - - func test_accept_suggestion_overlap_continue_typing_suggestion_in_the_middle() async throws { - let content = """ - print("He") - """ - let text = """ - print("Hello World! - """ - let suggestion = CodeSuggestion( - id: "", - text: text, - position: .init(line: 0, character: 6), - range: .init( - start: .init(line: 0, character: 0), - end: .init(line: 0, character: 6) - ) - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakIntoEditorStyleLines() - var cursor = CursorPosition(line: 0, character: 7) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 0, character: 19)) - XCTAssertEqual(lines.joined(separator: ""), """ - print("Hello World!") - - """) - } - - func test_accept_suggestion_overlap_continue_typing_has_suffix_typed_suggestion_has_multiple_lines( - ) async throws { - let content = """ - struct Cat {} - """ - let text = """ - struct Cat { - var name: String - var kind: String - } - """ - let suggestion = CodeSuggestion( - id: "", - text: text, - position: .init(line: 0, character: 6), - range: .init( - start: .init(line: 0, character: 0), - end: .init(line: 0, character: 6) - ) - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakIntoEditorStyleLines() - var cursor = CursorPosition(line: 0, character: 12) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 3, character: 1)) - XCTAssertEqual(lines.joined(separator: ""), """ - struct Cat { - var name: String - var kind: String - } - - """) - } - - func test_propose_suggestion_partial_overlap() async throws { - let content = "func quickSort() {}}" - 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( - id: "", - text: text, - position: .init(line: 0, character: 18), - range: .init( - start: .init(line: 0, character: 0), - end: .init(line: 0, character: 20) - ) - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakIntoEditorStyleLines() - var cursor = CursorPosition(line: 0, character: 18) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakIntoEditorStyleLines().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() {" - 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( - id: "", - text: text, - position: .init(line: 0, character: 18), - range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 1, character: 0) - ) - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakIntoEditorStyleLines() - var cursor = CursorPosition(line: 0, character: 18) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakIntoEditorStyleLines().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( - id: "", - text: text, - position: .init(line: 0, character: 7), - range: .init( - start: .init(line: 0, character: 0), - end: .init(line: 2, character: 1) - ) - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakIntoEditorStyleLines() - var cursor = CursorPosition(line: 0, character: 7) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 4, character: 1)) - XCTAssertEqual(lines.joined(separator: ""), """ - struct Dog { - func speak() { - print("woof") - } - } - - """) - } - - 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( - id: "", - text: text, - position: .init(line: 5, character: 34), - range: .init( - start: .init(line: 4, character: 7), - end: .init(line: 5, character: 34) - ) - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakIntoEditorStyleLines() - var cursor = CursorPosition(line: 5, character: 34) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakIntoEditorStyleLines().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() {} - - """) - } - - func test_replacing_single_line_in_the_middle_should_not_remove_the_next_character( - ) async throws { - let content = """ - apiKeyName: ,, - """ - - let suggestion = CodeSuggestion( - id: "", - text: "apiKeyName: azureOpenAIAPIKeyName", - position: .init(line: 0, character: 12), - range: .init( - start: .init(line: 0, character: 0), - end: .init(line: 0, character: 12) - ) - ) - - var lines = content.breakIntoEditorStyleLines() - var extraInfo = SuggestionInjector.ExtraInfo() - var cursor = CursorPosition(line: 5, character: 34) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - - XCTAssertEqual(cursor, .init(line: 0, character: 33)) - XCTAssertEqual(lines.joined(separator: ""), """ - apiKeyName: azureOpenAIAPIKeyName,, - - """) - } - - func test_remove_the_first_adjacent_placeholder_in_the_last_line( - ) async throws { - let content = """ - apiKeyName: <#T##value: BinaryInteger##BinaryInteger#> <#Hello#>, - """ - - let suggestion = CodeSuggestion( - id: "", - text: "apiKeyName: azureOpenAIAPIKeyName", - position: .init(line: 0, character: 12), - range: .init( - start: .init(line: 0, character: 0), - end: .init(line: 0, character: 12) - ) - ) - - var lines = content.breakIntoEditorStyleLines() - var extraInfo = SuggestionInjector.ExtraInfo() - var cursor = CursorPosition(line: 5, character: 34) - SuggestionInjector().acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completion: suggestion, - extraInfo: &extraInfo - ) - - XCTAssertEqual(cursor, .init(line: 0, character: 33)) - XCTAssertEqual(lines.joined(separator: ""), """ - apiKeyName: azureOpenAIAPIKeyName <#Hello#>, - - """) - } -} - -extension String { - func breakIntoEditorStyleLines() -> [String] { - split(separator: "\n", omittingEmptySubsequences: false).map { $0 + "\n" } - } -} - diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 002dcc2e..5b663fbe 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -4,34 +4,37 @@ ### 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. + +### CommunicationBridge + +It's responsible for maintaining the communication between the Copilot for Xcode/EditorExtension and ExtensionService. ### Core and Tool Most of the logics are implemented inside the package `Core` and `Tool`. -- 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 `SuggestionWidget` is responsible for the UI of the widgets. +- 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 -1. Update the xcconfig files, launchAgent.plist, and Tool/Configs/Configurations.swift. +1. Update the xcconfig files, bridgeLaunchAgent.plist, and Tool/Configs/Configurations.swift. 2. Build or archive the Copilot for Xcode target. -3. If Xcode complains that the pro package doesn't exist, please remove the package from the project, and update the last function in Core/Package.swift to return false. -## Testing Source Editor Extension +## 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. -Just run both the `ExtensionService` and the `EditorExtension` Target. Read [Testing Your Source Editor Extension](https://developer.apple.com/documentation/xcodekit/testing_your_source_editor_extension) for more details. +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. ## SwiftUI Previews diff --git a/EditorExtension/AcceptPromptToCodeCommand.swift b/EditorExtension/AcceptPromptToCodeCommand.swift index f7d7171c..7ac95c35 100644 --- a/EditorExtension/AcceptPromptToCodeCommand.swift +++ b/EditorExtension/AcceptPromptToCodeCommand.swift @@ -1,10 +1,10 @@ import Client import Foundation -import SuggestionModel +import SuggestionBasic import XcodeKit class AcceptPromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType { - var name: String { "Accept Prompt to Code" } + var name: String { "Accept Modification" } func perform( with invocation: XCSourceEditorCommandInvocation, 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 index b2dde69a..0e9537ee 100644 --- a/EditorExtension/CloseIdleTabsCommand.swift +++ b/EditorExtension/CloseIdleTabsCommand.swift @@ -1,6 +1,6 @@ import Client import Foundation -import SuggestionModel +import SuggestionBasic import XcodeKit class CloseIdleTabsCommand: NSObject, XCSourceEditorCommand, CommandType { 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 f1138270..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 { 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 401fdcd3..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 { 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 e2b1a47e..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 { 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 daa79734..ed0473d5 100644 --- a/EditorExtension/RealtimeSuggestionCommand.swift +++ b/EditorExtension/RealtimeSuggestionCommand.swift @@ -1,10 +1,10 @@ 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, diff --git a/EditorExtension/RejectSuggestionCommand.swift b/EditorExtension/RejectSuggestionCommand.swift index c19dcf5a..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 { 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 4b3882e2..f102f9d4 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -12,12 +12,14 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { [ GetSuggestionsCommand(), AcceptSuggestionCommand(), + AcceptSuggestionLineCommand(), RejectSuggestionCommand(), NextSuggestionCommand(), PreviousSuggestionCommand(), PromptToCodeCommand(), AcceptPromptToCodeCommand(), - ChatWithSelectionCommand(), + OpenChatCommand(), + ToggleRealtimeSuggestionsCommand(), ].map(makeCommandDefinition) } 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/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index e5567c27..9107c97a 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -2,6 +2,8 @@ import AppKit import Foundation import Preferences import XcodeInspector +import Dependencies +import Workspace extension AppDelegate { fileprivate var statusBarMenuIdentifier: NSUserInterfaceItemIdentifier { @@ -95,6 +97,12 @@ extension AppDelegate { action: #selector(reactivateObservationsToXcode), keyEquivalent: "" ) + + let resetWorkspacesItem = NSMenuItem( + title: "Reset workspaces", + action: #selector(destroyWorkspacePool), + keyEquivalent: "" + ) reactivateObservationsItem.target = self @@ -108,6 +116,7 @@ extension AppDelegate { statusBarMenu.addItem(xcodeInspectorDebug) statusBarMenu.addItem(accessibilityAPIPermission) statusBarMenu.addItem(reactivateObservationsItem) + statusBarMenu.addItem(resetWorkspacesItem) statusBarMenu.addItem(quitItem) statusBarMenu.delegate = self @@ -160,7 +169,7 @@ extension AppDelegate: NSMenuDelegate { menu.items.append(.text("Focused Element: N/A")) } - if let sourceEditor = inspector.focusedEditor { + if let sourceEditor = inspector.latestFocusedEditor { let label = sourceEditor.element.description menu.items .append(.text("Active Source Editor: \(label.isEmpty ? "Unknown" : label)")) @@ -217,6 +226,15 @@ extension AppDelegate: NSMenuDelegate { 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 @@ -234,17 +252,33 @@ private extension AppDelegate { } @objc func reactivateObservationsToXcode() { - XcodeInspector.shared.reactivateObservationsToXcode() + Task { + await XcodeInspector.shared.reactivateObservationsToXcode() + } } @objc func openExtensionManager() { guard let data = try? JSONEncoder().encode(ExtensionServiceRequests.OpenExtensionManager()) else { return } - service.handleXPCServiceRequests( - endpoint: ExtensionServiceRequests.OpenExtensionManager.endpoint, - requestBody: data, - reply: { _, _ in } - ) + 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) } } diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index b0d3936b..801ce37f 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -1,6 +1,7 @@ import FileChangeChecker import LaunchAgentManager import Logger +import Perception import Preferences import Service import ServiceManagement @@ -10,6 +11,7 @@ import UpdateChecker import UserDefaultsObserver import UserNotifications import XcodeInspector +import XPCShared let bundleIdentifierBase = Bundle.main .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String @@ -17,25 +19,28 @@ let serviceIdentifier = bundleIdentifierBase + ".ExtensionService" @main class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + @MainActor let service = Service.shared var statusBarItem: NSStatusItem! - var xpcListener: (NSXPCListener, ServiceDelegate)? - let updateChecker = - UpdateChecker( - hostBundle: locateHostBundleURL(url: Bundle.main.bundleURL) - .flatMap(Bundle.init(url:)) - ) + 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 } _ = 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() @@ -51,8 +56,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { @objc func quit() { Task { @MainActor in - await service.scheduledCleaner.closeAllChildProcesses() - exit(0) + await service.prepareForExit() + await xpcController?.quit() + NSApp.terminate(self) } } @@ -125,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, @@ -144,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 291eaac7..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 160db273..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 4fcd6278..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 e31a8d3b..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 e31a8d3b..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 ec264755..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 ec264755..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 4b760bc1..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 4b760bc1..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 8d777985..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/ExtensionService.entitlements b/ExtensionService/ExtensionService.entitlements index ae1430f1..5a41052f 100644 --- a/ExtensionService/ExtensionService.entitlements +++ b/ExtensionService/ExtensionService.entitlements @@ -6,8 +6,6 @@ $(TeamIdentifierPrefix)group.$(BUNDLE_IDENTIFIER_BASE) - com.apple.security.cs.disable-library-validation - keychain-access-groups $(AppIdentifierPrefix)$(BUNDLE_IDENTIFIER_BASE).Shared 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 bdeb59bb..636cb078 100644 --- a/LICENSE +++ b/LICENSE @@ -1,691 +1,21 @@ -# Copilot for Xcode Open Source License - -This license is a combination of the GPLv3 and some additional agreements. - -Features that requires a Plus license key are not included in this project, and are not open source. - -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 redistribution or commercial 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/Makefile b/Makefile deleted file mode 100644 index c0fbddcf..00000000 --- a/Makefile +++ /dev/null @@ -1,14 +0,0 @@ -setup: - echo "Setup." - -setup-langchain: - echo "Don't setup LangChain!" - cd Python; \ - curl -L https://github.com/beeware/Python-Apple-support/releases/download/3.11-b1/Python-3.11-macOS-support.b1.tar.gz -o Python-3.11-macOS-support.b1.tar.gz; \ - tar -xzvf Python-3.11-macOS-support.b1.tar.gz; \ - rm Python-3.11-macOS-support.b1.tar.gz; \ - cp module.modulemap.copy Python.xcframework/macos-arm64_x86_64/Headers/module.modulemap - cd Python/site-packages; \ - sh ./install.sh - -.PHONY: setup setup-langchain 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/Pro b/Pro deleted file mode 160000 index 4d30bd2c..00000000 --- a/Pro +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4d30bd2ce76748fab305bd076b3d778ab0c17e6b diff --git a/README.md b/README.md index cbff5a77..c4066a45 100644 --- a/README.md +++ b/README.md @@ -6,41 +6,51 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil Buy Me A Coffee -[Get a Plus License Key to unlock more features and support this project](https://intii.lemonsqueezy.com) - ## 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) - - [Granting Permissions to the App](#granting-permissions-to-the-app) - - [Setting Up Key Bindings](#setting-up-key-bindings) - - [Setting Up Suggestion Feature](#setting-up-suggestion-feature) - - [Setting Up GitHub Copilot](#setting-up-github-copilot) - - [Setting Up Codeium](#setting-up-codeium) - - [Using Locally Run LLMs](#using-locally-run-llms) - - [Setting Up Chat Feature](#setting-up-chat-feature) - - [Managing `CopilotForXcodeExtensionService.app`](#managing-copilotforxcodeextensionserviceapp) -- [Update](#update) -- [Feature](#feature) -- [Plus Features](#plus-features) -- [Limitations](#limitations) -- [License](#license) - -For frequently asked questions, check [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions). +- [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](https://github.com/intitni/CopilotForXcode/wiki) +For more information, check the [Wiki Page](https://copilotforxcode.intii.com/wiki). ## Prerequisites @@ -95,8 +105,13 @@ Open the app, the app will create a launch agent to setup a background running S 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. ### Granting Permissions to the App @@ -121,7 +136,8 @@ A [recommended setup](https://github.com/intitni/CopilotForXcode/issues/14) that | Command | Key Binding | | ------------------- | ------------------------------------------------------ | -| Accept Suggestions | `⌥}` (Or accept with Tab if Plus license is available) | +| Accept Suggestions | `⌥}` or Tab | +| Dismiss Suggestions | Esc | | Reject Suggestion | `⌥{` | | Next Suggestion | `⌥>` | | Previous Suggestion | `⌥<` | @@ -134,7 +150,7 @@ Another convenient method to access commands is by using the `⇧⌘/` shortcut #### Setting Up Global Hotkeys -Currently, the is only one global hotkey you can set to show/hide the widgets under the General tab from the host app. +Currently, there is only one global hotkey you can set to show/hide the widgets under the General tab from the host app. When this hotkey is not set to enabled globally, it will only work when the service app or Xcode is active. @@ -167,15 +183,6 @@ The installed language server is located at `~/Library/Application Support/com.i The installed language server is located at `~/Library/Application Support/com.intii.CopilotForXcode/Codeium/executable/`. -#### Using Locally Run LLMs - -You can also use locally run LLMs or as a suggestion provider using the [Custom Suggestion Service](https://github.com/intitni/CustomSuggestionServiceForCopilotForXcode) extension. It supports: - -- LLM with OpenAI compatible completions API -- LLM with OpenAI compatible chat completions API -- [Tabby](https://tabby.tabbyml.com) -- etc. - ### Setting Up Chat Feature 1. In the host app, navigate to "Service - Chat Model". @@ -208,7 +215,7 @@ The app can provide real-time code suggestions based on the files you have opene The feature provides two presentation modes: - Nearby Text Cursor: This mode shows suggestions based on the position of the text cursor. -- Floating Widget: This mode shows suggestions next to the circular widget. +- 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. @@ -228,7 +235,7 @@ Whenever your code is updated, the app will automatically fetch suggestions for Commands called by the app: -- 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. +- 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 @@ -244,7 +251,7 @@ 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 @@ -255,32 +262,14 @@ You can detach the chat panel by simply dragging it away. Once detached, the cha | Shortcut | Description | | :------: | --------------------------------------------------------------------------------------------------- | | `⌘W` | Close the chat tab. | -| `⌘M` | Minimize the chat, you can bring it back with any chat commands or by clicking the circular widget. | -| `⇧↩︎` | Add new line. | +| `⌘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 | -#### 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`. +#### Chat Commands -| Scope | Description | -| :--------: | ---------------------------------------------------------------------------------------- | -| `@file` | Includes the metadata of the editing document and line annotations in the system prompt. | -| `@code` | Includes the focused/selected code and everything from `@file` in the system prompt. | -| `@sense` | Experimental. Read the relevant information of the focused code | -| `@project` | Experimental. Access content of the project | -| `@web` | Allow the bot to search on Bing or query from a web page | - -`@code` is on by default, if `Use @code scope by default in chat context.` is on. Otherwise, `@file` will be on by default. - -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`. - -#### Chat Plugins - -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 +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 ``` /run echo hello @@ -294,14 +283,10 @@ If you need to end a plugin, you can just type | Command | Description | | :--------------------: | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `/run` | Runs the command under the project root. | +| `/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. | -| `/math` | Solves a math problem in natural language | -| `/search` | Search on Bing and summarize the results. You have to setup the Bing Search API in the host app before using it. | | `/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. | -| `/shortcutInput(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 send to the bot as a user message. | ### Prompt to Code @@ -317,28 +302,16 @@ This feature is recommended when you need to update a specific piece of code. So - Polishing and correcting grammar and spelling errors in the documentation. - Translating a localizable strings file. -#### Prompt to Code 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`. - -| Scope | Description | -| :--------: | ---------------------------------------------------------------------------------------- | -| `@sense` | Experimental. Read the relevant information of the focused code | - -To use scopes, you can prefix a message with `@sense`. - -You can use shorthand to represent a scope, such as `@sense`, and enable multiple scopes with `@c+web`. - #### Commands -- Prompt to Code: Open a prompt to code window, where you can use natural language to write or edit selected code. -- Accept Prompt to Code: Accept the result of prompt to 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. +- 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`. @@ -353,34 +326,6 @@ You can use the following template arguments in custom commands: | `{{active_editor_file_name}}` | The name of the active file in the editor. | | `{{clipboard}}` | The content in clipboard. | -## Plus Features - -The pre-built binary contains a set of exclusive features that can only be accessed with a Plus license key. To obtain a license key, please visit [this link](https://intii.lemonsqueezy.com). - -These features are included in another repo, and are not open sourced. - -The currently available Plus features include: - -- `@project` scope in chat to include project information in conversations. (experimental) -- Suggestion Cheatsheet that provides relevant content to the suggestion service. (experimental) -- `@sense` scope in chat and prompt to code to include relevant information of the focusing code. -- Terminal tab in chat panel. -- Unlimited chat/embedding models. -- Tab to accept suggestions. -- Persisted chat panel. -- Browser tab in chat panel. -- Unlimited custom commands. - -Since the app needs to manage license keys, it will send network request to `https://copilotforxcode-license.intii.com`, - -- when you activate the license key -- when you deactivate the license key -- when you validate the license key manually -- when you open the host app or the service app if a license key is available -- every 24 hours if a license key is available - -The request contains only the license key, the email address (only on activation), and an instance id. You are free to MITM the request to see what data is sent. - ## Limitations - 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. 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 b668dedd..c9ebe525 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -24,23 +24,44 @@ "testTargets" : [ { "target" : { - "containerPath" : "container:Core", - "identifier" : "ServiceTests", - "name" : "ServiceTests" + "containerPath" : "container:Tool", + "identifier" : "SharedUIComponentsTests", + "name" : "SharedUIComponentsTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "SuggestionInjectorTests", - "name" : "SuggestionInjectorTests" + "containerPath" : "container:Tool", + "identifier" : "ActiveDocumentChatContextCollectorTests", + "name" : "ActiveDocumentChatContextCollectorTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "CodeDiffTests", + "name" : "CodeDiffTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "SuggestionWidgetTests", - "name" : "SuggestionWidgetTests" + "identifier" : "ServiceUpdateMigrationTests", + "name" : "ServiceUpdateMigrationTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "ASTParserTests", + "name" : "ASTParserTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SuggestionProviderTests", + "name" : "SuggestionProviderTests" } }, { @@ -53,92 +74,113 @@ { "target" : { "containerPath" : "container:Tool", - "identifier" : "LangChainTests", - "name" : "LangChainTests" + "identifier" : "KeychainTests", + "name" : "KeychainTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "OpenAIServiceTests", - "name" : "OpenAIServiceTests" + "identifier" : "JoinJSONTests", + "name" : "JoinJSONTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "ChatServiceTests", - "name" : "ChatServiceTests" + "identifier" : "ServiceTests", + "name" : "ServiceTests" + } + }, + { + "target" : { + "containerPath" : "container:OverlayWindow", + "identifier" : "OverlayWindowTests", + "name" : "OverlayWindowTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "TokenEncoderTests", - "name" : "TokenEncoderTests" + "identifier" : "SuggestionBasicTests", + "name" : "SuggestionBasicTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SuggestionModelTests", - "name" : "SuggestionModelTests" + "identifier" : "LangChainTests", + "name" : "LangChainTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SharedUIComponentsTests", - "name" : "SharedUIComponentsTests" + "identifier" : "GitHubCopilotServiceTests", + "name" : "GitHubCopilotServiceTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "ASTParserTests", - "name" : "ASTParserTests" + "identifier" : "OpenAIServiceTests", + "name" : "OpenAIServiceTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "ServiceUpdateMigrationTests", - "name" : "ServiceUpdateMigrationTests" + "identifier" : "SuggestionWidgetTests", + "name" : "SuggestionWidgetTests" + } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "KeyBindingManagerTests", + "name" : "KeyBindingManagerTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "KeychainTests", - "name" : "KeychainTests" + "identifier" : "FocusedCodeFinderTests", + "name" : "FocusedCodeFinderTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "ActiveDocumentChatContextCollectorTests", - "name" : "ActiveDocumentChatContextCollectorTests" + "identifier" : "WebSearchServiceTests", + "name" : "WebSearchServiceTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "GitHubCopilotServiceTests", - "name" : "GitHubCopilotServiceTests" + "identifier" : "XcodeInspectorTests", + "name" : "XcodeInspectorTests" + } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "ChatServiceTests", + "name" : "ChatServiceTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "FocusedCodeFinderTests", - "name" : "FocusedCodeFinderTests" + "identifier" : "SuggestionInjectorTests", + "name" : "SuggestionInjectorTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "XcodeInspectorTests", - "name" : "XcodeInspectorTests" + "identifier" : "TokenEncoderTests", + "name" : "TokenEncoderTests" } } ], 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.swift b/Tool/Package.swift index 4fd93b6a..f303e44c 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -10,23 +10,27 @@ let package = Package( .library(name: "XPCShared", targets: ["XPCShared"]), .library(name: "Terminal", targets: ["Terminal"]), .library(name: "LangChain", targets: ["LangChain"]), - .library(name: "ExternalServices", targets: ["BingSearchService"]), + .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: "SuggestionModel", targets: ["SuggestionModel"]), + .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", "WorkspaceSuggestionService"]), + .library(name: "Workspace", targets: ["Workspace"]), + .library(name: "WorkspaceSuggestionService", targets: ["WorkspaceSuggestionService"]), .library( name: "SuggestionProvider", targets: ["SuggestionProvider", "GitHubCopilotService", "CodeiumService"] @@ -45,6 +49,15 @@ let package = Package( .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. @@ -60,17 +73,19 @@ let package = Package( .package(url: "https://github.com/intitni/Highlightr", branch: "master"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", - from: "0.55.0" + exact: "1.16.1" ), - .package(url: "https://github.com/apple/swift-syntax.git", exact: "509.0.2"), + .package(url: "https://github.com/apple/swift-syntax.git", from: "600.0.0"), .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), - .package(url: "https://github.com/krzyzanowskim/STTextView", from: "0.8.21"), // 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", from: "0.4.0"), + .package( + url: "https://github.com/intitni/CopilotForXcodeKit", + branch: "feature/custom-chat-tab" + ), // TreeSitter .package(url: "https://github.com/intitni/SwiftTreeSitter.git", branch: "main"), @@ -79,7 +94,7 @@ let package = Package( targets: [ // MARK: - Helpers - .target(name: "XPCShared", dependencies: ["SuggestionModel"]), + .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger"]), .target(name: "Configs"), @@ -89,8 +104,16 @@ let package = Package( .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: [ @@ -116,6 +139,14 @@ let package = Package( )] ), + .target( + name: "CustomCommandTemplateProcessor", + dependencies: [ + "XcodeInspector", + "SuggestionBasic", + ] + ), + .target(name: "DebounceFunction"), .target( @@ -149,13 +180,23 @@ let package = Package( ), .target( - name: "SuggestionModel", + 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: [ @@ -164,11 +205,51 @@ let package = Package( ), .testTarget( - name: "SuggestionModelTests", - dependencies: ["SuggestionModel"] + 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"), + .target( + name: "AXExtension", + dependencies: ["Logger"] + ), .target( name: "AXNotificationStream", @@ -182,7 +263,7 @@ let package = Package( name: "XcodeInspector", dependencies: [ "AXExtension", - "SuggestionModel", + "SuggestionBasic", "AXNotificationStream", "Logger", "Toast", @@ -198,19 +279,34 @@ let package = Package( .target(name: "AsyncPassthroughSubject"), + .target( + name: "BuiltinExtension", + dependencies: [ + "SuggestionBasic", + "SuggestionProvider", + "ChatBasic", + "Workspace", + "ChatTab", + "AIModel", + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + ] + ), + .target( name: "SharedUIComponents", dependencies: [ "Highlightr", "Preferences", - "SuggestionModel", - .product(name: "STTextView", package: "STTextView"), + "SuggestionBasic", + "DebounceFunction", + "CodeDiff", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), .testTarget(name: "SharedUIComponentsTests", dependencies: ["SharedUIComponents"]), .target(name: "ASTParser", dependencies: [ - "SuggestionModel", + "SuggestionBasic", .product(name: "SwiftTreeSitter", package: "SwiftTreeSitter"), .product(name: "TreeSitterObjC", package: "tree-sitter-objc"), ]), @@ -222,7 +318,7 @@ let package = Package( dependencies: [ "GitIgnoreCheck", "UserDefaultsObserver", - "SuggestionModel", + "SuggestionBasic", "Logger", "Preferences", "XcodeInspector", @@ -235,6 +331,8 @@ let package = Package( "Workspace", "SuggestionProvider", "XPCShared", + "BuiltinExtension", + "SuggestionInjector", ] ), @@ -243,7 +341,7 @@ let package = Package( dependencies: [ "Preferences", "ASTParser", - "SuggestionModel", + "SuggestionBasic", .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftParser", package: "swift-syntax"), ] @@ -265,6 +363,17 @@ let package = Package( ] ), + .target( + name: "CommandHandler", + dependencies: [ + "XcodeInspector", + "Preferences", + "ChatBasic", + "ModificationBasic", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + // MARK: - Services .target( @@ -273,32 +382,59 @@ let package = Package( "OpenAIService", "ObjectiveCExceptionHandling", "USearchIndex", + "ChatBasic", + .product(name: "JSONRPC", package: "JSONRPC"), .product(name: "Parsing", package: "swift-parsing"), .product(name: "SwiftSoup", package: "SwiftSoup"), ] ), - .target(name: "BingSearchService"), + .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: [ - "GitHubCopilotService", - "CodeiumService", + "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", - "SuggestionModel", + "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", @@ -312,10 +448,15 @@ let package = Package( dependencies: [ "LanguageClient", "Keychain", - "SuggestionModel", + "SuggestionBasic", "Preferences", "Terminal", "XcodeInspector", + "BuiltinExtension", + "ChatTab", + "SharedUIComponents", + .product(name: "JSONRPC", package: "JSONRPC"), + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] ), @@ -328,6 +469,10 @@ let package = Package( "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"), @@ -341,6 +486,7 @@ let package = Package( name: "OpenAIServiceTests", dependencies: [ "OpenAIService", + "ChatBasic", .product( name: "ComposableArchitecture", package: "swift-composable-architecture" @@ -352,10 +498,16 @@ let package = Package( .target( name: "ChatTab", - dependencies: [.product( - name: "ComposableArchitecture", - package: "swift-composable-architecture" - )] + dependencies: [ + "Preferences", + "Configs", + "AIModel", + .product(name: "CodableWrappers", package: "CodableWrappers"), + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), + ] ), // MARK: - Chat Context Collector @@ -363,7 +515,8 @@ let package = Package( .target( name: "ChatContextCollector", dependencies: [ - "SuggestionModel", + "SuggestionBasic", + "ChatBasic", "OpenAIService", ] ), @@ -371,6 +524,7 @@ let package = Package( .target( name: "ActiveDocumentChatContextCollector", dependencies: [ + "ASTParser", "ChatContextCollector", "OpenAIService", "Preferences", diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift index 06d0a022..145d0298 100644 --- a/Tool/Sources/AIModel/ChatModel.swift +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -23,6 +23,7 @@ public struct ChatModel: Codable, Equatable, Identifiable { case googleAI case ollama case claude + case gitHubCopilot } public struct Info: Codable, Equatable { @@ -38,12 +39,34 @@ public struct ChatModel: Codable, Equatable, Identifiable { public struct OpenAIInfo: Codable, Equatable { @FallbackDecoding public var organizationID: String + @FallbackDecoding + public var projectID: String - public init(organizationID: 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 @@ -53,6 +76,33 @@ public struct ChatModel: Codable, Equatable, Identifiable { } } + 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 @@ -63,6 +113,10 @@ public struct ChatModel: Codable, Equatable, Identifiable { public var maxTokens: Int @FallbackDecoding public var supportsFunctionCalling: Bool + @FallbackDecoding + public var supportsImage: Bool + @FallbackDecoding + public var supportsAudio: Bool @FallbackDecoding public var modelName: String @@ -72,6 +126,12 @@ public struct ChatModel: Codable, Equatable, Identifiable { 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 = "", @@ -79,20 +139,30 @@ public struct ChatModel: Codable, Equatable, Identifiable { 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() + 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 } } @@ -125,6 +195,8 @@ public struct ChatModel: Codable, Equatable, Identifiable { 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" } } } @@ -148,3 +220,19 @@ public struct EmptyChatModelOpenAIInfo: FallbackValueProvider { 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 index d86650fa..4e192dda 100644 --- a/Tool/Sources/AIModel/EmbeddingModel.swift +++ b/Tool/Sources/AIModel/EmbeddingModel.swift @@ -21,11 +21,13 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable { 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 @@ -44,6 +46,8 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable { public var openAIInfo: OpenAIInfo @FallbackDecoding public var ollamaInfo: OllamaInfo + @FallbackDecoding + public var customHeaderInfo: CustomHeaderInfo public init( apiKeyName: String = "", @@ -53,7 +57,8 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable { dimensions: Int = 1536, modelName: String = "", openAIInfo: OpenAIInfo = OpenAIInfo(), - ollamaInfo: OllamaInfo = OllamaInfo() + ollamaInfo: OllamaInfo = OllamaInfo(), + customHeaderInfo: CustomHeaderInfo = CustomHeaderInfo() ) { self.apiKeyName = apiKeyName self.baseURL = baseURL @@ -63,6 +68,7 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable { self.modelName = modelName self.openAIInfo = openAIInfo self.ollamaInfo = ollamaInfo + self.customHeaderInfo = customHeaderInfo } } @@ -87,6 +93,8 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable { 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" } } } diff --git a/Tool/Sources/ASTParser/ASTParser.swift b/Tool/Sources/ASTParser/ASTParser.swift index dd11d709..1f66fa85 100644 --- a/Tool/Sources/ASTParser/ASTParser.swift +++ b/Tool/Sources/ASTParser/ASTParser.swift @@ -1,4 +1,4 @@ -import SuggestionModel +import SuggestionBasic import SwiftTreeSitter import tree_sitter import TreeSitterObjC diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index d0dc22d0..e54bfaff 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import Logger // MARK: - State @@ -21,7 +22,7 @@ public extension AXUIElement { var value: String { (try? copyValue(key: kAXValueAttribute)) ?? "" } - + var intValue: Int? { (try? copyValue(key: kAXValueAttribute)) } @@ -57,7 +58,9 @@ public extension AXUIElement { } var isSourceEditor: Bool { - description == "Source Editor" + if !(description == "Source Editor" && role != kAXUnknownRole) { return false } + if let _ = firstParent(where: { $0.identifier == "editor context" }) { return true } + return false } var selectedTextRange: ClosedRange? { @@ -81,6 +84,35 @@ public extension AXUIElement { 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 @@ -135,6 +167,28 @@ public extension AXUIElement { (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) } @@ -166,8 +220,15 @@ public extension AXUIElement { func child( identifier: String? = nil, title: String? = nil, - role: 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 } @@ -181,19 +242,36 @@ public extension AXUIElement { if let target = child.child( identifier: identifier, title: title, - role: role + role: role, + depth: depth + 1 ) { return target } } return nil } - func children(where match: (AXUIElement) -> Bool) -> [AXUIElement] { + /// 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(where: match)) + all.append(contentsOf: child.children(depth: depth + 1, where: match)) } return all } @@ -204,12 +282,25 @@ public extension AXUIElement { return parent.firstParent(where: match) } - func firstChild(where match: (AXUIElement) -> Bool) -> AXUIElement? { + 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(where: match) { + if let target = child.firstChild(depth: depth + 1, where: match) { return target } } @@ -229,6 +320,97 @@ public extension AXUIElement { } } +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 { @@ -260,5 +442,7 @@ public extension AXUIElement { } } -extension AXError: 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 index 89fca015..b361f8ae 100644 --- a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift +++ b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift @@ -133,9 +133,12 @@ public final class AXNotificationStream: AsyncSequence { .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)") - pendingRegistrationNames.remove(name) case .invalidUIElementObserver: Logger.service.error("AXObserver: Invalid UI element observer") pendingRegistrationNames.remove(name) diff --git a/Tool/Sources/AppActivator/AppActivator.swift b/Tool/Sources/AppActivator/AppActivator.swift index b50f3bf4..2011360a 100644 --- a/Tool/Sources/AppActivator/AppActivator.swift +++ b/Tool/Sources/AppActivator/AppActivator.swift @@ -3,7 +3,7 @@ import Dependencies import XcodeInspector public extension NSWorkspace { - static func activateThisApp(delay: TimeInterval = 0.3) { + static func activateThisApp(delay: TimeInterval = 0.10) { Task { @MainActor in try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) @@ -17,32 +17,48 @@ public extension NSWorkspace { // Fallback solution - 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) + 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 = await XcodeInspector.shared.safe.previousActiveApplication + guard let app = XcodeInspector.shared.previousActiveApplication else { return } try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - _ = app.activate() + activateApp(app) } } static func activatePreviousActiveXcode(delay: TimeInterval = 0.2) { Task { @MainActor in - guard let app = await XcodeInspector.shared.safe.latestActiveXcode else { return } + guard let app = XcodeInspector.shared.latestActiveXcode else { return } try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - _ = app.activate() + 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 { 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/OpenAIService/FucntionCall/ChatGPTFunction.swift b/Tool/Sources/ChatBasic/ChatGPTFunction.swift similarity index 63% rename from Tool/Sources/OpenAIService/FucntionCall/ChatGPTFunction.swift rename to Tool/Sources/ChatBasic/ChatGPTFunction.swift index d2d8aaad..2a5a4af0 100644 --- a/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFunction.swift +++ b/Tool/Sources/ChatBasic/ChatGPTFunction.swift @@ -7,12 +7,39 @@ public enum ChatGPTFunctionCallPhase { 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 {} @@ -21,7 +48,7 @@ public protocol ChatGPTFunction { typealias NoArguments = NoChatGPTFunctionArguments associatedtype Arguments: Decodable associatedtype Result: ChatGPTFunctionResult - typealias ReportProgress = (String) async -> Void + 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. @@ -43,14 +70,18 @@ public extension ChatGPTFunction { argumentsJsonString: String, reportProgress: @escaping ReportProgress ) async throws -> Result { - do { - let arguments = try JSONDecoder() - .decode(Arguments.self, from: argumentsJsonString.data(using: .utf8) ?? Data()) - return try await call(arguments: arguments, reportProgress: reportProgress) - } catch { - await reportProgress("Error: Failed to decode arguments. \(error.localizedDescription)") - throw error - } + 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) } } @@ -64,20 +95,10 @@ public extension ChatGPTFunction where Arguments == NoArguments { public protocol ChatGPTArgumentsCollectingFunction: ChatGPTFunction where Result == String {} public extension ChatGPTArgumentsCollectingFunction { - @available( - *, - deprecated, - message: "This function is only used to get a structured output from the bot." - ) func prepare(reportProgress: @escaping ReportProgress = { _ in }) async { assertionFailure("This function is only used to get a structured output from the bot.") } - @available( - *, - deprecated, - message: "This function is only used to get a structured output from the bot." - ) func call( arguments: Arguments, reportProgress: @escaping ReportProgress = { _ in } @@ -85,12 +106,7 @@ public extension ChatGPTArgumentsCollectingFunction { assertionFailure("This function is only used to get a structured output from the bot.") return "" } - - @available( - *, - deprecated, - message: "This function is only used to get a structured output from the bot." - ) + func call( argumentsJsonString: String, reportProgress: @escaping ReportProgress @@ -100,12 +116,12 @@ public extension ChatGPTArgumentsCollectingFunction { } } -struct ChatGPTFunctionSchema: Codable, Equatable { - var name: String - var description: String - var parameters: JSONSchemaValue +public struct ChatGPTFunctionSchema: Codable, Equatable, Sendable { + public var name: String + public var description: String + public var parameters: JSONSchemaValue - init(name: String, description: String, 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/OpenAIService/FucntionCall/JSONSchema.swift b/Tool/Sources/ChatBasic/JSONSchema.swift similarity index 100% rename from Tool/Sources/OpenAIService/FucntionCall/JSONSchema.swift rename to Tool/Sources/ChatBasic/JSONSchema.swift diff --git a/Tool/Sources/ChatContextCollector/ChatContextCollector.swift b/Tool/Sources/ChatContextCollector/ChatContextCollector.swift index 711620b5..82d576c3 100644 --- a/Tool/Sources/ChatContextCollector/ChatContextCollector.swift +++ b/Tool/Sources/ChatContextCollector/ChatContextCollector.swift @@ -1,9 +1,10 @@ +import ChatBasic import Foundation import OpenAIService import Parsing public struct ChatContext { - public enum Scope: String, Equatable, CaseIterable, Codable { + public enum Scope: String, Equatable, CaseIterable, Codable, Sendable { case file case code case sense @@ -11,10 +12,10 @@ public struct ChatContext { case web } - public struct RetrievedContent { + 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 diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index a93e1347..8ca58f6c 100644 --- a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -1,4 +1,5 @@ import ASTParser +import ChatBasic import ChatContextCollector import Dependencies import FocusedCodeFinder @@ -6,7 +7,7 @@ import Foundation import GitIgnoreCheck import OpenAIService import Preferences -import SuggestionModel +import SuggestionBasic import XcodeInspector public final class ActiveDocumentChatContextCollector: ChatContextCollector { @@ -46,10 +47,16 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { 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 ask you about specific line from the latest message, \ - which is not included in the focused range. + - the user explicitly ask you about specific line of code, that is NOT in the focused range \(contextLineRange). """ if let annotations = context.focusedContext?.otherLineAnnotations, diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift index fd72e225..26fbe579 100644 --- a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift @@ -1,7 +1,8 @@ import ASTParser +import ChatBasic import Foundation import OpenAIService -import SuggestionModel +import SuggestionBasic struct GetCodeCodeAroundLineFunction: ChatGPTFunction { struct Arguments: Codable { @@ -21,6 +22,10 @@ struct GetCodeCodeAroundLineFunction: ChatGPTFunction { ``` """ } + + var userReadableContent: ChatGPTFunctionResultUserReadableContent { + .text(botReadableContent) + } } struct E: Error, LocalizedError { diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift index c0590b00..1ba9f334 100644 --- a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift @@ -2,7 +2,7 @@ import ChatContextCollector import Foundation import OpenAIService import Preferences -import SuggestionModel +import SuggestionBasic import XcodeInspector public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift index 9d3dfaa0..732601fe 100644 --- a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift @@ -1,4 +1,4 @@ -import SuggestionModel +import SuggestionBasic extension CursorPosition { var text: String { diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index cc10c240..e64e1728 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -1,8 +1,10 @@ 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 @@ -19,8 +21,6 @@ public typealias ChatTab = BaseChatTab & ChatTabType /// Defines a bunch of things a chat tab should implement. public protocol ChatTabType { - /// The type of the external dependency required by this chat tab. - associatedtype ExternalDependency /// Build the view for this chat tab. @ViewBuilder func buildView() -> any View @@ -37,18 +37,26 @@ public protocol ChatTabType { 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(externalDependency: ExternalDependency) -> [ChatTabBuilder] + 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, - externalDependency: ExternalDependency - ) async throws -> any ChatTabBuilder + 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. @@ -61,18 +69,26 @@ open class BaseChatTab { } } - public var id: String { chatTabViewStore.id } - public var title: String { chatTabViewStore.title } + 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 - /// The view store for chat tab info. You should only access it after `start` is called. - public let chatTabViewStore: ViewStoreOf - + private var didStart = false + private let storeObserver = NSObject() public init(store: StoreOf) { chatTabStore = store - chatTabViewStore = ViewStore(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. @@ -102,7 +118,7 @@ open class BaseChatTab { EmptyView().id(id) } } - + /// The icon for this chat tab. @ViewBuilder public var icon: some View { @@ -113,7 +129,7 @@ open class BaseChatTab { EmptyView().id(id) } } - + /// The tab item for this chat tab. @ViewBuilder public var menu: some View { @@ -135,6 +151,7 @@ open class BaseChatTab { if let tab = self as? (any ChatTabType) { tab.start() + chatTabStore.send(.tabContentUpdated) } } } @@ -162,13 +179,17 @@ public struct DisabledChatTabBuilder: ChatTabBuilder { public extension ChatTabType { /// The name of this chat tab type. var name: String { Self.name } -} -public extension ChatTabType where ExternalDependency == Void { - /// Available builders for this chat tab. - /// It's used to generate a list of tab types for user to create. - static func chatBuilders() -> [ChatTabBuilder] { - chatBuilders(externalDependency: ()) + /// 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) } } @@ -183,7 +204,7 @@ public class EmptyChatTab: ChatTab { } } - public static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] { + public static func chatBuilders() -> [ChatTabBuilder] { [Builder(title: "Empty")] } @@ -197,11 +218,11 @@ public class EmptyChatTab: ChatTab { public func buildTabItem() -> any View { Text("Empty-\(id)") } - + public func buildIcon() -> any View { Image(systemName: "square") } - + public func buildMenu() -> any View { Text("Empty-\(id)") } @@ -210,22 +231,19 @@ public class EmptyChatTab: ChatTab { return Data() } - public static func restore( - from data: Data, - externalDependency: Void - ) async throws -> any ChatTabBuilder { + 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() + reducer: { ChatTabItem() } )) } public func start() { - chatTabViewStore.send(.updateTitle("Empty-\(id)")) + chatTabStore.send(.updateTitle("Empty-\(id)")) } } diff --git a/Tool/Sources/ChatTab/ChatTabItem.swift b/Tool/Sources/ChatTab/ChatTabItem.swift index f54f8085..abf7aaa2 100644 --- a/Tool/Sources/ChatTab/ChatTabItem.swift +++ b/Tool/Sources/ChatTab/ChatTabItem.swift @@ -13,7 +13,8 @@ public struct AnyChatTabBuilder: Equatable { } } -public struct ChatTabItem: ReducerProtocol { +@Reducer +public struct ChatTabItem { public typealias State = ChatTabInfo public enum Action: Equatable { @@ -26,7 +27,7 @@ public struct ChatTabItem: ReducerProtocol { public init() {} - public var body: some ReducerProtocol { + public var body: some ReducerOf { Reduce { state, action in switch action { case let .updateTitle(title): diff --git a/Tool/Sources/ChatTab/ChatTabPool.swift b/Tool/Sources/ChatTab/ChatTabPool.swift index db5424a2..5f5b1c2f 100644 --- a/Tool/Sources/ChatTab/ChatTabPool.swift +++ b/Tool/Sources/ChatTab/ChatTabPool.swift @@ -8,7 +8,7 @@ public final class ChatTabPool { public var createStore: (String) -> StoreOf = { id in .init( initialState: .init(id: id, title: ""), - reducer: ChatTabItem() + reducer: { ChatTabItem() } ) } @@ -22,8 +22,8 @@ public final class ChatTabPool { pool[id] } - public func setTab(_ tab: any ChatTab) { - pool[tab.id] = tab + public func setTab(_ tab: any ChatTab, forId id: String) { + pool[id] = tab } public func removeTab(of id: String) { @@ -52,3 +52,4 @@ public extension EnvironmentValues { 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/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift deleted file mode 100644 index 9ea25108..00000000 --- a/Tool/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.8.5" - - 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/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/Tool/Sources/CodeiumService/CodeiumLanguageServer.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift similarity index 61% rename from Tool/Sources/CodeiumService/CodeiumLanguageServer.swift rename to Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift index ad0916e8..051994b9 100644 --- a/Tool/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() @@ -53,10 +57,26 @@ final class CodeiumLanguageServer { 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 @@ -175,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 { @@ -270,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/Tool/Sources/CodeiumService/CodeiumModels.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumModels.swift similarity index 96% rename from Tool/Sources/CodeiumService/CodeiumModels.swift rename to Tool/Sources/CodeiumService/LanguageServer/CodeiumModels.swift index 9137130d..486e5f45 100644 --- a/Tool/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/Tool/Sources/CodeiumService/CodeiumRequest.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumRequest.swift similarity index 56% rename from Tool/Sources/CodeiumService/CodeiumRequest.swift rename to Tool/Sources/CodeiumService/LanguageServer/CodeiumRequest.swift index 8e5d87b1..24ef5561 100644 --- a/Tool/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/Tool/Sources/CodeiumService/CodeiumSupportedLanguage.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumSupportedLanguage.swift similarity index 99% rename from Tool/Sources/CodeiumService/CodeiumSupportedLanguage.swift rename to Tool/Sources/CodeiumService/LanguageServer/CodeiumSupportedLanguage.swift index 3ffe57bc..659b3805 100644 --- a/Tool/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/Tool/Sources/CodeiumService/OpendDocumentPool.swift b/Tool/Sources/CodeiumService/LanguageServer/OpendDocumentPool.swift similarity index 100% rename from Tool/Sources/CodeiumService/OpendDocumentPool.swift rename to Tool/Sources/CodeiumService/LanguageServer/OpendDocumentPool.swift diff --git a/Tool/Sources/CodeiumService/CodeiumAuthService.swift b/Tool/Sources/CodeiumService/Services/CodeiumAuthService.swift similarity index 100% rename from Tool/Sources/CodeiumService/CodeiumAuthService.swift rename to Tool/Sources/CodeiumService/Services/CodeiumAuthService.swift diff --git a/Tool/Sources/CodeiumService/CodeiumService.swift b/Tool/Sources/CodeiumService/Services/CodeiumService.swift similarity index 70% rename from Tool/Sources/CodeiumService/CodeiumService.swift rename to Tool/Sources/CodeiumService/Services/CodeiumService.swift index 7448e102..046d2df2 100644 --- a/Tool/Sources/CodeiumService/CodeiumService.swift +++ b/Tool/Sources/CodeiumService/Services/CodeiumService.swift @@ -1,8 +1,9 @@ +import AppKit import Foundation import LanguageClient import LanguageServerProtocol import Logger -import SuggestionModel +import SuggestionBasic import XcodeInspector public protocol CodeiumSuggestionServiceType { @@ -12,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 @@ -27,6 +28,7 @@ enum CodeiumError: Error, LocalizedError { case languageServerNotInstalled case languageServerOutdated case languageServiceIsInstalling + case failedToConstructChatURL var errorDescription: String? { switch self { @@ -36,19 +38,23 @@ 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 @@ -64,14 +70,20 @@ public class CodeiumSuggestionService { 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 { @@ -84,13 +96,13 @@ 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 } @@ -116,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.") } @@ -133,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 @@ -178,7 +200,7 @@ public class CodeiumSuggestionService { } } -extension CodeiumSuggestionService { +extension CodeiumService { func getMetadata() async throws -> Metadata { guard let key = authService.key else { struct E: Error, LocalizedError { @@ -186,8 +208,8 @@ extension CodeiumSuggestionService { } throw E() } - var ideVersion = await XcodeInspector.shared.safe.latestActiveXcode?.version - ?? fallbackXcodeVersion + var ideVersion = await XcodeInspector.shared.latestActiveXcode?.version + ?? fallbackXcodeVersion let versionNumberSegmentCount = ideVersion.split(separator: ".").count if versionNumberSegmentCount == 2 { ideVersion += ".0" @@ -199,7 +221,7 @@ extension CodeiumSuggestionService { ide_version: ideVersion, extension_version: languageServerVersion, api_key: key, - session_id: CodeiumSuggestionService.sessionId, + session_id: CodeiumService.sessionId, request_id: requestCounter ) } @@ -220,15 +242,14 @@ 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() @@ -236,14 +257,12 @@ extension CodeiumSuggestionService: CodeiumSuggestionServiceType { 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), @@ -257,8 +276,7 @@ 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) @@ -268,16 +286,11 @@ extension CodeiumSuggestionService: CodeiumSuggestionServiceType { 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, @@ -305,11 +318,48 @@ 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( @@ -340,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 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/CustomAsyncAlgorithms/TimedDebounce.swift b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift index df296cc8..bb97d82f 100644 --- a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift +++ b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift @@ -46,18 +46,27 @@ public extension AsyncSequence { /// /// In the future when we drop macOS 12 support we should just use chunked from AsyncAlgorithms. func timedDebounce( - for duration: TimeInterval + for duration: TimeInterval, + reducer: @escaping @Sendable (Element, Element) -> Element ) -> AsyncThrowingStream { return AsyncThrowingStream { continuation in Task { - let function = TimedDebounceFunction(duration: duration) { value in - continuation.yield(value) - } + let storage = TimedDebounceStorage() + var lastTimeStamp = Date() do { for try await value in self { - await function(value) + 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) } - await function.finish() continuation.finish() } catch { continuation.finish(throwing: error) @@ -67,3 +76,19 @@ public extension AsyncSequence { } } +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/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift similarity index 89% rename from Core/Sources/ChatService/CustomCommandTemplateProcessor.swift rename to Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift index d9dae12e..891c8301 100644 --- a/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift +++ b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift @@ -1,6 +1,6 @@ import AppKit import Foundation -import SuggestionModel +import SuggestionBasic import XcodeInspector public struct CustomCommandTemplateProcessor { @@ -39,8 +39,8 @@ public struct CustomCommandTemplateProcessor { } func getEditorInformation() async -> EditorInformation { - let editorContent = await XcodeInspector.shared.safe.focusedEditor?.getContent() - let documentURL = await XcodeInspector.shared.safe.activeDocumentURL + let editorContent = await XcodeInspector.shared.latestFocusedEditor?.getContent() + let documentURL = await XcodeInspector.shared.activeDocumentURL let language = documentURL.map(languageIdentifierFromFileURL) ?? .plaintext return .init( diff --git a/Tool/Sources/DebounceFunction/DebounceFunction.swift b/Tool/Sources/DebounceFunction/DebounceFunction.swift index 3d6e26e5..66a5fdd1 100644 --- a/Tool/Sources/DebounceFunction/DebounceFunction.swift +++ b/Tool/Sources/DebounceFunction/DebounceFunction.swift @@ -11,6 +11,10 @@ public actor DebounceFunction { self.block = block } + public func cancel() { + task?.cancel() + } + public func callAsFunction(_ t: T) async { task?.cancel() task = Task { [block, duration] in @@ -20,3 +24,25 @@ public actor DebounceFunction { } } +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 index 3a0771c4..d0532397 100644 --- a/Tool/Sources/DebounceFunction/ThrottleFunction.swift +++ b/Tool/Sources/DebounceFunction/ThrottleFunction.swift @@ -8,7 +8,7 @@ public actor ThrottleFunction { var lastFinishTime: Date = .init(timeIntervalSince1970: 0) var now: () -> Date = { Date() } - public init(duration: TimeInterval, block: @escaping (T) async -> Void) { + public init(duration: TimeInterval, block: @escaping @Sendable (T) async -> Void) { self.duration = duration self.block = block } @@ -40,3 +40,40 @@ public actor ThrottleFunction { } } +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 index 245b2d16..817fe704 100644 --- a/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift +++ b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift @@ -1,7 +1,7 @@ import Foundation -import SuggestionModel +import SuggestionBasic -public struct ActiveDocumentContext { +public struct ActiveDocumentContext: Sendable { public var documentURL: URL public var relativePath: String public var language: CodeLanguage @@ -13,8 +13,8 @@ public struct ActiveDocumentContext { public var imports: [String] public var includes: [String] - public struct FocusedContext { - public struct Context: Equatable { + public struct FocusedContext: Sendable { + public struct Context: Equatable, Sendable { public var signature: String public var name: String public var range: CursorRange @@ -80,6 +80,21 @@ public struct ActiveDocumentContext { 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) diff --git a/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift index 175fd6e6..f3048335 100644 --- a/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift @@ -1,5 +1,5 @@ import Foundation -import SuggestionModel +import SuggestionBasic public struct CodeContext: Equatable { public typealias ScopeContext = ActiveDocumentContext.FocusedContext.Context diff --git a/Tool/Sources/FocusedCodeFinder/KnownLanguageFocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/KnownLanguageFocusedCodeFinder.swift index c78257b4..1571a770 100644 --- a/Tool/Sources/FocusedCodeFinder/KnownLanguageFocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/KnownLanguageFocusedCodeFinder.swift @@ -1,6 +1,6 @@ import Foundation import Preferences -import SuggestionModel +import SuggestionBasic public typealias KnownLanguageFocusedCodeFinder = BaseKnownLanguageFocusedCodeFinder & @@ -50,7 +50,7 @@ public protocol KnownLanguageFocusedCodeFinderType: FocusedCodeFinderType { func collectContextNodes( in document: Document, tree: Tree, - containingRange: SuggestionModel.CursorRange, + containingRange: SuggestionBasic.CursorRange, textProvider: @escaping TextProvider, rangeConverter: @escaping RangeConverter ) -> ContextInfo @@ -70,7 +70,7 @@ public protocol KnownLanguageFocusedCodeFinderType: FocusedCodeFinderType { public extension KnownLanguageFocusedCodeFinderType { func findFocusedCode( in document: Document, - containingRange range: SuggestionModel.CursorRange + containingRange range: SuggestionBasic.CursorRange ) -> CodeContext { guard let tree = parseSyntaxTree(from: document) else { return .empty } @@ -145,7 +145,7 @@ extension KnownLanguageFocusedCodeFinderType { func extractFocusedCode( in codeRange: CursorRange, in document: Document, - containingRange range: SuggestionModel.CursorRange + containingRange range: SuggestionBasic.CursorRange ) -> (code: String, lines: [String], codeRange: CursorRange) { var codeRange = codeRange let codeInCodeRange = EditorInformation.code( diff --git a/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCCodeFinder.swift index 0fa7dda2..1c72906e 100644 --- a/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCCodeFinder.swift @@ -1,7 +1,7 @@ import ASTParser import Foundation import Preferences -import SuggestionModel +import SuggestionBasic import SwiftTreeSitter public enum TreeSitterTextPosition { diff --git a/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCScopeHierarchySyntaxVisitor.swift b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCScopeHierarchySyntaxVisitor.swift index 23186bf8..66761894 100644 --- a/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCScopeHierarchySyntaxVisitor.swift +++ b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCScopeHierarchySyntaxVisitor.swift @@ -1,7 +1,7 @@ import ASTParser import Foundation import Preferences -import SuggestionModel +import SuggestionBasic import SwiftTreeSitter final class ObjectiveCScopeHierarchySyntaxVisitor: ASTTreeVisitor { diff --git a/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCSyntax.swift b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCSyntax.swift index 98f5307a..f49c3280 100644 --- a/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCSyntax.swift +++ b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCSyntax.swift @@ -75,7 +75,7 @@ enum ObjectiveCNodeType: String { /// `__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_sepcifier" + case classInterfaceAttributeSpecifier = "class_interface_attribute_specifier" } extension ObjectiveCNodeType { diff --git a/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift index e8a452cb..6676008d 100644 --- a/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift @@ -1,7 +1,7 @@ import ASTParser import Foundation import Preferences -import SuggestionModel +import SuggestionBasic import SwiftParser import SwiftSyntax diff --git a/Tool/Sources/FocusedCodeFinder/Swift/SwiftScopeHierarchySyntaxVisitor.swift b/Tool/Sources/FocusedCodeFinder/Swift/SwiftScopeHierarchySyntaxVisitor.swift index d353bf73..bdd224d1 100644 --- a/Tool/Sources/FocusedCodeFinder/Swift/SwiftScopeHierarchySyntaxVisitor.swift +++ b/Tool/Sources/FocusedCodeFinder/Swift/SwiftScopeHierarchySyntaxVisitor.swift @@ -1,7 +1,7 @@ import ASTParser import Foundation import Preferences -import SuggestionModel +import SuggestionBasic import SwiftParser import SwiftSyntax diff --git a/Tool/Sources/FocusedCodeFinder/UnknownLanguageFocusCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/UnknownLanguageFocusCodeFinder.swift index 3ce18ab1..53b12095 100644 --- a/Tool/Sources/FocusedCodeFinder/UnknownLanguageFocusCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/UnknownLanguageFocusCodeFinder.swift @@ -1,6 +1,6 @@ import Foundation import Preferences -import SuggestionModel +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. 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/Tool/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift similarity index 58% rename from Tool/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index e655a91d..817a6827 100644 --- a/Tool/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 } @@ -111,18 +141,13 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server { /// 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 = [] } @@ -130,6 +155,27 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server { 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,7 +255,72 @@ extension CustomJSONRPCLanguageServer { _ anyNotification: AnyJSONRPCNotification, data: Data, block: @escaping (Error?) -> Void + ) { + Task { + do { + try await serverNotificationHandler.handleNotification( + anyNotification, + data: data + ) + block(nil) + } catch { + block(error) + } + } + } + + func sendNotification( + _ notif: ClientNotification, + completionHandler: @escaping (ServerError?) -> Void + ) { + internalServer.sendNotification(notif, completionHandler: completionHandler) + } +} + +extension CustomJSONRPCLanguageServer { + private func handleRequest( + _ request: AnyJSONRPCRequest, + data: Data, + callback: @escaping (AnyJSONRPCResponse) -> Void ) -> Bool { + return false + } +} + +extension CustomJSONRPCLanguageServer { + public func sendRequest( + _ request: ClientRequest, + completionHandler: @escaping (ServerResult) -> Void + ) { + internalServer.sendRequest(request, completionHandler: completionHandler) + } +} + +@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 { @@ -218,58 +340,31 @@ extension CustomJSONRPCLanguageServer { Logger.gitHubCopilot .info("\(anyNotification.method): \(debugDescription)") } - block(nil) - return true case "LogMessage": if UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) { Logger.gitHubCopilot .info("\(anyNotification.method): \(debugDescription)") } - block(nil)// - return true case "statusNotification": if UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) { Logger.gitHubCopilot .info("\(anyNotification.method): \(debugDescription)") } - block(nil) - return true case "featureFlagsNotification": if UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) { Logger.gitHubCopilot .info("\(anyNotification.method): \(debugDescription)") } - block(nil) - return true + 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: - return false + throw ServerError.handlerUnavailable(methodName) } } - - public func sendNotification( - _ notif: ClientNotification, - completionHandler: @escaping (ServerError?) -> Void - ) { - internalServer.sendNotification(notif, completionHandler: completionHandler) - } -} - -extension CustomJSONRPCLanguageServer { - private func handleRequest( - _ request: AnyJSONRPCRequest, - data: Data, - callback: @escaping (AnyJSONRPCResponse) -> Void - ) -> Bool { - return false - } -} - -extension CustomJSONRPCLanguageServer { - public func sendRequest( - _ request: ClientRequest, - completionHandler: @escaping (ServerResult) -> Void - ) { - internalServer.sendRequest(request, completionHandler: completionHandler) - } } diff --git a/Tool/Sources/GitHubCopilotService/CustomStdioTransport.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift similarity index 100% rename from Tool/Sources/GitHubCopilotService/CustomStdioTransport.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotAccountStatus.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotAccountStatus.swift similarity index 100% rename from Tool/Sources/GitHubCopilotService/GitHubCopilotAccountStatus.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotAccountStatus.swift diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift similarity index 76% rename from Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift index f7013f08..edf59a50 100644 --- a/Tool/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 = "a4a6d6b3f9e284e7f5c849619e06cd228cad8abd" + let commitHash = "f89e977c87180519ba3b942200e3d05b17b1e2fc" let link = "https://github.com/github/copilot.vim/archive/\(commitHash).zip" return URL(string: link)! } - static let latestSupportedVersion = "1.25.0" + /// 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 @@ -127,7 +151,15 @@ public struct GitHubCopilotInstallationManager { return } - let lspURL = gitFolderURL.appendingPathComponent("dist") + 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) { diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift similarity index 53% rename from Tool/Sources/GitHubCopilotService/GitHubCopilotRequest.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index d9210485..d5681c1e 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -1,7 +1,8 @@ import Foundation import JSONRPC import LanguageServerProtocol -import SuggestionModel +import SuggestionBasic +import XcodeInspector struct GitHubCopilotDoc: Codable { var source: String @@ -13,7 +14,6 @@ struct GitHubCopilotDoc: Codable { 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 } @@ -49,8 +49,15 @@ public struct GitHubCopilotCodeSuggestion: Codable, Equatable { 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? { @@ -131,14 +138,20 @@ enum GitHubCopilotRequest { } var request: ClientRequest { + let pretendToBeVSCode = UserDefaults.shared + .value(for: \.gitHubCopilotPretendIDEToBeVSCode) var dict: [String: JSONValue] = [ - "editorInfo": .hash([ + "editorInfo": pretendToBeVSCode ? .hash([ + "name": "vscode", + "version": "1.99.3", + ]) : .hash([ "name": "Xcode", - "version": "", + "version": .string(xcodeVersion), ]), "editorPluginInfo": .hash([ "name": "Copilot for Xcode", - "version": "", + "version": .string(Bundle.main + .infoDictionary?["CFBundleShortVersionString"] as? String ?? ""), ]), ] @@ -241,6 +254,56 @@ enum GitHubCopilotRequest { } } + 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] @@ -280,5 +343,163 @@ enum GitHubCopilotRequest { ])) } } + + 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/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift similarity index 56% rename from Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 7af99ff1..486ddd92 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -1,10 +1,13 @@ import AppKit +import enum CopilotForXcodeKit.SuggestionServiceError import Foundation +import JSONRPC import LanguageClient import LanguageServerProtocol import Logger import Preferences -import SuggestionModel +import SuggestionBasic +import XcodeInspector public protocol GitHubCopilotAuthServiceType { func checkStatus() async throws -> GitHubCopilotAccountStatus @@ -19,36 +22,54 @@ public protocol GitHubCopilotSuggestionServiceType { func getCompletions( fileURL: URL, content: String, + originalContent: String, cursorPosition: CursorPosition, tabSize: Int, indentSize: Int, - usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool, - ignoreTrailingNewLinesAndSpaces: Bool + 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) 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) async throws -> E.Response + 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): @@ -62,7 +83,7 @@ enum GitHubCopilotError: Error, LocalizedError { 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." + 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: @@ -93,36 +114,88 @@ 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 (server, localServer) = try { + 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 agentJSURL = urls.executableURL.appendingPathComponent("copilot/dist/agent.js") - guard FileManager.default.fileExists(atPath: agentJSURL.path) else { +// 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, - "\"\(agentJSURL.path)\"", + "\"\(indexJSURL.path)\"", "--stdio", ].joined(separator: " ") executionParams = Process.ExecutionParameters( path: "/bin/bash", arguments: ["-i", "-l", "-c", command], - environment: [:], + environment: environment, currentDirectoryURL: urls.supportURL ) case .shell: @@ -130,13 +203,13 @@ public class GitHubCopilotBaseService { let nodePath = UserDefaults.shared.value(for: \.nodePath) let command = [ nodePath.isEmpty ? "node" : nodePath, - "\"\(agentJSURL.path)\"", + "\"\(indexJSURL.path)\"", "--stdio", ].joined(separator: " ") executionParams = Process.ExecutionParameters( path: shell, arguments: ["-i", "-l", "-c", command], - environment: [:], + environment: environment, currentDirectoryURL: urls.supportURL ) case .env: @@ -148,7 +221,7 @@ public class GitHubCopilotBaseService { path: "/usr/bin/env", arguments: [ nodePath.isEmpty ? "node" : nodePath, - agentJSURL.path, + indexJSURL.path, "--stdio", ], environment: [ @@ -158,12 +231,12 @@ public class GitHubCopilotBaseService { ) }() } - let localServer = CopilotLocalProcessServer(executionParameters: executionParams) + let localServer = CopilotLocalProcessServer( + executionParameters: executionParams, + serverNotificationHandler: notificationHandler + ) localServer.logMessages = UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) - localServer.notificationHandler = { _, respond in - respond(.timeout) - } let server = InitializingServer(server: localServer) server.initializeParamsProvider = { @@ -175,6 +248,8 @@ public class GitHubCopilotBaseService { experimental: nil ) + let pretendToBeVSCode = UserDefaults.shared + .value(for: \.gitHubCopilotPretendIDEToBeVSCode) return InitializeParams( processId: Int(ProcessInfo.processInfo.processIdentifier), clientInfo: .init( @@ -185,10 +260,30 @@ public class GitHubCopilotBaseService { locale: nil, rootPath: projectRootURL.path, rootUri: projectRootURL.path, - initializationOptions: nil, + 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: nil + workspaceFolders: [WorkspaceFolder( + uri: projectRootURL.absoluteString, + name: projectRootURL.lastPathComponent + )] ) } @@ -198,15 +293,18 @@ public class GitHubCopilotBaseService { self.server = server localProcessServer = localServer + let notifications = NotificationCenter.default + .notifications(named: .gitHubCopilotShouldRefreshEditorInformation) Task { [weak self] in - _ = try? await server.sendRequest(GitHubCopilotRequest.SetEditorInfo()) + _ = try? await server.sendRequest( + GitHubCopilotRequest.SetEditorInfo(xcodeVersion: xcodeVersion() ?? "16.0") + ) - for await _ in NotificationCenter.default - .notifications(named: .gitHubCopilotShouldRefreshEditorInformation) - { - print("Yes!") + for await _ in notifications { guard self != nil else { return } - _ = try? await server.sendRequest(GitHubCopilotRequest.SetEditorInfo()) + _ = try? await server.sendRequest( + GitHubCopilotRequest.SetEditorInfo(xcodeVersion: xcodeVersion() ?? "16.0") + ) } } } @@ -249,6 +347,21 @@ public class GitHubCopilotBaseService { 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, @@ -320,8 +433,7 @@ public final class GitHubCopilotAuthService: GitHubCopilotBaseService, public static let shared = TheActor() } -@GitHubCopilotSuggestionActor -public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, +public final class GitHubCopilotService: GitHubCopilotBaseService, GitHubCopilotSuggestionServiceType { private var ongoingTasks = Set>() @@ -334,77 +446,113 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, super.init(designatedServer: designatedServer) } + @GitHubCopilotSuggestionActor public func getCompletions( fileURL: URL, content: String, + originalContent: String, cursorPosition: CursorPosition, tabSize: Int, indentSize: Int, - usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool, - ignoreTrailingNewLinesAndSpaces: Bool + usesTabsForIndentation: 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.. [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 } - return true - } - .map { - let suggestion = CodeSuggestion( - id: $0.uuid, - text: $0.text, - position: $0.position, - range: $0.range + 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)" ) - if ignoreTrailingNewLinesAndSpaces { - var updated = suggestion - var text = updated.text[...] - while let last = text.last, last.isNewline || last.isWhitespace { - text = text.dropLast(1) - } - updated.text = String(text) - return updated - } - return suggestion + 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() } - try Task.checkCancellation() - return completions + throw error + } catch { + await recoverContent() + throw error + } } ongoingTasks.insert(task) @@ -412,22 +560,28 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, 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 @@ -449,14 +603,19 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, ) } - public func notifyChangeTextDocument(fileURL: URL, content: String) async throws { + @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: 0, + version: version, contentChange: .init( range: nil, rangeLength: nil, @@ -467,26 +626,68 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, ) } + @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) async throws -> E.Response { - try await sendRequest(endpoint.request) + 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 index b2c48114..53f5bf39 100644 --- a/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift +++ b/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift @@ -54,9 +54,9 @@ public struct DefaultGitIgnoredChecker: GitIgnoredChecker { do { let result = try await terminal.runCommand( "/bin/bash", - arguments: ["-c", "git check-ignore \"\(fileURL.path)\""], + arguments: ["-c", "git check-ignore ${TARGET_FILE}"], currentDirectoryURL: gitFolderURL, - environment: [:] + environment: ["TARGET_FILE": fileURL.path] ) if result.isEmpty { return false } return true @@ -76,9 +76,9 @@ public struct DefaultGitIgnoredChecker: GitIgnoredChecker { do { let result = try await terminal.runCommand( "/bin/bash", - arguments: ["-c", "git check-ignore \(filePaths)"], + arguments: ["-c", "git check-ignore ${TARGET_FILE}"], currentDirectoryURL: gitFolderURL, - environment: [:] + environment: ["TARGET_FILE": filePaths] ) return result .split(whereSeparator: \.isNewline) 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/LangChain/AgentTool.swift b/Tool/Sources/LangChain/AgentTool.swift index d221adad..652bbfac 100644 --- a/Tool/Sources/LangChain/AgentTool.swift +++ b/Tool/Sources/LangChain/AgentTool.swift @@ -1,3 +1,4 @@ +import ChatBasic import Foundation import OpenAIService @@ -36,7 +37,7 @@ public class FunctionCallingAgentTool: AgentTool, ChatGPTFun try await function.call(arguments: arguments, reportProgress: reportProgress) } - public var argumentSchema: OpenAIService.JSONSchemaValue { function.argumentSchema } + public var argumentSchema: ChatBasic.JSONSchemaValue { function.argumentSchema } public typealias Arguments = F.Arguments public typealias Result = F.Result diff --git a/Tool/Sources/LangChain/Chains/CombineAnswersChain.swift b/Tool/Sources/LangChain/Chains/CombineAnswersChain.swift index d199b4f2..3838713c 100644 --- a/Tool/Sources/LangChain/Chains/CombineAnswersChain.swift +++ b/Tool/Sources/LangChain/Chains/CombineAnswersChain.swift @@ -1,6 +1,7 @@ import Foundation import Logger import OpenAIService +import Preferences public class CombineAnswersChain: Chain { public struct Input: Decodable { @@ -16,7 +17,8 @@ public class CombineAnswersChain: Chain { public let chatModelChain: ChatModelChain public init( - configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(), + configuration: ChatGPTConfiguration = + UserPreferenceChatGPTConfiguration(chatModelKey: \.preferredChatModelIdForUtilities), extraInstructions: String = "" ) { chatModelChain = .init( diff --git a/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift b/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift index f9c399f3..8b889ff7 100644 --- a/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift +++ b/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift @@ -11,6 +11,7 @@ public final class QAInformationRetrievalChain: Chain { public struct Output { public var information: String public var sourceDocuments: [Document] + public var distance: [Float] } public init( @@ -79,7 +80,11 @@ public final class QAInformationRetrievalChain: Chain { callbackManagers: callbackManagers ) - return .init(information: relevantInformation, sourceDocuments: documents.map(\.document)) + return .init( + information: relevantInformation, + sourceDocuments: documents.map(\.document), + distance: documents.map(\.distance) + ) } public func parseOutput(_ output: Output) -> String { diff --git a/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift b/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift index 3b24e6ad..81d060e0 100644 --- a/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift +++ b/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift @@ -1,5 +1,7 @@ +import ChatBasic import Foundation import OpenAIService +import Preferences public final class RefineDocumentChain: Chain { public struct Input { @@ -75,7 +77,10 @@ public final class RefineDocumentChain: Chain { func buildChatModel() -> ChatModelChain { .init( chatModel: OpenAIChat( - configuration: UserPreferenceChatGPTConfiguration().overriding { + configuration: UserPreferenceChatGPTConfiguration( + chatModelKey: \.preferredChatModelIdForUtilities + ) + .overriding { $0.temperature = 0 $0.runFunctionsAutomatically = false }, diff --git a/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift b/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift index 4c9f696a..29f8b73e 100644 --- a/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift +++ b/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift @@ -1,5 +1,7 @@ +import ChatBasic import Foundation import OpenAIService +import Preferences public final class RelevantInformationExtractionChain: Chain { public struct Input { @@ -51,7 +53,10 @@ public final class RelevantInformationExtractionChain: Chain { func buildChatModel() -> ChatModelChain { .init( chatModel: OpenAIChat( - configuration: UserPreferenceChatGPTConfiguration().overriding { + configuration: UserPreferenceChatGPTConfiguration( + chatModelKey: \.preferredChatModelIdForUtilities + ) + .overriding { $0.temperature = 0.5 $0.runFunctionsAutomatically = false }, diff --git a/Tool/Sources/LangChain/Chains/StructuredOutputChatModelChain.swift b/Tool/Sources/LangChain/Chains/StructuredOutputChatModelChain.swift index 6ea1dbb5..b362bbce 100644 --- a/Tool/Sources/LangChain/Chains/StructuredOutputChatModelChain.swift +++ b/Tool/Sources/LangChain/Chains/StructuredOutputChatModelChain.swift @@ -1,3 +1,4 @@ +import ChatBasic import Foundation import Logger import OpenAIService diff --git a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift index 83bd827a..2023e3c9 100644 --- a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift +++ b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift @@ -26,7 +26,7 @@ public struct OpenAIChat: ChatModel { ) async throws -> ChatMessage { let memory = memory ?? EmptyChatGPTMemory() - let service = ChatGPTService( + let service = LegacyChatGPTService( memory: memory, configuration: configuration, functionProvider: functionProvider diff --git a/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift b/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift index 494f91e2..c484ab4a 100644 --- a/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift +++ b/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift @@ -36,9 +36,6 @@ public struct WebLoader: DocumentLoader { for url in urls { let strategy: LoadWebPageMainContentStrategy = { switch url { - case let url - where url.absoluteString.contains("developer.apple.com/documentation"): - return Developer_Apple_Documentation_LoadContentStrategy() default: return DefaultLoadContentStrategy() } @@ -210,30 +207,5 @@ extension WebLoader { return true } } - - /// https://developer.apple.com/documentation - struct Developer_Apple_Documentation_LoadContentStrategy: LoadWebPageMainContentStrategy { - func load( - _ document: SwiftSoup.Document, - metadata: Document.Metadata - ) throws -> [Document] { - if let mainContent = try? { - 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(_ document: SwiftSoup.Document) -> Bool { - do { - return !(try document.getElementsByTag("main").isEmpty()) - } catch { - return false - } - } - } } diff --git a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift index 4e467106..5880616c 100644 --- a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift +++ b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift @@ -26,6 +26,9 @@ public extension TextSplitter { 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) } @@ -48,6 +51,41 @@ public extension TextSplitter { 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 { @@ -83,14 +121,14 @@ public extension TextSplitter { 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) } diff --git a/Tool/Sources/LangChain/Embedding/OpenAIEmbedding.swift b/Tool/Sources/LangChain/Embedding/OpenAIEmbedding.swift index cdcb2a7d..84cc6b66 100644 --- a/Tool/Sources/LangChain/Embedding/OpenAIEmbedding.swift +++ b/Tool/Sources/LangChain/Embedding/OpenAIEmbedding.swift @@ -45,33 +45,12 @@ extension OpenAIEmbedding { func getEmbeddings( documents: [Document] ) async throws -> [EmbeddedDocument] { - try await withThrowingTaskGroup( - of: (document: Document, embeddings: [Float]).self - ) { group in - for document in documents { - group.addTask { - var retryCount = 6 - var previousError: Error? - while retryCount > 0 { - do { - let embeddings = try await service.embed(text: document.pageContent) - .data - .map(\.embedding).first ?? [] - return (document, embeddings) - } catch { - retryCount -= 1 - previousError = error - } - } - throw previousError ?? CancellationError() - } - } - var all = [EmbeddedDocument]() - for try await result in group { - all.append(.init(document: result.document, embeddings: result.embeddings)) + try await service.embed(text: documents.map(\.pageContent)).data + .compactMap { + let index = $0.index + guard index >= 0, index < documents.endIndex else { return nil } + return EmbeddedDocument(document: documents[index], embeddings: $0.embedding) } - return all - } } /// OpenAI's embedding API doesn't support embedding inputs longer than the max token. @@ -112,27 +91,27 @@ extension OpenAIEmbedding { do { if text.chunkedTokens.count <= 1 { // if possible, we should just let OpenAI do the tokenization. - return ( + return try ( text.document, - try await service.embed(text: text.document.pageContent) + await service.embed(text: text.document.pageContent) .data .map(\.embedding) ) } if shouldAverageLongEmbeddings { - return ( + return try ( text.document, - try await service.embed(tokens: text.chunkedTokens) + await service.embed(tokens: text.chunkedTokens) .data .map(\.embedding) ) } // if `shouldAverageLongEmbeddings` is false, // we only embed the first chunk to save some money. - return ( + return try ( text.document, - try await service.embed(tokens: [text.chunkedTokens.first ?? []]) + await service.embed(tokens: [text.chunkedTokens.first ?? []]) .data .map(\.embedding) ) diff --git a/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift b/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift index e0505bf8..69ac8106 100644 --- a/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift +++ b/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift @@ -18,7 +18,7 @@ public actor TemporaryUSearch: VectorStore { let index: USearchIndex var documents: [USearchLabel: LabeledDocument] = [:] - public init(identifier: String, dimensions: Int = 1536 /* text-embedding-ada-002 */ ) { + public init(identifier: String, dimensions: Int) { self.identifier = calculateMD5Hash(identifier) index = .init( metric: .IP, @@ -29,8 +29,8 @@ public actor TemporaryUSearch: VectorStore { } /// Load a USearch index if found. - public static func load(identifier: String) async -> TemporaryUSearch? { - let it = TemporaryUSearch(identifier: identifier) + public static func load(identifier: String, dimensions: Int) async -> TemporaryUSearch? { + let it = TemporaryUSearch(identifier: identifier, dimensions: dimensions) do { try await it.load() return it @@ -40,8 +40,8 @@ public actor TemporaryUSearch: VectorStore { } /// Create a readonly USearch instance if the index is found. - public static func view(identifier: String) async -> TemporaryUSearch? { - let it = TemporaryUSearch(identifier: identifier) + public static func view(identifier: String, dimensions: Int) async -> TemporaryUSearch? { + let it = TemporaryUSearch(identifier: identifier, dimensions: dimensions) do { try await it.view() return it diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index b24912ed..58d280f0 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -22,6 +22,9 @@ public final class Logger { public static let retrieval = Logger(category: "Retrieval") public static let license = Logger(category: "License") public static let `extension` = Logger(category: "Extension") + public static let communicationBridge = Logger(category: "CommunicationBridge") + public static let chatProxy = Logger(category: "ChatProxy") + public static let debug = Logger(category: "Debug") #if DEBUG /// Use a temp logger to log something temporary. I won't be available in release builds. public static let temp = Logger(category: "Temp") @@ -50,7 +53,11 @@ public final class Logger { osLogType = .error } + #if DEBUG + os_log("%{public}@", log: osLog, type: osLogType, "\(file):\(line) \(function)\n\n\(message)" as CVarArg) + #else os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg) + #endif } public func debug( diff --git a/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift b/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift new file mode 100644 index 00000000..bb3a71e9 --- /dev/null +++ b/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift @@ -0,0 +1,269 @@ +import Foundation + +/// Parse a stream that contains explanation followed by a code block. +public actor ExplanationThenCodeStreamParser { + enum State { + case explanation + case code + case codeOpening + case codeClosing + } + + public enum Fragment: Sendable { + case explanation(String) + case code(String) + } + + struct Buffer { + var content: String = "" + } + + var _buffer: Buffer = .init() + var isAtBeginning = true + var buffer: String { _buffer.content } + var state: State = .explanation + let fullCodeDelimiter = "```" + + public init() {} + + private func appendBuffer(_ character: Character) { + _buffer.content.append(character) + } + + private func appendBuffer(_ content: String) { + _buffer.content += content + } + + private func resetBuffer() { + _buffer.content = "" + } + + func flushBuffer() -> String? { + if buffer.isEmpty { return nil } + guard let targetIndex = _buffer.content.lastIndex(where: { $0 != "`" && !$0.isNewline }) + else { return nil } + let prefix = _buffer.content[...targetIndex] + if prefix.isEmpty { return nil } + let nextIndex = _buffer.content.index( + targetIndex, + offsetBy: 1, + limitedBy: _buffer.content.endIndex + ) ?? _buffer.content.endIndex + + if nextIndex == _buffer.content.endIndex { + _buffer.content = "" + } else { + _buffer.content = String( + _buffer.content[nextIndex...] + ) + } + + // If we flushed something, we are no longer at the beginning + isAtBeginning = false + return String(prefix) + } + + func flushBufferIfNeeded(into results: inout [Fragment]) { + switch state { + case .explanation: + if let flushed = flushBuffer() { + results.append(.explanation(flushed)) + } + case .code: + if let flushed = flushBuffer() { + results.append(.code(flushed)) + } + case .codeOpening, .codeClosing: + break + } + } + + public func yield(_ fragment: String) -> [Fragment] { + var results: [Fragment] = [] + + func flushBuffer() { + flushBufferIfNeeded(into: &results) + } + + for character in fragment { + switch state { + case .explanation: + func forceFlush() { + if !buffer.isEmpty { + isAtBeginning = false + results.append(.explanation(buffer)) + resetBuffer() + } + } + + switch character { + case "`": + if let last = buffer.last, last == "`" || last.isNewline { + flushBuffer() + // if we are seeing the pattern of "\n`" or "``" + // that mean we may be hitting a code delimiter + appendBuffer(character) + let shouldOpenCodeBlock: Bool = { + guard buffer.hasSuffix(fullCodeDelimiter) + else { return false } + if isAtBeginning { return true } + let temp = String(buffer.dropLast(fullCodeDelimiter.count)) + if let last = temp.last, last.isNewline { + return true + } + return false + }() + // if we meet a code delimiter while in explanation state, + // it means we are opening a code block + if shouldOpenCodeBlock { + results.append(.explanation( + String(buffer.dropLast(fullCodeDelimiter.count)) + .trimmingTrailingCharacters(in: .whitespacesAndNewlines) + )) + resetBuffer() + state = .codeOpening + } + } else { + // Otherwise, the backtick is probably part of the explanation. + forceFlush() + appendBuffer(character) + } + case let char where char.isNewline: + // we keep the trailing new lines in case they are right + // ahead of the code block that should be ignored. + if let last = buffer.last, last.isNewline { + flushBuffer() + appendBuffer(character) + } else { + forceFlush() + appendBuffer(character) + } + default: + appendBuffer(character) + } + case .code: + func forceFlush() { + if !buffer.isEmpty { + isAtBeginning = false + results.append(.code(buffer)) + resetBuffer() + } + } + + switch character { + case "`": + if let last = buffer.last, last == "`" || last.isNewline { + flushBuffer() + // if we are seeing the pattern of "\n`" or "``" + // that mean we may be hitting a code delimiter + appendBuffer(character) + let possibleClosingDelimiter: String? = { + guard buffer.hasSuffix(fullCodeDelimiter) else { return nil } + let temp = String(buffer.dropLast(fullCodeDelimiter.count)) + if let last = temp.last, last.isNewline { + return "\(last)\(fullCodeDelimiter)" + } + return nil + }() + // if we meet a code delimiter while in code state, + // // it means we are closing the code block + if let possibleClosingDelimiter { + results.append(.code( + String(buffer.dropLast(possibleClosingDelimiter.count)) + )) + resetBuffer() + appendBuffer(possibleClosingDelimiter) + state = .codeClosing + } + } else { + // Otherwise, the backtick is probably part of the code. + forceFlush() + appendBuffer(character) + } + + case let char where char.isNewline: + if let last = buffer.last, last.isNewline { + flushBuffer() + appendBuffer(character) + } else { + forceFlush() + appendBuffer(character) + } + default: + appendBuffer(character) + } + case .codeOpening: + // skip the code block fence + if character.isNewline { + state = .code + } + case .codeClosing: + appendBuffer(character) + switch character { + case "`": + let possibleClosingDelimiter: String? = { + guard buffer.hasSuffix(fullCodeDelimiter) else { return nil } + let temp = String(buffer.dropLast(fullCodeDelimiter.count)) + if let last = temp.last, last.isNewline { + return "\(last)\(fullCodeDelimiter)" + } + return nil + }() + // if we meet another code delimiter while in codeClosing state, + // it means the previous code delimiter was part of the code + if let possibleClosingDelimiter { + results.append(.code( + String(buffer.dropLast(possibleClosingDelimiter.count)) + )) + resetBuffer() + appendBuffer(possibleClosingDelimiter) + } + default: + break + } + } + } + + flushBuffer() + + return results + } + + public func finish() -> [Fragment] { + guard !buffer.isEmpty else { return [] } + + var results: [Fragment] = [] + switch state { + case .explanation: + results.append( + .explanation(buffer.trimmingTrailingCharacters(in: .whitespacesAndNewlines)) + ) + case .code: + results.append(.code(buffer)) + case .codeClosing: + break + case .codeOpening: + break + } + resetBuffer() + + return results + } +} + +extension String { + func trimmingTrailingCharacters(in characterSet: CharacterSet) -> String { + guard !isEmpty else { + return "" + } + var unicodeScalars = unicodeScalars + while let scalar = unicodeScalars.last { + if !characterSet.contains(scalar) { + return String(unicodeScalars) + } + unicodeScalars.removeLast() + } + return "" + } +} + diff --git a/Tool/Sources/ModificationBasic/ModificationAgent.swift b/Tool/Sources/ModificationBasic/ModificationAgent.swift new file mode 100644 index 00000000..a29224af --- /dev/null +++ b/Tool/Sources/ModificationBasic/ModificationAgent.swift @@ -0,0 +1,117 @@ +import ChatBasic +import ComposableArchitecture +import Foundation +import SuggestionBasic + +public enum ModificationAgentResponse { + case code(String) + case explanation(String) +} + +public struct ModificationAgentRequest { + public var code: String + public var requirement: String + public var source: ModificationSource + public var isDetached: Bool + public var extraSystemPrompt: String? + public var range: CursorRange + public var references: [ChatMessage.Reference] + public var topics: [ChatMessage.Reference] + + public struct ModificationSource: Equatable { + public var language: CodeLanguage + public var documentURL: URL + public var projectRootURL: URL + public var content: String + public var lines: [String] + + public init( + language: CodeLanguage, + documentURL: URL, + projectRootURL: URL, + content: String, + lines: [String] + ) { + self.language = language + self.documentURL = documentURL + self.projectRootURL = projectRootURL + self.content = content + self.lines = lines + } + } + + public init( + code: String, + requirement: String, + source: ModificationSource, + isDetached: Bool, + extraSystemPrompt: String? = nil, + range: CursorRange, + references: [ChatMessage.Reference], + topics: [ChatMessage.Reference] + ) { + self.code = code + self.requirement = requirement + self.source = source + self.isDetached = isDetached + self.extraSystemPrompt = extraSystemPrompt + self.range = range + self.references = references + self.topics = topics + } +} + +public protocol ModificationAgent { + typealias Request = ModificationAgentRequest + typealias Response = ModificationAgentResponse + + func send(_ request: Request) -> AsyncThrowingStream +} + +public struct ModificationSnippet: Equatable, Identifiable { + public let id = UUID() + public var startLineIndex: Int + public var originalCode: String + public var modifiedCode: String + public var description: String + public var error: String? + public var attachedRange: CursorRange + + public init( + startLineIndex: Int, + originalCode: String, + modifiedCode: String, + description: String, + error: String?, + attachedRange: CursorRange + ) { + self.startLineIndex = startLineIndex + self.originalCode = originalCode + self.modifiedCode = modifiedCode + self.description = description + self.error = error + self.attachedRange = attachedRange + } +} + +public enum ModificationAttachedTarget: Equatable { + case file(URL, projectURL: URL, code: String, lines: [String]) + case dynamic +} + +public struct ModificationHistoryNode { + public var snippets: IdentifiedArrayOf + public var instruction: NSAttributedString + public var references: [ChatMessage.Reference] + + public init( + snippets: IdentifiedArrayOf, + instruction: NSAttributedString, + references: [ChatMessage.Reference] + ) { + self.snippets = snippets + self.instruction = instruction + self.references = references + } +} + diff --git a/Tool/Sources/ModificationBasic/ModificationState.swift b/Tool/Sources/ModificationBasic/ModificationState.swift new file mode 100644 index 00000000..51b7b28b --- /dev/null +++ b/Tool/Sources/ModificationBasic/ModificationState.swift @@ -0,0 +1,87 @@ +import ChatBasic +import Foundation +import IdentifiedCollections +import SuggestionBasic + +public struct ModificationState { + public typealias Source = ModificationAgentRequest.ModificationSource + + public var source: Source + public var history: [ModificationHistoryNode] = [] + public var snippets: IdentifiedArrayOf = [] + public var isGenerating: Bool = false + public var extraSystemPrompt: String + public var isAttachedToTarget: Bool = true + public var status = [String]() + public var references: [ChatMessage.Reference] = [] + + public init( + source: Source, + history: [ModificationHistoryNode] = [], + snippets: IdentifiedArrayOf, + extraSystemPrompt: String, + isAttachedToTarget: Bool, + isGenerating: Bool = false, + status: [String] = [], + references: [ChatMessage.Reference] = [] + ) { + self.history = history + self.snippets = snippets + self.isGenerating = isGenerating + self.isAttachedToTarget = isAttachedToTarget + self.extraSystemPrompt = extraSystemPrompt + self.source = source + self.status = status + self.references = references + } + + public init( + source: Source, + originalCode: String, + attachedRange: CursorRange, + instruction: String, + extraSystemPrompt: String + ) { + self.init( + source: source, + snippets: [ + .init( + startLineIndex: 0, + originalCode: originalCode, + modifiedCode: originalCode, + description: "", + error: nil, + attachedRange: attachedRange + ), + ], + extraSystemPrompt: extraSystemPrompt, + isAttachedToTarget: !attachedRange.isEmpty + ) + } + + public mutating func popHistory() -> NSAttributedString? { + if !history.isEmpty { + let last = history.removeLast() + references = last.references + snippets = last.snippets + let instruction = last.instruction + return instruction + } + + return nil + } + + public mutating func pushHistory(instruction: NSAttributedString) { + history.append(.init(snippets: snippets, instruction: instruction, references: references)) + let oldSnippets = snippets + snippets = IdentifiedArrayOf() + for var snippet in oldSnippets { + snippet.originalCode = snippet.modifiedCode + snippet.modifiedCode = "" + snippet.description = "" + snippet.error = nil + snippets.append(snippet) + } + } +} + diff --git a/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift new file mode 100644 index 00000000..888c301f --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift @@ -0,0 +1,130 @@ +import AsyncAlgorithms +import BuiltinExtension +import ChatBasic +import Foundation +import XcodeInspector + +#warning("This is a temporary implementation for proof of concept.") + +actor BuiltinExtensionChatCompletionsService { + typealias RequestBody = ChatCompletionsRequestBody + + enum CustomError: Swift.Error, LocalizedError { + case chatServiceNotFound + + var errorDescription: String? { + switch self { + case .chatServiceNotFound: + return "Chat service not found." + } + } + } + + var extensionManager: BuiltinExtensionManager { .shared } + + let extensionIdentifier: String + let requestBody: RequestBody + + init(extensionIdentifier: String, requestBody: RequestBody) { + self.extensionIdentifier = extensionIdentifier + self.requestBody = requestBody + } +} + +extension BuiltinExtensionChatCompletionsService: ChatCompletionsAPI { + func callAsFunction() async throws -> ChatCompletionResponseBody { + let stream: AsyncThrowingStream = + try await callAsFunction() + + var id: String? = nil + var model = "" + var content = "" + for try await chunk in stream { + if let chunkId = chunk.id { id = chunkId } + if model.isEmpty, let chunkModel = chunk.model { model = chunkModel } + content.append(chunk.message?.content ?? "") + } + + return .init( + id: id, + object: "", + model: model, + message: .init(role: .assistant, content: content), + otherChoices: [], + finishReason: "", + usage: nil + ) + } +} + +extension BuiltinExtensionChatCompletionsService: ChatCompletionsStreamAPI { + func callAsFunction( + ) async throws -> AsyncThrowingStream { + let service = try getChatService() + let (message, history) = extractMessageAndHistory(from: requestBody) + guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL, + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL + else { throw CancellationError() } + let stream = await service.sendMessage( + message, + history: history, + references: [], + workspace: .init( + workspaceURL: workspaceURL, + projectURL: projectURL + ) + ) + let responseID = UUID().uuidString + return stream.map { text in + ChatCompletionsStreamDataChunk( + id: responseID, + object: nil, + model: "github-copilot", + message: .init( + role: .assistant, + content: text, + toolCalls: nil + ), + finishReason: nil + ) + }.toStream() + } +} + +extension BuiltinExtensionChatCompletionsService { + func getChatService() throws -> any BuiltinExtensionChatServiceType { + guard let ext = extensionManager.extensions + .first(where: { $0.extensionIdentifier == extensionIdentifier }), + let service = ext.chatService as? BuiltinExtensionChatServiceType + else { + throw CustomError.chatServiceNotFound + } + return service + } +} + +extension BuiltinExtensionChatCompletionsService { + func extractMessageAndHistory( + from request: RequestBody + ) -> (message: String, history: [ChatMessage]) { + let messages = request.messages + + if let lastIndexNotUserMessage = messages.lastIndex(where: { $0.role != .user }) { + let message = messages[(lastIndexNotUserMessage + 1)...] + .map { $0.content } + .joined(separator: "\n\n") + let history = Array(messages[0...lastIndexNotUserMessage]) + return (message, history.map { + .init( + id: UUID().uuidString, + role: $0.role.asChatMessageRole, + content: $0.content + ) + }) + } else { // everything is user message + let message = messages.map { $0.content }.joined(separator: "\n\n") + return (message, []) + } + } +} + diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift new file mode 100644 index 00000000..8f8e3feb --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift @@ -0,0 +1,134 @@ +import AIModel +import ChatBasic +import Dependencies +import Foundation + +protocol ChatCompletionsAPIBuilder { + func buildStreamAPI( + model: ChatModel, + endpoint: URL, + apiKey: String, + requestBody: ChatCompletionsRequestBody + ) -> any ChatCompletionsStreamAPI + + func buildNonStreamAPI( + model: ChatModel, + endpoint: URL, + apiKey: String, + requestBody: ChatCompletionsRequestBody + ) -> any ChatCompletionsAPI +} + +struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder { + func buildStreamAPI( + model: ChatModel, + endpoint: URL, + apiKey: String, + requestBody: ChatCompletionsRequestBody + ) -> any ChatCompletionsStreamAPI { + if model.id == "com.github.copilot" { + return BuiltinExtensionChatCompletionsService( + extensionIdentifier: model.id, + requestBody: requestBody + ) + } + + switch model.format { + case .googleAI: + return GoogleAIChatCompletionsService( + apiKey: apiKey, + model: model, + requestBody: requestBody, + baseURL: endpoint.absoluteString + ) + case .openAI, .openAICompatible, .azureOpenAI: + return OpenAIChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .ollama: + return OllamaChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .claude: + return ClaudeChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .gitHubCopilot: + return GitHubCopilotChatCompletionsService( + model: model, + requestBody: requestBody + ) + } + } + + func buildNonStreamAPI( + model: ChatModel, + endpoint: URL, + apiKey: String, + requestBody: ChatCompletionsRequestBody + ) -> any ChatCompletionsAPI { + if model.id == "com.github.copilot" { + return BuiltinExtensionChatCompletionsService( + extensionIdentifier: model.id, + requestBody: requestBody + ) + } + + switch model.format { + case .googleAI: + return GoogleAIChatCompletionsService( + apiKey: apiKey, + model: model, + requestBody: requestBody, + baseURL: endpoint.absoluteString + ) + case .openAI, .openAICompatible, .azureOpenAI: + return OpenAIChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .ollama: + return OllamaChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .claude: + return ClaudeChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .gitHubCopilot: + return GitHubCopilotChatCompletionsService( + model: model, + requestBody: requestBody + ) + } + } +} + +struct ChatCompletionsAPIBuilderDependencyKey: DependencyKey { + static var liveValue: ChatCompletionsAPIBuilder = DefaultChatCompletionsAPIBuilder() +} + +extension DependencyValues { + var chatCompletionsAPIBuilder: ChatCompletionsAPIBuilder { + get { self[ChatCompletionsAPIBuilderDependencyKey.self] } + set { self[ChatCompletionsAPIBuilderDependencyKey.self] = newValue } + } +} + diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift index a86aba7b..76325f6f 100644 --- a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift +++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift @@ -1,16 +1,17 @@ import AIModel +import ChatBasic import CodableWrappers import Foundation import Preferences -struct ChatCompletionsRequestBody: Codable, Equatable { - struct Message: Codable, Equatable { - enum Role: String, Codable, Equatable { +struct ChatCompletionsRequestBody: Equatable { + struct Message: Equatable { + enum Role: String, Equatable { case system case user case assistant case tool - + var asChatMessageRole: ChatMessage.Role { switch self { case .system: @@ -24,6 +25,30 @@ struct ChatCompletionsRequestBody: Codable, Equatable { } } } + + struct Image: Equatable { + enum Format: String { + case png = "image/png" + case jpeg = "image/jpeg" + case gif = "image/gif" + } + var base64EncodeData: String + var format: Format + + var dataURLString: String { + return "data:\(format.rawValue);base64,\(base64EncodeData)" + } + } + + struct Audio: Equatable { + enum Format: String { + case wav + case mp3 + } + + var data: Data + var format: Format + } /// The role of the message. var role: Role @@ -33,23 +58,29 @@ struct ChatCompletionsRequestBody: Codable, Equatable { /// name of the function call, and include the result in `content`. /// /// - important: It's required when the role is `function`. - var name: String? + var name: String? = nil /// Tool calls in an assistant message. - var toolCalls: [MessageToolCall]? + var toolCalls: [MessageToolCall]? = nil /// When we want to call a tool, we have to provide the id of the call. /// /// - important: It's required when the role is `tool`. - var toolCallId: String? + var toolCallId: String? = nil + /// Images to include in the message. + var images: [Image] = [] + /// Audios to include in the message. + var audios: [Audio] = [] + /// Cache the message if possible. + var cacheIfPossible: Bool = false } - struct MessageFunctionCall: Codable, Equatable { + struct MessageFunctionCall: Equatable { /// The name of the var name: String /// A JSON string. var arguments: String? } - struct MessageToolCall: Codable, Equatable { + struct MessageToolCall: Equatable { /// The id of the tool call. var id: String /// The type of the tool. @@ -58,7 +89,7 @@ struct ChatCompletionsRequestBody: Codable, Equatable { var function: MessageFunctionCall } - struct Tool: Codable, Equatable { + struct Tool: Equatable { var type: String = "function" var function: ChatGPTFunctionSchema } @@ -140,6 +171,23 @@ protocol ChatCompletionsStreamAPI { func callAsFunction() async throws -> AsyncThrowingStream } +extension ChatCompletionsStreamAPI { + static func setupExtraHeaderFields( + _ request: inout URLRequest, + model: ChatModel, + apiKey: String + ) async { + let parser = HeaderValueParser() + for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { + let value = await parser.parse( + field.value, + context: .init(modelName: model.info.modelName, apiKey: apiKey) + ) + request.setValue(value, forHTTPHeaderField: field.key) + } + } +} + extension AsyncSequence { func toStream() -> AsyncThrowingStream { AsyncThrowingStream { continuation in @@ -177,14 +225,24 @@ struct ChatCompletionsStreamDataChunk { var role: ChatCompletionsRequestBody.Message.Role? var content: String? + var reasoningContent: String? var toolCalls: [ToolCall]? } + struct Usage: Codable, Equatable { + var promptTokens: Int? + var completionTokens: Int? + + var cachedTokens: Int? + var otherUsage: [String: Int] + } + var id: String? var object: String? var model: String? var message: Delta? var finishReason: String? + var usage: Usage? } // MARK: - Non Stream API @@ -193,8 +251,55 @@ protocol ChatCompletionsAPI { func callAsFunction() async throws -> ChatCompletionResponseBody } -struct ChatCompletionResponseBody: Codable, Equatable { - typealias Message = ChatCompletionsRequestBody.Message +struct ChatCompletionResponseBody: Equatable { + struct Message: Equatable { + typealias Role = ChatCompletionsRequestBody.Message.Role + typealias MessageToolCall = ChatCompletionsRequestBody.MessageToolCall + + /// The role of the message. + var role: Role + /// The content of the message. + var content: String? + /// The reasoning content of the message. + var reasoningContent: String? + /// When we want to reply to a function call with the result, we have to provide the + /// name of the function call, and include the result in `content`. + /// + /// - important: It's required when the role is `function`. + var name: String? + /// Tool calls in an assistant message. + var toolCalls: [MessageToolCall]? + /// When we want to call a tool, we have to provide the id of the call. + /// + /// - important: It's required when the role is `tool`. + var toolCallId: String? + } + + struct Usage: Equatable { + var promptTokens: Int + var completionTokens: Int + + var cachedTokens: Int + var otherUsage: [String: Int] + + mutating func merge(with other: ChatCompletionsStreamDataChunk.Usage) { + promptTokens += other.promptTokens ?? 0 + completionTokens += other.completionTokens ?? 0 + cachedTokens += other.cachedTokens ?? 0 + for (key, value) in other.otherUsage { + otherUsage[key, default: 0] += value + } + } + + mutating func merge(with other: Self) { + promptTokens += other.promptTokens + completionTokens += other.completionTokens + cachedTokens += other.cachedTokens + for (key, value) in other.otherUsage { + otherUsage[key, default: 0] += value + } + } + } var id: String? var object: String @@ -202,5 +307,6 @@ struct ChatCompletionResponseBody: Codable, Equatable { var message: Message var otherChoices: [Message] var finishReason: String + var usage: Usage? } diff --git a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift index 081795c4..223eab79 100644 --- a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift @@ -1,22 +1,32 @@ import AIModel import AsyncAlgorithms +import ChatBasic import CodableWrappers import Foundation +import JoinJSON import Logger import Preferences +#warning("Update the definitions") /// https://docs.anthropic.com/claude/reference/messages_post public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { + /// https://docs.anthropic.com/en/docs/about-claude/models public enum KnownModel: String, CaseIterable { - case claude3Opus = "claude-3-opus-20240229" + case claude37Sonnet = "claude-3-7-sonnet-latest" + case claude35Sonnet = "claude-3-5-sonnet-latest" + case claude35Haiku = "claude-3-5-haiku-latest" + case claude3Opus = "claude-3-opus-latest" case claude3Sonnet = "claude-3-sonnet-20240229" case claude3Haiku = "claude-3-haiku-20240307" public var contextWindow: Int { switch self { + case .claude35Sonnet: return 200_000 + case .claude35Haiku: return 200_000 case .claude3Opus: return 200_000 case .claude3Sonnet: return 200_000 case .claude3Haiku: return 200_000 + case .claude37Sonnet: return 200_000 } } } @@ -31,11 +41,11 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet var type: String var errorDescription: String? { - error?.message ?? "Unknown Error" + error?.message ?? error?.type ?? type } } - enum MessageRole: String, Codable { + public enum MessageRole: String, Codable { case user case assistant @@ -54,6 +64,7 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet var content_block: ContentBlock? var delta: Delta? var error: APIError? + var usage: ResponseBody.Usage? struct Message: Decodable { var id: String @@ -63,7 +74,7 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet var model: String var stop_reason: String? var stop_sequence: String? - var usage: Usage? + var usage: ResponseBody.Usage? } struct ContentBlock: Decodable { @@ -72,16 +83,10 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet } struct Delta: Decodable { - var type: String + var type: String? var text: String? var stop_reason: String? var stop_sequence: String? - var usage: Usage? - } - - struct Usage: Decodable { - var input_tokens: Int? - var output_tokens: Int? } } @@ -109,6 +114,8 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet struct Usage: Codable, Equatable { var input_tokens: Int? var output_tokens: Int? + var cache_creation_input_tokens: Int? + var cache_read_input_tokens: Int? } var id: String? @@ -121,33 +128,42 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet var stop_sequence: String? } - struct RequestBody: Encodable, Equatable { - struct MessageContent: Encodable, Equatable { - enum MessageContentType: String, Encodable, Equatable { + public struct RequestBody: Codable, Equatable { + public struct CacheControl: Codable, Equatable, Sendable { + public enum CacheControlType: String, Codable, Equatable, Sendable { + case ephemeral + } + + public var type: CacheControlType = .ephemeral + } + + public struct MessageContent: Codable, Equatable { + public enum MessageContentType: String, Codable, Equatable { case text case image } - struct ImageSource: Encodable, Equatable { - var type: String = "base64" + public struct ImageSource: Codable, Equatable { + public var type: String = "base64" /// currently support the base64 source type for images, /// and the image/jpeg, image/png, image/gif, and image/webp media types. - var media_type: String = "image/jpeg" - var data: String + public var media_type: String = "image/jpeg" + public var data: String } - var type: MessageContentType - var text: String? - var source: ImageSource? + public var type: MessageContentType + public var text: String? + public var source: ImageSource? + public var cache_control: CacheControl? } - struct Message: Encodable, Equatable { + public struct Message: Codable, Equatable { /// The role of the message. - var role: MessageRole + public var role: MessageRole /// The content of the message. - var content: [MessageContent] + public var content: [MessageContent] - mutating func appendText(_ text: String) { + public mutating func appendText(_ text: String) { var otherContents = [MessageContent]() var existedText = "" for existed in content { @@ -167,13 +183,26 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet } } - var model: String - var system: String - var messages: [Message] - var temperature: Double? - var stream: Bool? - var stop_sequences: [String]? - var max_tokens: Int + public struct SystemPrompt: Codable, Equatable { + public var type = "text" + public var text: String + public var cache_control: CacheControl? + } + + public struct Tool: Codable, Equatable { + public var name: String + public var description: String + public var input_schema: JSONSchemaValue + } + + public var model: String + public var system: [SystemPrompt] + public var messages: [Message] + public var temperature: Double? + public var stream: Bool? + public var stop_sequences: [String]? + public var max_tokens: Int + public var tools: [RequestBody.Tool]? } var apiKey: String @@ -203,9 +232,12 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.setValue("prompt-caching-2024-07-31", forHTTPHeaderField: "anthropic-beta") if !apiKey.isEmpty { request.setValue(apiKey, forHTTPHeaderField: "x-api-key") } + Self.setupCustomBody(&request, model: model) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.bytes(for: request) guard let response = response as? HTTPURLResponse else { @@ -241,7 +273,13 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet StreamDataChunk.self, from: line.data(using: .utf8) ?? Data() ) + if let error = chunk.error { + throw error + } return .init(chunk: chunk, done: chunk.type == "message_stop") + } catch let error as APIError { + Logger.service.error(error.errorDescription ?? "Unknown Error") + throw error } catch { Logger.service.error("Error decoding stream data: \(error)") return .init(chunk: nil, done: false) @@ -259,9 +297,12 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.setValue("prompt-caching-2024-07-31", forHTTPHeaderField: "anthropic-beta") if !apiKey.isEmpty { request.setValue(apiKey, forHTTPHeaderField: "x-api-key") } + Self.setupCustomBody(&request, model: model) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -282,6 +323,15 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet throw error } } + + static func setupCustomBody(_ request: inout URLRequest, model: ChatModel) { + let join = JoinJSON() + let jsonBody = model.info.customBodyInfo.jsonBody + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let data = request.httpBody, !jsonBody.isEmpty else { return } + let newBody = join.join(data, with: jsonBody) + request.httpBody = newBody + } } extension ClaudeChatCompletionsService.ResponseBody { @@ -299,13 +349,26 @@ extension ClaudeChatCompletionsService.ResponseBody { } ), otherChoices: [], - finishReason: stop_reason ?? "" + finishReason: stop_reason ?? "", + usage: .init( + promptTokens: usage.input_tokens ?? 0, + completionTokens: usage.output_tokens ?? 0, + cachedTokens: usage.cache_read_input_tokens ?? 0, + otherUsage: { + var otherUsage = [String: Int]() + if let cacheCreation = usage.cache_creation_input_tokens { + otherUsage["cache_creation_input_tokens"] = cacheCreation + } + return otherUsage + }() + ) ) } } extension ClaudeChatCompletionsService.StreamDataChunk { func formalized() -> ChatCompletionsStreamDataChunk { + let usage = usage ?? message?.usage return .init( id: message?.id, object: "chat.completions", @@ -319,7 +382,19 @@ extension ClaudeChatCompletionsService.StreamDataChunk { } return nil }(), - finishReason: delta?.stop_reason + finishReason: delta?.stop_reason, + usage: .init( + promptTokens: usage?.input_tokens, + completionTokens: usage?.output_tokens, + cachedTokens: usage?.cache_read_input_tokens, + otherUsage: { + var otherUsage = [String: Int]() + if let cacheCreation = usage?.cache_creation_input_tokens { + otherUsage["cache_creation_input_tokens"] = cacheCreation + } + return otherUsage + }() + ) ) } } @@ -327,42 +402,173 @@ extension ClaudeChatCompletionsService.StreamDataChunk { extension ClaudeChatCompletionsService.RequestBody { init(_ body: ChatCompletionsRequestBody) { model = body.model + let prefixChecks = [ + "claude-3-5-sonnet", "claude-3-5-haiku", "claude-3-opus", "claude-3-haiku", + "claude-3.5-sonnet", "claude-3.5-haiku", + ] + let supportsPromptCache = if prefixChecks.contains(where: model.hasPrefix) { + true + } else { + false + } - var systemPrompts = [String]() + var systemPrompts = [SystemPrompt]() var nonSystemMessages = [Message]() + enum JoinType { + case joinMessage + case appendToList + case padMessageAndAppendToList + } + + func checkJoinType(for message: ChatCompletionsRequestBody.Message) -> JoinType { + guard let last = nonSystemMessages.last else { return .appendToList } + let newMessageRole: ClaudeChatCompletionsService.MessageRole = message.role == .user + ? .user + : .assistant + + if newMessageRole != last.role { + return .appendToList + } + + if message.cacheIfPossible != last.content + .contains(where: { $0.cache_control != nil }) + { + return .padMessageAndAppendToList + } + + return .joinMessage + } + + /// Claude only supports caching at most 4 messages. + var cacheControlMax = 4 + + func consumeCacheControl() -> Bool { + if cacheControlMax > 0 { + cacheControlMax -= 1 + return true + } + return false + } + + func convertMessageContent( + _ message: ChatCompletionsRequestBody.Message + ) -> [MessageContent] { + var content = [MessageContent]() + + content.append(.init(type: .text, text: message.content, cache_control: { + if message.cacheIfPossible, supportsPromptCache, consumeCacheControl() { + return .init() + } else { + return nil + } + }())) + for image in message.images { + content.append(.init(type: .image, source: .init( + type: "base64", + media_type: image.format.rawValue, + data: image.base64EncodeData + ))) + } + + return content + } + + func convertMessage(_ message: ChatCompletionsRequestBody.Message) -> Message { + let role: ClaudeChatCompletionsService.MessageRole = switch message.role { + case .system: .assistant + case .assistant, .tool: .assistant + case .user: .user + } + + let content: [MessageContent] = convertMessageContent(message) + + return .init(role: role, content: content) + } + for message in body.messages { switch message.role { case .system: - systemPrompts.append(message.content) + systemPrompts.append(.init(text: message.content, cache_control: { + if message.cacheIfPossible, supportsPromptCache, consumeCacheControl() { + return .init() + } else { + return nil + } + }())) case .tool, .assistant: - if let last = nonSystemMessages.last, last.role == .assistant { - nonSystemMessages[nonSystemMessages.endIndex - 1].appendText(message.content) - } else { - nonSystemMessages.append(.init( - role: .assistant, - content: [.init(type: .text, text: message.content)] - )) + switch checkJoinType(for: message) { + case .appendToList: + nonSystemMessages.append(convertMessage(message)) + case .padMessageAndAppendToList, .joinMessage: + nonSystemMessages[nonSystemMessages.endIndex - 1].content + .append(contentsOf: convertMessageContent(message)) } case .user: - if let last = nonSystemMessages.last, last.role == .user { - nonSystemMessages[nonSystemMessages.endIndex - 1].appendText(message.content) - } else { - nonSystemMessages.append(.init( - role: .user, - content: [.init(type: .text, text: message.content)] - )) + switch checkJoinType(for: message) { + case .appendToList: + nonSystemMessages.append(convertMessage(message)) + case .padMessageAndAppendToList, .joinMessage: + nonSystemMessages[nonSystemMessages.endIndex - 1].content + .append(contentsOf: convertMessageContent(message)) } } } messages = nonSystemMessages - system = systemPrompts.joined(separator: "\n\n") - .trimmingCharacters(in: .whitespacesAndNewlines) + system = systemPrompts temperature = body.temperature stream = body.stream stop_sequences = body.stop max_tokens = body.maxTokens ?? 4000 } + + func formalized() -> ChatCompletionsRequestBody { + return .init( + model: model, + messages: system.map { system in + let convertedMessage = ChatCompletionsRequestBody.Message( + role: .system, + content: system.text, + cacheIfPossible: system.cache_control != nil + ) + return convertedMessage + } + messages.map { message in + var convertedMessage = ChatCompletionsRequestBody.Message( + role: message.role == .user ? .user : .assistant, + content: "", + cacheIfPossible: message.content.contains(where: { $0.cache_control != nil }) + ) + for messageContent in message.content { + switch messageContent.type { + case .text: + if let text = messageContent.text { + convertedMessage.content += text + } + case .image: + if let source = messageContent.source { + convertedMessage.images.append( + .init( + base64EncodeData: source.data, + format: { + switch source.media_type { + case "image/png": return .png + case "image/gif": return .gif + default: return .jpeg + } + }() + ) + ) + } + } + } + return convertedMessage + }, + temperature: temperature, + stream: stream, + stop: stop_sequences, + maxTokens: max_tokens + ) + } } diff --git a/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift b/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift index 0715e0f1..6a63ee7b 100644 --- a/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift +++ b/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift @@ -1,6 +1,7 @@ import AIModel import Foundation import Preferences +import CodableWrappers protocol EmbeddingAPI { func embed(text: String) async throws -> EmbeddingResponse @@ -8,21 +9,49 @@ protocol EmbeddingAPI { func embed(tokens: [[Int]]) async throws -> EmbeddingResponse } +extension EmbeddingAPI { + static func setupExtraHeaderFields( + _ request: inout URLRequest, + model: EmbeddingModel, + apiKey: String + ) async { + let parser = HeaderValueParser() + for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { + let value = await parser.parse( + field.value, + context: .init(modelName: model.info.modelName, apiKey: apiKey) + ) + request.setValue(value, forHTTPHeaderField: field.key) + } + } +} + public struct EmbeddingResponse: Decodable { public struct Object: Decodable { public var embedding: [Float] public var index: Int + @FallbackDecoding public var object: String } + @FallbackDecoding public var data: [Object] + @FallbackDecoding public var model: String public struct Usage: Decodable { + @FallbackDecoding public var prompt_tokens: Int + @FallbackDecoding public var total_tokens: Int + + public struct Fallback: FallbackValueProvider { + public static var defaultValue: Usage { Usage(prompt_tokens: 0, total_tokens: 0) } + } } + @FallbackDecoding public var usage: Usage } + diff --git a/Tool/Sources/OpenAIService/APIs/GitHubCopilotChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/GitHubCopilotChatCompletionsService.swift new file mode 100644 index 00000000..2f200909 --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/GitHubCopilotChatCompletionsService.swift @@ -0,0 +1,112 @@ +import AIModel +import AsyncAlgorithms +import ChatBasic +import Foundation +import GitHubCopilotService +import Logger +import Preferences + +public enum AvailableGitHubCopilotModel: String, CaseIterable { + case claude35sonnet = "claude-3.5-sonnet" + case o1Mini = "o1-mini" + case o1 = "o1" + case gpt4Turbo = "gpt-4-turbo" + case gpt4oMini = "gpt-4o-mini" + case gpt4o = "gpt-4o" + case gpt4 = "gpt-4" + case gpt35Turbo = "gpt-3.5-turbo" + + public var contextWindow: Int { + switch self { + case .claude35sonnet: + return 200_000 + case .o1Mini: + return 128_000 + case .o1: + return 128_000 + case .gpt4Turbo: + return 128_000 + case .gpt4oMini: + return 128_000 + case .gpt4o: + return 128_000 + case .gpt4: + return 32_768 + case .gpt35Turbo: + return 16_384 + } + } +} + +/// Looks like it's used in many other popular repositories so maybe it's safe. +actor GitHubCopilotChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { + + let chatModel: ChatModel + let requestBody: ChatCompletionsRequestBody + + init( + model: ChatModel, + requestBody: ChatCompletionsRequestBody + ) { + var model = model + model.format = .openAICompatible + chatModel = model + self.requestBody = requestBody + } + + func callAsFunction() async throws + -> AsyncThrowingStream + { + let service = try await buildService() + return try await service() + } + + func callAsFunction() async throws -> ChatCompletionResponseBody { + let service = try await buildService() + return try await service() + } + + private func buildService() async throws -> OpenAIChatCompletionsService { + let token = try await GitHubCopilotExtension.fetchToken() + + guard let endpoint = URL(string: token.endpoints.api + "/chat/completions") else { + throw ChatGPTServiceError.endpointIncorrect + } + + return OpenAIChatCompletionsService( + apiKey: token.token, + model: chatModel, + endpoint: endpoint, + requestBody: requestBody + ) { request in + +// POST /chat/completions HTTP/2 +// :authority: api.individual.githubcopilot.com +// authorization: Bearer * +// x-request-id: * +// openai-organization: github-copilot +// vscode-sessionid: * +// vscode-machineid: * +// editor-version: vscode/1.89.1 +// editor-plugin-version: Copilot for Xcode/0.35.5 +// copilot-language-server-version: 1.236.0 +// x-github-api-version: 2023-07-07 +// openai-intent: conversation-panel +// content-type: application/json +// user-agent: GithubCopilot/1.236.0 +// content-length: 9061 +// accept: */* +// accept-encoding: gzip,deflate,br + + 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") + } + } +} + diff --git a/Tool/Sources/OpenAIService/APIs/GitHubCopilotEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/GitHubCopilotEmbeddingService.swift new file mode 100644 index 00000000..627694a4 --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/GitHubCopilotEmbeddingService.swift @@ -0,0 +1,72 @@ +import AIModel +import AsyncAlgorithms +import ChatBasic +import Foundation +import GitHubCopilotService +import Logger +import Preferences + +/// Looks like it's used in many other popular repositories so maybe it's safe. +actor GitHubCopilotEmbeddingService: EmbeddingAPI { + let chatModel: EmbeddingModel + + init(model: EmbeddingModel) { + var model = model + model.format = .openAICompatible + chatModel = model + } + + func embed(text: String) async throws -> EmbeddingResponse { + let service = try await buildService() + return try await service.embed(text: text) + } + + func embed(texts: [String]) async throws -> EmbeddingResponse { + let service = try await buildService() + return try await service.embed(texts: texts) + } + + func embed(tokens: [[Int]]) async throws -> EmbeddingResponse { + let service = try await buildService() + return try await service.embed(tokens: tokens) + } + + private func buildService() async throws -> OpenAIEmbeddingService { + let token = try await GitHubCopilotExtension.fetchToken() + + return OpenAIEmbeddingService( + apiKey: token.token, + model: chatModel, + endpoint: token.endpoints.api + "/embeddings" + ) { request in + +// POST /chat/completions HTTP/2 +// :authority: api.individual.githubcopilot.com +// authorization: Bearer * +// x-request-id: * +// openai-organization: github-copilot +// vscode-sessionid: * +// vscode-machineid: * +// editor-version: vscode/1.89.1 +// editor-plugin-version: Copilot for Xcode/0.35.5 +// copilot-language-server-version: 1.236.0 +// x-github-api-version: 2023-07-07 +// openai-intent: conversation-panel +// content-type: application/json +// user-agent: GithubCopilot/1.236.0 +// content-length: 9061 +// accept: */* +// accept-encoding: gzip,deflate,br + + 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") + } + } +} + diff --git a/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift index 2770b6e2..6e4384c5 100644 --- a/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift @@ -7,20 +7,17 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA let apiKey: String let model: ChatModel var requestBody: ChatCompletionsRequestBody - let prompt: ChatGPTPrompt let baseURL: String init( apiKey: String, model: ChatModel, requestBody: ChatCompletionsRequestBody, - prompt: ChatGPTPrompt, baseURL: String ) { self.apiKey = apiKey self.model = model self.requestBody = requestBody - self.prompt = prompt self.baseURL = baseURL } @@ -36,9 +33,7 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA ? .init() : .init(apiVersion: model.info.googleGenerativeAIInfo.apiVersion) ) - let history = prompt.googleAICompatible.history.map { message in - ModelContent(message) - } + let history = Self.convertMessages(requestBody.messages) do { let response = try await aiModel.generateContent(history) @@ -86,7 +81,7 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA ? .init() : .init(apiVersion: model.info.googleGenerativeAIInfo.apiVersion) ) - let history = prompt.googleAICompatible.history.map { message in + let history = requestBody.messages.map { message in ModelContent(message) } @@ -135,15 +130,15 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA return stream } -} -extension ChatGPTPrompt { - var googleAICompatible: ChatGPTPrompt { - var history = self.history - var reformattedHistory = [ChatMessage]() + static func convertMessages( + _ messages: [ChatCompletionsRequestBody.Message] + ) -> [ModelContent] { + var history = messages + var reformattedHistory = [ChatCompletionsRequestBody.Message]() // We don't want to combine the new user message with others. - let newUserMessage: ChatMessage? = if history.last?.role == .user { + let newUserMessage: ChatCompletionsRequestBody.Message? = if history.last?.role == .user { history.removeLast() } else { nil @@ -154,7 +149,6 @@ extension ChatGPTPrompt { guard lastIndex >= 0 else { // first message if message.role == .system { reformattedHistory.append(.init( - id: message.id, role: .user, content: ModelContent.convertContent(of: message) )) @@ -174,8 +168,7 @@ extension ChatGPTPrompt { if ModelContent.convertRole(lastMessage.role) == ModelContent .convertRole(message.role) { - let newMessage = ChatMessage( - id: message.id, + let newMessage = ChatCompletionsRequestBody.Message( role: message.role == .assistant ? .assistant : .user, content: """ \(ModelContent.convertContent(of: lastMessage)) @@ -197,7 +190,7 @@ extension ChatGPTPrompt { .convertRole(newUserMessage.role) { // Add dummy message - let dummyMessage = ChatMessage( + let dummyMessage = ChatCompletionsRequestBody.Message( role: .assistant, content: "OK" ) @@ -206,47 +199,47 @@ extension ChatGPTPrompt { reformattedHistory.append(newUserMessage) } - return .init( - history: reformattedHistory, - references: references, - remainingTokenCount: remainingTokenCount - ) + return reformattedHistory.map(ModelContent.init) } } extension ModelContent { - static func convertRole(_ role: ChatMessage.Role) -> String { + static func convertRole(_ role: ChatCompletionsRequestBody.Message.Role) -> String { switch role { - case .user, .system: + case .user, .system, .tool: return "user" case .assistant: return "model" } } - static func convertContent(of message: ChatMessage) -> String { + static func convertContent(of message: ChatCompletionsRequestBody.Message) -> String { switch message.role { case .system: - return "System Prompt:\n\(message.content ?? " ")" + return "System Prompt:\n\(message.content)" case .user: - return message.content ?? " " + return message.content + case .tool: + return """ + Result of function ID: \(message.toolCallId ?? "") + \(message.content) + """ case .assistant: if let toolCalls = message.toolCalls { return toolCalls.map { call in - let response = call.response - return """ + """ + Function ID: \(call.id) Call function: \(call.function.name) - Arguments: \(call.function.arguments) - Result: \(response.content) + Arguments: \(call.function.arguments ?? "{}") """ }.joined(separator: "\n") } else { - return message.content ?? " " + return message.content } } } - init(_ message: ChatMessage) { + init(_ message: ChatCompletionsRequestBody.Message) { let role = Self.convertRole(message.role) let parts = [ModelContent.Part.text(Self.convertContent(of: message))] self = .init(role: role, parts: parts) @@ -284,7 +277,8 @@ extension GenerateContentResponse { model: "", message: message, otherChoices: otherMessages, - finishReason: candidates.first?.finishReason?.rawValue ?? "" + finishReason: candidates.first?.finishReason?.rawValue ?? "", + usage: nil ) } diff --git a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift index e2ef4d5a..9ac6e0dd 100644 --- a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift @@ -59,6 +59,13 @@ extension OllamaChatCompletionsService: ChatCompletionsAPI { let encoder = JSONEncoder() request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -94,7 +101,8 @@ extension OllamaChatCompletionsService: ChatCompletionsAPI { ) } ?? .init(role: .assistant, content: ""), otherChoices: [], - finishReason: "" + finishReason: "", + usage: nil ) } } @@ -134,6 +142,13 @@ extension OllamaChatCompletionsService: ChatCompletionsStreamAPI { let encoder = JSONEncoder() request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + let (result, response) = try await URLSession.shared.bytes(for: request) guard let response = response as? HTTPURLResponse else { diff --git a/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift index dfd170cc..1e0f2933 100644 --- a/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift +++ b/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift @@ -12,6 +12,7 @@ struct OllamaEmbeddingService: EmbeddingAPI { var embedding: [Float] } + let apiKey: String let model: EmbeddingModel let endpoint: String @@ -25,6 +26,14 @@ struct OllamaEmbeddingService: EmbeddingAPI { model: model.info.modelName )) request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { + request.setValue(field.value, forHTTPHeaderField: field.key) + } let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index f3fd8911..93c5987d 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -1,11 +1,13 @@ import AIModel import AsyncAlgorithms +import ChatBasic import Foundation +import JoinJSON import Logger import Preferences /// https://platform.openai.com/docs/api-reference/chat/create -actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { +public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { struct CompletionAPIError: Error, Decodable, LocalizedError { struct ErrorDetail: Decodable { var message: String @@ -53,12 +55,7 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - do { - error = try container.decode(ErrorDetail.self, forKey: .error) - } catch { - print(error) - self.error = nil - } + error = try container.decode(ErrorDetail.self, forKey: .error) message = { if let e = try? container.decode(MistralAIErrorMessage.self, forKey: .message) { return CompletionAPIError.Message.mistralAI(e) @@ -71,16 +68,18 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } } - enum MessageRole: String, Codable { + public enum MessageRole: String, Codable, Sendable { case system case user case assistant case function case tool + case developer var formalized: ChatCompletionsRequestBody.Message.Role { switch self { case .system: return .system + case .developer: return .system case .user: return .user case .assistant: return .assistant case .function: return .tool @@ -89,37 +88,84 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } } - struct StreamDataChunk: Codable { - var id: String? - var object: String? - var model: String? - var choices: [Choice]? + public struct StreamDataChunk: Codable, Sendable { + public var id: String? + public var provider: String? + public var object: String? + public var model: String? + public var choices: [Choice]? + public var usage: ResponseBody.Usage? + public var created: Int? - struct Choice: Codable { - var delta: Delta? - var index: Int? - var finish_reason: String? + public struct Choice: Codable, Sendable { + public var delta: Delta? + public var index: Int? + public var finish_reason: String? - struct Delta: Codable { - var role: MessageRole? - var content: String? - var function_call: RequestBody.MessageFunctionCall? - var tool_calls: [RequestBody.MessageToolCall]? + public struct Delta: Codable, Sendable { + public var role: MessageRole? + public var content: String? + public var reasoning_content: String? + public var reasoning: String? + public var function_call: RequestBody.MessageFunctionCall? + public var tool_calls: [RequestBody.MessageToolCall]? + + public init( + role: MessageRole? = nil, + content: String? = nil, + reasoning_content: String? = nil, + reasoning: String? = nil, + function_call: RequestBody.MessageFunctionCall? = nil, + tool_calls: [RequestBody.MessageToolCall]? = nil + ) { + self.role = role + self.content = content + self.reasoning_content = reasoning_content + self.reasoning = reasoning + self.function_call = function_call + self.tool_calls = tool_calls + } } + + public init(delta: Delta? = nil, index: Int? = nil, finish_reason: String? = nil) { + self.delta = delta + self.index = index + self.finish_reason = finish_reason + } + } + + public init( + id: String? = nil, + provider: String? = nil, + object: String? = nil, + model: String? = nil, + choices: [Choice]? = nil, + usage: ResponseBody.Usage? = nil, + created: Int? = nil + ) { + self.id = id + self.provider = provider + self.object = object + self.model = model + self.choices = choices + self.usage = usage + self.created = created } } - struct ResponseBody: Codable, Equatable { - struct Message: Codable, Equatable { + public struct ResponseBody: Codable, Equatable { + public struct Message: Codable, Equatable, Sendable { /// The role of the message. - var role: MessageRole + public var role: MessageRole /// The content of the message. - var content: String? + public var content: String? + public var reasoning_content: String? + public var reasoning: String? /// When we want to reply to a function call with the result, we have to provide the /// name of the function call, and include the result in `content`. /// /// - important: It's required when the role is `function`. - var name: String? + public var name: String? /// When the bot wants to call a function, it will reply with a function call in format: /// ```json /// { @@ -127,101 +173,464 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI /// "arguments": "{ \"location\": \"earth\" }" /// } /// ``` - var function_call: RequestBody.MessageFunctionCall? + public var function_call: RequestBody.MessageFunctionCall? /// Tool calls in an assistant message. - var tool_calls: [RequestBody.MessageToolCall]? + public var tool_calls: [RequestBody.MessageToolCall]? + + public init( + role: MessageRole, + content: String? = nil, + reasoning_content: String? = nil, + reasoning: String? = nil, + name: String? = nil, + function_call: RequestBody.MessageFunctionCall? = nil, + tool_calls: [RequestBody.MessageToolCall]? = nil + ) { + self.role = role + self.content = content + self.reasoning_content = reasoning_content + self.reasoning = reasoning + self.name = name + self.function_call = function_call + self.tool_calls = tool_calls + } } - struct Choice: Codable, Equatable { - var message: Message - var index: Int? - var finish_reason: String? + public struct Choice: Codable, Equatable, Sendable { + public var message: Message + public var index: Int? + public var finish_reason: String? + + public init(message: Message, index: Int? = nil, finish_reason: String? = nil) { + self.message = message + self.index = index + self.finish_reason = finish_reason + } } - struct Usage: Codable, Equatable { - var prompt_tokens: Int? - var completion_tokens: Int? - var total_tokens: Int? + public struct Usage: Codable, Equatable, Sendable { + public var prompt_tokens: Int? + public var completion_tokens: Int? + public var total_tokens: Int? + public var prompt_tokens_details: PromptTokensDetails? + public var completion_tokens_details: CompletionTokensDetails? + + public struct PromptTokensDetails: Codable, Equatable, Sendable { + public var cached_tokens: Int? + public var audio_tokens: Int? + + public init(cached_tokens: Int? = nil, audio_tokens: Int? = nil) { + self.cached_tokens = cached_tokens + self.audio_tokens = audio_tokens + } + } + + public struct CompletionTokensDetails: Codable, Equatable, Sendable { + public var reasoning_tokens: Int? + public var audio_tokens: Int? + + public init(reasoning_tokens: Int? = nil, audio_tokens: Int? = nil) { + self.reasoning_tokens = reasoning_tokens + self.audio_tokens = audio_tokens + } + } + + public init( + prompt_tokens: Int? = nil, + completion_tokens: Int? = nil, + total_tokens: Int? = nil, + prompt_tokens_details: PromptTokensDetails? = nil, + completion_tokens_details: CompletionTokensDetails? = nil + ) { + self.prompt_tokens = prompt_tokens + self.completion_tokens = completion_tokens + self.total_tokens = total_tokens + self.prompt_tokens_details = prompt_tokens_details + self.completion_tokens_details = completion_tokens_details + } } - var id: String? - var object: String - var model: String - var usage: Usage - var choices: [Choice] + public var id: String? + public var object: String + public var model: String + public var usage: Usage + public var choices: [Choice] + + public init( + id: String? = nil, + object: String, + model: String, + usage: Usage, + choices: [Choice] + ) { + self.id = id + self.object = object + self.model = model + self.usage = usage + self.choices = choices + } } - struct RequestBody: Codable, Equatable { - struct Message: Codable, Equatable { + public struct RequestBody: Codable, Equatable { + public typealias ClaudeCacheControl = ClaudeChatCompletionsService.RequestBody.CacheControl + + public struct GitHubCopilotCacheControl: Codable, Equatable, Sendable { + public var type: String + + public init(type: String = "ephemeral") { + self.type = type + } + } + + public struct Message: Codable, Equatable { + public enum MessageContent: Codable, Equatable { + public struct TextContentPart: Codable, Equatable { + public var type = "text" + public var text: String + public var cache_control: ClaudeCacheControl? + + public init( + type: String = "text", + text: String, + cache_control: ClaudeCacheControl? = nil + ) { + self.type = type + self.text = text + self.cache_control = cache_control + } + } + + public struct ImageContentPart: Codable, Equatable { + public struct ImageURL: Codable, Equatable { + public var url: String + public var detail: String? + + public init(url: String, detail: String? = nil) { + self.url = url + self.detail = detail + } + } + + public var type = "image_url" + public var image_url: ImageURL + + public init(type: String = "image_url", image_url: ImageURL) { + self.type = type + self.image_url = image_url + } + } + + public struct AudioContentPart: Codable, Equatable { + public struct InputAudio: Codable, Equatable { + public var data: String + public var format: String + + public init(data: String, format: String) { + self.data = data + self.format = format + } + } + + public var type = "input_audio" + public var input_audio: InputAudio + + public init(type: String = "input_audio", input_audio: InputAudio) { + self.type = type + self.input_audio = input_audio + } + } + + public enum ContentPart: Codable, Equatable { + case text(TextContentPart) + case image(ImageContentPart) + case audio(AudioContentPart) + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .text(text): + try container.encode(text) + case let .image(image): + try container.encode(image) + case let .audio(audio): + try container.encode(audio) + } + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + var errors: [Error] = [] + + do { + let text = try container.decode(String.self) + self = .text(.init(text: text)) + return + } catch { + errors.append(error) + } + + do { + let text = try container.decode(TextContentPart.self) + self = .text(text) + return + } catch { + errors.append(error) + } + + do { + let image = try container.decode(ImageContentPart.self) + self = .image(image) + return + } catch { + errors.append(error) + } + + do { + let audio = try container.decode(AudioContentPart.self) + self = .audio(audio) + return + } catch { + errors.append(error) + } + + struct E: Error, LocalizedError { + let errors: [Error] + + var errorDescription: String? { + "Failed to decode ContentPart: \(errors.map { $0.localizedDescription }.joined(separator: "; "))" + } + } + throw E(errors: errors) + } + } + + case contentParts([ContentPart]) + case text(String) + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .contentParts(parts): + try container.encode(parts) + case let .text(text): + try container.encode(text) + } + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + var errors: [Error] = [] + + do { + let parts = try container.decode([ContentPart].self) + self = .contentParts(parts) + return + } catch { + errors.append(error) + } + + do { + let text = try container.decode(String.self) + self = .text(text) + return + } catch { + errors.append(error) + } + + do { // Null + _ = try container.decode([ContentPart]?.self) + self = .contentParts([]) + return + } catch { + errors.append(error) + } + + struct E: Error, LocalizedError { + let errors: [Error] + + var errorDescription: String? { + "Failed to decode MessageContent: \(errors.map { $0.localizedDescription }.joined(separator: "; "))" + } + } + throw E(errors: errors) + } + } + /// The role of the message. - var role: MessageRole + public var role: MessageRole /// The content of the message. - var content: String + public var content: MessageContent /// When we want to reply to a function call with the result, we have to provide the /// name of the function call, and include the result in `content`. /// /// - important: It's required when the role is `function`. - var name: String? + public var name: String? /// Tool calls in an assistant message. - var tool_calls: [MessageToolCall]? + public var tool_calls: [MessageToolCall]? /// When we want to call a tool, we have to provide the id of the call. /// /// - important: It's required when the role is `tool`. - var tool_call_id: String? + public var tool_call_id: String? /// When the bot wants to call a function, it will reply with a function call. /// /// Deprecated. - var function_call: MessageFunctionCall? + public var function_call: MessageFunctionCall? + #warning("TODO: when to use it?") + /// Cache control for GitHub Copilot models. + public var copilot_cache_control: GitHubCopilotCacheControl? + + public init( + role: MessageRole, + content: MessageContent, + name: String? = nil, + tool_calls: [MessageToolCall]? = nil, + tool_call_id: String? = nil, + function_call: MessageFunctionCall? = nil, + copilot_cache_control: GitHubCopilotCacheControl? = nil + ) { + self.role = role + self.content = content + self.name = name + self.tool_calls = tool_calls + self.tool_call_id = tool_call_id + self.function_call = function_call + self.copilot_cache_control = copilot_cache_control + } } - struct MessageFunctionCall: Codable, Equatable { + public struct MessageFunctionCall: Codable, Equatable, Sendable { /// The name of the - var name: String? + public var name: String? /// A JSON string. - var arguments: String? + public var arguments: String? + + public init(name: String? = nil, arguments: String? = nil) { + self.name = name + self.arguments = arguments + } } - struct MessageToolCall: Codable, Equatable { + public struct MessageToolCall: Codable, Equatable, Sendable { /// When it's returned as a data chunk, use the index to identify the tool call. - var index: Int? + public var index: Int? /// The id of the tool call. - var id: String? + public var id: String? /// The type of the tool. - var type: String? + public var type: String? /// The function call. - var function: MessageFunctionCall? + public var function: MessageFunctionCall? + + public init( + index: Int? = nil, + id: String? = nil, + type: String? = nil, + function: MessageFunctionCall? = nil + ) { + self.index = index + self.id = id + self.type = type + self.function = function + } } - struct Tool: Codable, Equatable { - var type: String = "function" - var function: ChatGPTFunctionSchema + public struct Tool: Codable, Equatable, Sendable { + public var type: String = "function" + public var function: ChatGPTFunctionSchema + + public init(type: String, function: ChatGPTFunctionSchema) { + self.type = type + self.function = function + } } - var model: String - var messages: [Message] - var temperature: Double? - var stream: Bool? - var stop: [String]? - var max_tokens: Int? - var tool_choice: FunctionCallStrategy? - var tools: [Tool]? + public struct StreamOptions: Codable, Equatable, Sendable { + public var include_usage: Bool = true + + public init(include_usage: Bool = true) { + self.include_usage = include_usage + } + } + + public var model: String + public var messages: [Message] + public var temperature: Double? + public var stream: Bool? + public var stop: [String]? + public var max_completion_tokens: Int? + public var tool_choice: FunctionCallStrategy? + public var tools: [Tool]? + public var stream_options: StreamOptions? + + public init( + model: String, + messages: [Message], + temperature: Double? = nil, + stream: Bool? = nil, + stop: [String]? = nil, + max_completion_tokens: Int? = nil, + tool_choice: FunctionCallStrategy? = nil, + tools: [Tool]? = nil, + stream_options: StreamOptions? = nil + ) { + self.model = model + self.messages = messages + self.temperature = temperature + self.stream = stream + self.stop = stop + self.max_completion_tokens = max_completion_tokens + self.tool_choice = tool_choice + self.tools = tools + self.stream_options = stream_options + } } var apiKey: String var endpoint: URL var requestBody: RequestBody var model: ChatModel + let requestModifier: ((inout URLRequest) -> Void)? init( apiKey: String, model: ChatModel, endpoint: URL, - requestBody: ChatCompletionsRequestBody + requestBody: ChatCompletionsRequestBody, + requestModifier: ((inout URLRequest) -> Void)? = nil ) { self.apiKey = apiKey self.endpoint = endpoint - self.requestBody = .init(requestBody) + self.requestBody = .init( + requestBody, + endpoint: endpoint, + enforceMessageOrder: model.info.openAICompatibleInfo.enforceMessageOrder, + supportsMultipartMessageContent: model.info.openAICompatibleInfo + .supportsMultipartMessageContent, + requiresBeginWithUserMessage: model.info.openAICompatibleInfo + .requiresBeginWithUserMessage, + canUseTool: model.info.supportsFunctionCalling, + supportsImage: model.info.supportsImage, + supportsAudio: model.info.supportsAudio, + supportsTemperature: { + guard model.format == .openAI else { return true } + if let chatGPTModel = ChatGPTModel(rawValue: model.info.modelName) { + return chatGPTModel.supportsTemperature + } else if model.info.modelName.hasPrefix("o") { + return false + } + return true + }(), + supportsSystemPrompt: { + guard model.format == .openAI else { return true } + if let chatGPTModel = ChatGPTModel(rawValue: model.info.modelName) { + return chatGPTModel.supportsSystemPrompt + } else if model.info.modelName.hasPrefix("o") { + return false + } + return true + }() + ) self.model = model + self.requestModifier = requestModifier } func callAsFunction() async throws @@ -233,28 +642,13 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI let encoder = JSONEncoder() request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if !apiKey.isEmpty { - switch model.format { - case .openAI: - if !model.info.openAIInfo.organizationID.isEmpty { - request.setValue( - model.info.openAIInfo.organizationID, - forHTTPHeaderField: "OpenAI-Organization" - ) - } - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .openAICompatible: - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .azureOpenAI: - request.setValue(apiKey, forHTTPHeaderField: "api-key") - case .googleAI: - assertionFailure("Unsupported") - case .ollama: - assertionFailure("Unsupported") - case .claude: - assertionFailure("Unsupported") - } - } + + Self.setupCustomBody(&request, model: model) + Self.setupAppInformation(&request) + Self.setupAPIKey(&request, model: model, apiKey: apiKey) + Self.setupGitHubCopilotVisionField(&request, model: model) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + requestModifier?(&request) let (result, response) = try await URLSession.shared.bytes(for: request) guard let response = response as? HTTPURLResponse else { @@ -267,9 +661,15 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } guard let data = text.data(using: .utf8) else { throw ChatGPTServiceError.responseInvalid } + if response.statusCode == 403 { + throw ChatGPTServiceError.unauthorized(text) + } let decoder = JSONDecoder() let error = try? decoder.decode(CompletionAPIError.self, from: data) - throw error ?? ChatGPTServiceError.responseInvalid + throw error ?? ChatGPTServiceError.otherError( + text + + "\n\nPlease check your model settings, some capabilities may not be supported by the model." + ) } let stream = ResponseStream(result: result) { @@ -297,26 +697,95 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } func callAsFunction() async throws -> ChatCompletionResponseBody { - requestBody.stream = false - var request = URLRequest(url: endpoint) - request.httpMethod = "POST" - let encoder = JSONEncoder() - request.httpBody = try encoder.encode(requestBody) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let stream: AsyncThrowingStream = + try await callAsFunction() + + var body = ChatCompletionResponseBody( + id: nil, + object: "", + model: "", + message: .init(role: .assistant, content: ""), + otherChoices: [], + finishReason: "", + usage: .init( + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + otherUsage: [:] + ) + ) + for try await chunk in stream { + if let id = chunk.id { + body.id = id + } + if let finishReason = chunk.finishReason { + body.finishReason = finishReason + } + if let model = chunk.model { + body.model = model + } + if let object = chunk.object { + body.object = object + } + if let role = chunk.message?.role { + body.message.role = role + } + if let text = chunk.message?.content { + let existed = body.message.content ?? "" + body.message.content = existed + text + } + if let usage = chunk.usage { + body.usage?.merge(with: usage) + } + } + return body + } + + static func setupAppInformation(_ request: inout URLRequest) { + if #available(macOS 13.0, *) { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } else { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } + } + + static func setupAPIKey(_ request: inout URLRequest, model: ChatModel, apiKey: String) { if !apiKey.isEmpty { switch model.format { case .openAI: if !model.info.openAIInfo.organizationID.isEmpty { request.setValue( - "OpenAI-Organization", - forHTTPHeaderField: model.info.openAIInfo.organizationID + model.info.openAIInfo.organizationID, + forHTTPHeaderField: "OpenAI-Organization" ) } + + if !model.info.openAIInfo.projectID.isEmpty { + request.setValue( + model.info.openAIInfo.projectID, + forHTTPHeaderField: "OpenAI-Project" + ) + } + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") case .openAICompatible: request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") case .azureOpenAI: request.setValue(apiKey, forHTTPHeaderField: "api-key") + case .gitHubCopilot: + break case .googleAI: assertionFailure("Unsupported") case .ollama: @@ -325,25 +794,28 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI assertionFailure("Unsupported") } } + } - let (result, response) = try await URLSession.shared.data(for: request) - guard let response = response as? HTTPURLResponse else { - throw ChatGPTServiceError.responseInvalid + static func setupGitHubCopilotVisionField(_ request: inout URLRequest, model: ChatModel) { + if model.info.supportsImage { + request.setValue("true", forHTTPHeaderField: "copilot-vision-request") } + } - 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") + static func setupCustomBody(_ request: inout URLRequest, model: ChatModel) { + switch model.format { + case .openAI, .openAICompatible: + break + default: + return } - do { - let body = try JSONDecoder().decode(ResponseBody.self, from: result) - return body.formalized() - } catch { - dump(error) - throw error - } + let join = JoinJSON() + let jsonBody = model.info.customBodyInfo.jsonBody + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let data = request.httpBody, !jsonBody.isEmpty else { return } + let newBody = join.join(data, with: jsonBody) + request.httpBody = newBody } } @@ -356,6 +828,7 @@ extension OpenAIChatCompletionsService.ResponseBody { .init( role: message.role.formalized, content: message.content ?? "", + reasoningContent: message.reasoning_content ?? message.reasoning ?? "", toolCalls: { if let toolCalls = message.tool_calls { return toolCalls.map { toolCall in @@ -394,13 +867,24 @@ extension OpenAIChatCompletionsService.ResponseBody { otherMessages = [] } + let usage = ChatCompletionResponseBody.Usage( + promptTokens: usage.prompt_tokens ?? 0, + completionTokens: usage.completion_tokens ?? 0, + cachedTokens: usage.prompt_tokens_details?.cached_tokens ?? 0, + otherUsage: [ + "audio_tokens": usage.completion_tokens_details?.audio_tokens ?? 0, + "reasoning_tokens": usage.completion_tokens_details?.reasoning_tokens ?? 0, + ] + ) + return .init( id: id, object: object, model: model, message: message, otherChoices: otherMessages, - finishReason: choices.first?.finish_reason ?? "" + finishReason: choices.first?.finish_reason ?? "", + usage: usage ) } } @@ -416,6 +900,8 @@ extension OpenAIChatCompletionsService.StreamDataChunk { return .init( role: choice.delta?.role?.formalized, content: choice.delta?.content, + reasoningContent: choice.delta?.reasoning_content + ?? choice.delta?.reasoning, toolCalls: { if let toolCalls = choice.delta?.tool_calls { return toolCalls.map { @@ -451,14 +937,326 @@ extension OpenAIChatCompletionsService.StreamDataChunk { } return nil }(), - finishReason: choices?.first?.finish_reason + finishReason: choices?.first?.finish_reason, + usage: .init( + promptTokens: usage?.prompt_tokens, + completionTokens: usage?.completion_tokens, + cachedTokens: usage?.prompt_tokens_details?.cached_tokens, + otherUsage: { + var dict = [String: Int]() + if let audioTokens = usage?.completion_tokens_details?.audio_tokens { + dict["audio_tokens"] = audioTokens + } + if let reasoningTokens = usage?.completion_tokens_details?.reasoning_tokens { + dict["reasoning_tokens"] = reasoningTokens + } + return dict + }() + ) ) } } extension OpenAIChatCompletionsService.RequestBody { - init(_ body: ChatCompletionsRequestBody) { + static func convertContentPart( + content: String, + images: [ChatCompletionsRequestBody.Message.Image], + audios: [ChatCompletionsRequestBody.Message.Audio] + ) -> [Message.MessageContent.ContentPart] { + var all = [Message.MessageContent.ContentPart]() + all.append(.text(.init(text: content))) + + for image in images { + all.append(.image(.init( + image_url: .init( + url: image.dataURLString, + detail: nil + ) + ))) + } + + for audio in audios { + all.append(.audio(.init( + input_audio: .init( + data: audio.data.base64EncodedString(), + format: audio.format.rawValue + ) + ))) + } + + return all + } + + static func convertContentPart( + _ part: ClaudeChatCompletionsService.RequestBody.MessageContent + ) -> Message.MessageContent.ContentPart? { + switch part.type { + case .text: + return .text(.init(text: part.text ?? "", cache_control: part.cache_control)) + case .image: + let type = part.source?.type ?? "base64" + let base64Data = part.source?.data ?? "" + let mediaType = part.source?.media_type ?? "image/png" + return .image(.init(image_url: .init(url: "data:\(mediaType);\(type),\(base64Data)"))) + } + } + + static func joinMessageContent( + _ message: inout Message, + content: String, + images: [ChatCompletionsRequestBody.Message.Image], + audios: [ChatCompletionsRequestBody.Message.Audio], + supportsMultipartMessageContent: Bool + ) { + if supportsMultipartMessageContent { + switch message.role { + case .system, .developer, .assistant, .user: + let newParts = Self.convertContentPart( + content: content, + images: images, + audios: audios + ) + if case let .contentParts(existingParts) = message.content { + message.content = .contentParts(existingParts + newParts) + } else { + message.content = .contentParts(newParts) + } + case .tool, .function: + if case let .text(existingText) = message.content { + message.content = .text(existingText + "\n\n" + content) + } else { + message.content = .text(content) + } + } + } else { + switch message.role { + case .system, .developer, .assistant, .user: + if case let .text(existingText) = message.content { + message.content = .text(existingText + "\n\n" + content) + } else { + message.content = .text(content) + } + case .tool, .function: + if case let .text(existingText) = message.content { + message.content = .text(existingText + "\n\n" + content) + } else { + message.content = .text(content) + } + } + } + } + + init( + _ body: ChatCompletionsRequestBody, + endpoint: URL, + enforceMessageOrder: Bool, + supportsMultipartMessageContent: Bool, + requiresBeginWithUserMessage: Bool, + canUseTool: Bool, + supportsImage: Bool, + supportsAudio: Bool, + supportsTemperature: Bool, + supportsSystemPrompt: Bool + ) { + let supportsMultipartMessageContent = if supportsAudio || supportsImage { + true + } else { + supportsMultipartMessageContent + } + temperature = body.temperature + stream = body.stream + stop = body.stop + max_completion_tokens = body.maxTokens + tool_choice = body.toolChoice + tools = body.tools?.map { + Tool( + type: $0.type, + function: $0.function + ) + } + stream_options = if body.stream ?? false { + StreamOptions() + } else { + nil + } + model = body.model + + var body = body + + if !supportsTemperature { + temperature = nil + } + if !supportsSystemPrompt { + for (index, message) in body.messages.enumerated() { + if message.role == .system { + body.messages[index].role = .user + } + } + } + + if requiresBeginWithUserMessage { + let firstUserIndex = body.messages.firstIndex(where: { $0.role == .user }) ?? 0 + let endIndex = firstUserIndex + for i in stride(from: endIndex - 1, to: 0, by: -1) + where i >= 0 && body.messages.endIndex > i + { + body.messages.remove(at: i) + } + } + + // Special case for Claude through OpenRouter + + if endpoint.absoluteString.contains("openrouter.ai"), model.hasPrefix("anthropic/") { + body.model = model.replacingOccurrences(of: "anthropic/", with: "") + let claudeRequestBody = ClaudeChatCompletionsService.RequestBody(body) + messages = claudeRequestBody.system.map { + Message( + role: .system, + content: .contentParts([.text(.init( + text: $0.text, + cache_control: $0.cache_control + ))]) + ) + } + claudeRequestBody.messages.map { + (message: ClaudeChatCompletionsService.RequestBody.Message) in + let role: OpenAIChatCompletionsService.MessageRole = switch message.role { + case .user: .user + case .assistant: .assistant + } + return Message( + role: role, + content: .contentParts(message.content.compactMap(Self.convertContentPart)), + name: nil, + tool_calls: nil, + tool_call_id: nil + ) + } + return + } + + // Enforce message order + + if enforceMessageOrder { + var systemPrompts = [Message.MessageContent.ContentPart]() + var nonSystemMessages = [Message]() + + for message in body.messages { + switch (message.role, canUseTool) { + case (.system, _): + systemPrompts.append(contentsOf: Self.convertContentPart( + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + )) + case (.tool, true): + if let last = nonSystemMessages.last, last.role == .tool { + Self.joinMessageContent( + &nonSystemMessages[nonSystemMessages.endIndex - 1], + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [], + supportsMultipartMessageContent: supportsMultipartMessageContent + ) + } else { + nonSystemMessages.append(.init( + role: .tool, + content: { + if supportsMultipartMessageContent { + return .contentParts(Self.convertContentPart( + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + )) + } + return .text(message.content) + }(), + tool_calls: message.toolCalls?.map { tool in + MessageToolCall( + id: tool.id, + type: tool.type, + function: MessageFunctionCall( + name: tool.function.name, + arguments: tool.function.arguments + ) + ) + } + )) + } + case (.assistant, _), (.tool, false): + if let last = nonSystemMessages.last, last.role == .assistant { + Self.joinMessageContent( + &nonSystemMessages[nonSystemMessages.endIndex - 1], + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [], + supportsMultipartMessageContent: supportsMultipartMessageContent + ) + } else { + nonSystemMessages.append(.init( + role: .assistant, + content: { + if supportsMultipartMessageContent { + return .contentParts(Self.convertContentPart( + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + )) + } + return .text(message.content) + }() + )) + } + case (.user, _): + if let last = nonSystemMessages.last, last.role == .user { + Self.joinMessageContent( + &nonSystemMessages[nonSystemMessages.endIndex - 1], + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [], + supportsMultipartMessageContent: supportsMultipartMessageContent + ) + } else { + nonSystemMessages.append(.init( + role: .user, + content: { + if supportsMultipartMessageContent { + return .contentParts(Self.convertContentPart( + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + )) + } + return .text(message.content) + }(), + name: message.name, + tool_call_id: message.toolCallId + )) + } + } + } + messages = [ + .init( + role: .system, + content: { + if supportsMultipartMessageContent { + return .contentParts(systemPrompts) + } + let textParts = systemPrompts.compactMap { + if case let .text(text) = $0 { return text.text } + return nil + } + + return .text(textParts.joined(separator: "\n\n")) + }() + ), + ] + nonSystemMessages + + return + } + + // Default + messages = body.messages.map { message in .init( role: { @@ -473,7 +1271,21 @@ extension OpenAIChatCompletionsService.RequestBody { return .tool } }(), - content: message.content, + content: { + // always prefer text only content if possible. + if supportsMultipartMessageContent { + let images = supportsImage ? message.images : [] + let audios = supportsAudio ? message.audios : [] + if !images.isEmpty || !audios.isEmpty { + return .contentParts(Self.convertContentPart( + content: message.content, + images: images, + audios: audios + )) + } + } + return .text(message.content) + }(), name: message.name, tool_calls: message.toolCalls?.map { tool in MessageToolCall( @@ -488,17 +1300,6 @@ extension OpenAIChatCompletionsService.RequestBody { tool_call_id: message.toolCallId ) } - temperature = body.temperature - stream = body.stream - stop = body.stop - max_tokens = body.maxTokens - tool_choice = body.toolChoice - tools = body.tools?.map { - Tool( - type: $0.type, - function: $0.function - ) - } } } diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift index 140e9d09..f6edf3b7 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift @@ -12,17 +12,25 @@ struct OpenAIEmbeddingService: EmbeddingAPI { var input: [[Int]] var model: String } - + let apiKey: String let model: EmbeddingModel let endpoint: String - + var requestModifier: ((inout URLRequest) -> Void)? = nil + public func embed(text: String) async throws -> EmbeddingResponse { return try await embed(texts: [text]) } public func embed(texts text: [String]) async throws -> EmbeddingResponse { guard let url = URL(string: endpoint) else { throw ChatGPTServiceError.endpointIncorrect } + if text.isEmpty { + return .init( + data: [], + model: model.info.modelName, + usage: .init(prompt_tokens: 0, total_tokens: 0) + ) + } var request = URLRequest(url: url) request.httpMethod = "POST" let encoder = JSONEncoder() @@ -31,24 +39,11 @@ struct OpenAIEmbeddingService: EmbeddingAPI { model: model.info.modelName )) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if !apiKey.isEmpty { - switch model.format { - case .openAI: - if model.info.openAIInfo.organizationID.isEmpty { - request.setValue( - "OpenAI-Organization", - forHTTPHeaderField: model.info.openAIInfo.organizationID - ) - } - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .openAICompatible: - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .azureOpenAI: - request.setValue(apiKey, forHTTPHeaderField: "api-key") - case .ollama: - assertionFailure("Unsupported") - } - } + + Self.setupAppInformation(&request) + Self.setupAPIKey(&request, model: model, apiKey: apiKey) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + requestModifier?(&request) let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -65,20 +60,18 @@ struct OpenAIEmbeddingService: EmbeddingAPI { } let embeddingResponse = try JSONDecoder().decode(EmbeddingResponse.self, from: result) - #if DEBUG - Logger.service.info(""" - Embedding usage - - number of strings: \(text.count) - - prompt tokens: \(embeddingResponse.usage.prompt_tokens) - - total tokens: \(embeddingResponse.usage.total_tokens) - - """) - #endif return embeddingResponse } public func embed(tokens: [[Int]]) async throws -> EmbeddingResponse { guard let url = URL(string: endpoint) else { throw ChatGPTServiceError.endpointIncorrect } + if tokens.isEmpty { + return .init( + data: [], + model: model.info.modelName, + usage: .init(prompt_tokens: 0, total_tokens: 0) + ) + } var request = URLRequest(url: url) request.httpMethod = "POST" let encoder = JSONEncoder() @@ -87,24 +80,11 @@ struct OpenAIEmbeddingService: EmbeddingAPI { model: model.info.modelName )) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if !apiKey.isEmpty { - switch model.format { - case .openAI: - if model.info.openAIInfo.organizationID.isEmpty { - request.setValue( - "OpenAI-Organization", - forHTTPHeaderField: model.info.openAIInfo.organizationID - ) - } - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .openAICompatible: - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .azureOpenAI: - request.setValue(apiKey, forHTTPHeaderField: "api-key") - case .ollama: - assertionFailure("Unsupported") - } - } + + Self.setupAppInformation(&request) + Self.setupAPIKey(&request, model: model, apiKey: apiKey) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + requestModifier?(&request) let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -121,16 +101,50 @@ struct OpenAIEmbeddingService: EmbeddingAPI { } let embeddingResponse = try JSONDecoder().decode(EmbeddingResponse.self, from: result) - #if DEBUG - Logger.service.info(""" - Embedding usage - - number of strings: \(tokens.count) - - prompt tokens: \(embeddingResponse.usage.prompt_tokens) - - total tokens: \(embeddingResponse.usage.total_tokens) - - """) - #endif return embeddingResponse } + + static func setupAppInformation(_ request: inout URLRequest) { + if #available(macOS 13.0, *) { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } else { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } + } + + static func setupAPIKey(_ request: inout URLRequest, model: EmbeddingModel, apiKey: String) { + if !apiKey.isEmpty { + switch model.format { + case .openAI: + if model.info.openAIInfo.organizationID.isEmpty { + request.setValue( + model.info.openAIInfo.organizationID, + forHTTPHeaderField: "OpenAI-Organization" + ) + } + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .openAICompatible: + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .azureOpenAI: + request.setValue(apiKey, forHTTPHeaderField: "api-key") + case .gitHubCopilot: + break + case .ollama: + assertionFailure("Unsupported") + } + } + } } diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIResponsesRawService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIResponsesRawService.swift new file mode 100644 index 00000000..818c4616 --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/OpenAIResponsesRawService.swift @@ -0,0 +1,235 @@ +import AIModel +import AsyncAlgorithms +import ChatBasic +import Foundation +import GitHubCopilotService +import JoinJSON +import Logger +import Preferences + +/// https://platform.openai.com/docs/api-reference/responses/create +public actor OpenAIResponsesRawService { + struct CompletionAPIError: Error, Decodable, LocalizedError { + struct ErrorDetail: Decodable { + var message: String + var type: String? + var param: String? + var code: String? + } + + struct MistralAIErrorMessage: Decodable { + struct Detail: Decodable { + var msg: String? + } + + var message: String? + var msg: String? + var detail: [Detail]? + } + + enum Message { + case raw(String) + case mistralAI(MistralAIErrorMessage) + } + + var error: ErrorDetail? + var message: Message + + var errorDescription: String? { + if let message = error?.message { return message } + switch message { + case let .raw(string): + return string + case let .mistralAI(mistralAIErrorMessage): + return mistralAIErrorMessage.message + ?? mistralAIErrorMessage.msg + ?? mistralAIErrorMessage.detail?.first?.msg + ?? "Unknown Error" + } + } + + enum CodingKeys: String, CodingKey { + case error + case message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + error = try container.decode(ErrorDetail.self, forKey: .error) + message = { + if let e = try? container.decode(MistralAIErrorMessage.self, forKey: .message) { + return CompletionAPIError.Message.mistralAI(e) + } + if let e = try? container.decode(String.self, forKey: .message) { + return .raw(e) + } + return .raw("Unknown Error") + }() + } + } + + var apiKey: String + var endpoint: URL + var requestBody: [String: Any] + var model: ChatModel + let requestModifier: ((inout URLRequest) -> Void)? + + public init( + apiKey: String, + model: ChatModel, + endpoint: URL, + requestBody: Data, + requestModifier: ((inout URLRequest) -> Void)? = nil + ) { + self.apiKey = apiKey + self.endpoint = endpoint + self.requestBody = ( + try? JSONSerialization.jsonObject(with: requestBody) as? [String: Any] + ) ?? [:] + self.requestBody["model"] = model.info.modelName + self.model = model + self.requestModifier = requestModifier + } + + public func callAsFunction() async throws + -> URLSession.AsyncBytes + { + requestBody["stream"] = true + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.httpBody = try JSONSerialization.data( + withJSONObject: requestBody, + options: [] + ) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + Self.setupAppInformation(&request) + await Self.setupAPIKey(&request, model: model, apiKey: apiKey) + Self.setupGitHubCopilotVisionField(&request, model: model) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + requestModifier?(&request) + + 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 } + if response.statusCode == 403 { + throw ChatGPTServiceError.unauthorized(text) + } + let decoder = JSONDecoder() + let error = try? decoder.decode(CompletionAPIError.self, from: data) + throw error ?? ChatGPTServiceError.otherError( + text + + "\n\nPlease check your model settings, some capabilities may not be supported by the model." + ) + } + + return result + } + + public func callAsFunction() async throws -> Data { + let stream: URLSession.AsyncBytes = try await callAsFunction() + + return try await stream.reduce(into: Data()) { partialResult, byte in + partialResult.append(byte) + } + } + + static func setupAppInformation(_ request: inout URLRequest) { + if #available(macOS 13.0, *) { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } else { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } + } + + static func setupAPIKey(_ request: inout URLRequest, model: ChatModel, apiKey: String) async { + if !apiKey.isEmpty { + switch model.format { + case .openAI: + if !model.info.openAIInfo.organizationID.isEmpty { + request.setValue( + model.info.openAIInfo.organizationID, + forHTTPHeaderField: "OpenAI-Organization" + ) + } + + if !model.info.openAIInfo.projectID.isEmpty { + request.setValue( + model.info.openAIInfo.projectID, + forHTTPHeaderField: "OpenAI-Project" + ) + } + + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .openAICompatible: + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .azureOpenAI: + request.setValue(apiKey, forHTTPHeaderField: "api-key") + case .gitHubCopilot: + break + case .googleAI: + assertionFailure("Unsupported") + case .ollama: + assertionFailure("Unsupported") + case .claude: + assertionFailure("Unsupported") + } + } + + if model.format == .gitHubCopilot, + let token = try? await GitHubCopilotExtension.fetchToken() + { + 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") + } + } + + static func setupGitHubCopilotVisionField(_ request: inout URLRequest, model: ChatModel) { + if model.info.supportsImage { + request.setValue("true", forHTTPHeaderField: "copilot-vision-request") + } + } + + static func setupExtraHeaderFields( + _ request: inout URLRequest, + model: ChatModel, + apiKey: String + ) async { + let parser = HeaderValueParser() + for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { + let value = await parser.parse( + field.value, + context: .init(modelName: model.info.modelName, apiKey: apiKey) + ) + request.setValue(value, forHTTPHeaderField: field.key) + } + } +} + diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 76b39322..b02f4f2b 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -1,22 +1,18 @@ import AIModel import AsyncAlgorithms +import ChatBasic import Dependencies import Foundation import IdentifiedCollections +import Logger import Preferences -public protocol ChatGPTServiceType { - var memory: ChatGPTMemory { get set } - var configuration: ChatGPTConfiguration { get set } - func send(content: String, summary: String?) async throws -> AsyncThrowingStream - func stopReceivingMessage() async -} - public enum ChatGPTServiceError: Error, LocalizedError { case chatModelNotAvailable case embeddingModelNotAvailable case endpointIncorrect case responseInvalid + case unauthorized(String) case otherError(String) public var errorDescription: String? { @@ -29,6 +25,8 @@ public enum ChatGPTServiceError: Error, LocalizedError { return "ChatGPT endpoint is incorrect" case .responseInvalid: return "Response is invalid" + case let .unauthorized(reason): + return "Unauthorized: \(reason)" case let .otherError(content): return content } @@ -65,183 +63,194 @@ public struct ChatGPTError: Error, Codable, LocalizedError { } } -typealias ChatCompletionsStreamAPIBuilder = ( - String, - ChatModel, - URL, - ChatCompletionsRequestBody, - ChatGPTPrompt -) -> any ChatCompletionsStreamAPI - -typealias ChatCompletionsAPIBuilder = ( - String, - ChatModel, - URL, - ChatCompletionsRequestBody, - ChatGPTPrompt -) -> any ChatCompletionsAPI +public enum ChatGPTResponse: Equatable { + case status([String]) + case partialText(String) + case partialReasoning(String) + case toolCalls([ChatMessage.ToolCall]) + case usage( + promptTokens: Int, + completionTokens: Int, + cachedTokens: Int, + otherUsage: [String: Int] + ) +} -public class ChatGPTService: ChatGPTServiceType { - public var memory: ChatGPTMemory - public var configuration: ChatGPTConfiguration - public var functionProvider: ChatGPTFunctionProvider +public typealias ChatGPTResponseStream = AsyncThrowingStream - var runningTask: Task? - var buildCompletionStreamAPI: ChatCompletionsStreamAPIBuilder = { - apiKey, model, endpoint, requestBody, prompt in - switch model.format { - case .googleAI: - return GoogleAIChatCompletionsService( - apiKey: apiKey, - model: model, - requestBody: requestBody, - prompt: prompt, - baseURL: endpoint.absoluteString - ) - case .openAI, .openAICompatible, .azureOpenAI: - return OpenAIChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - case .ollama: - return OllamaChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - case .claude: - return ClaudeChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) +public extension ChatGPTResponseStream { + func asText() async throws -> String { + var text = "" + for try await case let .partialText(response) in self { + text += response } + return text } - var buildCompletionAPI: ChatCompletionsAPIBuilder = { - apiKey, model, endpoint, requestBody, prompt in - switch model.format { - case .googleAI: - return GoogleAIChatCompletionsService( - apiKey: apiKey, - model: model, - requestBody: requestBody, - prompt: prompt, - baseURL: endpoint.absoluteString - ) - case .openAI, .openAICompatible, .azureOpenAI: - return OpenAIChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - case .ollama: - return OllamaChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - case .claude: - return ClaudeChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) + func asToolCalls() async throws -> [ChatMessage.ToolCall] { + var toolCalls = [ChatMessage.ToolCall]() + for try await case let .toolCalls(calls) in self { + toolCalls.append(contentsOf: calls) } + return toolCalls } + func asArray() async throws -> [ChatGPTResponse] { + var responses = [ChatGPTResponse]() + for try await response in self { + responses.append(response) + } + return responses + } +} + +public protocol ChatGPTServiceType { + typealias Response = ChatGPTResponse + var configuration: ChatGPTConfiguration { get set } + func send(_ memory: ChatGPTMemory) -> ChatGPTResponseStream +} + +public class ChatGPTService: ChatGPTServiceType { + public var configuration: ChatGPTConfiguration + public var utilityConfiguration: ChatGPTConfiguration + public var functionProvider: ChatGPTFunctionProvider + public init( - memory: ChatGPTMemory = AutoManagedChatGPTMemory( - systemPrompt: "", - configuration: UserPreferenceChatGPTConfiguration(), - functionProvider: NoChatGPTFunctionProvider() - ), configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(), + utilityConfiguration: ChatGPTConfiguration = + UserPreferenceChatGPTConfiguration(chatModelKey: \.preferredChatModelIdForUtilities), functionProvider: ChatGPTFunctionProvider = NoChatGPTFunctionProvider() ) { - self.memory = memory self.configuration = configuration + self.utilityConfiguration = utilityConfiguration self.functionProvider = functionProvider } @Dependency(\.uuid) var uuid @Dependency(\.date) var date - - /// Send a message and stream the reply. - public func send( - content: String, - summary: String? = nil - ) async throws -> AsyncThrowingStream { - if !content.isEmpty || summary != nil { - let newMessage = ChatMessage( - id: uuid().uuidString, - role: .user, - content: content, - name: nil, - toolCalls: nil, - summary: summary, - references: [] - ) - await memory.appendMessage(newMessage) - } - + @Dependency(\.chatCompletionsAPIBuilder) var chatCompletionsAPIBuilder + + /// Send the memory and stream the reply. While it's returning the results in a + /// ``ChatGPTResponseStream``, it's also streaming the results to the memory. + /// + /// If ``ChatGPTConfiguration/runFunctionsAutomatically`` is enabled, the service will handle + /// the tool calls inside the function. Otherwise, it will return the tool calls to the caller. + public func send(_ memory: ChatGPTMemory) -> ChatGPTResponseStream { return Debugger.$id.withValue(.init()) { - AsyncThrowingStream { continuation in + ChatGPTResponseStream { continuation in let task = Task(priority: .userInitiated) { do { var pendingToolCalls = [ChatMessage.ToolCall]() var sourceMessageId = "" var isInitialCall = true + loop: while !pendingToolCalls.isEmpty || isInitialCall { try Task.checkCancellation() isInitialCall = false - for toolCall in pendingToolCalls { - if !configuration.runFunctionsAutomatically { - break loop + + var functionCallResponses = [ChatCompletionsRequestBody.Message]() + + if !pendingToolCalls.isEmpty { + if configuration.runFunctionsAutomatically { + var toolCallStatuses = [String: String]() { + didSet { + if toolCallStatuses != oldValue { + continuation.yield(.status( + Array(toolCallStatuses.values).sorted() + )) + } + } + } + for toolCall in pendingToolCalls { + let id = toolCall.id + for await response in await runFunctionCall( + toolCall, + memory: memory, + sourceMessageId: sourceMessageId + ) { + switch response { + case let .output(output): + functionCallResponses.append(.init( + role: .tool, + content: output, + toolCallId: id + )) + case let .status(status): + toolCallStatuses[id] = status + } + } + toolCallStatuses[id] = nil + } + toolCallStatuses = [:] + } else { + if !configuration.runFunctionsAutomatically { + continuation.yield(.toolCalls(pendingToolCalls)) + continuation.finish() + return + } } - await runFunctionCall( - toolCall, - sourceMessageId: sourceMessageId - ) } - sourceMessageId = uuid() - .uuidString + String(date().timeIntervalSince1970) - let stream = try await sendMemory(proposedId: sourceMessageId) - #if DEBUG - var reply = "" - #endif + sourceMessageId = uuid().uuidString + let stream = try await sendRequest( + memory: memory, + proposedMessageId: sourceMessageId + ) for try await content in stream { try Task.checkCancellation() switch content { - case let .text(text): - continuation.yield(text) - #if DEBUG - reply.append(text) - #endif - - case let .toolCall(toolCall): - await prepareFunctionCall( - toolCall, - sourceMessageId: sourceMessageId + case let .partialText(text): + continuation.yield(ChatGPTResponse.partialText(text)) + + case let .partialReasoning(text): + continuation.yield(ChatGPTResponse.partialReasoning(text)) + + case let .partialToolCalls(toolCalls): + guard configuration.runFunctionsAutomatically else { break } + var toolCallStatuses = [String: String]() { + didSet { + if toolCallStatuses != oldValue { + continuation.yield(.status( + Array(toolCallStatuses.values).sorted() + )) + } + } + } + for toolCall in toolCalls.keys.sorted() { + if let toolCallValue = toolCalls[toolCall] { + for await status in await prepareFunctionCall( + toolCallValue, + memory: memory, + sourceMessageId: sourceMessageId + ) { + toolCallStatuses[toolCallValue.id] = status + } + } + } + case let .usage( + promptTokens, + completionTokens, + cachedTokens, + otherUsage + ): + continuation.yield( + .usage( + promptTokens: promptTokens, + completionTokens: completionTokens, + cachedTokens: cachedTokens, + otherUsage: otherUsage + ) ) } } - pendingToolCalls = await memory.history - .last { $0.id == sourceMessageId }? - .toolCalls ?? [] + let replyMessage = await memory.history + .last { $0.id == sourceMessageId } + pendingToolCalls = replyMessage?.toolCalls ?? [] #if DEBUG - Debugger.didReceiveResponse(content: reply) + Debugger.didReceiveResponse(content: replyMessage?.content ?? "") #endif } @@ -259,65 +268,33 @@ public class ChatGPTService: ChatGPTServiceType { } } } - - /// Send a message and get the reply in return. - public func sendAndWait( - content: String, - summary: String? = nil - ) async throws -> String? { - if !content.isEmpty || summary != nil { - let newMessage = ChatMessage( - id: uuid().uuidString, - role: .user, - content: content, - summary: summary - ) - await memory.appendMessage(newMessage) - } - return try await Debugger.$id.withValue(.init()) { - let message = try await sendMemoryAndWait() - var finalResult = message?.content - var toolCalls = message?.toolCalls - while let sourceMessageId = message?.id, let calls = toolCalls, !calls.isEmpty { - try Task.checkCancellation() - if !configuration.runFunctionsAutomatically { - break - } - toolCalls = nil - for call in calls { - await runFunctionCall(call, sourceMessageId: sourceMessageId) - } - guard let nextMessage = try await sendMemoryAndWait() else { break } - finalResult = nextMessage.content - toolCalls = nextMessage.toolCalls - } - - #if DEBUG - Debugger.didReceiveResponse(content: finalResult ?? "N/A") - Debugger.didFinish() - #endif - - return finalResult - } - } - - #warning("TODO: Move the cancellation up to the caller.") - public func stopReceivingMessage() { - runningTask?.cancel() - runningTask = nil - } } // - MARK: Internal extension ChatGPTService { enum StreamContent { - case text(String) - case toolCall(ChatMessage.ToolCall) + case partialReasoning(String) + case partialText(String) + case partialToolCalls([Int: ChatMessage.ToolCall]) + case usage( + promptTokens: Int, + completionTokens: Int, + cachedTokens: Int, + otherUsage: [String: Int] + ) + } + + enum FunctionCallResult { + case status(String) + case output(String) } /// Send the memory as prompt to ChatGPT, with stream enabled. - func sendMemory(proposedId: String) async throws -> AsyncThrowingStream { + func sendRequest( + memory: ChatGPTMemory, + proposedMessageId: String + ) async throws -> AsyncThrowingStream { let prompt = await memory.generatePrompt() guard let model = configuration.model else { @@ -329,12 +306,11 @@ extension ChatGPTService { let requestBody = createRequestBody(prompt: prompt, model: model, stream: true) - let api = buildCompletionStreamAPI( - configuration.apiKey, - model, - url, - requestBody, - prompt + let api = chatCompletionsAPIBuilder.buildStreamAPI( + model: model, + endpoint: url, + apiKey: configuration.apiKey, + requestBody: requestBody ) #if DEBUG @@ -345,15 +321,24 @@ extension ChatGPTService { let task = Task { do { await memory.streamMessage( - id: proposedId, + id: proposedMessageId, role: .assistant, references: prompt.references ) let chunks = try await api() + var usage: ChatCompletionResponseBody.Usage = .init( + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + otherUsage: [:] + ) for try await chunk in chunks { - if Task.isCancelled { - throw CancellationError() + try Task.checkCancellation() + + if let newUsage = chunk.usage { + usage.merge(with: newUsage) } + guard let delta = chunk.message else { continue } // The api will always return a function call with JSON object. @@ -373,25 +358,32 @@ extension ChatGPTService { } await memory.streamMessage( - id: proposedId, + id: proposedMessageId, role: delta.role?.asChatMessageRole, content: delta.content, toolCalls: toolCalls ) if let toolCalls { - for toolCall in toolCalls.values { - continuation.yield(.toolCall(toolCall)) - } + continuation.yield(.partialToolCalls(toolCalls)) } if let content = delta.content { - continuation.yield(.text(content)) + continuation.yield(.partialText(content)) } - try await Task.sleep(nanoseconds: 3_000_000) + if let reasoning = delta.reasoningContent { + continuation.yield(.partialReasoning(reasoning)) + } } + Logger.service.info("ChatGPT usage: \(usage)") + continuation.yield(.usage( + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + cachedTokens: usage.cachedTokens, + otherUsage: usage.otherUsage + )) continuation.finish() } catch let error as CancellationError { continuation.finish(throwing: error) @@ -399,6 +391,7 @@ extension ChatGPTService { continuation.finish(throwing: error) } catch { await memory.appendMessage(.init( + id: uuid().uuidString, role: .assistant, content: error.localizedDescription )) @@ -406,145 +399,132 @@ extension ChatGPTService { } } - runningTask = task - continuation.onTermination = { _ in task.cancel() } } } - /// Send the memory as prompt to ChatGPT, with stream disabled. - func sendMemoryAndWait() async throws -> ChatMessage? { - let proposedId = uuid().uuidString + String(date().timeIntervalSince1970) - let prompt = await memory.generatePrompt() - - guard let model = configuration.model else { - throw ChatGPTServiceError.chatModelNotAvailable - } - guard let url = URL(string: configuration.endpoint) else { - throw ChatGPTServiceError.endpointIncorrect - } - - let requestBody = createRequestBody(prompt: prompt, model: model, stream: false) - - let api = buildCompletionAPI( - configuration.apiKey, - model, - url, - requestBody, - prompt - ) - - #if DEBUG - Debugger.didSendRequestBody(body: requestBody) - #endif - - let response = try await api() - - let choice = response.message - let message = ChatMessage( - id: proposedId, - role: { - switch choice.role { - case .system: .system - case .user: .user - case .assistant: .assistant - case .tool: .user + /// When a function call is detected, but arguments are not yet ready, we can call this + /// to report the status. + func prepareFunctionCall( + _ call: ChatMessage.ToolCall, + memory: ChatGPTMemory, + sourceMessageId: String + ) async -> AsyncStream { + return .init { continuation in + guard let function = functionProvider.function(named: call.function.name) else { + continuation.finish() + return + } + let task = Task { + await memory.streamToolCallResponse( + id: sourceMessageId, + toolCallId: call.id + ) + await function.prepare { summary in + continuation.yield(summary) + await memory.streamToolCallResponse( + id: sourceMessageId, + toolCallId: call.id, + summary: summary + ) } - }(), - content: choice.content, - name: choice.name, - toolCalls: choice.toolCalls?.map { - ChatMessage.ToolCall(id: $0.id, type: $0.type, function: .init( - name: $0.function.name, - arguments: $0.function.arguments ?? "" - )) - }, - references: prompt.references - ) - await memory.appendMessage(message) - return message - } + continuation.finish() + } - /// When a function call is detected, but arguments are not yet ready, we can call this - /// to insert a message placeholder in memory. - func prepareFunctionCall(_ call: ChatMessage.ToolCall, sourceMessageId: String) async { - guard let function = functionProvider.function(named: call.function.name) else { return } - await memory.streamToolCallResponse(id: sourceMessageId, toolCallId: call.id) - await function.prepare { [weak self] summary in - await self?.memory.streamToolCallResponse( - id: sourceMessageId, - toolCallId: call.id, - summary: summary - ) + continuation.onTermination = { _ in + task.cancel() + } } } - /// Run a function call from the bot, and insert the result in memory. + /// Run a function call from the bot. @discardableResult func runFunctionCall( _ call: ChatMessage.ToolCall, + memory: ChatGPTMemory, sourceMessageId: String - ) async -> String { + ) async -> AsyncStream { #if DEBUG Debugger.didReceiveFunction(name: call.function.name, arguments: call.function.arguments) #endif - guard let function = functionProvider.function(named: call.function.name) else { - return await fallbackFunctionCall(call, sourceMessageId: sourceMessageId) - } - - await memory.streamToolCallResponse( - id: sourceMessageId, - toolCallId: call.id - ) + return .init { continuation in + let task = Task { + guard let function = functionProvider.function(named: call.function.name) else { + let response = await fallbackFunctionCall( + call, + memory: memory, + sourceMessageId: sourceMessageId + ) + continuation.yield(.output(response)) + continuation.finish() + return + } - do { - // Run the function - let result = try await function.call(argumentsJsonString: call.function.arguments) { - [weak self] summary in - await self?.memory.streamToolCallResponse( + await memory.streamToolCallResponse( id: sourceMessageId, - toolCallId: call.id, - summary: summary + toolCallId: call.id ) - } - #if DEBUG - Debugger.didReceiveFunctionResult(result: result.botReadableContent) - #endif + do { + // Run the function + let result = try await function + .call(argumentsJsonString: call.function.arguments) { summary in + continuation.yield(.status(summary)) + await memory.streamToolCallResponse( + id: sourceMessageId, + toolCallId: call.id, + summary: summary + ) + } - await memory.streamToolCallResponse( - id: sourceMessageId, - toolCallId: call.id, - content: result.botReadableContent - ) + #if DEBUG + Debugger.didReceiveFunctionResult(result: result.botReadableContent) + #endif - return result.botReadableContent - } catch { - // For errors, use the error message as the result. - let content = "Error: \(error.localizedDescription)" + await memory.streamToolCallResponse( + id: sourceMessageId, + toolCallId: call.id, + content: result.botReadableContent + ) - #if DEBUG - Debugger.didReceiveFunctionResult(result: content) - #endif + continuation.yield(.output(result.botReadableContent)) + continuation.finish() + } catch { + // For errors, use the error message as the result. + let content = "Error: \(error.localizedDescription)" + + #if DEBUG + Debugger.didReceiveFunctionResult(result: content) + #endif + + await memory.streamToolCallResponse( + id: sourceMessageId, + toolCallId: call.id, + content: content, + summary: content + ) - await memory.streamToolCallResponse( - id: sourceMessageId, - toolCallId: call.id, - content: content - ) - return content + continuation.yield(.output(content)) + continuation.finish() + } + } + + continuation.onTermination = { _ in + task.cancel() + } } } /// Mock a function call result when the bot is calling a function that is not implemented. func fallbackFunctionCall( _ call: ChatMessage.ToolCall, + memory: ChatGPTMemory, sourceMessageId: String ) async -> String { - let memory = ConversationChatGPTMemory(systemPrompt: { + let temporaryMemory = ConversationChatGPTMemory(systemPrompt: { if call.function.name == "python" { return """ Act like a Python interpreter. @@ -562,29 +542,27 @@ extension ChatGPTService { }()) let service = ChatGPTService( - memory: memory, - configuration: OverridingChatGPTConfiguration(overriding: configuration, with: .init( - temperature: 0 - )), + configuration: OverridingChatGPTConfiguration( + overriding: utilityConfiguration, + with: .init(temperature: 0) + ), functionProvider: NoChatGPTFunctionProvider() ) - let content: String = await { - do { - return try await service.sendAndWait(content: """ - \(call.function.arguments) - """) ?? "No result." - } catch { - return "No result." - } - }() - await memory.streamToolCallResponse( - id: sourceMessageId, - toolCallId: call.id, - content: content, - summary: "Finished running function." - ) - return content + let stream = service.send(temporaryMemory) + + do { + let result = try await stream.asText() + await memory.streamToolCallResponse( + id: sourceMessageId, + toolCallId: call.id, + content: result, + summary: "Finished running function." + ) + return result + } catch { + return error.localizedDescription + } } func createRequestBody( @@ -593,13 +571,25 @@ extension ChatGPTService { stream: Bool ) -> ChatCompletionsRequestBody { let serviceSupportsFunctionCalling = switch model.format { - case .openAI, .openAICompatible, .azureOpenAI: + case .openAI, .openAICompatible, .azureOpenAI, .gitHubCopilot: model.info.supportsFunctionCalling case .ollama, .googleAI, .claude: false } let messages = prompt.history.flatMap { chatMessage in + let images = chatMessage.images.map { image in + ChatCompletionsRequestBody.Message.Image( + base64EncodeData: image.base64EncodedData, + format: { + switch image.format { + case .png: .png + case .jpeg: .jpeg + case .gif: .gif + } + }() + ) + } var all = [ChatCompletionsRequestBody.Message]() all.append(ChatCompletionsRequestBody.Message( role: { @@ -626,7 +616,10 @@ extension ChatGPTService { } else { nil } - }() + }(), + images: images, + audios: [], + cacheIfPossible: chatMessage.cacheIfPossible )) for call in chatMessage.toolCalls ?? [] { @@ -675,16 +668,10 @@ extension ChatGPTService { return requestBody } -} -extension ChatGPTService { - func changeBuildCompletionStreamAPI(_ builder: @escaping ChatCompletionsStreamAPIBuilder) { - buildCompletionStreamAPI = builder + func maxTokenForReply(maxToken: Int, remainingTokens: Int?) -> Int? { + guard let remainingTokens else { return nil } + return min(maxToken / 2, remainingTokens) } } -func maxTokenForReply(maxToken: Int, remainingTokens: Int?) -> Int? { - guard let remainingTokens else { return nil } - return min(maxToken / 2, remainingTokens) -} - diff --git a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift index 9f464233..710a2ff0 100644 --- a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift @@ -39,3 +39,91 @@ public extension ChatGPTConfiguration { } } +public final class OverridingChatGPTConfiguration: ChatGPTConfiguration { + public struct Overriding: Codable { + public var temperature: Double? + public var modelId: String? + public var model: ChatModel? + public var stop: [String]? + public var maxTokens: Int? + public var minimumReplyTokens: Int? + public var runFunctionsAutomatically: Bool? + public var apiKey: String? + + public init( + temperature: Double? = nil, + modelId: String? = nil, + model: ChatModel? = nil, + stop: [String]? = nil, + maxTokens: Int? = nil, + minimumReplyTokens: Int? = nil, + runFunctionsAutomatically: Bool? = nil, + apiKey: String? = nil + ) { + self.temperature = temperature + self.modelId = modelId + self.model = model + self.stop = stop + self.maxTokens = maxTokens + self.minimumReplyTokens = minimumReplyTokens + self.runFunctionsAutomatically = runFunctionsAutomatically + self.apiKey = apiKey + } + } + + private let configuration: ChatGPTConfiguration + public var overriding = Overriding() + public var textWindowTerminator: ((String) -> Bool)? + + public init( + overriding configuration: any ChatGPTConfiguration, + with overrides: Overriding = .init() + ) { + overriding = overrides + self.configuration = configuration + } + + public var temperature: Double { + overriding.temperature ?? configuration.temperature + } + + public var model: ChatModel? { + if let model = overriding.model { return model } + let models = UserDefaults.shared.value(for: \.chatModels) + guard let id = overriding.modelId else { return configuration.model } + if id == "com.github.copilot" { + return .init(id: id, name: "GitHub Copilot", format: .openAI, info: .init()) + } + guard let model = models.first(where: { $0.id == id }) else { return configuration.model } + return model + } + + public var stop: [String] { + overriding.stop ?? configuration.stop + } + + public var maxTokens: Int { + if let maxTokens = overriding.maxTokens { return maxTokens } + if let model { return model.info.maxTokens } + return configuration.maxTokens + } + + public var minimumReplyTokens: Int { + if let minimumReplyTokens = overriding.minimumReplyTokens { return minimumReplyTokens } + return maxTokens / 5 + } + + public var runFunctionsAutomatically: Bool { + overriding.runFunctionsAutomatically ?? configuration.runFunctionsAutomatically + } + + public var apiKey: String { + if let apiKey = overriding.apiKey { return apiKey } + guard let name = model?.info.apiKeyName else { return configuration.apiKey } + return (try? Keychain.apiKey.get(name)) ?? "" + } + + public var shouldEndTextWindow: (String) -> Bool { + textWindowTerminator ?? configuration.shouldEndTextWindow + } +} diff --git a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift index fdaba303..267f1945 100644 --- a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift @@ -35,3 +35,52 @@ public extension EmbeddingConfiguration { } } +public class OverridingEmbeddingConfiguration: EmbeddingConfiguration { + public struct Overriding { + public var modelId: String? + public var model: EmbeddingModel? + public var maxTokens: Int? + public var dimensions: Int? + + public init( + modelId: String? = nil, + model: EmbeddingModel? = nil, + maxTokens: Int? = nil, + dimensions: Int? = nil + ) { + self.modelId = modelId + self.model = model + self.maxTokens = maxTokens + self.dimensions = dimensions + } + } + + private let configuration: EmbeddingConfiguration + public var overriding = Overriding() + + public init( + overriding configuration: any EmbeddingConfiguration, + with overrides: Overriding = .init() + ) { + overriding = overrides + self.configuration = configuration + } + + public var model: EmbeddingModel? { + if let model = overriding.model { return model } + let models = UserDefaults.shared.value(for: \.embeddingModels) + guard let id = overriding.modelId, + let model = models.first(where: { $0.id == id }) + else { return configuration.model } + return model + } + + public var maxToken: Int { + overriding.maxTokens ?? configuration.maxToken + } + + public var dimensions: Int { + overriding.dimensions ?? configuration.dimensions + } +} + diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift index 2272d60e..f8a3b009 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift @@ -1,4 +1,5 @@ import AIModel +import ChatBasic import Foundation import Keychain import Preferences @@ -15,12 +16,18 @@ public struct UserPreferenceChatGPTConfiguration: ChatGPTConfiguration { if let chatModelKey { let id = UserDefaults.shared.value(for: chatModelKey) + if id == "com.github.copilot" { + return .init(id: id, name: "GitHub Copilot", format: .openAI, info: .init()) + } if let model = models.first(where: { $0.id == id }) { return model } } let id = UserDefaults.shared.value(for: \.defaultChatFeatureChatModelId) + if id == "com.github.copilot", chatModelKey != \.preferredChatModelIdForUtilities { + return .init(id: id, name: "GitHub Copilot", format: .openAI, info: .init()) + } return models.first { $0.id == id } ?? models.first } @@ -40,7 +47,7 @@ public struct UserPreferenceChatGPTConfiguration: ChatGPTConfiguration { public var runFunctionsAutomatically: Bool { true } - + public var shouldEndTextWindow: (String) -> Bool { { _ in true } } @@ -50,90 +57,3 @@ public struct UserPreferenceChatGPTConfiguration: ChatGPTConfiguration { } } -public class OverridingChatGPTConfiguration: ChatGPTConfiguration { - public struct Overriding: Codable { - public var temperature: Double? - public var modelId: String? - public var model: ChatModel? - public var stop: [String]? - public var maxTokens: Int? - public var minimumReplyTokens: Int? - public var runFunctionsAutomatically: Bool? - public var apiKey: String? - - public init( - temperature: Double? = nil, - modelId: String? = nil, - model: ChatModel? = nil, - stop: [String]? = nil, - maxTokens: Int? = nil, - minimumReplyTokens: Int? = nil, - runFunctionsAutomatically: Bool? = nil, - apiKey: String? = nil - ) { - self.temperature = temperature - self.modelId = modelId - self.model = model - self.stop = stop - self.maxTokens = maxTokens - self.minimumReplyTokens = minimumReplyTokens - self.runFunctionsAutomatically = runFunctionsAutomatically - self.apiKey = apiKey - } - } - - private let configuration: ChatGPTConfiguration - public var overriding = Overriding() - public var textWindowTerminator: ((String) -> Bool)? - - public init( - overriding configuration: any ChatGPTConfiguration, - with overrides: Overriding = .init() - ) { - overriding = overrides - self.configuration = configuration - } - - public var temperature: Double { - overriding.temperature ?? configuration.temperature - } - - public var model: ChatModel? { - if let model = overriding.model { return model } - let models = UserDefaults.shared.value(for: \.chatModels) - guard let id = overriding.modelId, - let model = models.first(where: { $0.id == id }) - else { return configuration.model } - return model - } - - public var stop: [String] { - overriding.stop ?? configuration.stop - } - - public var maxTokens: Int { - if let maxTokens = overriding.maxTokens { return maxTokens } - if let model { return model.info.maxTokens } - return configuration.maxTokens - } - - public var minimumReplyTokens: Int { - if let minimumReplyTokens = overriding.minimumReplyTokens { return minimumReplyTokens } - return maxTokens / 5 - } - - public var runFunctionsAutomatically: Bool { - overriding.runFunctionsAutomatically ?? configuration.runFunctionsAutomatically - } - - public var apiKey: String { - if let apiKey = overriding.apiKey { return apiKey } - guard let name = model?.info.apiKeyName else { return configuration.apiKey } - return (try? Keychain.apiKey.get(name)) ?? configuration.apiKey - } - - public var shouldEndTextWindow: (String) -> Bool { - textWindowTerminator ?? configuration.shouldEndTextWindow - } -} - diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift index de400ba5..d7c711de 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift @@ -1,4 +1,5 @@ import AIModel +import ChatBasic import Foundation import Preferences @@ -39,52 +40,3 @@ public struct UserPreferenceEmbeddingConfiguration: EmbeddingConfiguration { } } -public class OverridingEmbeddingConfiguration: EmbeddingConfiguration { - public struct Overriding { - public var modelId: String? - public var model: EmbeddingModel? - public var maxTokens: Int? - public var dimensions: Int? - - public init( - modelId: String? = nil, - model: EmbeddingModel? = nil, - maxTokens: Int? = nil, - dimensions: Int? = nil - ) { - self.modelId = modelId - self.model = model - self.maxTokens = maxTokens - self.dimensions = dimensions - } - } - - private let configuration: EmbeddingConfiguration - public var overriding = Overriding() - - public init( - overriding configuration: any EmbeddingConfiguration, - with overrides: Overriding = .init() - ) { - overriding = overrides - self.configuration = configuration - } - - public var model: EmbeddingModel? { - if let model = overriding.model { return model } - let models = UserDefaults.shared.value(for: \.embeddingModels) - guard let id = overriding.modelId, - let model = models.first(where: { $0.id == id }) - else { return configuration.model } - return model - } - - public var maxToken: Int { - overriding.maxTokens ?? configuration.maxToken - } - - public var dimensions: Int { - overriding.dimensions ?? configuration.dimensions - } -} - diff --git a/Tool/Sources/OpenAIService/Debug/Debug.swift b/Tool/Sources/OpenAIService/Debug/Debug.swift index 31864964..37db7031 100644 --- a/Tool/Sources/OpenAIService/Debug/Debug.swift +++ b/Tool/Sources/OpenAIService/Debug/Debug.swift @@ -7,20 +7,15 @@ enum Debugger { #if DEBUG static func didSendRequestBody(body: ChatCompletionsRequestBody) { - do { - let json = try JSONEncoder().encode(body) - let center = NotificationCenter.default - center.post( - name: .init("ServiceDebugger.ChatRequestDebug.requestSent"), - object: nil, - userInfo: [ - "id": id ?? UUID(), - "data": json, - ] - ) - } catch { - print("Failed to encode request body: \(error)") - } + let center = NotificationCenter.default + center.post( + name: .init("ServiceDebugger.ChatRequestDebug.requestSent"), + object: nil, + userInfo: [ + "id": id ?? UUID(), + "data": body, + ] + ) } static func didReceiveFunction(name: String, arguments: String) { diff --git a/Tool/Sources/OpenAIService/EmbeddingService.swift b/Tool/Sources/OpenAIService/EmbeddingService.swift index d5bf2f41..d0bf1116 100644 --- a/Tool/Sources/OpenAIService/EmbeddingService.swift +++ b/Tool/Sources/OpenAIService/EmbeddingService.swift @@ -1,3 +1,4 @@ +import ChatBasic import Foundation import Logger @@ -22,9 +23,14 @@ public struct EmbeddingService { ).embed(text: text) case .ollama: embeddingResponse = try await OllamaEmbeddingService( + apiKey: configuration.apiKey, model: model, endpoint: configuration.endpoint ).embed(text: text) + case .gitHubCopilot: + embeddingResponse = try await GitHubCopilotEmbeddingService( + model: model + ).embed(text: text) } #if DEBUG @@ -53,9 +59,14 @@ public struct EmbeddingService { ).embed(texts: text) case .ollama: embeddingResponse = try await OllamaEmbeddingService( + apiKey: configuration.apiKey, model: model, endpoint: configuration.endpoint ).embed(texts: text) + case .gitHubCopilot: + embeddingResponse = try await GitHubCopilotEmbeddingService( + model: model + ).embed(texts: text) } #if DEBUG @@ -84,9 +95,14 @@ public struct EmbeddingService { ).embed(tokens: tokens) case .ollama: embeddingResponse = try await OllamaEmbeddingService( + apiKey: configuration.apiKey, model: model, endpoint: configuration.endpoint ).embed(tokens: tokens) + case .gitHubCopilot: + embeddingResponse = try await GitHubCopilotEmbeddingService( + model: model + ).embed(tokens: tokens) } #if DEBUG diff --git a/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift b/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift index c3a60341..23a9b729 100644 --- a/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift +++ b/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift @@ -1,3 +1,4 @@ +import ChatBasic import Foundation public protocol ChatGPTFunctionProvider { @@ -5,7 +6,7 @@ public protocol ChatGPTFunctionProvider { var functionCallStrategy: FunctionCallStrategy? { get } } -extension ChatGPTFunctionProvider { +public extension ChatGPTFunctionProvider { func function(named: String) -> (any ChatGPTFunction)? { functions.first(where: { $0.name == named }) } @@ -16,3 +17,4 @@ public struct NoChatGPTFunctionProvider: ChatGPTFunctionProvider { public var functions: [any ChatGPTFunction] { [] } public init() {} } + diff --git a/Tool/Sources/OpenAIService/HeaderValueParser.swift b/Tool/Sources/OpenAIService/HeaderValueParser.swift new file mode 100644 index 00000000..0042ea75 --- /dev/null +++ b/Tool/Sources/OpenAIService/HeaderValueParser.swift @@ -0,0 +1,104 @@ +import Foundation +import GitHubCopilotService +import Logger +import Terminal + +public struct HeaderValueParser { + public enum Placeholder: String { + case gitHubCopilotOBearerToken = "github_copilot_bearer_token" + case apiKey = "api_key" + case modelName = "model_name" + } + + public struct Context { + public var modelName: String + public var apiKey: String + public var gitHubCopilotToken: () async -> GitHubCopilotExtension.Token? + public var shellEnvironmentVariable: (_ key: String) async -> String? + + public init( + modelName: String, + apiKey: String, + gitHubCopilotToken: (() async -> GitHubCopilotExtension.Token?)? = nil, + shellEnvironmentVariable: ((_: String) async -> String?)? = nil + ) { + self.modelName = modelName + self.apiKey = apiKey + self.gitHubCopilotToken = gitHubCopilotToken ?? { + try? await GitHubCopilotExtension.fetchToken() + } + self.shellEnvironmentVariable = shellEnvironmentVariable ?? { p in + let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/bash" + let terminal = Terminal() + return try? await terminal.runCommand( + shell, + arguments: ["-i", "-l", "-c", "echo $\(p)"], + environment: [:] + ) + } + } + } + + public init() {} + + /// Replace `{{PlaceHolder}}` with exact values. + public func parse(_ value: String, context: Context) async -> String { + var parsedValue = value + let placeholderRanges = findPlaceholderRanges(in: parsedValue) + + for (range, placeholderText) in placeholderRanges.reversed() { + let cleanPlaceholder = placeholderText + .trimmingCharacters(in: CharacterSet(charactersIn: "{}")) + + var replacement: String? + if let knownPlaceholder = Placeholder(rawValue: cleanPlaceholder) { + async let token = context.gitHubCopilotToken() + switch knownPlaceholder { + case .gitHubCopilotOBearerToken: + replacement = await token?.token + case .apiKey: + replacement = context.apiKey + case .modelName: + replacement = context.modelName + } + } else { + replacement = await context.shellEnvironmentVariable(cleanPlaceholder) + } + + if let replacement { + parsedValue.replaceSubrange( + range, + with: replacement.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } else { + parsedValue.replaceSubrange(range, with: "none") + } + } + + return parsedValue + } + + private func findPlaceholderRanges(in string: String) -> [(Range, String)] { + var ranges: [(Range, String)] = [] + let pattern = #"\{\{[^}]+\}\}"# + + do { + let regex = try NSRegularExpression(pattern: pattern) + let matches = regex.matches( + in: string, + range: NSRange(string.startIndex..., in: string) + ) + + for match in matches { + if let range = Range(match.range, in: string) { + ranges.append((range, String(string[range]))) + } + } + } catch { + Logger.service.error("Failed to find placeholders in string: \(string)") + } + + return ranges + } +} + diff --git a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift new file mode 100644 index 00000000..d33f7e7f --- /dev/null +++ b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift @@ -0,0 +1,108 @@ +import AIModel +import AsyncAlgorithms +import ChatBasic +import Dependencies +import Foundation +import IdentifiedCollections +import Preferences + +@available(*, deprecated, message: "Use ChatGPTServiceType instead.") +public protocol LegacyChatGPTServiceType { + var memory: ChatGPTMemory { get set } + var configuration: ChatGPTConfiguration { get set } + func send(content: String, summary: String?) async throws -> AsyncThrowingStream + func stopReceivingMessage() async +} + +@available(*, deprecated, message: "Use ChatGPTServiceType instead.") +public class LegacyChatGPTService: LegacyChatGPTServiceType { + public var memory: ChatGPTMemory + public var configuration: ChatGPTConfiguration + public var functionProvider: ChatGPTFunctionProvider + + var runningTask: Task, Never>? + + public init( + memory: ChatGPTMemory = AutoManagedChatGPTMemory( + systemPrompt: "", + configuration: UserPreferenceChatGPTConfiguration(), + functionProvider: NoChatGPTFunctionProvider(), + maxNumberOfMessages: .max + ), + configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(), + functionProvider: ChatGPTFunctionProvider = NoChatGPTFunctionProvider() + ) { + self.memory = memory + self.configuration = configuration + self.functionProvider = functionProvider + } + + @Dependency(\.uuid) var uuid + @Dependency(\.date) var date + @Dependency(\.chatCompletionsAPIBuilder) var chatCompletionsAPIBuilder + + /// Send a message and stream the reply. + public func send( + content: String, + summary: String? = nil + ) async throws -> AsyncThrowingStream { + let task = Task { + if !content.isEmpty || summary != nil { + let newMessage = ChatMessage( + id: uuid().uuidString, + role: .user, + content: content, + name: nil, + toolCalls: nil, + summary: summary, + references: [] + ) + await memory.appendMessage(newMessage) + } + + let service = ChatGPTService( + configuration: configuration, + functionProvider: functionProvider + ) + + let responses = service.send(memory) + + return responses.compactMap { response in + switch response { + case let .partialText(token): return token + default: return nil + } + }.eraseToThrowingStream() + } + runningTask = task + return await task.value + } + + /// Send a message and get the reply in return. + public func sendAndWait( + content: String, + summary: String? = nil + ) async throws -> String? { + if !content.isEmpty || summary != nil { + let newMessage = ChatMessage( + id: uuid().uuidString, + role: .user, + content: content, + summary: summary + ) + await memory.appendMessage(newMessage) + } + + let service = ChatGPTService( + configuration: configuration, + functionProvider: functionProvider + ) + + return try await service.send(memory).asText() + } + + public func stopReceivingMessage() { + runningTask?.cancel() + } +} + diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift index 7675c9ff..6da5e86b 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift @@ -1,3 +1,4 @@ +import ChatBasic import Foundation import Logger import Preferences @@ -37,6 +38,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { public var retrievedContent: [ChatMessage.Reference] = [] public var configuration: ChatGPTConfiguration public var functionProvider: ChatGPTFunctionProvider + public var maxNumberOfMessages: Int var onHistoryChange: () -> Void = {} @@ -46,6 +48,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { systemPrompt: String, configuration: ChatGPTConfiguration, functionProvider: ChatGPTFunctionProvider, + maxNumberOfMessages: Int = .max, composeHistory: @escaping HistoryComposer = { /// Default Format: /// ``` @@ -69,6 +72,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { self.configuration = configuration self.functionProvider = functionProvider self.composeHistory = composeHistory + self.maxNumberOfMessages = maxNumberOfMessages } public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) { @@ -109,10 +113,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { extension AutoManagedChatGPTMemory { /// https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb - func generateSendingHistory( - maxNumberOfMessages: Int = UserDefaults.shared.value(for: \.chatGPTMaxMessageCount), - strategy: AutoManagedChatGPTMemoryStrategy - ) async -> ChatGPTPrompt { + func generateSendingHistory(strategy: AutoManagedChatGPTMemoryStrategy) async -> ChatGPTPrompt { // handle no function support models let ( diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift index 1451bf67..6d527e3d 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift @@ -1,3 +1,4 @@ +import ChatBasic import Foundation import GoogleGenerativeAI import Logger @@ -29,4 +30,3 @@ extension AutoManagedChatGPTMemory { } } - diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift index 3619a7e9..a1deaed5 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift @@ -1,8 +1,10 @@ +import ChatBasic import Foundation import Logger import TokenEncoder extension AutoManagedChatGPTMemory { + #warning("TODO: Need to fix the tokenizer or supports model specified tokenizers.") struct OpenAIStrategy: AutoManagedChatGPTMemoryStrategy { static let encoder: TokenEncoder = TiktokenCl100kBaseTokenEncoder() @@ -56,6 +58,10 @@ extension TokenEncoder { } return await group.reduce(0, +) }) + for image in message.images { + encodingContent.append(image.urlString) + total += Int(Double(image.urlString.count) * 1.1) + } return total } diff --git a/Tool/Sources/OpenAIService/Memory/ChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/ChatGPTMemory.swift index 33300ee8..8f75779b 100644 --- a/Tool/Sources/OpenAIService/Memory/ChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/ChatGPTMemory.swift @@ -1,3 +1,4 @@ +import ChatBasic import Foundation public struct ChatGPTPrompt: Equatable { diff --git a/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift new file mode 100644 index 00000000..2325e0c4 --- /dev/null +++ b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift @@ -0,0 +1,299 @@ +import ChatBasic +import Foundation +import Logger +import Preferences +import TokenEncoder + +/// A memory that automatically manages the history according to max tokens and the template rules. +public actor TemplateChatGPTMemory: ChatGPTMemory { + public private(set) var memoryTemplate: MemoryTemplate + public var history: [ChatMessage] { memoryTemplate.resolved() } + public var configuration: ChatGPTConfiguration + public var functionProvider: ChatGPTFunctionProvider + + public init( + memoryTemplate: MemoryTemplate, + configuration: ChatGPTConfiguration, + functionProvider: ChatGPTFunctionProvider + ) { + self.memoryTemplate = memoryTemplate + self.configuration = configuration + self.functionProvider = functionProvider + } + + public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) async { + update(&memoryTemplate.followUpMessages) + } + + public func generatePrompt() async -> ChatGPTPrompt { + let strategy: AutoManagedChatGPTMemoryStrategy = switch configuration.model?.format { + case .googleAI: AutoManagedChatGPTMemory.GoogleAIStrategy(configuration: configuration) + default: AutoManagedChatGPTMemory.OpenAIStrategy() + } + + var memoryTemplate = self.memoryTemplate + func checkTokenCount() async -> Bool { + let history = memoryTemplate.resolved() + var tokenCount = 0 + for message in history { + tokenCount += await strategy.countToken(message) + } + for function in functionProvider.functions { + tokenCount += await strategy.countToken(function) + } + return tokenCount <= configuration.maxTokens - configuration.minimumReplyTokens + } + + var truncationTimes = 500 + while !(await checkTokenCount()) { + do { + truncationTimes -= 1 + if truncationTimes <= 0 { + throw CancellationError() + } + try Task.checkCancellation() + try await memoryTemplate.truncate() + } catch { + Logger.service.error("Failed to truncate prompt template: \(error)") + break + } + } + + return ChatGPTPrompt(history: memoryTemplate.resolved()) + } +} + +public struct MemoryTemplate { + public struct Message { + public struct DynamicContent: ExpressibleByStringLiteral { + public enum Content: ExpressibleByStringLiteral { + case text(String) + case list([String], formatter: ([String]) -> String) + case priorityList( + [(content: String, priority: Int)], + formatter: ([String]) -> String + ) + + public init(stringLiteral value: String) { + self = .text(value) + } + } + + public var content: Content + public var priority: Int + public var isEmpty: Bool { + switch content { + case let .text(text): + return text.isEmpty + case let .list(list, _): + return list.isEmpty + case let .priorityList(list, _): + return list.isEmpty + } + } + + public init(stringLiteral value: String) { + content = .text(value) + priority = .max + } + + public init(_ content: Content, priority: Int = .max) { + self.content = content + self.priority = priority + } + } + + public var chatMessage: ChatMessage + public var dynamicContent: [DynamicContent] = [] + public var priority: Int + + public func resolved() -> ChatMessage? { + var baseMessage = chatMessage + guard !dynamicContent.isEmpty else { + if baseMessage.isEmpty { return nil } + return baseMessage + } + + let contents: [String] = dynamicContent.compactMap { content in + if content.isEmpty { return nil } + switch content.content { + case let .text(text): + return text + case let .list(list, formatter): + return formatter(list) + case let .priorityList(list, formatter): + return formatter(list.map { $0.0 }) + } + } + + let composedContent = contents.joined(separator: "\n\n") + if composedContent.isEmpty { return nil } + + baseMessage.content = composedContent + return baseMessage + } + + public var isEmpty: Bool { + if !dynamicContent.isEmpty { return dynamicContent.allSatisfy { $0.isEmpty } } + if let toolCalls = chatMessage.toolCalls, !toolCalls.isEmpty { + return false + } + if let content = chatMessage.content, !content.isEmpty { + return false + } + return true + } + + public init( + chatMessage: ChatMessage, + dynamicContent: [DynamicContent] = [], + priority: Int = .max + ) { + self.chatMessage = chatMessage + self.dynamicContent = dynamicContent + self.priority = priority + } + } + + public var messages: [Message] + public var followUpMessages: [ChatMessage] + + public typealias TruncateRule = ( + _ messages: inout [Message], + _ followUpMessages: inout [ChatMessage] + ) async throws -> Void + + let truncateRule: TruncateRule? + + public init( + messages: [Message], + followUpMessages: [ChatMessage] = [], + truncateRule: TruncateRule? = nil + ) { + self.messages = messages + self.truncateRule = truncateRule + self.followUpMessages = followUpMessages + } + + func resolved() -> [ChatMessage] { + messages.compactMap { message in message.resolved() } + followUpMessages + } + + func truncated() async throws -> MemoryTemplate { + var copy = self + try await copy.truncate() + return copy + } + + mutating func truncate() async throws { + if Task.isCancelled { return } + + if let truncateRule = truncateRule { + try await truncateRule(&messages, &followUpMessages) + return + } + + try await Self.defaultTruncateRule()(&messages, &followUpMessages) + } + + public struct DefaultTruncateRuleOptions { + public var numberOfContentListItemToKeep: (Int) -> Int = { $0 * 2 / 3 } + } + + public static func defaultTruncateRule( + options updateOptions: (inout DefaultTruncateRuleOptions) -> Void = { _ in } + ) -> TruncateRule { + var options = DefaultTruncateRuleOptions() + updateOptions(&options) + return { messages, followUpMessages in + + // Remove the oldest followup messages when available. + + if followUpMessages.count > 20 { + followUpMessages.removeFirst(followUpMessages.count / 2) + return + } + + if followUpMessages.count > 2 { + if followUpMessages.count.isMultiple(of: 2) { + followUpMessages.removeFirst(2) + } else { + followUpMessages.removeFirst(1) + } + return + } + + // Remove according to the priority. + + var truncatingMessageIndex: Int? + for (index, message) in messages.enumerated() { + if message.priority == .max { continue } + if let previousIndex = truncatingMessageIndex, + message.priority < messages[previousIndex].priority + { + truncatingMessageIndex = index + } + } + + guard let truncatingMessageIndex else { throw CancellationError() } + var truncatingMessage: Message { + get { messages[truncatingMessageIndex] } + set { messages[truncatingMessageIndex] = newValue } + } + + if truncatingMessage.isEmpty { + messages.remove(at: truncatingMessageIndex) + return + } + + truncatingMessage.dynamicContent.removeAll(where: { $0.isEmpty }) + + var truncatingContentIndex: Int? + for (index, content) in truncatingMessage.dynamicContent.enumerated() { + if content.isEmpty { continue } + if let previousIndex = truncatingContentIndex, + content.priority < truncatingMessage.dynamicContent[previousIndex].priority + { + truncatingContentIndex = index + } + } + + guard let truncatingContentIndex else { throw CancellationError() } + var truncatingContent: Message.DynamicContent { + get { truncatingMessage.dynamicContent[truncatingContentIndex] } + set { truncatingMessage.dynamicContent[truncatingContentIndex] = newValue } + } + + switch truncatingContent.content { + case .text: + truncatingMessage.dynamicContent.remove(at: truncatingContentIndex) + case let .list(list, formatter): + let count = options.numberOfContentListItemToKeep(list.count) + if count > 0 { + truncatingContent.content = .list( + Array(list.prefix(count)), + formatter: formatter + ) + } else { + truncatingMessage.dynamicContent.remove(at: truncatingContentIndex) + } + case let .priorityList(list, formatter): + let count = options.numberOfContentListItemToKeep(list.count) + if count > 0 { + let orderedList = list.enumerated() + let orderedByPriority = orderedList + .sorted { $0.element.priority >= $1.element.priority } + let kept = orderedByPriority.prefix(count) + let reordered = kept.sorted { $0.offset < $1.offset } + truncatingContent.content = .priorityList( + Array(reordered.map { $0.element }), + formatter: formatter + ) + } else { + truncatingMessage.dynamicContent.remove(at: truncatingContentIndex) + } + } + } + } +} + diff --git a/Tool/Sources/OpenAIService/Models.swift b/Tool/Sources/OpenAIService/Models.swift index af95a6e5..98355339 100644 --- a/Tool/Sources/OpenAIService/Models.swift +++ b/Tool/Sources/OpenAIService/Models.swift @@ -1,3 +1,4 @@ +import ChatBasic import CodableWrappers import Foundation @@ -8,163 +9,5 @@ struct Cancellable { } } -public struct ChatMessage: Equatable, Codable { - public typealias ID = String - - public enum Role: String, Codable, Equatable { - case system - case user - case assistant - } - - public struct FunctionCall: Codable, Equatable { - public var name: String - public var arguments: String - public init(name: String, arguments: String) { - self.name = name - self.arguments = arguments - } - } - - public struct ToolCall: Codable, Equatable, Identifiable { - public var id: String - public var type: String - public var function: FunctionCall - 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) - } - } - - public struct ToolCallResponse: Codable, Equatable { - public var content: String - public var summary: String? - public init(content: String, summary: String?) { - self.content = content - self.summary = summary - } - } - - public struct Reference: Codable, Equatable { - public enum Kind: String, Codable { - case `class` - case `struct` - case `enum` - case `actor` - case `protocol` - case `extension` - case `case` - case property - case `typealias` - case function - case method - case text - case webpage - case other - } - - public var title: String - public var subTitle: String - public var uri: String - public var content: String - public var startLine: Int? - public var endLine: Int? - @FallbackDecoding - public var kind: Kind - - public init( - title: String, - subTitle: String, - content: String, - uri: String, - startLine: Int?, - endLine: Int?, - kind: Kind - ) { - self.title = title - self.subTitle = subTitle - self.content = content - self.uri = uri - self.startLine = startLine - self.endLine = endLine - self.kind = kind - } - } - - /// 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 number of tokens of this message. - var tokensCount: Int? - - /// The references of this message. - @FallbackDecoding> - public var references: [Reference] - - /// Is the message considered empty. - 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, - role: Role, - content: String?, - name: String? = nil, - toolCalls: [ToolCall]? = nil, - summary: String? = nil, - tokenCount: Int? = nil, - references: [Reference] = [] - ) { - self.role = role - self.content = content - self.name = name - self.toolCalls = toolCalls - self.summary = summary - self.id = id - tokensCount = tokenCount - self.references = references - } -} - -public struct ReferenceKindFallback: FallbackValueProvider { - public static var defaultValue: ChatMessage.Reference.Kind { .other } -} - -public struct ChatMessageRoleFallback: FallbackValueProvider { - public static var defaultValue: ChatMessage.Role { .user } -} +public typealias ChatMessage = ChatBasic.ChatMessage diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 780ec9cf..7b955d50 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -100,6 +100,11 @@ public struct UserDefaultPreferenceKeys { defaultValue: false, key: "InstallBetaBuilds" ) + + public let debugOverlayPanel = PreferenceKey( + defaultValue: false, + key: "DebugOverlayPanel" + ) } // MARK: - OpenAI Account Settings @@ -188,8 +193,20 @@ public extension UserDefaultPreferenceKeys { .init(defaultValue: .env, key: "RunNodeWith") } - var gitHubCopilotIgnoreTrailingNewLines: PreferenceKey { - .init(defaultValue: true, key: "GitHubCopilotIgnoreTrailingNewLines") + var gitHubCopilotLoadKeyChainCertificates: PreferenceKey { + .init(defaultValue: false, key: "GitHubCopilotLoadKeyChainCertificates") + } + + var gitHubCopilotPretendIDEToBeVSCode: PreferenceKey { + .init(defaultValue: false, key: "GitHubCopilotPretendIDEToBeVSCode") + } + + var gitHubCopilotModelId: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotModelId") + } + + var gitHubCopilotModelFamily: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotModelFamily") } } @@ -211,27 +228,25 @@ public extension UserDefaultPreferenceKeys { var codeiumApiUrl: PreferenceKey { .init(defaultValue: "", key: "CodeiumApiUrl") } + + var codeiumEnterpriseVersion: PreferenceKey { + .init(defaultValue: "", key: "CodeiumEnterpriseVersion") + } + + var codeiumIndexEnabled: PreferenceKey { + .init(defaultValue: false, key: "CodeiumIndexEnabled") + } + + var codeiumIndexingMaxFileSize: PreferenceKey { + .init(defaultValue: 5000, key: "CodeiumIndexingMaxFileSize") + } } // MARK: - Chat Models public extension UserDefaultPreferenceKeys { var chatModels: PreferenceKey<[ChatModel]> { - .init(defaultValue: [ - .init( - id: UUID().uuidString, - name: "OpenAI", - format: .openAI, - info: .init( - apiKeyName: "", - baseURL: "", - isFullURL: false, - maxTokens: ChatGPTModel.gpt35Turbo.maxToken, - supportsFunctionCalling: true, - modelName: ChatGPTModel.gpt35Turbo.rawValue - ) - ), - ], key: "ChatModels") + .init(defaultValue: [], key: "ChatModels") } var chatGPTLanguage: PreferenceKey { @@ -251,20 +266,7 @@ public extension UserDefaultPreferenceKeys { public extension UserDefaultPreferenceKeys { var embeddingModels: PreferenceKey<[EmbeddingModel]> { - .init(defaultValue: [ - .init( - id: UUID().uuidString, - name: "OpenAI", - format: .openAI, - info: .init( - apiKeyName: "", - baseURL: "", - isFullURL: false, - maxTokens: OpenAIEmbeddingModel.textEmbeddingAda002.maxToken, - modelName: OpenAIEmbeddingModel.textEmbeddingAda002.rawValue - ) - ), - ], key: "EmbeddingModels") + .init(defaultValue: [], key: "EmbeddingModels") } } @@ -302,6 +304,10 @@ public extension UserDefaultPreferenceKeys { var hideCommonPrecedingSpacesInPromptToCode: PreferenceKey { .init(defaultValue: true, key: "HideCommonPrecedingSpacesInPromptToCode") } + + var wrapCodeInPromptToCode: PreferenceKey { + .init(defaultValue: false, key: "WrapCodeInPromptToCode") + } } // MARK: - Suggestion @@ -355,6 +361,30 @@ public extension UserDefaultPreferenceKeys { .init(defaultValue: true, key: "AcceptSuggestionWithTab") } + var acceptSuggestionWithModifierCommand: PreferenceKey { + .init(defaultValue: false, key: "SuggestionWithModifierCommand") + } + + var acceptSuggestionWithModifierOption: PreferenceKey { + .init(defaultValue: false, key: "SuggestionWithModifierOption") + } + + var acceptSuggestionWithModifierControl: PreferenceKey { + .init(defaultValue: false, key: "SuggestionWithModifierControl") + } + + var acceptSuggestionWithModifierShift: PreferenceKey { + .init(defaultValue: false, key: "SuggestionWithModifierShift") + } + + var acceptSuggestionWithModifierOnlyForSwift: PreferenceKey { + .init(defaultValue: false, key: "SuggestionWithModifierOnlyForSwift") + } + + var acceptSuggestionLineWithModifierControl: PreferenceKey { + .init(defaultValue: true, key: "SuggestionLineWithModifierControl") + } + var dismissSuggestionWithEsc: PreferenceKey { .init(defaultValue: true, key: "DismissSuggestionWithEsc") } @@ -362,6 +392,10 @@ public extension UserDefaultPreferenceKeys { var isSuggestionSenseEnabled: PreferenceKey { .init(defaultValue: false, key: "IsSuggestionSenseEnabled") } + + var isSuggestionTypeInTheMiddleEnabled: PreferenceKey { + .init(defaultValue: true, key: "IsSuggestionTypeInTheMiddleEnabled") + } } // MARK: - Chat @@ -409,15 +443,7 @@ public extension UserDefaultPreferenceKeys { var defaultChatSystemPrompt: PreferenceKey { .init( - defaultValue: """ - You are a helpful senior programming assistant. - You should respond in natural language. - Your response should be correct, concise, clear, informative and logical. - Use markdown if you need to present code, table, list, etc. - 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 concisely. - Make your answer short and structured. - """, + defaultValue: "", key: "DefaultChatSystemPrompt" ) } @@ -427,7 +453,7 @@ public extension UserDefaultPreferenceKeys { } var wrapCodeInChatCodeBlock: PreferenceKey { - .init(defaultValue: true, key: "WrapCodeInChatCodeBlock") + .init(defaultValue: false, key: "WrapCodeInChatCodeBlock") } var enableFileScopeByDefaultInChatContext: PreferenceKey { @@ -461,6 +487,44 @@ public extension UserDefaultPreferenceKeys { var preferredChatModelIdForWebScope: PreferenceKey { .init(defaultValue: "", key: "PreferredChatModelIdForWebScope") } + + var preferredChatModelIdForUtilities: PreferenceKey { + .init(defaultValue: "", key: "PreferredChatModelIdForUtilities") + } + + enum ChatPanelFloatOnTopOption: Int, Codable, Equatable { + case alwaysOnTop + case onTopWhenXcodeIsActive + case never + } + + var chatPanelFloatOnTopOption: PreferenceKey { + .init(defaultValue: .onTopWhenXcodeIsActive, key: "ChatPanelFloatOnTopOption") + } + + var disableFloatOnTopWhenTheChatPanelIsDetached: PreferenceKey { + .init(defaultValue: false, key: "DisableFloatOnTopWhenTheChatPanelIsDetached") + } + + var keepFloatOnTopIfChatPanelAndXcodeOverlaps: PreferenceKey { + .init(defaultValue: true, key: "KeepFloatOnTopIfChatPanelAndXcodeOverlaps") + } + + var openChatMode: PreferenceKey> { + .init(defaultValue: .init(.chatPanel), key: "DefaultOpenChatMode") + } + + var legacyOpenChatMode: DeprecatedPreferenceKey { + .init(defaultValue: .chatPanel, key: "OpenChatMode") + } + + var openChatInBrowserURL: PreferenceKey { + .init(defaultValue: "", key: "OpenChatInBrowserURL") + } + + var openChatInBrowserInInAppBrowser: PreferenceKey { + .init(defaultValue: true, key: "OpenChatInBrowserInInAppBrowser") + } } // MARK: - Theme @@ -514,7 +578,7 @@ public extension UserDefaultPreferenceKeys { key: "ChatCodeFont" ) } - + var terminalFont: PreferenceKey> { .init( defaultValue: .init(.init(nsFont: .monospacedSystemFont(ofSize: 12, weight: .regular))), @@ -523,14 +587,49 @@ public extension UserDefaultPreferenceKeys { } } -// MARK: - Bing Search +// MARK: - Search public extension UserDefaultPreferenceKeys { - var bingSearchSubscriptionKey: PreferenceKey { + enum SearchProvider: String, Codable, CaseIterable { + case headlessBrowser + case serpAPI + } + + enum SerpAPIEngine: String, Codable, CaseIterable { + case google + case baidu + case bing + case duckDuckGo = "duckduckgo" + } + + enum HeadlessBrowserEngine: String, Codable, CaseIterable { + case google + case baidu + case bing + case duckDuckGo = "duckduckgo" + } + + var searchProvider: PreferenceKey { + .init(defaultValue: .headlessBrowser, key: "SearchProvider") + } + + var serpAPIEngine: PreferenceKey { + .init(defaultValue: .google, key: "SerpAPIEngine") + } + + var serpAPIKeyName: PreferenceKey { + .init(defaultValue: "", key: "SerpAPIKeyName") + } + + var headlessBrowserEngine: PreferenceKey { + .init(defaultValue: .google, key: "HeadlessBrowserEngine") + } + + var bingSearchSubscriptionKey: DeprecatedPreferenceKey { .init(defaultValue: "", key: "BingSearchSubscriptionKey") } - var bingSearchEndpoint: PreferenceKey { + var bingSearchEndpoint: DeprecatedPreferenceKey { .init( defaultValue: "https://api.bing.microsoft.com/v7.0/search/", key: "BingSearchEndpoint" @@ -550,7 +649,9 @@ public extension UserDefaultPreferenceKeys { extraSystemPrompt: "", prompt: "Explain the selected code concisely, step-by-step.", useExtraSystemPrompt: true - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), .init( commandId: "BuiltInCustomCommandAddDocumentationToSelection", @@ -560,7 +661,9 @@ public extension UserDefaultPreferenceKeys { prompt: "Add documentation on top of the code. Use triple slash if the language supports it.", continuousMode: false, generateDescription: true - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), .init( commandId: "BuiltInCustomCommandSendCodeToChat", @@ -573,7 +676,9 @@ public extension UserDefaultPreferenceKeys { ``` """, useExtraSystemPrompt: true - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), ], key: "CustomCommands") } @@ -595,7 +700,7 @@ public extension UserDefaultPreferenceKeys { } var useCustomScrollViewWorkaround: FeatureFlag { - .init(defaultValue: true, key: "FeatureFlag-UseCustomScrollViewWorkaround") + .init(defaultValue: false, key: "FeatureFlag-UseCustomScrollViewWorkaround") } var triggerActionWithAccessibilityAPI: FeatureFlag { @@ -680,5 +785,9 @@ public extension UserDefaultPreferenceKeys { var useCloudflareDomainNameForLicenseCheck: FeatureFlag { .init(defaultValue: false, key: "FeatureFlag-UseCloudflareDomainNameForLicenseCheck") } + + var doNotInstallLaunchAgentAutomatically: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-DoNotInstallLaunchAgentAutomatically") + } } diff --git a/Tool/Sources/Preferences/Types/ChatGPTModel.swift b/Tool/Sources/Preferences/Types/ChatGPTModel.swift index d56ac4a1..54893fb6 100644 --- a/Tool/Sources/Preferences/Types/ChatGPTModel.swift +++ b/Tool/Sources/Preferences/Types/ChatGPTModel.swift @@ -1,25 +1,26 @@ import Foundation -public enum ChatGPTModel: String { +public enum ChatGPTModel: String, CaseIterable { case gpt35Turbo = "gpt-3.5-turbo" case gpt35Turbo16k = "gpt-3.5-turbo-16k" + case gpt4o = "gpt-4o" + case gpt4oMini = "gpt-4o-mini" case gpt4 = "gpt-4" case gpt432k = "gpt-4-32k" case gpt4Turbo = "gpt-4-turbo" - case gpt40314 = "gpt-4-0314" - case gpt40613 = "gpt-4-0613" - case gpt41106Preview = "gpt-4-1106-preview" case gpt4VisionPreview = "gpt-4-vision-preview" - case gpt4TurboPreview = "gpt-4-turbo-preview" - case gpt4Turbo20240409 = "gpt-4-turbo-2024-04-09" - case gpt35Turbo0301 = "gpt-3.5-turbo-0301" - case gpt35Turbo0613 = "gpt-3.5-turbo-0613" - case gpt35Turbo1106 = "gpt-3.5-turbo-1106" - case gpt35Turbo0125 = "gpt-3.5-turbo-0125" - case gpt35Turbo16k0613 = "gpt-3.5-turbo-16k-0613" case gpt432k0314 = "gpt-4-32k-0314" case gpt432k0613 = "gpt-4-32k-0613" case gpt40125 = "gpt-4-0125-preview" + case gpt4_1 = "gpt-4.1" + case gpt4_1Mini = "gpt-4.1-mini" + case gpt4_1Nano = "gpt-4.1-nano" + case o1 = "o1" + case o1Preview = "o1-preview" + case o1Pro = "o1-pro" + case o3Mini = "o3-mini" + case o3 = "o3" + case o4Mini = "o4-mini" } public extension ChatGPTModel { @@ -27,53 +28,72 @@ public extension ChatGPTModel { 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 - case .gpt35Turbo0613: - return 4096 - case .gpt35Turbo1106: - return 16385 - case .gpt35Turbo0125: return 16385 case .gpt35Turbo16k: return 16385 - case .gpt35Turbo16k0613: - return 16385 - case .gpt40613: - return 8192 case .gpt432k0613: return 32768 - case .gpt41106Preview: - return 128000 case .gpt4VisionPreview: - return 128000 - case .gpt4TurboPreview: - return 128000 + return 128_000 case .gpt40125: - return 128000 + return 128_000 case .gpt4Turbo: - return 128000 - case .gpt4Turbo20240409: - return 128000 + return 128_000 + case .gpt4o: + return 128_000 + case .gpt4oMini: + return 128_000 + case .o1Preview: + return 128_000 + case .o1: + return 200_000 + case .o3Mini: + return 200_000 + case .gpt4_1: + return 1_047_576 + case .gpt4_1Mini: + return 1_047_576 + case .gpt4_1Nano: + return 1_047_576 + case .o1Pro: + return 200_000 + case .o3: + return 200_000 + case .o4Mini: + return 200_000 } } - + var supportsImages: Bool { switch self { - case .gpt4VisionPreview, .gpt4Turbo, .gpt4Turbo20240409: + case .gpt4VisionPreview, .gpt4Turbo, .gpt4o, .gpt4oMini, .o1Preview, .o1, .o3Mini: return true default: return false } } + + var supportsTemperature: Bool { + switch self { + case .o1Preview, .o1, .o3Mini: + return false + default: + return true + } + } + + var supportsSystemPrompt: Bool { + switch self { + case .o1Preview, .o1, .o3Mini: + return false + default: + return true + } + } } -extension ChatGPTModel: CaseIterable {} diff --git a/Tool/Sources/Preferences/Types/CustomCommand.swift b/Tool/Sources/Preferences/Types/CustomCommand.swift index b462e8a3..38c837e0 100644 --- a/Tool/Sources/Preferences/Types/CustomCommand.swift +++ b/Tool/Sources/Preferences/Types/CustomCommand.swift @@ -30,27 +30,63 @@ public struct CustomCommand: Codable, Equatable { ) } + public struct Attachment: Codable, Equatable { + public enum Kind: Codable, Equatable, Hashable { + case activeDocument + case debugArea + case clipboard + case senseScope + case projectScope + case webScope + case gitStatus + case gitLog + case file(path: String) + } + public var kind: Kind + public init(kind: Kind) { + self.kind = kind + } + } + 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) { + public var ignoreExistingAttachments: Bool + public var attachments: [Attachment] + + public init( + commandId: String, + name: String, + feature: Feature, + ignoreExistingAttachments: Bool, + attachments: [Attachment] + ) { self.commandId = commandId self.name = name self.feature = feature + self.ignoreExistingAttachments = ignoreExistingAttachments + self.attachments = attachments } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) commandId = try container.decodeIfPresent(String.self, forKey: .commandId) name = try container.decode(String.self, forKey: .name) - feature = (try? container - .decode(CustomCommand.Feature.self, forKey: .feature)) ?? .chatWithSelection( - extraSystemPrompt: "", - prompt: "", - useExtraSystemPrompt: false - ) + feature = ( + try? container + .decode(CustomCommand.Feature.self, forKey: .feature) + ) ?? .chatWithSelection( + extraSystemPrompt: "", + prompt: "", + useExtraSystemPrompt: false + ) + ignoreExistingAttachments = try container.decodeIfPresent( + Bool.self, + forKey: .ignoreExistingAttachments + ) ?? false + attachments = try container.decodeIfPresent([Attachment].self, forKey: .attachments) ?? [] } var legacyId: String { diff --git a/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift b/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift index 58f433c4..23de7f5e 100644 --- a/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift +++ b/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift @@ -1,6 +1,12 @@ import Foundation public enum GoogleGenerativeAIModel: String { + case gemini25FlashPreview = "gemini-2.5-flash-preview-04-17" + case gemini25ProPreview = "gemini-2.5-pro-preview-05-06" + case gemini20Flash = "gemini-2.0-flash" + case gemini20FlashLite = "gemini-2.0-flash-lite" + case gemini15Pro = "gemini-1.5-pro" + case gemini15Flash = "gemini-1.5-flash" case geminiPro = "gemini-pro" } @@ -9,6 +15,18 @@ public extension GoogleGenerativeAIModel { switch self { case .geminiPro: return 32768 + case .gemini15Flash: + return 1_048_576 + case .gemini15Pro: + return 2_097_152 + case .gemini25FlashPreview: + return 1_048_576 + case .gemini25ProPreview: + return 1_048_576 + case .gemini20Flash: + return 1_048_576 + case .gemini20FlashLite: + return 1_048_576 } } } diff --git a/Tool/Sources/Preferences/Types/OpenAIEmbeddingModel.swift b/Tool/Sources/Preferences/Types/OpenAIEmbeddingModel.swift index 76cc5498..64f88fbc 100644 --- a/Tool/Sources/Preferences/Types/OpenAIEmbeddingModel.swift +++ b/Tool/Sources/Preferences/Types/OpenAIEmbeddingModel.swift @@ -1,4 +1,6 @@ public enum OpenAIEmbeddingModel: String, CaseIterable { + case textEmbedding3Small = "text-embedding-3-small" + case textEmbedding3Large = "text-embedding-3-large" case textEmbeddingAda002 = "text-embedding-ada-002" } @@ -7,6 +9,21 @@ public extension OpenAIEmbeddingModel { switch self { case .textEmbeddingAda002: return 8191 + case .textEmbedding3Small: + return 8191 + case .textEmbedding3Large: + return 8191 + } + } + + var dimensions: Int { + switch self { + case .textEmbeddingAda002: + return 1536 + case .textEmbedding3Small: + return 1536 + case .textEmbedding3Large: + return 3072 } } } diff --git a/Tool/Sources/Preferences/Types/OpenChatMode.swift b/Tool/Sources/Preferences/Types/OpenChatMode.swift new file mode 100644 index 00000000..c744a6b5 --- /dev/null +++ b/Tool/Sources/Preferences/Types/OpenChatMode.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum OpenChatMode: Codable, Equatable, Identifiable, Hashable { + public var id: String { + switch self { + case .chatPanel: + return "chatPanel" + case .browser: + return "browser" + case let .builtinExtension(extensionIdentifier, id, _): + return "builtinExtension-\(extensionIdentifier)-\(id)" + case let .externalExtension(extensionIdentifier, id, _): + return "externalExtension-\(extensionIdentifier)-\(id)" + } + } + + public enum LegacyOpenChatMode: String { + case chatPanel + case browser + case codeiumChat + } + + case chatPanel + case browser + case builtinExtension(extensionIdentifier: String, id: String, tabName: String) + case externalExtension(extensionIdentifier: String, id: String, tabName: String) +} + diff --git a/Tool/Sources/Preferences/Types/StorableColors.swift b/Tool/Sources/Preferences/Types/StorableColors.swift index 2d7e4f83..070092c0 100644 --- a/Tool/Sources/Preferences/Types/StorableColors.swift +++ b/Tool/Sources/Preferences/Types/StorableColors.swift @@ -1,6 +1,6 @@ import Foundation -public struct StorableColor: Codable { +public struct StorableColor: Codable, Equatable { public var red: Double public var green: Double public var blue: Double @@ -18,7 +18,7 @@ public struct StorableColor: Codable { import SwiftUI public extension StorableColor { var swiftUIColor: SwiftUI.Color { - SwiftUI.Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha) + SwiftUI.Color(nsColor: nsColor) } } #endif @@ -28,7 +28,7 @@ import AppKit public extension StorableColor { var nsColor: NSColor { NSColor( - srgbRed: CGFloat(red), + calibratedRed: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: CGFloat(alpha) diff --git a/Tool/Sources/Preferences/Types/StorableFont.swift b/Tool/Sources/Preferences/Types/StorableFont.swift index f6d72dd3..18098442 100644 --- a/Tool/Sources/Preferences/Types/StorableFont.swift +++ b/Tool/Sources/Preferences/Types/StorableFont.swift @@ -1,7 +1,7 @@ import AppKit import Foundation -public struct StorableFont: Codable { +public struct StorableFont: Codable, Equatable { public var nsFont: NSFont public init(nsFont: NSFont) { @@ -13,7 +13,7 @@ public struct StorableFont: Codable { } public init(from decoder: Decoder) throws { - var container = try decoder.container(keyedBy: CodingKeys.self) + let container = try decoder.container(keyedBy: CodingKeys.self) let fontData = try container.decode(Data.self, forKey: .nsFont) guard let nsFont = try NSKeyedUnarchiver.unarchivedObject( ofClass: NSFont.self, diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift index 5052e1a8..078ca58c 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -51,6 +51,21 @@ public extension UserDefaults { weight: .regular ))) ) + shared.setupDefaultValue( + for: \.openChatMode, + defaultValue: { + switch shared.deprecatedValue(for: \.legacyOpenChatMode) { + case .chatPanel: return .init(.chatPanel) + case .browser: return .init(.browser) + case .codeiumChat: + return .init(.builtinExtension( + extensionIdentifier: "com.codeium", + id: "Codeium Chat", + tabName: "Codeium Chat" + )) + } + }() + ) } } @@ -65,7 +80,7 @@ extension String: UserDefaultsStorable {} extension Data: UserDefaultsStorable {} extension URL: UserDefaultsStorable {} -extension Array: RawRepresentable where Element: Codable { +extension Array: @retroactive RawRepresentable where Element: Codable { public init?(rawValue: String) { guard let data = rawValue.data(using: .utf8), let result = try? JSONDecoder().decode([Element].self, from: data) @@ -111,6 +126,8 @@ public struct UserDefaultsStorageBox: RawRepresentable { } } +extension UserDefaultsStorageBox: Equatable where Element: Equatable {} + public extension UserDefaultsType { // MARK: Normal Types diff --git a/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift new file mode 100644 index 00000000..a952311b --- /dev/null +++ b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift @@ -0,0 +1,123 @@ +import ChatBasic +import ComposableArchitecture +import Dependencies +import Foundation +import ModificationBasic +import SuggestionBasic +import SwiftUI + +public enum PromptToCodeCustomization { + public static var CustomizedUI: any PromptToCodeCustomizedUI = NoPromptToCodeCustomizedUI() + public static var contextInputControllerFactory: ( + Shared + ) -> PromptToCodeContextInputController = { _ in + DefaultPromptToCodeContextInputController() + } +} + +public struct PromptToCodeCustomizationContextWrapper: View { + @State var context: AnyObject + let content: (AnyObject) -> Content + + public init(context: O, @ViewBuilder content: @escaping (O) -> Content) { + self.context = context + self.content = { context in + content(context as! O) + } + } + + public var body: some View { + content(context) + } +} + +public protocol PromptToCodeCustomizedUI { + typealias PromptToCodeCustomizedViews = ( + extraMenuItems: AnyView?, + extraButtons: AnyView?, + extraAcceptButtonVariants: AnyView?, + contextInputField: AnyView? + ) + + func callAsFunction( + state: Shared, + delegate: PromptToCodeContextInputControllerDelegate, + contextInputController: PromptToCodeContextInputController, + isInputFieldFocused: FocusState, + @ViewBuilder view: @escaping (PromptToCodeCustomizedViews) -> V + ) -> PromptToCodeCustomizationContextWrapper +} + +public protocol PromptToCodeContextInputControllerDelegate { + func modifyCodeButtonClicked() +} + +public protocol PromptToCodeContextInputController: Perception.Perceptible { + var instruction: NSAttributedString { get set } + + func resolveContext( + forDocumentURL: URL, + onStatusChange: @escaping ([String]) async -> Void + ) async -> ( + instruction: String, + references: [ChatMessage.Reference], + topics: [ChatMessage.Reference], + agent: (() -> any ModificationAgent)? + ) +} + +struct NoPromptToCodeCustomizedUI: PromptToCodeCustomizedUI { + private class Context {} + + func callAsFunction( + state: Shared, + delegate: PromptToCodeContextInputControllerDelegate, + contextInputController: PromptToCodeContextInputController, + isInputFieldFocused: FocusState, + @ViewBuilder view: @escaping (PromptToCodeCustomizedViews) -> V + ) -> PromptToCodeCustomizationContextWrapper { + PromptToCodeCustomizationContextWrapper(context: Context()) { _ in + view(( + extraMenuItems: nil, + extraButtons: nil, + extraAcceptButtonVariants: nil, + contextInputField: nil + )) + } + } +} + +@Perceptible +public final class DefaultPromptToCodeContextInputController: PromptToCodeContextInputController { + public var instruction: NSAttributedString = .init() + public var instructionString: String { + get { instruction.string } + set { instruction = .init(string: newValue) } + } + + public func appendNewLineToPromptButtonTapped() { + let mutable = NSMutableAttributedString( + attributedString: instruction + ) + mutable.append(NSAttributedString(string: "\n")) + instruction = mutable + } + + public func resolveContext( + forDocumentURL: URL, + onStatusChange: @escaping ([String]) async -> Void + ) -> ( + instruction: String, + references: [ChatMessage.Reference], + topics: [ChatMessage.Reference], + agent: (() -> any ModificationAgent)? + ) { + return ( + instruction: instructionString, + references: [], + topics: [], + agent: nil + ) + } +} + diff --git a/Tool/Sources/RAGChatAgent/RAGChatAgent.swift b/Tool/Sources/RAGChatAgent/RAGChatAgent.swift new file mode 100644 index 00000000..5e7306e9 --- /dev/null +++ b/Tool/Sources/RAGChatAgent/RAGChatAgent.swift @@ -0,0 +1,91 @@ +import AIModel +import ChatBasic +import Foundation +import OpenAIService + +public class RAGChatAgent: ChatAgent { + public let configuration: RAGChatAgentConfiguration + + public init(configuration: RAGChatAgentConfiguration) { + self.configuration = configuration + } + + public func send(_ request: Request) async -> AsyncThrowingStream { + let stream = AsyncThrowingStream { continuation in + let task = Task(priority: .userInitiated) { + do { + let service = try await createService(for: request) + let response = try await service.send(content: request.text, summary: nil) + for try await item in response { + if Task.isCancelled { + continuation.finish() + return + } + continuation.yield(.contentToken(item)) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + + return stream + } +} + +extension RAGChatAgent { + func createService(for request: Request) async throws -> LegacyChatGPTServiceType { + guard let chatGPTConfiguration = configuration.chatGPTConfiguration + else { throw CancellationError() } + let functionProvider = ChatFunctionProvider() + let memory = AutoManagedChatGPTMemory( + systemPrompt: configuration.modelConfiguration.systemPrompt, + configuration: chatGPTConfiguration, + functionProvider: functionProvider + ) + + await memory.mutateHistory { messages in + for history in request.history { + messages.append(history) + } + } + + return LegacyChatGPTService( + memory: memory, + configuration: chatGPTConfiguration, + functionProvider: functionProvider + ) + } + + var allCapabilities: [String: any RAGChatAgentCapability] { + RAGChatAgentCapabilityContainer.capabilities + } + + func capability(for identifier: String) -> (any RAGChatAgentCapability)? { + allCapabilities[identifier] + } +} + +final class ChatFunctionProvider: ChatGPTFunctionProvider { + var functions: [any ChatGPTFunction] = [] + + init() {} + + func removeAll() { + functions = [] + } + + func append(functions others: [any ChatGPTFunction]) { + functions.append(contentsOf: others) + } + + var functionCallStrategy: OpenAIService.FunctionCallStrategy? { + nil + } +} + diff --git a/Tool/Sources/RAGChatAgent/RAGChatAgentCapability.swift b/Tool/Sources/RAGChatAgent/RAGChatAgentCapability.swift new file mode 100644 index 00000000..519e3a45 --- /dev/null +++ b/Tool/Sources/RAGChatAgent/RAGChatAgentCapability.swift @@ -0,0 +1,59 @@ +import ChatBasic +import Foundation + +/// A singleton that stores all the possible capabilities of an ``RAGChatAgent``. +public enum RAGChatAgentCapabilityContainer { + static var capabilities: [String: any RAGChatAgentCapability] = [:] + static func add(_ capability: any RAGChatAgentCapability) { + capabilities[capability.id] = capability + } + + static func add(_ capabilities: [any RAGChatAgentCapability]) { + capabilities.forEach { add($0) } + } +} + +/// A protocol that defines the capability of an ``RAGChatAgent``. +protocol RAGChatAgentCapability: Identifiable { + typealias Request = ChatAgentRequest + typealias Reference = ChatAgentContext.Reference + + /// The name to be displayed to the user. + var name: String { get } + /// The identifier of the capability. + var id: String { get } + /// Fetch the context for a given request. It can return a portion of the context at a time. + func fetchContext(for request: ChatAgentRequest) async -> AsyncStream +} + +public struct ChatAgentContext { + public typealias Reference = ChatMessage.Reference + + /// Extra system prompt to be included in the chat request. + public var extraSystemPrompt: String? + /// References to be included in the chat request. + public var references: [Reference] + /// Functions to be included in the chat request. + public var functions: [any ChatGPTFunction] + + public init( + extraSystemPrompt: String? = nil, + references: [ChatMessage.Reference] = [], + functions: [any ChatGPTFunction] = [] + ) { + self.extraSystemPrompt = extraSystemPrompt + self.references = references + self.functions = functions + } +} + +// MARK: - Default Implementation + +extension RAGChatAgentCapability { + func fetchContext(for request: ChatAgentRequest) async -> AsyncStream { + return AsyncStream { continuation in + continuation.finish() + } + } +} + diff --git a/Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift b/Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift new file mode 100644 index 00000000..fc7123e6 --- /dev/null +++ b/Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift @@ -0,0 +1,119 @@ +import AIModel +import ChatBasic +import CodableWrappers +import Foundation +import OpenAIService +import Preferences +import Keychain + +public struct RAGChatAgentConfiguration: Codable { + public struct ModelConfiguration: Codable { + public var maxTokens: Int + public var minimumReplyTokens: Int + public var temperature: Double + public var systemPrompt: String + + public init( + maxTokens: Int, + minimumReplyTokens: Int, + temperature: Double, + systemPrompt: String + ) { + self.maxTokens = maxTokens + self.minimumReplyTokens = minimumReplyTokens + self.temperature = temperature + self.systemPrompt = systemPrompt + } + } + + public struct ConversationConfiguration: Codable { + public var maxTurns: Int + public var isConversationIsolated: Bool + public var respondInLanguage: String + + public init(maxTurns: Int, isConversationIsolated: Bool, respondInLanguage: String) { + self.maxTurns = maxTurns + self.isConversationIsolated = isConversationIsolated + self.respondInLanguage = respondInLanguage + } + } + + public enum ServiceProvider: Codable { + case chatModel(id: String) + case extensionService(id: String) + } + + public var id: String + public var name: String + public var serviceProvider: ServiceProvider + @FallbackDecoding + public var capabilityIds: Set + + public var modelConfiguration: ModelConfiguration + public var conversationConfiguration: ConversationConfiguration + var _otherConfigurations: Data + + public init( + id: String, + name: String, + serviceProvider: ServiceProvider, + capabilityIds: Set, + modelConfiguration: ModelConfiguration, + conversationConfiguration: ConversationConfiguration, + otherConfigurations: OtherConfiguration + ) throws { + self.id = id + self.name = name + self.serviceProvider = serviceProvider + self.capabilityIds = capabilityIds + self.modelConfiguration = modelConfiguration + self.conversationConfiguration = conversationConfiguration + _otherConfigurations = try JSONEncoder().encode(otherConfigurations) + } + + public func otherConfigurations( + as: Configuration.Type = Configuration.self + ) throws -> Configuration { + try JSONDecoder().decode(Configuration.self, from: _otherConfigurations) + } + + public mutating func setOtherConfigurations( + _ otherConfigurations: Configuration + ) throws { + _otherConfigurations = try JSONEncoder().encode(otherConfigurations) + } + + var chatGPTConfiguration: ChatGPTConfiguration? { + guard case let .chatModel(id) = serviceProvider else { return nil } + return .init( + model: { + let models = UserDefaults.shared.value(for: \.chatModels) + let id = UserDefaults.shared.value(for: \.defaultChatFeatureChatModelId) + return models.first { $0.id == id } + ?? models.first + }(), + temperature: modelConfiguration.temperature, + stop: [], + maxTokens: modelConfiguration.maxTokens, + minimumReplyTokens: modelConfiguration.minimumReplyTokens, + runFunctionsAutomatically: false, + shouldEndTextWindow: { _ in false } + ) + } + + struct ChatGPTConfiguration: OpenAIService.ChatGPTConfiguration { + var model: ChatModel? + var temperature: Double + var stop: [String] + var maxTokens: Int + var minimumReplyTokens: Int + var runFunctionsAutomatically: Bool + var shouldEndTextWindow: (String) -> Bool + + var apiKey: String { + guard let name = model?.info.apiKeyName else { return "" } + return (try? Keychain.apiKey.get(name)) ?? "" + } + } +} + diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift new file mode 100644 index 00000000..22c616f7 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -0,0 +1,555 @@ +import CodeDiff +import DebounceFunction +import Foundation +import Perception +import SwiftUI + +public struct AsyncCodeBlock: View { + @State var storage = Storage() + @Environment(\.colorScheme) var colorScheme + + /// If original code is provided, diff will be generated. + let originalCode: String? + /// The code to present. + let code: String + /// The language of the code. + let language: String + /// The index of the first line. + let startLineIndex: Int + /// The scenario of the code block. + let scenario: String + /// The font of the code block. + let font: NSFont + /// The default foreground color of the code block. + let proposedForegroundColor: Color? + /// The ranges to dim in the code. + let dimmedCharacterCount: DimmedCharacterCount + /// Whether to drop common leading spaces of each line. + let droppingLeadingSpaces: Bool + /// Whether to ignore whole line change in diff. + let ignoreWholeLineChangeInDiff: Bool + + public init( + code: String, + originalCode: String? = nil, + language: String, + startLineIndex: Int, + scenario: String, + font: NSFont, + droppingLeadingSpaces: Bool, + proposedForegroundColor: Color?, + dimmedCharacterCount: DimmedCharacterCount = .init(prefix: 0, suffix: 0), + ignoreWholeLineChangeInDiff: Bool = true + ) { + self.code = code + self.originalCode = originalCode + self.startLineIndex = startLineIndex + self.language = language + self.scenario = scenario + self.font = font + self.proposedForegroundColor = proposedForegroundColor + self.dimmedCharacterCount = dimmedCharacterCount + self.droppingLeadingSpaces = droppingLeadingSpaces + self.ignoreWholeLineChangeInDiff = ignoreWholeLineChangeInDiff + } + + var foregroundColor: Color { + proposedForegroundColor ?? (colorScheme == .dark ? .white : .black) + } + + public var body: some View { + WithPerceptionTracking { + let commonPrecedingSpaceCount = storage.highlightStorage.commonPrecedingSpaceCount + VStack(spacing: 2) { + ForEach(Array(storage.highlightedContent.enumerated()), id: \.0) { item in + let (index, attributedString) = item + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("\(index + startLineIndex + 1)") + .multilineTextAlignment(.trailing) + .foregroundColor(foregroundColor.opacity(0.5)) + .frame(minWidth: 40) + Text(AttributedString(attributedString)) + .foregroundColor(foregroundColor.opacity(0.3)) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .lineSpacing(4) + .overlay(alignment: .topLeading) { + if index == 0, commonPrecedingSpaceCount > 0 { + Text("\(commonPrecedingSpaceCount + 1)") + .padding(.top, -12) + .font(.footnote) + .foregroundStyle(foregroundColor) + .opacity(0.3) + } + } + } + } + } + .foregroundColor(.white) + .font(.init(font)) + .padding(.leading, 4) + .padding(.trailing) + .padding(.top, commonPrecedingSpaceCount > 0 ? 16 : 4) + .padding(.bottom, 4) + .onAppear { + storage.dimmedCharacterCount = dimmedCharacterCount + storage.ignoreWholeLineChangeInDiff = ignoreWholeLineChangeInDiff + storage.highlightStorage.highlight(debounce: false, for: self) + storage.diffStorage.diff(for: self) + } + .onChange(of: code) { code in + storage.code = code + storage.highlightStorage.highlight(debounce: true, for: self) + storage.diffStorage.diff(for: self) + } + .onChange(of: originalCode) { originalCode in + storage.originalCode = originalCode + storage.diffStorage.diff(for: self) + } + .onChange(of: colorScheme) { _ in + storage.highlightStorage.highlight(debounce: true, for: self) + } + .onChange(of: droppingLeadingSpaces) { _ in + storage.highlightStorage.highlight(debounce: true, for: self) + } + .onChange(of: scenario) { _ in + storage.highlightStorage.highlight(debounce: true, for: self) + } + .onChange(of: language) { _ in + storage.highlightStorage.highlight(debounce: true, for: self) + } + .onChange(of: proposedForegroundColor) { _ in + storage.highlightStorage.highlight(debounce: true, for: self) + } + .onChange(of: dimmedCharacterCount) { value in + storage.dimmedCharacterCount = value + } + .onChange(of: ignoreWholeLineChangeInDiff) { value in + storage.ignoreWholeLineChangeInDiff = value + } + } + } +} + +// MARK: - Storage + +extension AsyncCodeBlock { + nonisolated static let queue = DispatchQueue( + label: "code-block-highlight", + qos: .userInteractive, + attributes: .concurrent + ) + + public struct DimmedCharacterCount: Equatable { + public var prefix: Int + public var suffix: Int + public init(prefix: Int, suffix: Int) { + self.prefix = prefix + self.suffix = suffix + } + } + + @Perceptible + class Storage { + var dimmedCharacterCount: DimmedCharacterCount = .init(prefix: 0, suffix: 0) + let diffStorage = DiffStorage() + let highlightStorage = HighlightStorage() + var ignoreWholeLineChangeInDiff: Bool = true + + var code: String? { + get { highlightStorage.code } + set { + highlightStorage.code = newValue + diffStorage.code = newValue + } + } + + var originalCode: String? { + get { diffStorage.originalCode } + set { diffStorage.originalCode = newValue } + } + + var highlightedContent: [NSAttributedString] { + let commonPrecedingSpaceCount = highlightStorage.commonPrecedingSpaceCount + let highlightedCode = highlightStorage.highlightedCode + .map(NSMutableAttributedString.init(attributedString:)) + + Self.dim( + highlightedCode, + commonPrecedingSpaceCount: commonPrecedingSpaceCount, + dimmedCharacterCount: dimmedCharacterCount + ) + + if let diffResult = diffStorage.diffResult { + Self.presentDiff( + highlightedCode, + commonPrecedingSpaceCount: commonPrecedingSpaceCount, + ignoreWholeLineChange: ignoreWholeLineChangeInDiff, + diffResult: diffResult + ) + } + + return highlightedCode + } + + static func dim( + _ highlightedCode: [NSMutableAttributedString], + commonPrecedingSpaceCount: Int, + dimmedCharacterCount: DimmedCharacterCount + ) { + func dim( + _ line: NSMutableAttributedString, + in targetRange: Range, + opacity: Double + ) { + let targetRange = NSRange(targetRange, in: line.string) + line.enumerateAttribute( + .foregroundColor, + in: NSRange(location: 0, length: line.length) + ) { value, range, _ in + guard let color = value as? NSColor else { return } + let opacity = max(0.1, color.alphaComponent * opacity) + let intersection = NSIntersectionRange(targetRange, range) + guard !(intersection.length == 0) else { return } + let rangeA = intersection + line.addAttribute( + .foregroundColor, + value: color.withAlphaComponent(opacity), + range: rangeA + ) + + let rangeB = NSRange( + location: intersection.upperBound, + length: range.upperBound - intersection.upperBound + ) + line.addAttribute( + .foregroundColor, + value: color, + range: rangeB + ) + } + } + + if dimmedCharacterCount.prefix > commonPrecedingSpaceCount, + let firstLine = highlightedCode.first + { + let dimmedCount = dimmedCharacterCount.prefix - commonPrecedingSpaceCount + let startIndex = firstLine.string.startIndex + let endIndex = firstLine.string.utf16.index( + startIndex, + offsetBy: min(firstLine.length, max(0, dimmedCount)), + limitedBy: firstLine.string.endIndex + ) ?? firstLine.string.endIndex + if endIndex > startIndex { + dim(firstLine, in: startIndex.. mutableString.length { + continue + } + mutableString.addAttributes([ + .backgroundColor: NSColor.systemGreen.withAlphaComponent(0.2), + ], range: range) + } + } + } else if let firstMutableString = highlightedCode.first, + let oldLine = diffResult.line(at: 0, in: \.oldSnippet), + oldLine.text.count > commonPrecedingSpaceCount + { + // Only highlight the diffs inside the dimmed area + let scopeRange = NSRange( + location: 0, + length: min( + oldLine.text.count - commonPrecedingSpaceCount, + firstMutableString.length + ) + ) + if let line = diffResult.line(at: 0, in: \.newSnippet), + case let .mutated(changes) = line.diff, !changes.isEmpty + { + for change in changes { + let offset = change.offset - commonPrecedingSpaceCount + let range = NSRange( + location: max(0, offset), + length: max(0, change.element.count + (offset < 0 ? offset : 0)) + ) + guard let limitedRange = limitRange(range, inside: scopeRange) + else { continue } + firstMutableString.addAttributes([ + .backgroundColor: NSColor.systemGreen.withAlphaComponent(0.2), + ], range: limitedRange) + } + } + } + + let lastLineIndex = highlightedCode.endIndex - 1 + if lastLineIndex >= 0 { + if let line = diffResult.line(at: lastLineIndex, in: \.oldSnippet), + case let .mutated(changes) = line.diff, + changes.count == 1, + let change = changes.last, + change.offset + change.element.count == line.text.count + { + let lastLine = highlightedCode[lastLineIndex] + lastLine.append(.init(string: String(change.element), attributes: [ + .foregroundColor: NSColor.systemRed.withAlphaComponent(0.5), + .backgroundColor: NSColor.systemRed.withAlphaComponent(0.2), + ])) + } + } + } + } + + @Perceptible + class DiffStorage { + private(set) var diffResult: CodeDiff.SnippetDiff? + + @PerceptionIgnored var originalCode: String? + @PerceptionIgnored var code: String? + @PerceptionIgnored private var diffTask: Task? + + func diff(for view: AsyncCodeBlock) { + performDiff(for: view) + } + + private func performDiff(for view: AsyncCodeBlock) { + diffTask?.cancel() + let code = code ?? view.code + guard let originalCode = originalCode ?? view.originalCode else { + diffResult = nil + return + } + + diffTask = Task { + let result = await withUnsafeContinuation { continuation in + AsyncCodeBlock.queue.async { + let result = CodeDiff().diff(snippet: code, from: originalCode) + continuation.resume(returning: result) + } + } + try Task.checkCancellation() + await MainActor.run { + diffResult = result + } + } + } + } + + @Perceptible + class HighlightStorage { + private(set) var highlightedCode = [NSAttributedString]() + private(set) var commonPrecedingSpaceCount = 0 + + @PerceptionIgnored var code: String? + @PerceptionIgnored private var foregroundColor: Color = .primary + @PerceptionIgnored private var debounceFunction: DebounceFunction? + @PerceptionIgnored private var highlightTask: Task? + + init() { + debounceFunction = .init(duration: 0.1, block: { view in + self.highlight(for: view) + }) + } + + func highlight(debounce: Bool, for view: AsyncCodeBlock) { + if debounce { + Task { @MainActor in await debounceFunction?(view) } + } else { + highlight(for: view) + } + } + + private func highlight(for view: AsyncCodeBlock) { + highlightTask?.cancel() + let code = self.code ?? view.code + let language = view.language + let scenario = view.scenario + let brightMode = view.colorScheme != .dark + let droppingLeadingSpaces = view.droppingLeadingSpaces + let font = CodeHighlighting.SendableFont(font: view.font) + foregroundColor = view.foregroundColor + + if highlightedCode.isEmpty { + let content = CodeHighlighting.convertToCodeLines( + .init(string: code), + middleDotColor: brightMode + ? NSColor.black.withAlphaComponent(0.1) + : NSColor.white.withAlphaComponent(0.1), + droppingLeadingSpaces: droppingLeadingSpaces, + replaceSpacesWithMiddleDots: true + ) + highlightedCode = content.code + commonPrecedingSpaceCount = content.commonLeadingSpaceCount + } + + highlightTask = Task { + let result = await withUnsafeContinuation { continuation in + AsyncCodeBlock.queue.async { + let content = CodeHighlighting.highlighted( + code: code, + language: language, + scenario: scenario, + brightMode: brightMode, + droppingLeadingSpaces: droppingLeadingSpaces, + font: font + ) + continuation.resume(returning: content) + } + } + try Task.checkCancellation() + await MainActor.run { + self.highlightedCode = result.0 + self.commonPrecedingSpaceCount = result.1 + } + } + } + } + + static func limitRange(_ nsRange: NSRange, inside another: NSRange) -> NSRange? { + let intersection = NSIntersectionRange(nsRange, another) + guard intersection.length > 0 else { return nil } + return intersection + } +} + +#Preview("Single Line Suggestion") { + AsyncCodeBlock( + code: " let foo = Bar()", + originalCode: " var foo // comment", + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary, + dimmedCharacterCount: .init(prefix: 11, suffix: 0) + ) + .frame(width: 400, height: 100) +} + +#Preview("Single Line Suggestion / Appending Suffix") { + AsyncCodeBlock( + code: " let foo = Bar() // comment", + originalCode: " var foo // comment", + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary, + dimmedCharacterCount: .init(prefix: 11, suffix: 11) + ) + .frame(width: 400, height: 100) +} + +#Preview("Multiple Line Suggestion") { + AsyncCodeBlock( + code: " let foo = Bar()\n print(foo)", + originalCode: " var foo // comment\n print(bar)", + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary, + dimmedCharacterCount: .init(prefix: 11, suffix: 0) + ) + .frame(width: 400, height: 100) +} + +#Preview("Multiple Line Suggestion Including Whole Line Change in Diff") { + AsyncCodeBlock( + code: "// comment\n let foo = Bar()\n print(bar)\n print(foo)", + originalCode: " let foo = Bar()\n", + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary, + dimmedCharacterCount: .init(prefix: 11, suffix: 0), + ignoreWholeLineChangeInDiff: false + ) + .frame(width: 400, height: 100) +} + +#Preview("Updating Content") { + struct UpdateContent: View { + @State var index = 0 + struct Case { + let code: String + let originalCode: String + } + + let cases: [Case] = [ + .init(code: "foo(123)\nprint(foo)", originalCode: "bar(234)\nprint(bar)"), + .init(code: "bar(456)", originalCode: "baz(567)"), + ] + + var body: some View { + VStack { + Button("Update") { + index = (index + 1) % cases.count + } + AsyncCodeBlock( + code: cases[index].code, + originalCode: cases[index].originalCode, + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary, + dimmedCharacterCount: .init(prefix: 0, suffix: 0) + ) + } + } + } + + return UpdateContent() + .frame(width: 400, height: 200) +} + diff --git a/Tool/Sources/SharedUIComponents/AsyncDiffCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncDiffCodeBlock.swift new file mode 100644 index 00000000..537ead34 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/AsyncDiffCodeBlock.swift @@ -0,0 +1,508 @@ +import CodeDiff +import DebounceFunction +import Foundation +import Perception +import SwiftUI + +public struct AsyncDiffCodeBlock: View { + @State var storage = Storage() + @Environment(\.colorScheme) var colorScheme + + /// If original code is provided, diff will be generated. + let originalCode: String? + /// The code to present. + let code: String + /// The language of the code. + let language: String + /// The index of the first line. + let startLineIndex: Int + /// The scenario of the code block. + let scenario: String + /// The font of the code block. + let font: NSFont + /// The default foreground color of the code block. + let proposedForegroundColor: Color? + /// Whether to drop common leading spaces of each line. + let droppingLeadingSpaces: Bool + /// Whether to render the last diff section that only contains removals. + let skipLastOnlyRemovalSection: Bool + + public init( + code: String, + originalCode: String? = nil, + language: String, + startLineIndex: Int, + scenario: String, + font: NSFont, + droppingLeadingSpaces: Bool, + proposedForegroundColor: Color?, + ignoreWholeLineChangeInDiff: Bool = true, + skipLastOnlyRemovalSection: Bool = false + ) { + self.code = code + self.originalCode = originalCode + self.startLineIndex = startLineIndex + self.language = language + self.scenario = scenario + self.font = font + self.proposedForegroundColor = proposedForegroundColor + self.droppingLeadingSpaces = droppingLeadingSpaces + self.skipLastOnlyRemovalSection = skipLastOnlyRemovalSection + } + + var foregroundColor: Color { + proposedForegroundColor ?? (colorScheme == .dark ? .white : .black) + } + + public var body: some View { + WithPerceptionTracking { + let commonPrecedingSpaceCount = storage.highlightStorage.commonPrecedingSpaceCount + VStack(spacing: 0) { + lines + } + .foregroundColor(.white) + .font(.init(font)) + .padding(.leading, 4) + .padding(.trailing) + .padding(.top, commonPrecedingSpaceCount > 0 ? 16 : 4) + .padding(.bottom, 4) + .onAppear { + storage.highlightStorage.highlight(debounce: false, for: self) + storage.diffStorage.diff(for: self) + } + .onChange(of: code) { code in + storage.code = code + storage.highlightStorage.highlight(debounce: true, for: self) + storage.diffStorage.diff(for: self) + } + .onChange(of: originalCode) { originalCode in + storage.originalCode = originalCode + storage.diffStorage.diff(for: self) + } + .onChange(of: colorScheme) { _ in + storage.highlightStorage.highlight(debounce: true, for: self) + } + .onChange(of: droppingLeadingSpaces) { _ in + storage.highlightStorage.highlight(debounce: true, for: self) + } + .onChange(of: scenario) { _ in + storage.highlightStorage.highlight(debounce: true, for: self) + } + .onChange(of: language) { _ in + storage.highlightStorage.highlight(debounce: true, for: self) + } + .onChange(of: proposedForegroundColor) { _ in + storage.highlightStorage.highlight(debounce: true, for: self) + } + .onChange(of: skipLastOnlyRemovalSection) { _ in + storage.skipLastOnlyRemovalSection = skipLastOnlyRemovalSection + } + } + } + + @ViewBuilder + var lines: some View { + WithPerceptionTracking { + let commonPrecedingSpaceCount = storage.highlightStorage.commonPrecedingSpaceCount + ForEach(storage.highlightedContent) { line in + LineView( + isFirstLine: line.id == storage.highlightedContent.first?.id, + commonPrecedingSpaceCount: commonPrecedingSpaceCount, + line: line, + startLineIndex: startLineIndex, + foregroundColor: foregroundColor + ) + } + } + } + + struct LineView: View { + let isFirstLine: Bool + let commonPrecedingSpaceCount: Int + let line: Storage.Line + let startLineIndex: Int + let foregroundColor: Color + + var body: some View { + let attributedString = line.string + let lineIndex = line.index + startLineIndex + 1 + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("\(lineIndex)") + .multilineTextAlignment(.trailing) + .foregroundColor(foregroundColor.opacity(0.5)) + .frame(minWidth: 40) + Text(AttributedString(attributedString)) + .foregroundColor(foregroundColor.opacity(0.3)) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .lineSpacing(4) + .overlay(alignment: .topLeading) { + if isFirstLine, commonPrecedingSpaceCount > 0 { + Text("\(commonPrecedingSpaceCount + 1)") + .padding(.top, -12) + .font(.footnote) + .foregroundStyle(foregroundColor) + .opacity(0.3) + } + } + } + .padding(.vertical, 1) + .background( + line.kind == .added ? Color.green.opacity(0.2) : line + .kind == .deleted ? Color.red.opacity(0.2) : nil + ) + } + } +} + +// MARK: - Storage + +extension AsyncDiffCodeBlock { + nonisolated static let queue = DispatchQueue( + label: "code-block-highlight", + qos: .userInteractive, + attributes: .concurrent + ) + + public struct DimmedCharacterCount: Equatable { + public var prefix: Int + public var suffix: Int + public init(prefix: Int, suffix: Int) { + self.prefix = prefix + self.suffix = suffix + } + } + + @Perceptible + class Storage { + let diffStorage = DiffStorage() + let highlightStorage = HighlightStorage() + var skipLastOnlyRemovalSection: Bool = false + + var code: String? { + get { highlightStorage.code } + set { + highlightStorage.code = newValue + diffStorage.code = newValue + } + } + + var originalCode: String? { + get { diffStorage.originalCode } + set { diffStorage.originalCode = newValue } + } + + struct Line: Identifiable { + enum Kind { + case added + case deleted + case unchanged + } + + let index: Int + let kind: Kind + let string: NSAttributedString + + var id: String { "\(index)-\(kind)-\(string.string)" } + } + + var highlightedContent: [Line] { + let commonPrecedingSpaceCount = highlightStorage.commonPrecedingSpaceCount + let highlightedCode = highlightStorage.highlightedCode + let highlightedOriginalCode = highlightStorage.highlightedOriginalCode + + if let diffResult = diffStorage.diffResult { + return Self.presentDiff( + new: highlightedCode, + original: highlightedOriginalCode, + commonPrecedingSpaceCount: commonPrecedingSpaceCount, + skipLastOnlyRemovalSection: skipLastOnlyRemovalSection, + diffResult: diffResult + ) + } + + return highlightedCode.enumerated().map { + Line(index: $0, kind: .unchanged, string: $1) + } + } + + static func presentDiff( + new highlightedCode: [NSAttributedString], + original originalHighlightedCode: [NSAttributedString], + commonPrecedingSpaceCount: Int, + skipLastOnlyRemovalSection: Bool, + diffResult: CodeDiff.SnippetDiff + ) -> [Line] { + var lines = [Line]() + + for (index, section) in diffResult.sections.enumerated() { + guard !section.isEmpty else { continue } + + if skipLastOnlyRemovalSection, + index == diffResult.sections.count - 1, + section.newSnippet.isEmpty + { + continue + } + + for (index, line) in section.oldSnippet.enumerated() { + if line.diff == .unchanged { continue } + let lineIndex = section.oldOffset + index + if lineIndex >= 0, lineIndex < originalHighlightedCode.count { + let oldLine = originalHighlightedCode[lineIndex] + lines.append(Line(index: lineIndex, kind: .deleted, string: oldLine)) + } + } + + for (index, line) in section.newSnippet.enumerated() { + let lineIndex = section.newOffset + index + guard lineIndex >= 0, lineIndex < highlightedCode.count else { continue } + if line.diff == .unchanged { + let newLine = highlightedCode[lineIndex] + lines.append(Line(index: lineIndex, kind: .unchanged, string: newLine)) + } else { + let newLine = highlightedCode[lineIndex] + lines.append(Line(index: lineIndex, kind: .added, string: newLine)) + } + } + } + + return lines + } + } + + @Perceptible + class DiffStorage { + private(set) var diffResult: CodeDiff.SnippetDiff? + + @PerceptionIgnored var originalCode: String? + @PerceptionIgnored var code: String? + @PerceptionIgnored private var diffTask: Task? + + func diff(for view: AsyncDiffCodeBlock) { + performDiff(for: view) + } + + private func performDiff(for view: AsyncDiffCodeBlock) { + diffTask?.cancel() + let code = code ?? view.code + guard let originalCode = originalCode ?? view.originalCode else { + diffResult = nil + return + } + + diffTask = Task { + let result = await withUnsafeContinuation { continuation in + AsyncCodeBlock.queue.async { + let result = CodeDiff().diff(snippet: code, from: originalCode) + continuation.resume(returning: result) + } + } + try Task.checkCancellation() + await MainActor.run { + diffResult = result + } + } + } + } + + @Perceptible + class HighlightStorage { + private(set) var highlightedOriginalCode = [NSAttributedString]() + private(set) var highlightedCode = [NSAttributedString]() + private(set) var commonPrecedingSpaceCount = 0 + + @PerceptionIgnored var code: String? + @PerceptionIgnored var originalCode: String? + @PerceptionIgnored private var foregroundColor: Color = .primary + @PerceptionIgnored private var debounceFunction: DebounceFunction? + @PerceptionIgnored private var highlightTask: Task? + + init() { + debounceFunction = .init(duration: 0.1, block: { view in + self.highlight(for: view) + }) + } + + func highlight(debounce: Bool, for view: AsyncDiffCodeBlock) { + if debounce { + Task { @MainActor in await debounceFunction?(view) } + } else { + highlight(for: view) + } + } + + private func highlight(for view: AsyncDiffCodeBlock) { + highlightTask?.cancel() + let code = self.code ?? view.code + let originalCode = self.originalCode ?? view.originalCode + let language = view.language + let scenario = view.scenario + let brightMode = view.colorScheme != .dark + let droppingLeadingSpaces = view.droppingLeadingSpaces + let font = CodeHighlighting.SendableFont(font: view.font) + foregroundColor = view.foregroundColor + + if highlightedCode.isEmpty { + let content = CodeHighlighting.convertToCodeLines( + [.init(string: code), .init(string: originalCode ?? "")], + middleDotColor: brightMode + ? NSColor.black.withAlphaComponent(0.1) + : NSColor.white.withAlphaComponent(0.1), + droppingLeadingSpaces: droppingLeadingSpaces, + replaceSpacesWithMiddleDots: true + ) + highlightedCode = content.code[0] + highlightedOriginalCode = content.code[1] + commonPrecedingSpaceCount = content.commonLeadingSpaceCount + } + + highlightTask = Task { + let result = await withUnsafeContinuation { continuation in + AsyncCodeBlock.queue.async { + let content = CodeHighlighting.highlighted( + code: [code, originalCode ?? ""], + language: language, + scenario: scenario, + brightMode: brightMode, + droppingLeadingSpaces: droppingLeadingSpaces, + font: font + ) + continuation.resume(returning: content) + } + } + try Task.checkCancellation() + await MainActor.run { + self.highlightedCode = result.0[0] + self.highlightedOriginalCode = result.0[1] + self.commonPrecedingSpaceCount = result.1 + } + } + } + } + + static func limitRange(_ nsRange: NSRange, inside another: NSRange) -> NSRange? { + let intersection = NSIntersectionRange(nsRange, another) + guard intersection.length > 0 else { return nil } + return intersection + } +} + +#Preview("Single Line Suggestion") { + AsyncDiffCodeBlock( + code: " let foo = Bar()", + originalCode: " var foo // comment", + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary + ) + .frame(width: 400, height: 100) +} + +#Preview("Multiple Line Suggestion") { + AsyncDiffCodeBlock( + code: " let foo = Bar()\n print(foo)\n print(a)", + originalCode: " var foo // comment\n print(bar)\n print(a)", + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary + ) + .frame(width: 400, height: 100) +} + +#Preview("Multiple Line Suggestion Including Whole Line Change in Diff") { + AsyncDiffCodeBlock( + code: "// comment\n let foo = Bar()\n print(bar)\n print(foo)\n", + originalCode: " let foo = Bar()\n", + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary + ) + .frame(width: 400, height: 100) +} + +#Preview("Updating Content") { + struct UpdateContent: View { + @State var index = 0 + struct Case { + let code: String + let originalCode: String + } + + let cases: [Case] = [ + .init(code: "foo(123)\nprint(foo)", originalCode: "bar(234)\nprint(bar)"), + .init(code: "bar(456)", originalCode: "baz(567)"), + ] + + var body: some View { + VStack { + Button("Update") { + index = (index + 1) % cases.count + } + AsyncDiffCodeBlock( + code: cases[index].code, + originalCode: cases[index].originalCode, + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary + ) + } + } + } + + return UpdateContent() + .frame(width: 400, height: 200) +} + +#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()) + ScrollView { + AsyncDiffCodeBlock( + code: newCode, + originalCode: originalCode, + language: "swift", + startLineIndex: 0, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary + ) + } + } + .padding() + .frame(height: 600) + } + } + + return V() +} diff --git a/Tool/Sources/SharedUIComponents/CodeBlock.swift b/Tool/Sources/SharedUIComponents/CodeBlock.swift index 5d9884ca..86cb4fde 100644 --- a/Tool/Sources/SharedUIComponents/CodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/CodeBlock.swift @@ -13,6 +13,7 @@ public struct CodeBlock: View { public let font: NSFont public let droppingLeadingSpaces: Bool public let proposedForegroundColor: Color? + public let wrapCode: Bool public init( code: String, @@ -23,7 +24,8 @@ public struct CodeBlock: View { firstLinePrecedingSpaceCount: Int = 0, font: NSFont, droppingLeadingSpaces: Bool, - proposedForegroundColor: Color? + proposedForegroundColor: Color?, + wrapCode: Bool = true ) { self.code = code self.language = language @@ -34,6 +36,7 @@ public struct CodeBlock: View { self.firstLinePrecedingSpaceCount = firstLinePrecedingSpaceCount self.font = font self.proposedForegroundColor = proposedForegroundColor + self.wrapCode = wrapCode let padding = firstLinePrecedingSpaceCount > 0 ? String(repeating: " ", count: firstLinePrecedingSpaceCount) : "" @@ -81,7 +84,9 @@ public struct CodeBlock: View { .foregroundColor(.white) .font(.init(font)) .padding(.leading, 4) - .padding([.trailing, .top, .bottom]) + .padding(.trailing) + .padding(.top, commonPrecedingSpaceCount > 0 ? 16 : 4) + .padding(.bottom, 4) } static func highlight( @@ -92,7 +97,7 @@ public struct CodeBlock: View { font: NSFont, droppingLeadingSpaces: Bool ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { - return highlighted( + return CodeHighlighting.highlighted( code: code, language: language, scenario: scenario, diff --git a/Tool/Sources/SharedUIComponents/CopyButton.swift b/Tool/Sources/SharedUIComponents/CopyButton.swift index 022e84df..f5be3807 100644 --- a/Tool/Sources/SharedUIComponents/CopyButton.swift +++ b/Tool/Sources/SharedUIComponents/CopyButton.swift @@ -4,36 +4,60 @@ import SwiftUI public struct CopyButton: View { public var copy: () -> Void @State var isCopied = false - + public init(copy: @escaping () -> Void) { self.copy = copy } - + public 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.fill" : "doc.on.doc.fill") + .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) + ) + .background { + RoundedRectangle(cornerRadius: 4, style: .circular) + .fill(Color.primary.opacity(0.1)) } - }) { - 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) + .padding(4) + .simultaneousGesture( + TapGesture() + .onEnded { _ in + 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 + } + } + } + ) + } +} + +public struct DraggableCopyButton: View { + public var content: () -> String + + public init(content: @escaping () -> String) { + self.content = content + } + + public var body: some View { + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(content(), forType: .string) + } + .onDrag { + NSItemProvider(object: content() as NSString) } - .buttonStyle(.borderless) } } + diff --git a/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift b/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift deleted file mode 100644 index b455b9a0..00000000 --- a/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift +++ /dev/null @@ -1,302 +0,0 @@ -import STTextView -import SwiftUI - -private let insetBottom = 12 as Double -private let insetTop = 12 as Double - -/// This SwiftUI view can be used to view and edit rich text. -struct _CodeBlock: View { - @Binding private var selection: NSRange? - @State private var contentHeight: Double = 500 - let font: NSFont - let commonPrecedingSpaceCount: Int - let highlightedCode: AttributedString - let colorScheme: ColorScheme - let droppingLeadingSpaces: Bool - let scenario: String - - /// Create a text edit view with a certain text that uses a certain options. - /// - Parameters: - /// - text: The attributed string content - /// - options: Editor options - /// - plugins: Editor plugins - public init( - code: String, - language: String, - firstLinePrecedingSpaceCount: Int, - scenario: String, - colorScheme: ColorScheme, - font: NSFont, - droppingLeadingSpaces: Bool, - selection: Binding = .constant(nil) - ) { - _selection = selection - self.font = font - self.colorScheme = colorScheme - self.droppingLeadingSpaces = droppingLeadingSpaces - self.scenario = scenario - - let padding = firstLinePrecedingSpaceCount > 0 - ? String(repeating: " ", count: firstLinePrecedingSpaceCount) - : "" - let result = Self.highlight( - code: padding + code, - language: language, - scenario: scenario, - colorScheme: colorScheme, - font: font, - droppingLeadingSpaces: droppingLeadingSpaces - ) - commonPrecedingSpaceCount = result.commonLeadingSpaceCount - highlightedCode = result.code - } - - public var body: some View { - _CodeBlockRepresentable( - text: highlightedCode, - selection: $selection, - font: font, - onHeightChange: { height in - print("Q", height) - contentHeight = height - } - ) - .frame(height: contentHeight, alignment: .topLeading) - .background(.background) - .colorScheme(colorScheme) - .onAppear { - print("") - } - } - - static func highlight( - code: String, - language: String, - scenario: String, - colorScheme: ColorScheme, - font: NSFont, - droppingLeadingSpaces: Bool - ) -> (code: AttributedString, commonLeadingSpaceCount: Int) { - let (lines, commonLeadingSpaceCount) = highlighted( - code: code, - language: language, - scenario: scenario, - brightMode: colorScheme != .dark, - droppingLeadingSpaces: droppingLeadingSpaces, - font: font, - replaceSpacesWithMiddleDots: false - ) - - let string = NSMutableAttributedString() - for (index, line) in lines.enumerated() { - string.append(line) - if index < lines.count - 1 { - string.append(NSAttributedString(string: "\n")) - } - } - - return (code: .init(string), commonLeadingSpaceCount: commonLeadingSpaceCount) - } -} - -private struct _CodeBlockRepresentable: NSViewRepresentable { - @Environment(\.isEnabled) private var isEnabled - @Environment(\.lineSpacing) private var lineSpacing - - @Binding private var selection: NSRange? - let text: AttributedString - let font: NSFont - let onHeightChange: (Double) -> Void - - init( - text: AttributedString, - selection: Binding, - font: NSFont, - onHeightChange: @escaping (Double) -> Void - ) { - self.text = text - _selection = selection - self.font = font - self.onHeightChange = onHeightChange - } - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = STTextViewFrameObservable.scrollableTextView() - scrollView.contentInsets = .init(top: 0, left: 0, bottom: insetBottom, right: 0) - scrollView.automaticallyAdjustsContentInsets = false - let textView = scrollView.documentView as! STTextView - textView.delegate = context.coordinator - textView.highlightSelectedLine = false - textView.widthTracksTextView = true - textView.heightTracksTextView = true - textView.isEditable = true - - textView.setSelectedRange(NSRange()) - let lineNumberRuler = STLineNumberRulerView(textView: textView) - lineNumberRuler.backgroundColor = .clear - lineNumberRuler.separatorColor = .clear - lineNumberRuler.rulerInsets = .init(leading: 10, trailing: 10) - scrollView.verticalRulerView = lineNumberRuler - let columnNumberRuler = ColumnRuler(textView: textView) - scrollView.horizontalRulerView = columnNumberRuler - scrollView.rulersVisible = true - - context.coordinator.isUpdating = true - textView.setAttributedString(NSAttributedString(text)) - context.coordinator.isUpdating = false - - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - context.coordinator.parent = self - - let textView = scrollView.documentView as! STTextViewFrameObservable - - textView.onHeightChange = onHeightChange - textView.showsInvisibleCharacters = true - textView.textContainer.lineBreakMode = .byCharWrapping - - if let columnNumberRuler = scrollView.horizontalRulerView as? ColumnRuler { - columnNumberRuler.columnNumber = 5 - } - - do { - context.coordinator.isUpdating = true - if context.coordinator.isDidChangeText == false { - textView.setAttributedString(.init(text)) - } - context.coordinator.isUpdating = false - context.coordinator.isDidChangeText = false - } - - if textView.selectedRange() != selection, let selection { - textView.setSelectedRange(selection) - } - - if textView.isSelectable != isEnabled { - textView.isSelectable = isEnabled - } - - textView.isEditable = false - - if !textView.widthTracksTextView { - textView.widthTracksTextView = false - } - - if !textView.heightTracksTextView { - textView.heightTracksTextView = true - } - - if textView.font != font { - textView.font = font - } - } - - func makeCoordinator() -> TextCoordinator { - TextCoordinator(parent: self) - } - - private func styledAttributedString(_ typingAttributes: [NSAttributedString.Key: Any]) - -> AttributedString - { - let paragraph = (typingAttributes[.paragraphStyle] as! NSParagraphStyle) - .mutableCopy() as! NSMutableParagraphStyle - if paragraph.lineSpacing != lineSpacing { - paragraph.lineSpacing = lineSpacing - var typingAttributes = typingAttributes - typingAttributes[.paragraphStyle] = paragraph - - let attributeContainer = AttributeContainer(typingAttributes) - var styledText = text - styledText.mergeAttributes(attributeContainer, mergePolicy: .keepNew) - return styledText - } - - return text - } - - class TextCoordinator: STTextViewDelegate { - var parent: _CodeBlockRepresentable - var isUpdating: Bool = false - var isDidChangeText: Bool = false - var enqueuedValue: AttributedString? - - init(parent: _CodeBlockRepresentable) { - self.parent = parent - } - - func textViewDidChangeText(_ notification: Notification) { - guard let textView = notification.object as? STTextView else { - return - } - - (textView as! STTextViewFrameObservable).recalculateSize() - } - - func textViewDidChangeSelection(_ notification: Notification) { - guard let textView = notification.object as? STTextView else { - return - } - - Task { @MainActor in - self.parent.selection = textView.selectedRange() - } - } - } -} - -private class STTextViewFrameObservable: STTextView { - var onHeightChange: ((Double) -> Void)? - func recalculateSize() { - var maxY = 0 as Double - textLayoutManager.enumerateTextLayoutFragments( - in: textLayoutManager.documentRange, - options: [.ensuresLayout] - ) { fragment in - print(fragment.layoutFragmentFrame) - maxY = max(maxY, fragment.layoutFragmentFrame.maxY) - return true - } - onHeightChange?(maxY) - } -} - -private final class ColumnRuler: NSRulerView { - var columnNumber: Int = 0 - - private var textView: STTextView? { - clientView as? STTextView - } - - public required init(textView: STTextView, scrollView: NSScrollView? = nil) { - super.init( - scrollView: scrollView ?? textView.enclosingScrollView, - orientation: .verticalRuler - ) - clientView = textView - ruleThickness = insetBottom - } - - @available(*, unavailable) - required init(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func draw(_: NSRect) { - guard let context: CGContext = NSGraphicsContext.current?.cgContext else { return } - NSColor.windowBackgroundColor.withAlphaComponent(0.6).setFill() - context.fill(bounds) - - let insetLeft = scrollView?.verticalRulerView?.bounds.width ?? 0 - var drawingBounds = bounds - drawingBounds.origin.x += insetLeft + 4 - let fontSize = 10 as Double - drawingBounds.origin.y = (insetTop - fontSize) / 2 - NSString(string: "\(columnNumber)").draw(in: drawingBounds, withAttributes: [ - .font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular), - .foregroundColor: NSColor.tertiaryLabelColor, - ]) - } -} - diff --git a/Tool/Sources/SharedUIComponents/ModifierFlagsMonitor.swift b/Tool/Sources/SharedUIComponents/ModifierFlagsMonitor.swift new file mode 100644 index 00000000..1ae82b7f --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ModifierFlagsMonitor.swift @@ -0,0 +1,55 @@ +import Cocoa +import Foundation +import SwiftUI + +public extension View { + func modifierFlagsMonitor() -> some View { + ModifierFlagsMonitorWrapper { self } + } +} + +public extension EnvironmentValues { + var modifierFlags: NSEvent.ModifierFlags { + get { self[ModifierFlagsEnvironmentKey.self] } + set { self[ModifierFlagsEnvironmentKey.self] = newValue } + } +} + +final class ModifierFlagsMonitor { + private var monitor: Any? + + deinit { stop() } + + func start(binding: Binding) { + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged]) { event in + binding.wrappedValue = event.modifierFlags + return event + } + } + + func stop() { + if let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + } +} + +struct ModifierFlagsMonitorWrapper: View { + @ViewBuilder let content: () -> Content + @State private var modifierFlags: NSEvent.ModifierFlags = [] + @State private var eventMonitor = ModifierFlagsMonitor() + + var body: some View { + content() + .environment(\.modifierFlags, modifierFlags) + .onAppear { eventMonitor.start(binding: $modifierFlags) } + .onDisappear { eventMonitor.stop() } + } +} + +struct ModifierFlagsEnvironmentKey: EnvironmentKey { + static let defaultValue: NSEvent.ModifierFlags = [] +} + diff --git a/Core/Sources/HostApp/SharedComponents/SubSection.swift b/Tool/Sources/SharedUIComponents/SubSection.swift similarity index 82% rename from Core/Sources/HostApp/SharedComponents/SubSection.swift rename to Tool/Sources/SharedUIComponents/SubSection.swift index b294e3e4..4fc78274 100644 --- a/Core/Sources/HostApp/SharedComponents/SubSection.swift +++ b/Tool/Sources/SharedUIComponents/SubSection.swift @@ -1,17 +1,17 @@ import SwiftUI -struct SubSection: View { - let title: Title - let description: Description - @ViewBuilder let content: () -> Content +public struct SubSection: View { + public let title: Title + public let description: Description + @ViewBuilder public let content: () -> Content - init(title: Title, description: Description, @ViewBuilder content: @escaping () -> Content) { + public init(title: Title, description: Description, @ViewBuilder content: @escaping () -> Content) { self.title = title self.description = description self.content = content } - var body: some View { + public var body: some View { VStack(alignment: .leading) { if !(title is EmptyView && description is EmptyView) { VStack(alignment: .leading, spacing: 8) { @@ -43,31 +43,31 @@ struct SubSection: View { } } -extension SubSection where Description == Text { +public extension SubSection where Description == Text { init(title: Title, description: String, @ViewBuilder content: @escaping () -> Content) { self.init(title: title, description: Text(description), content: content) } } -extension SubSection where Description == EmptyView { +public extension SubSection where Description == EmptyView { init(title: Title, @ViewBuilder content: @escaping () -> Content) { self.init(title: title, description: EmptyView(), content: content) } } -extension SubSection where Title == EmptyView { +public extension SubSection where Title == EmptyView { init(description: Description, @ViewBuilder content: @escaping () -> Content) { self.init(title: EmptyView(), description: description, content: content) } } -extension SubSection where Title == EmptyView, Description == EmptyView { +public extension SubSection where Title == EmptyView, Description == EmptyView { init(@ViewBuilder content: @escaping () -> Content) { self.init(title: EmptyView(), description: EmptyView(), content: content) } } -extension SubSection where Title == EmptyView, Description == Text { +public extension SubSection where Title == EmptyView, Description == Text { init(description: String, @ViewBuilder content: @escaping () -> Content) { self.init(title: EmptyView(), description: description, content: content) } diff --git a/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift index b6dd0c02..2e99b1c7 100644 --- a/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift +++ b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift @@ -1,157 +1,313 @@ import AppKit import Foundation import Highlightr -import SuggestionModel +import SuggestionBasic import SwiftUI -public func highlightedCodeBlock( - code: String, - language: String, - scenario: String, - brightMode: Bool, - font: NSFont -) -> NSAttributedString { - var language = language - // Workaround: Highlightr uses a different identifier for Objective-C. - if language.lowercased().hasPrefix("objective"), language.lowercased().hasSuffix("c") { - language = "objectivec" +public enum CodeHighlighting { + public struct SendableFont: @unchecked Sendable { + public let font: NSFont + public init(font: NSFont) { + self.font = font + } } - func unhighlightedCode() -> NSAttributedString { - return NSAttributedString( - string: code, - attributes: [ - .foregroundColor: brightMode ? NSColor.black : NSColor.white, - .font: font, - ] + + public static func highlightedCodeBlock( + code: String, + language: String, + scenario: String, + brightMode: Bool, + font: SendableFont + ) -> NSAttributedString { + highlightedCodeBlock( + code: code, + language: language, + scenario: scenario, + brightMode: brightMode, + font: font.font ) } - guard let highlighter = Highlightr() else { - return unhighlightedCode() + + public static func highlightedCodeBlock( + code: String, + language: String, + scenario: String, + brightMode: Bool, + font: NSFont + ) -> NSAttributedString { + 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: font, + ] + ) + } + guard let highlighter = Highlightr() else { + return unhighlightedCode() + } + highlighter.setTheme(to: { + let mode = brightMode ? "light" : "dark" + if scenario.isEmpty { + return mode + } + return "\(scenario)-\(mode)" + }()) + highlighter.theme.setCodeFont(font) + guard let formatted = highlighter.highlight(code, as: language) else { + return unhighlightedCode() + } + if formatted.string == "undefined" { + return unhighlightedCode() + } + return formatted } - highlighter.setTheme(to: { - let mode = brightMode ? "light" : "dark" - if scenario.isEmpty { - return mode + + public static func highlightedCodeBlocks( + code: [String], + language: String, + scenario: String, + brightMode: Bool, + font: NSFont + ) -> [NSAttributedString] { + 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(_ code: String) -> NSAttributedString { + return NSAttributedString( + string: code, + attributes: [ + .foregroundColor: brightMode ? NSColor.black : NSColor.white, + .font: font, + ] + ) } - return "\(scenario)-\(mode)" - }()) - highlighter.theme.setCodeFont(font) - guard let formatted = highlighter.highlight(code, as: language) else { - return unhighlightedCode() + guard let highlighter = Highlightr() else { + return code.map(unhighlightedCode) + } + highlighter.setTheme(to: { + let mode = brightMode ? "light" : "dark" + if scenario.isEmpty { + return mode + } + return "\(scenario)-\(mode)" + }()) + highlighter.theme.setCodeFont(font) + + var formattedCodeBlocks = [NSAttributedString]() + for code in code { + guard let formatted = highlighter.highlight(code, as: language) else { + formattedCodeBlocks.append(unhighlightedCode(code)) + continue + } + if formatted.string == "undefined" { + formattedCodeBlocks.append(unhighlightedCode(code)) + continue + } + formattedCodeBlocks.append(formatted) + } + return formattedCodeBlocks } - if formatted.string == "undefined" { - return unhighlightedCode() + + public static func highlighted( + code: String, + language: String, + scenario: String, + brightMode: Bool, + droppingLeadingSpaces: Bool, + font: SendableFont, + replaceSpacesWithMiddleDots: Bool = true + ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { + highlighted( + code: code, + language: language, + scenario: scenario, + brightMode: brightMode, + droppingLeadingSpaces: droppingLeadingSpaces, + font: font.font, + replaceSpacesWithMiddleDots: replaceSpacesWithMiddleDots + ) } - return formatted -} -public func highlighted( - code: String, - language: String, - scenario: String, - brightMode: Bool, - droppingLeadingSpaces: Bool, - font: NSFont, - replaceSpacesWithMiddleDots: Bool = true -) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { - let formatted = highlightedCodeBlock( - code: code, - language: language, - scenario: scenario, - brightMode: brightMode, - font: font - ) - let middleDotColor = brightMode - ? NSColor.black.withAlphaComponent(0.1) - : NSColor.white.withAlphaComponent(0.1) - return convertToCodeLines( - formatted, - middleDotColor: middleDotColor, - droppingLeadingSpaces: droppingLeadingSpaces, - replaceSpacesWithMiddleDots: replaceSpacesWithMiddleDots - ) -} + public static func highlighted( + code: [String], + language: String, + scenario: String, + brightMode: Bool, + droppingLeadingSpaces: Bool, + font: SendableFont, + replaceSpacesWithMiddleDots: Bool = true + ) -> (code: [[NSAttributedString]], commonLeadingSpaceCount: Int) { + highlighted( + code: code, + language: language, + scenario: scenario, + brightMode: brightMode, + droppingLeadingSpaces: droppingLeadingSpaces, + font: font.font, + replaceSpacesWithMiddleDots: replaceSpacesWithMiddleDots + ) + } -func convertToCodeLines( - _ formattedCode: NSAttributedString, - middleDotColor: NSColor, - droppingLeadingSpaces: Bool, - replaceSpacesWithMiddleDots: Bool = true -) -> (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 + public static func highlighted( + code: String, + language: String, + scenario: String, + brightMode: Bool, + droppingLeadingSpaces: Bool, + font: NSFont, + replaceSpacesWithMiddleDots: Bool = true + ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { + let result = highlighted( + code: [code], + language: language, + scenario: scenario, + brightMode: brightMode, + droppingLeadingSpaces: droppingLeadingSpaces, + font: font, + replaceSpacesWithMiddleDots: replaceSpacesWithMiddleDots + ) + return (result.code.first ?? [], result.commonLeadingSpaceCount) + } + + public static func highlighted( + code: [String], + language: String, + scenario: String, + brightMode: Bool, + droppingLeadingSpaces: Bool, + font: NSFont, + replaceSpacesWithMiddleDots: Bool = true + ) -> (code: [[NSAttributedString]], commonLeadingSpaceCount: Int) { + let formatted = highlightedCodeBlocks( + code: code, + language: language, + scenario: scenario, + brightMode: brightMode, + font: font + ) + let middleDotColor = brightMode + ? NSColor.black.withAlphaComponent(0.1) + : NSColor.white.withAlphaComponent(0.1) + return convertToCodeLines( + formatted, + middleDotColor: middleDotColor, + droppingLeadingSpaces: droppingLeadingSpaces, + replaceSpacesWithMiddleDots: replaceSpacesWithMiddleDots + ) + } + + public static func convertToCodeLines( + _ formattedCode: NSAttributedString, + middleDotColor: NSColor, + droppingLeadingSpaces: Bool, + replaceSpacesWithMiddleDots: Bool = true + ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { + let result = convertToCodeLines( + [formattedCode], + middleDotColor: middleDotColor, + droppingLeadingSpaces: droppingLeadingSpaces, + replaceSpacesWithMiddleDots: replaceSpacesWithMiddleDots + ) + return (result.code.first ?? [], result.commonLeadingSpaceCount) } - let separatedInput = input.splitByNewLine(omittingEmptySubsequences: false) - .map { String($0) } - 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 } + public static func convertToCodeLines( + _ formattedCode: [NSAttributedString], + middleDotColor: NSColor, + droppingLeadingSpaces: Bool, + replaceSpacesWithMiddleDots: Bool = true + ) -> (code: [[NSAttributedString]], commonLeadingSpaceCount: Int) { + let inputs = formattedCode.map { $0.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 } - result = i - break + return false } - 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("") - } + + let separatedInputs = inputs.map { $0.splitByNewLine(omittingEmptySubsequences: false) + .map { String($0) } } - if replaceSpacesWithMiddleDots { - // 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) + let commonLeadingSpaceCount = { + if !droppingLeadingSpaces { return 0 } + let split = separatedInputs.flatMap { $0 } + 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 outputs = [[NSAttributedString]]() + for (separatedInput, formattedCode) in zip(separatedInputs, formattedCode) { + 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("") + } } - } catch {} + + if replaceSpacesWithMiddleDots { + // 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 + } + outputs.append(output) } - output.append(mutable) - start += range.length + 1 + return (outputs, commonLeadingSpaceCount) } - return (output, commonLeadingSpaceCount) } diff --git a/Tool/Sources/SharedUIComponents/TabContainer.swift b/Tool/Sources/SharedUIComponents/TabContainer.swift new file mode 100644 index 00000000..9a61d93b --- /dev/null +++ b/Tool/Sources/SharedUIComponents/TabContainer.swift @@ -0,0 +1,82 @@ +import Dependencies +import Foundation +import SwiftUI + +public final class ExternalTabContainer { + public static var tabContainers = [String: ExternalTabContainer]() + + public struct TabItem: Identifiable { + public var id: String + public var title: String + public var description: String + public var image: String + public let viewBuilder: () -> AnyView + + public init( + id: String, + title: String, + description: String = "", + image: String = "", + @ViewBuilder viewBuilder: @escaping () -> V + ) { + self.id = id + self.title = title + self.description = description + self.image = image + self.viewBuilder = { AnyView(viewBuilder()) } + } + } + + public var tabs: [TabItem] = [] + public init() { tabs = [] } + + public static func tabContainer(for id: String) -> ExternalTabContainer { + if let tabContainer = tabContainers[id] { + return tabContainer + } + let tabContainer = ExternalTabContainer() + tabContainers[id] = tabContainer + return tabContainer + } + + @ViewBuilder + public func tabView(for id: String) -> some View { + if let tab = tabs.first(where: { $0.id == id }) { + tab.viewBuilder() + } + } + + public func registerTab( + id: String, + title: String, + description: String = "", + image: String = "", + @ViewBuilder viewBuilder: @escaping () -> V + ) { + tabs.append(TabItem( + id: id, + title: title, + description: description, + image: image, + viewBuilder: viewBuilder + )) + } + + public static func registerTab( + for tabContainerId: String, + id: String, + title: String, + description: String = "", + image: String = "", + @ViewBuilder viewBuilder: @escaping () -> V + ) { + tabContainer(for: tabContainerId).registerTab( + id: id, + title: title, + description: description, + image: image, + viewBuilder: viewBuilder + ) + } +} + diff --git a/Tool/Sources/SharedUIComponents/XcodeStyleFrame.swift b/Tool/Sources/SharedUIComponents/XcodeStyleFrame.swift new file mode 100644 index 00000000..d3f715ea --- /dev/null +++ b/Tool/Sources/SharedUIComponents/XcodeStyleFrame.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftUI + +public struct XcodeLikeFrame: View { + @Environment(\.colorScheme) var colorScheme + let content: Content + let cornerRadius: Double + + public init(cornerRadius: Double, content: Content) { + self.content = content + self.cornerRadius = cornerRadius + } + + public var body: some View { + content + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .background( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Material.bar) + ) + .overlay( + RoundedRectangle(cornerRadius: max(0, cornerRadius), style: .continuous) + .stroke(Color.black.opacity(0.1), style: .init(lineWidth: 1)) + ) // Add an extra border just incase the background is not displayed. + .overlay( + RoundedRectangle(cornerRadius: max(0, cornerRadius - 1), style: .continuous) + .stroke(Color.white.opacity(0.1), style: .init(lineWidth: 1)) + .padding(1) + ) + } +} + +public extension View { + func xcodeStyleFrame(cornerRadius: Double? = nil) -> some View { + if #available(macOS 26.0, *) { + XcodeLikeFrame(cornerRadius: cornerRadius ?? 14, content: self) + } else { + XcodeLikeFrame(cornerRadius: cornerRadius ?? 10, content: self) + } + } +} + diff --git a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift new file mode 100644 index 00000000..c9967a49 --- /dev/null +++ b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift @@ -0,0 +1,66 @@ +import CodableWrappers +import Foundation + +public struct CodeSuggestion: Codable, Equatable { + public struct Description: Codable, Equatable { + public enum Kind: Codable, Equatable { + case warning + case action + } + + public var kind: Kind + public var content: String + + public init(kind: Kind, content: String) { + self.kind = kind + self.content = content + } + } + + public init( + id: String, + text: String, + position: CursorPosition, + range: CursorRange, + replacingLines: [String] = [], + descriptions: [Description] = [], + middlewareComments: [String] = [], + metadata: [String: String] = [:] + ) { + self.text = text + self.position = position + self.id = id + self.range = range + self.replacingLines = replacingLines + self.descriptions = descriptions + self.middlewareComments = middlewareComments + self.metadata = metadata + } + + public static func == (lhs: CodeSuggestion, rhs: CodeSuggestion) -> Bool { + return lhs.text == rhs.text + && lhs.position == rhs.position + && lhs.id == rhs.id + && lhs.range == rhs.range + && lhs.descriptions == rhs.descriptions + && lhs.middlewareComments == rhs.middlewareComments + } + + /// 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 id: String + /// The range of the original code that should be replaced. + public var range: CursorRange + /// Descriptions about this code suggestion + @FallbackDecoding public var replacingLines: [String] + /// Descriptions about this code suggestion + @FallbackDecoding public var descriptions: [Description] + /// A place to store comments inserted by middleware for debugging use. + @FallbackDecoding public var middlewareComments: [String] + /// A place to store extra data. + @FallbackDecoding public var metadata: [String: String] +} + diff --git a/Tool/Sources/SuggestionModel/EditorInformation.swift b/Tool/Sources/SuggestionBasic/EditorInformation.swift similarity index 71% rename from Tool/Sources/SuggestionModel/EditorInformation.swift rename to Tool/Sources/SuggestionBasic/EditorInformation.swift index 40d15c37..38d6e26d 100644 --- a/Tool/Sources/SuggestionModel/EditorInformation.swift +++ b/Tool/Sources/SuggestionBasic/EditorInformation.swift @@ -1,14 +1,14 @@ import Foundation import Parsing -public struct EditorInformation { - public struct LineAnnotation { +public struct EditorInformation: Sendable { + public struct LineAnnotation: Sendable { public var type: String public var line: Int public var message: String } - public struct SourceEditorContent { + public struct SourceEditorContent: Sendable { /// The content of the source editor. public var content: String /// The content of the source editor in lines. Every line should ends with `\n`. @@ -17,11 +17,15 @@ public struct EditorInformation { public var selections: [CursorRange] /// The cursor position of the source editor. public var cursorPosition: CursorPosition + /// The cursor position as offset. + public var cursorOffset: Int /// Line annotations of the source editor. public var lineAnnotations: [LineAnnotation] public var selectedContent: String { + guard !lines.isEmpty else { return "" } if let range = selections.first { + if range.isEmpty { return "" } let startIndex = min( max(0, range.start.line), lines.endIndex - 1 @@ -41,12 +45,14 @@ public struct EditorInformation { lines: [String], selections: [CursorRange], cursorPosition: CursorPosition, + cursorOffset: Int, lineAnnotations: [String] ) { self.content = content self.lines = lines self.selections = selections self.cursorPosition = cursorPosition + self.cursorOffset = cursorOffset self.lineAnnotations = lineAnnotations.map(EditorInformation.parseLineAnnotation) } } @@ -86,8 +92,10 @@ public struct EditorInformation { public static func lines(in code: [String], containing range: CursorRange) -> [String] { guard !code.isEmpty else { return [] } + guard range.start.line <= range.end.line else { return [] } let startIndex = min(max(0, range.start.line), code.endIndex - 1) let endIndex = min(max(startIndex, range.end.line), code.endIndex - 1) + guard startIndex <= endIndex else { return [] } let selectedLines = code[startIndex...endIndex] return Array(selectedLines) } @@ -97,18 +105,42 @@ public struct EditorInformation { inside range: CursorRange, ignoreColumns: Bool = false ) -> (code: String, lines: [String]) { + if range.start == range.end { + // Empty selection (cursor only): return empty code but include the containing line + return ("", lines(in: code, containing: range)) + } + guard range.start < range.end else { return ("", []) } + let rangeLines = lines(in: code, containing: range) if ignoreColumns { return (rangeLines.joined(), rangeLines) } var content = rangeLines if !content.isEmpty { - let dropLastCount = max(0, content[content.endIndex - 1].count - range.end.character) - content[content.endIndex - 1] = String( - content[content.endIndex - 1].dropLast(dropLastCount) - ) - let dropFirstCount = max(0, range.start.character) - content[0] = String(content[0].dropFirst(dropFirstCount)) + let lastLine = content[content.endIndex - 1] + let droppedEndIndex = lastLine.utf16.index( + lastLine.utf16.startIndex, + offsetBy: range.end.character, + limitedBy: lastLine.utf16.endIndex + ) ?? lastLine.utf16.endIndex + content[content.endIndex - 1] = if droppedEndIndex > lastLine.utf16.startIndex { + String(lastLine[..? + public var modificationRanges: [String: CursorRange] = [:] public var modifications: [Modification] = [] public init() {} } @@ -23,7 +23,6 @@ public struct SuggestionInjector { ) { extraInfo.didChangeContent = true extraInfo.didChangeCursorPosition = true - extraInfo.suggestionRange = nil let start = completion.range.start let end = completion.range.end let suggestionContent = completion.text @@ -34,7 +33,7 @@ public struct SuggestionInjector { } let firstRemovedLine = content[safe: start.line] - let lastRemovedLine = content[safe: end.line] + let lastRemovedLine = completion.replacingLines[safe: max(0, end.line - start.line)] let startLine = max(0, start.line) let endLine = max(start.line, min(end.line, content.endIndex - 1)) if startLine < content.endIndex { @@ -54,12 +53,12 @@ public struct SuggestionInjector { start.character < firstRemovedLine.count, !toBeInserted.isEmpty { - let leftoverRange = firstRemovedLine.startIndex..<(firstRemovedLine.index( - firstRemovedLine.startIndex, + let leftoverRange = firstRemovedLine.utf16.startIndex..<(firstRemovedLine.utf16.index( + firstRemovedLine.utf16.startIndex, offsetBy: start.character, - limitedBy: firstRemovedLine.endIndex - ) ?? firstRemovedLine.endIndex) - var leftover = firstRemovedLine[leftoverRange] + limitedBy: firstRemovedLine.utf16.endIndex + ) ?? firstRemovedLine.utf16.endIndex) + var leftover = String(firstRemovedLine[leftoverRange]) if leftover.last?.isNewline ?? false { leftover.removeLast(1) } @@ -72,11 +71,12 @@ public struct SuggestionInjector { let recoveredSuffixLength = recoverSuffixIfNeeded( endOfReplacedContent: end, toBeInserted: &toBeInserted, - lastRemovedLine: lastRemovedLine, + originalLastRemovedLine: lastRemovedLine, lineEnding: lineEnding ) - let cursorCol = toBeInserted[toBeInserted.endIndex - 1].count - 1 - recoveredSuffixLength + let cursorCol = toBeInserted[toBeInserted.endIndex - 1].utf16.count + - 1 - recoveredSuffixLength let insertingIndex = min(start.line, content.endIndex) content.insert(contentsOf: toBeInserted, at: insertingIndex) extraInfo.modifications.append(.inserted(insertingIndex, toBeInserted)) @@ -84,55 +84,109 @@ public struct SuggestionInjector { line: startLine + toBeInserted.count - 1, character: max(0, cursorCol) ) + extraInfo.modificationRanges[completion.id] = .init(start: start, end: cursorPosition) + } + + public func acceptSuggestions( + intoContentWithoutSuggestion content: inout [String], + cursorPosition: inout CursorPosition, + completions: [CodeSuggestion], + extraInfo: inout ExtraInfo + ) { + let sortedCompletions = completions.sorted { + if $0.range.start.line < $1.range.start.line { + true + } else if $0.range.start.line == $1.range.start.line { + $0.range.start.character < $1.range.start.character + } else { + false + } + } + + for var completion in sortedCompletions { + let lineCountChange: Int = { + var accumulation = 0 + let endIndex = completion.range.start.line + for modification in extraInfo.modifications { + switch modification { + case let .deleted(range): + if range.lowerBound <= endIndex { + accumulation -= range.count + if range.upperBound >= endIndex { + accumulation += range.upperBound - endIndex + } + } + case let .inserted(index, lines): + if index <= endIndex { + accumulation += lines.count + } + } + } + return accumulation + }() + + if lineCountChange != 0 { + completion.position = CursorPosition( + line: completion.position.line + lineCountChange, + character: completion.position.character + ) + completion.range = CursorRange( + start: CursorPosition( + line: completion.range.start.line + lineCountChange, + character: completion.range.start.character + ), + end: CursorPosition( + line: completion.range.end.line + lineCountChange, + character: completion.range.end.character + ) + ) + } + + completion.replacingLines = { + let start = completion.range.start.line + let end = completion.range.end.line + if start >= content.endIndex { + return [] + } + if end < content.endIndex { + return Array(content[start...end]) + } + return Array(content[start...]) + }() + + // Accept the suggestion + acceptSuggestion( + intoContentWithoutSuggestion: &content, + cursorPosition: &cursorPosition, + completion: completion, + extraInfo: &extraInfo + ) + } } func recoverSuffixIfNeeded( endOfReplacedContent end: CursorPosition, toBeInserted: inout [String], - lastRemovedLine: String?, + originalLastRemovedLine: String?, lineEnding: String ) -> Int { // If there is no line removed, there is no need to recover anything. - guard let lastRemovedLine, !lastRemovedLine.isEmptyOrNewLine else { return 0 } + guard let lastRemovedLine = originalLastRemovedLine, + !lastRemovedLine.isEmptyOrNewLine else { return 0 } let lastRemovedLineCleaned = lastRemovedLine.droppedLineBreak() - // If the replaced range covers the whole line, return immediately. - guard end.character >= 0, end.character - 1 < lastRemovedLineCleaned.count else { return 0 } - - // if we are not inserting anything, return immediately. - guard !toBeInserted.isEmpty, - let first = toBeInserted.first?.droppedLineBreak(), !first.isEmpty, - let last = toBeInserted.last?.droppedLineBreak(), !last.isEmpty - else { return 0 } - - // case 1: user keeps typing as the suggestion suggests. - - if first.hasPrefix(lastRemovedLineCleaned) { - return 0 - } - - // case 2: user also typed the suffix of the suggestion (or auto-completed by Xcode) - // locate the split index, the prefix of which matches the suggestion prefix. - var splitIndex: String.Index? - - for offset in end.character..` @@ -156,18 +210,18 @@ public struct SuggestionInjector { toBeInserted[toBeInserted.endIndex - 1] = lastInsertingLine - return suffix.count + return suffix.utf16.count } } public struct SuggestionAnalyzer { struct Result { - enum InsertPostion { + enum InsertPosition { case currentLine case nextLine } - var insertPosition: InsertPostion + var insertPosition: InsertPosition var commonPrefix: String? } diff --git a/Tool/Sources/SuggestionModel/CodeSuggestion.swift b/Tool/Sources/SuggestionModel/CodeSuggestion.swift deleted file mode 100644 index e6b2d85c..00000000 --- a/Tool/Sources/SuggestionModel/CodeSuggestion.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -public struct CodeSuggestion: Codable, Equatable { - public init( - id: String, - text: String, - position: CursorPosition, - range: CursorRange - ) { - self.text = text - self.position = position - self.id = id - self.range = range - } - - /// 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 id: String - /// The range of the original code that should be replaced. - public var range: CursorRange -} diff --git a/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift b/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift deleted file mode 100644 index fac572b9..00000000 --- a/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift +++ /dev/null @@ -1,88 +0,0 @@ -import CodeiumService -import Foundation -import Preferences -import SuggestionModel - -public actor CodeiumSuggestionProvider: SuggestionServiceProvider { - public nonisolated var configuration: SuggestionServiceConfiguration { - .init( - acceptsRelevantCodeSnippets: true, - mixRelevantCodeSnippetsInSource: true, - acceptsRelevantSnippetsFromOpenedFiles: false - ) - } - - let projectRootURL: URL - let onServiceLaunched: (SuggestionServiceProvider) -> Void - var codeiumService: CodeiumSuggestionServiceType? - - public init( - projectRootURL: URL, - onServiceLaunched: @escaping (SuggestionServiceProvider) -> 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 - } -} - -public extension CodeiumSuggestionProvider { - func getSuggestions(_ request: SuggestionRequest) async throws - -> [SuggestionModel.CodeSuggestion] - { - try await (createCodeiumServiceIfNeeded()).getCompletions( - fileURL: request.fileURL, - content: request.content, - cursorPosition: request.cursorPosition, - tabSize: request.tabSize, - indentSize: request.indentSize, - usesTabsForIndentation: request.usesTabsForIndentation, - ignoreSpaceOnlySuggestions: request.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/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift b/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift deleted file mode 100644 index ca330a2f..00000000 --- a/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift +++ /dev/null @@ -1,93 +0,0 @@ -import Foundation -import GitHubCopilotService -import Preferences -import SuggestionModel - -public actor GitHubCopilotSuggestionProvider: SuggestionServiceProvider { - public nonisolated var configuration: SuggestionServiceConfiguration { - .init( - acceptsRelevantCodeSnippets: true, - mixRelevantCodeSnippetsInSource: true, - acceptsRelevantSnippetsFromOpenedFiles: false - ) - } - - let projectRootURL: URL - let onServiceLaunched: (SuggestionServiceProvider) -> Void - var gitHubCopilotService: GitHubCopilotSuggestionServiceType? - - public init( - projectRootURL: URL, - onServiceLaunched: @escaping (SuggestionServiceProvider) -> 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 - } -} - -public extension GitHubCopilotSuggestionProvider { - func getSuggestions(_ request: SuggestionRequest) async throws - -> [SuggestionModel.CodeSuggestion] - { - try await (createGitHubCopilotServiceIfNeeded()).getCompletions( - fileURL: request.fileURL, - content: request.content, - cursorPosition: request.cursorPosition, - tabSize: request.tabSize, - indentSize: request.indentSize, - usesTabsForIndentation: request.usesTabsForIndentation, - ignoreSpaceOnlySuggestions: request.ignoreSpaceOnlySuggestions, - ignoreTrailingNewLinesAndSpaces: UserDefaults.shared - .value(for: \.gitHubCopilotIgnoreTrailingNewLines) - ) - } - - 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/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift new file mode 100644 index 00000000..227aac22 --- /dev/null +++ b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift @@ -0,0 +1,119 @@ +import Foundation +import SuggestionBasic + +public struct PostProcessingSuggestionServiceMiddleware: SuggestionServiceMiddleware { + public init() {} + + public func getSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] { + let suggestions = try await next(request) + + return suggestions.compactMap { + var suggestion = $0 + if suggestion.text.allSatisfy({ $0.isWhitespace || $0.isNewline }) { return nil } + Self.removeTrailingWhitespacesAndNewlines(&suggestion) + Self.removeRedundantClosingParenthesis(&suggestion, lines: request.lines) + if !Self.checkIfSuggestionHasNoEffect(suggestion, request: request) { return nil } + Self.injectReplacingLines(&suggestion, request: request) + return suggestion + } + } + + static func removeTrailingWhitespacesAndNewlines(_ suggestion: inout CodeSuggestion) { + suggestion.text = suggestion.text.removedTrailingWhitespacesAndNewlines() + } + + static func injectReplacingLines( + _ suggestion: inout CodeSuggestion, + request: SuggestionRequest + ) { + guard !request.lines.isEmpty else { return } + let range = suggestion.range + let lowerBound = max(0, range.start.line) + let upperBound = max(lowerBound, min(request.lines.count - 1, range.end.line)) + suggestion.replacingLines = Array(request.lines[lowerBound...upperBound]) + } + + /// Remove the parenthesis in the last line of the suggestion if + /// - It contains only closing parenthesis + /// - It's identical to the next line below the range of the suggestion + static func removeRedundantClosingParenthesis( + _ suggestion: inout CodeSuggestion, + lines: [String] + ) { + let nextLineIndex = suggestion.range.end.line + 1 + guard nextLineIndex < lines.endIndex, nextLineIndex >= 0 else { return } + let nextLine = lines[nextLineIndex].dropLast(1) + let lineBreakIndex = suggestion.text.lastIndex(where: { $0.isNewline }) + let lastLineIndex = if let index = lineBreakIndex { + suggestion.text.index(after: index) + } else { + suggestion.text.startIndex + } + guard lastLineIndex < suggestion.text.endIndex else { return } + let lastSuggestionLine = suggestion.text[lastLineIndex...] + guard lastSuggestionLine == nextLine else { return } + + let closingParenthesis: [Character] = [")", "]", "}", ">"] + let validCharacters = Set(closingParenthesis + [" ", ","]) + + let trimmedLastSuggestionLine = nextLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedLastSuggestionLine.isEmpty else { return } + + if trimmedLastSuggestionLine == "```" + || trimmedLastSuggestionLine == "\"\"\"" + || trimmedLastSuggestionLine.allSatisfy({ validCharacters.contains($0) }) + { + if let lastIndex = lineBreakIndex { + suggestion.text = String(suggestion.text[.. Bool { + // We only check suggestions that are on a single line. + if suggestion.range.isOneLine { + let line = suggestion.range.start.line + if line >= 0, line < request.lines.count { + let replacingText = request.lines[line] + + let start = suggestion.range.start.character + let end = suggestion.range.end.character + if let endIndex = replacingText.utf16.index( + replacingText.startIndex, + offsetBy: end, + limitedBy: replacingText.endIndex + ), + let startIndex = replacingText.utf16.index( + replacingText.startIndex, + offsetBy: start, + limitedBy: endIndex + ), + startIndex < endIndex + { + let replacingRange = startIndex.. String { + var text = self[...] + while let last = text.last, last.isNewline || last.isWhitespace { + text = text.dropLast(1) + } + return String(text) + } + + func removedTrailingCharacters(in set: CharacterSet) -> String { + var text = self[...] + while let last = text.last, set.containsUnicodeScalars(of: last) { + text = text.dropLast(1) + } + return String(text) + } + + func removeLeadingCharacters(in set: CharacterSet) -> String { + var text = self[...] + while let first = text.first, set.containsUnicodeScalars(of: first) { + text = text.dropFirst() + } + return String(text) + } +} + +extension CharacterSet { + func containsUnicodeScalars(of character: Character) -> Bool { + return character.unicodeScalars.allSatisfy(contains(_:)) + } +} diff --git a/Tool/Sources/SuggestionProvider/SuggestionProvider.swift b/Tool/Sources/SuggestionProvider/SuggestionProvider.swift index e59c4a64..24265613 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionProvider.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionProvider.swift @@ -1,43 +1,47 @@ import AppKit import struct CopilotForXcodeKit.SuggestionServiceConfiguration +import struct CopilotForXcodeKit.WorkspaceInfo import Foundation import Preferences -import SuggestionModel +import SuggestionBasic import UserDefaultsObserver public struct SuggestionRequest { public var fileURL: URL public var relativePath: String public var content: String + public var originalContent: String public var lines: [String] public var cursorPosition: CursorPosition + public var cursorOffset: Int public var tabSize: Int public var indentSize: Int public var usesTabsForIndentation: Bool - public var ignoreSpaceOnlySuggestions: Bool public var relevantCodeSnippets: [RelevantCodeSnippet] public init( fileURL: URL, relativePath: String, content: String, + originalContent: String, lines: [String], cursorPosition: CursorPosition, + cursorOffset: Int, tabSize: Int, indentSize: Int, usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool, relevantCodeSnippets: [RelevantCodeSnippet] ) { self.fileURL = fileURL self.relativePath = relativePath self.content = content + self.originalContent = content self.lines = lines self.cursorPosition = cursorPosition + self.cursorOffset = cursorOffset self.tabSize = tabSize self.indentSize = indentSize self.usesTabsForIndentation = usesTabsForIndentation - self.ignoreSpaceOnlySuggestions = ignoreSpaceOnlySuggestions self.relevantCodeSnippets = relevantCodeSnippets } } @@ -55,15 +59,19 @@ public struct RelevantCodeSnippet: Codable { } public protocol SuggestionServiceProvider { - func getSuggestions(_ request: SuggestionRequest) 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 + func getSuggestions( + _ request: SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async throws -> [CodeSuggestion] + func notifyAccepted( + _ suggestion: CodeSuggestion, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async + func notifyRejected( + _ suggestions: [CodeSuggestion], + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async + func cancelRequest(workspaceInfo: CopilotForXcodeKit.WorkspaceInfo) async var configuration: SuggestionServiceConfiguration { get async } } diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift new file mode 100644 index 00000000..e89fe938 --- /dev/null +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift @@ -0,0 +1,27 @@ +import CopilotForXcodeKit +import Foundation +import SuggestionBasic + +public protocol SuggestionServiceEventHandler { + func didAccept(_ suggestion: SuggestionBasic.CodeSuggestion, workspaceInfo: WorkspaceInfo) + func didReject(_ suggestions: [SuggestionBasic.CodeSuggestion], workspaceInfo: WorkspaceInfo) +} + +public enum SuggestionServiceEventHandlerContainer { + static var builtinHandlers: [SuggestionServiceEventHandler] = [] + + static var customHandlers: [SuggestionServiceEventHandler] = [] + + public static var handlers: [SuggestionServiceEventHandler] { + builtinHandlers + customHandlers + } + + public static func addHandler(_ handler: SuggestionServiceEventHandler) { + customHandlers.append(handler) + } + + public static func addHandlers(_ handlers: [SuggestionServiceEventHandler]) { + customHandlers.append(contentsOf: handlers) + } +} + diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift index c447131f..7d08aeaa 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift @@ -1,6 +1,6 @@ import Foundation import Logger -import SuggestionModel +import SuggestionBasic public protocol SuggestionServiceMiddleware { typealias Next = (SuggestionRequest) async throws -> [CodeSuggestion] @@ -15,21 +15,43 @@ public protocol SuggestionServiceMiddleware { public enum SuggestionServiceMiddlewareContainer { static var builtInMiddlewares: [SuggestionServiceMiddleware] = [ DisabledLanguageSuggestionServiceMiddleware(), + PostProcessingSuggestionServiceMiddleware() ] + + static var leadingMiddlewares: [SuggestionServiceMiddleware] = [] - static var customMiddlewares: [SuggestionServiceMiddleware] = [] + static var trailingMiddlewares: [SuggestionServiceMiddleware] = [] public static var middlewares: [SuggestionServiceMiddleware] { - builtInMiddlewares + customMiddlewares + leadingMiddlewares + builtInMiddlewares + trailingMiddlewares } public static func addMiddleware(_ middleware: SuggestionServiceMiddleware) { - customMiddlewares.append(middleware) + trailingMiddlewares.append(middleware) + } + + public static func addMiddlewares(_ middlewares: [SuggestionServiceMiddleware]) { + trailingMiddlewares.append(contentsOf: middlewares) + } + + public static func addLeadingMiddleware(_ middleware: SuggestionServiceMiddleware) { + leadingMiddlewares.append(middleware) + } + + public static func addLeadingMiddlewares(_ middlewares: [SuggestionServiceMiddleware]) { + leadingMiddlewares.append(contentsOf: middlewares) } } public struct DisabledLanguageSuggestionServiceMiddleware: SuggestionServiceMiddleware { public init() {} + + struct DisabledLanguageError: Error, LocalizedError { + let language: String + var errorDescription: String? { + "Suggestion service is disabled for \(language)." + } + } public func getSuggestion( _ request: SuggestionRequest, @@ -40,10 +62,7 @@ public struct DisabledLanguageSuggestionServiceMiddleware: SuggestionServiceMidd if UserDefaults.shared.value(for: \.suggestionFeatureDisabledLanguageList) .contains(where: { $0 == language.rawValue }) { - #if DEBUG - Logger.service.info("Suggestion service is disabled for \(language).") - #endif - return [] + throw DisabledLanguageError(language: language.rawValue) } return try await next(request) diff --git a/Tool/Sources/Terminal/Terminal.swift b/Tool/Sources/Terminal/Terminal.swift index 89812c4b..7ed68789 100644 --- a/Tool/Sources/Terminal/Terminal.swift +++ b/Tool/Sources/Terminal/Terminal.swift @@ -9,6 +9,13 @@ public protocol TerminalType { environment: [String: String] ) -> AsyncThrowingStream + func streamLineForCommand( + _ command: String, + arguments: [String], + currentDirectoryURL: URL?, + environment: [String: String] + ) -> AsyncThrowingStream + func runCommand( _ command: String, arguments: [String], @@ -71,7 +78,8 @@ public final class Terminal: TerminalType, @unchecked Sendable { continuation = cont } - Task { [continuation, self] in + Task { [continuation, process, self] in + _ = self let notificationCenter = NotificationCenter.default let notifications = notificationCenter.notifications( named: FileHandle.readCompletionNotification, @@ -79,26 +87,33 @@ public final class Terminal: TerminalType, @unchecked Sendable { ) for await notification in notifications { let userInfo = notification.userInfo + guard let object = notification.object as? FileHandle, + object === outputPipe.fileHandleForReading + else { + continue + } if let data = userInfo?[NSFileHandleNotificationDataItem] as? Data, let content = String(data: data, encoding: .utf8), !content.isEmpty { continuation?.yield(content) } - if !(self.process?.isRunning ?? false) { - let reason = self.process?.terminationReason ?? .exit - let status = self.process?.terminationStatus ?? 1 - if let output = (self.process?.standardOutput as? Pipe)?.fileHandleForReading - .readDataToEndOfFile(), - let content = String(data: output, encoding: .utf8), + if !process.isRunning { + if let fileHandle = (process.standardOutput as? Pipe)? + .fileHandleForReading, + let data = try? fileHandle.readToEnd(), + let content = String(data: data, encoding: .utf8), !content.isEmpty { continuation?.yield(content) } + let status = process.terminationStatus + if status == 0 { continuation?.finish() } else { + let reason = process.terminationReason continuation?.finish(throwing: TerminationError( reason: reason, status: status @@ -125,6 +140,46 @@ public final class Terminal: TerminalType, @unchecked Sendable { return contentStream } + public func streamLineForCommand( + _ command: String = "/bin/bash", + arguments: [String], + currentDirectoryURL: URL? = nil, + environment: [String: String] + ) -> AsyncThrowingStream { + let chunkStream = streamCommand( + command, + arguments: arguments, + currentDirectoryURL: currentDirectoryURL, + environment: environment + ) + + return AsyncThrowingStream { continuation in + Task { + var buffer = "" + do { + for try await chunk in chunkStream { + buffer.append(chunk) + + while let range = buffer.range(of: "\n") { + let line = String(buffer[.. Bool { + lhs.label == rhs.label + } + + public var label: Text + public var action: () -> Void + public init(label: Text, action: @escaping () -> Void) { + self.label = label + self.action = action + } + } + public var namespace: String? public var id: UUID public var type: ToastType public var content: Text - public init(id: UUID, type: ToastType, namespace: String? = nil, content: Text) { + public var buttons: [MessageButton] + public init( + id: UUID, + type: ToastType, + namespace: String? = nil, + content: Text, + buttons: [MessageButton] = [] + ) { self.namespace = namespace self.id = id self.type = type self.content = content + self.buttons = buttons } } @Published public var messages: [Message] = [] + // Track removal tasks for each toast + private var removalTasks: [UUID: Task] = [:] + public init(messages: [Message]) { self.messages = messages } - public func toast(content: String, type: ToastType, namespace: String? = nil) { - let id = UUID() - let message = Message(id: id, type: type, namespace: namespace, content: Text(content)) - + public func toast( + content: String, + type: ToastType, + namespace: String? = nil, + buttons: [Message.MessageButton] = [], + duration: TimeInterval = 4 + ) { Task { @MainActor in + // Find existing message with same content and type (and namespace) + if let existingIndex = messages.firstIndex(where: { + $0.type == type && + $0.content == Text(content) && + $0.namespace == namespace + }) { + let existingMessage = messages[existingIndex] + // Cancel previous removal task + removalTasks[existingMessage.id]?.cancel() + // Start new removal task for this message + removalTasks[existingMessage.id] = Task { @MainActor in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + withAnimation(.easeInOut(duration: 0.2)) { + messages.removeAll { $0.id == existingMessage.id } + } + removalTasks.removeValue(forKey: existingMessage.id) + } + return + } + + let id = UUID() + let message = Message( + id: id, + type: type, + namespace: namespace, + content: Text(content), + buttons: buttons.map { b in + Message.MessageButton(label: b.label, action: { [weak self] in + b.action() + withAnimation(.easeInOut(duration: 0.2)) { + self?.messages.removeAll { $0.id == id } + } + }) + } + ) + 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 } + + removalTasks[id] = Task { @MainActor in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + withAnimation(.easeInOut(duration: 0.2)) { + messages.removeAll { $0.id == id } + } + removalTasks.removeValue(forKey: id) } } } } -public struct Toast: ReducerProtocol { +@Reducer +public struct Toast { public typealias Message = ToastController.Message + + @ObservableState public struct State: Equatable { var isObservingToastController = false public var messages: [Message] = [] @@ -104,7 +174,7 @@ public struct Toast: ReducerProtocol { public init() {} - public var body: some ReducerProtocol { + public var body: some ReducerOf { Reduce { state, action in switch action { case .start: @@ -134,4 +204,3 @@ public struct Toast: ReducerProtocol { } } } - diff --git a/Tool/Sources/WebScrapper/WebScrapper.swift b/Tool/Sources/WebScrapper/WebScrapper.swift new file mode 100644 index 00000000..e7c45725 --- /dev/null +++ b/Tool/Sources/WebScrapper/WebScrapper.swift @@ -0,0 +1,161 @@ +import Foundation +import SwiftSoup +import WebKit + +@MainActor +public final class WebScrapper { + final class NavigationDelegate: NSObject, WKNavigationDelegate { + weak var scrapper: WebScrapper? + + public nonisolated func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { + Task { @MainActor in + let scrollToBottomScript = "window.scrollTo(0, document.body.scrollHeight);" + _ = try? await webView.evaluateJavaScript(scrollToBottomScript) + self.scrapper?.webViewDidFinishLoading = true + } + } + + public nonisolated func webView( + _: WKWebView, + didFail _: WKNavigation!, + withError error: Error + ) { + Task { @MainActor in + self.scrapper?.navigationError = error + self.scrapper?.webViewDidFinishLoading = true + } + } + } + + public var webView: WKWebView + + var webViewDidFinishLoading = false + var navigationError: (any Error)? + let navigationDelegate: NavigationDelegate = .init() + + enum WebScrapperError: Error { + case retry + } + + public init() async { + let jsonRuleList = ###""" + [ + { + "trigger": { + "url-filter": ".*", + "resource-type": ["font"] + }, + "action": { + "type": "block" + } + }, + { + "trigger": { + "url-filter": ".*", + "resource-type": ["image"] + }, + "action": { + "type": "block" + } + }, + { + "trigger": { + "url-filter": ".*", + "resource-type": ["media"] + }, + "action": { + "type": "block" + } + } + ] + """### + + let list = try? await WKContentRuleListStore.default().compileContentRuleList( + forIdentifier: "web-scrapping", + encodedContentRuleList: jsonRuleList + ) + + let configuration = WKWebViewConfiguration() + if let list { + configuration.userContentController.add(list) + } + configuration.allowsAirPlayForMediaPlayback = false + configuration.mediaTypesRequiringUserActionForPlayback = .all + configuration.defaultWebpagePreferences.preferredContentMode = .desktop + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + configuration.websiteDataStore = .nonPersistent() + configuration.applicationNameForUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15" + + if #available(iOS 17.0, macOS 14.0, *) { + configuration.allowsInlinePredictions = false + } + + // The web page need the web view to have a size to load correctly. + let webView = WKWebView( + frame: .init(x: 0, y: 0, width: 800, height: 5000), + configuration: configuration + ) + self.webView = webView + navigationDelegate.scrapper = self + webView.navigationDelegate = navigationDelegate + } + + public func fetch( + url: URL, + validate: @escaping (SwiftSoup.Document) -> Bool = { _ in true }, + timeout: TimeInterval = 15, + retryLimit: Int = 50 + ) 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) + } + let deadline = Date().addingTimeInterval(timeout) + if let navigationError { throw navigationError } + while retryCount < retryLimit, Date() < deadline { + if let html = try? await getHTML(), !html.isEmpty, + let document = try? SwiftSoup.parse(html, url.path), + validate(document) + { + return html + } + retryCount += 1 + try await Task.sleep(nanoseconds: 100_000_000) + } + + enum Error: Swift.Error, LocalizedError { + case failToValidate + + var errorDescription: String? { + switch self { + case .failToValidate: + return "Failed to validate the HTML content within the given timeout and retry limit." + } + } + } + throw Error.failToValidate + } + + 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"; +""" + diff --git a/Tool/Sources/WebSearchService/SearchServices/AppleDocumentationSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/AppleDocumentationSearchService.swift new file mode 100644 index 00000000..680c4fb6 --- /dev/null +++ b/Tool/Sources/WebSearchService/SearchServices/AppleDocumentationSearchService.swift @@ -0,0 +1,60 @@ +import Foundation +import SwiftSoup +import WebKit +import WebScrapper + +struct AppleDocumentationSearchService: SearchService { + func search(query: String) async throws -> WebSearchResult { + let queryEncoded = query + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let url = URL(string: "https://developer.apple.com/search/?q=\(queryEncoded)")! + + let scrapper = await WebScrapper() + let html = try await scrapper.fetch(url: url) { document in + DeveloperDotAppleResultParser.validate(document: document) + } + + return try DeveloperDotAppleResultParser.parse(html: html) + } +} + +enum DeveloperDotAppleResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + guard let _ = try? document.select("ul.search-results").first + else { return false } + return true + } + + static func parse(html: String) throws -> WebSearchResult { + let document = try SwiftSoup.parse(html) + let searchResult = try? document.select("ul.search-results").first + + guard let searchResult else { return .init(webPages: []) } + + var results: [WebSearchResult.WebPage] = [] + for element in searchResult.children() { + if let titleElement = try? element.select("p.result-title"), + let link = try? titleElement.select("a").attr("href"), + !link.isEmpty + { + let title = (try? titleElement.text()) ?? "" + let snippet = (try? element.select("p.result-description").text()) + ?? (try? element.select("ul.breadcrumb-list").text()) + ?? "" + results.append(WebSearchResult.WebPage( + urlString: { + if link.hasPrefix("/") { + return "https://developer.apple.com\(link)" + } + return link + }(), + title: title, + snippet: snippet + )) + } + } + + return WebSearchResult(webPages: results) + } +} + diff --git a/Tool/Sources/BingSearchService/BingSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/BingSearchService.swift similarity index 64% rename from Tool/Sources/BingSearchService/BingSearchService.swift rename to Tool/Sources/WebSearchService/SearchServices/BingSearchService.swift index 4cc4b88c..0f373168 100644 --- a/Tool/Sources/BingSearchService/BingSearchService.swift +++ b/Tool/Sources/WebSearchService/SearchServices/BingSearchService.swift @@ -1,19 +1,19 @@ import Foundation -public struct BingSearchResult: Codable { - public var webPages: WebPages +struct BingSearchResult: Codable { + var webPages: WebPages - public struct WebPages: Codable { - public var webSearchUrl: String - public var totalEstimatedMatches: Int - public var value: [WebPageValue] + struct WebPages: Codable { + var webSearchUrl: String + var totalEstimatedMatches: Int + var value: [WebPageValue] - public struct WebPageValue: Codable { - public var id: String - public var name: String - public var url: String - public var displayUrl: String - public var snippet: String + struct WebPageValue: Codable { + var id: String + var name: String + var url: String + var displayUrl: String + var snippet: String } } } @@ -37,21 +37,32 @@ enum BingSearchError: Error, LocalizedError { case let .searchURLFormatIncorrect(url): return "The search URL format is incorrect: \(url)" case .subscriptionKeyNotAvailable: - return "The I didn't provide a subscription key to use Bing search." + return "Bing search subscription key is not available, please set it up in the service tab." } } } -public struct BingSearchService { - public var subscriptionKey: String - public var searchURL: String +struct BingSearchService: SearchService { + var subscriptionKey: String + var searchURL: String - public init(subscriptionKey: String, searchURL: String) { + init(subscriptionKey: String, searchURL: String) { self.subscriptionKey = subscriptionKey self.searchURL = searchURL } - public func search( + func search(query: String) async throws -> WebSearchResult { + let result = try await search(query: query, numberOfResult: 10) + return WebSearchResult(webPages: result.webPages.value.map { + WebSearchResult.WebPage( + urlString: $0.url, + title: $0.name, + snippet: $0.snippet + ) + }) + } + + func search( query: String, numberOfResult: Int, freshness: String? = nil diff --git a/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift new file mode 100644 index 00000000..949004ca --- /dev/null +++ b/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift @@ -0,0 +1,285 @@ +import Foundation +import SwiftSoup +import WebKit +import WebScrapper + +struct HeadlessBrowserSearchService: SearchService { + let engine: WebSearchProvider.HeadlessBrowserEngine + + func search(query: String) async throws -> WebSearchResult { + let queryEncoded = query + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let url = switch engine { + case .google: + URL(string: "https://www.google.com/search?q=\(queryEncoded)")! + case .baidu: + URL(string: "https://www.baidu.com/s?wd=\(queryEncoded)")! + case .duckDuckGo: + URL(string: "https://duckduckgo.com/?q=\(queryEncoded)")! + case .bing: + URL(string: "https://www.bing.com/search?q=\(queryEncoded)")! + } + + let scrapper = await WebScrapper() + let html = try await scrapper.fetch(url: url) { document in + switch engine { + case .google: + return GoogleSearchResultParser.validate(document: document) + case .baidu: + return BaiduSearchResultParser.validate(document: document) + case .duckDuckGo: + return DuckDuckGoSearchResultParser.validate(document: document) + case .bing: + return BingSearchResultParser.validate(document: document) + } + } + + switch engine { + case .google: + return try GoogleSearchResultParser.parse(html: html) + case .baidu: + return await BaiduSearchResultParser.parse(html: html) + case .duckDuckGo: + return DuckDuckGoSearchResultParser.parse(html: html) + case .bing: + return BingSearchResultParser.parse(html: html) + } + } +} + +enum GoogleSearchResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + guard let _ = try? document.select("#rso").first + else { return false } + return true + } + + static func parse(html: String) throws -> WebSearchResult { + let document = try SwiftSoup.parse(html) + let searchResult = try document.select("#rso").first + + guard let searchResult else { return .init(webPages: []) } + + var results: [WebSearchResult.WebPage] = [] + for element in searchResult.children() { + if let title = try? element.select("h3").text(), + let link = try? element.select("a").attr("href"), + !link.isEmpty, + // A magic class name. + let snippet = try? element.select("div.VwiC3b").first()?.text() + ?? element.select("span.st").first()?.text() + { + results.append(WebSearchResult.WebPage( + urlString: link, + title: title, + snippet: snippet + )) + } + } + + return WebSearchResult(webPages: results) + } +} + +enum BaiduSearchResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + return (try? document.select("#content_left").first()) != nil + } + + static func getRealLink(from baiduLink: String) async -> String { + guard let url = URL(string: baiduLink) else { + return baiduLink + } + + let config = URLSessionConfiguration.default + config.httpShouldSetCookies = true + config.httpCookieAcceptPolicy = .always + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.addValue( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + forHTTPHeaderField: "User-Agent" + ) + + let redirectCapturer = RedirectCapturer() + let session = URLSession( + configuration: config, + delegate: redirectCapturer, + delegateQueue: nil + ) + + do { + let _ = try await session.data(for: request) + + if let finalURL = redirectCapturer.finalURL { + return finalURL.absoluteString + } + + return baiduLink + } catch { + return baiduLink + } + } + + class RedirectCapturer: NSObject, URLSessionTaskDelegate { + var finalURL: URL? + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + finalURL = request.url + completionHandler(request) + } + } + + static func parse(html: String) async -> WebSearchResult { + let document = try? SwiftSoup.parse(html) + let elements = try? document?.select("#content_left").first()?.children() + + var results: [WebSearchResult.WebPage] = [] + if let elements = elements { + for element in elements { + if let titleElement = try? element.select("h3").first(), + let link = try? element.select("a").attr("href"), + link.hasPrefix("http") + { + let realLink = await getRealLink(from: link) + let title = (try? titleElement.text()) ?? "" + let snippet = { + let abstract = try? element.select("div[data-module=\"abstract\"]").text() + if let abstract, !abstract.isEmpty { + return abstract + } + return (try? titleElement.nextElementSibling()?.text()) ?? "" + }() + results.append(WebSearchResult.WebPage( + urlString: realLink, + title: title, + snippet: snippet + )) + } + } + } + + return WebSearchResult(webPages: results) + } +} + +enum DuckDuckGoSearchResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + return (try? document.select(".react-results--main").first()) != nil + } + + static func parse(html: String) -> WebSearchResult { + let document = try? SwiftSoup.parse(html) + let body = document?.body() + + var results: [WebSearchResult.WebPage] = [] + + if let reactResults = try? body?.select(".react-results--main") { + for object in reactResults { + for element in object.children() { + if let linkElement = try? element.select("a[data-testid=\"result-title-a\"]"), + let link = try? linkElement.attr("href"), + link.hasPrefix("http"), + let titleElement = try? element.select("span").first() + { + let title = (try? titleElement.select("span").first()?.text()) ?? "" + let snippet = ( + try? element.select("[data-result=snippet]").first()?.text() + ) ?? "" + + results.append(WebSearchResult.WebPage( + urlString: link, + title: title, + snippet: snippet + )) + } + } + } + } + + return WebSearchResult(webPages: results) + } +} + +enum BingSearchResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + return (try? document.select("#b_results").first()) != nil + } + + static func getRealLink(from bingLink: String) -> String { + guard let url = URL(string: bingLink) else { return bingLink } + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let queryItems = components.queryItems, + var uParam = queryItems.first(where: { $0.name == "u" })?.value + { + if uParam.hasPrefix("a1aHR") { + uParam.removeFirst() + uParam.removeFirst() + } + + func decode() -> String? { + guard let decodedData = Data(base64Encoded: uParam), + let decodedString = String(data: decodedData, encoding: .utf8) + else { return nil } + return decodedString + } + + if let decodedString = decode() { + return decodedString + } + uParam += "=" + if let decodedString = decode() { + return decodedString + } + uParam += "=" + if let decodedString = decode() { + return decodedString + } + } + + return bingLink + } + + static func parse(html: String) -> WebSearchResult { + let document = try? SwiftSoup.parse(html) + let searchResults = try? document?.select("#b_results").first() + + var results: [WebSearchResult.WebPage] = [] + if let elements = try? searchResults?.select("li.b_algo") { + for element in elements { + if let titleElement = try? element.select("h2").first(), + let linkElement = try? titleElement.select("a").first(), + let link = try? linkElement.attr("href"), + link.hasPrefix("http") + { + let link = getRealLink(from: link) + let title = (try? titleElement.text()) ?? "" + let snippet = { + if let it = try? element.select(".b_caption p").first()?.text(), + !it.isEmpty { return it } + if let it = try? element.select(".b_lineclamp2").first()?.text(), + !it.isEmpty { return it } + return (try? element.select("p").first()?.text()) ?? "" + }() + + results.append(WebSearchResult.WebPage( + urlString: link, + title: title, + snippet: snippet + )) + } + } + } + + return WebSearchResult(webPages: results) + } +} + diff --git a/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift b/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift new file mode 100644 index 00000000..0fa7a1ee --- /dev/null +++ b/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift @@ -0,0 +1,67 @@ +import Foundation + +struct SerpAPIResponse: Codable { + var organic_results: [OrganicResult] + + struct OrganicResult: Codable { + var position: Int? + var title: String? + var link: String? + var snippet: String? + + func toWebSearchResult() -> WebSearchResult.WebPage? { + guard let link, let title else { return nil } + return WebSearchResult.WebPage(urlString: link, title: title, snippet: snippet ?? "") + } + } + + func toWebSearchResult() -> WebSearchResult { + return WebSearchResult(webPages: organic_results.compactMap { $0.toWebSearchResult() }) + } +} + +struct SerpAPISearchService: SearchService { + let engine: WebSearchProvider.SerpAPIEngine + let endpoint: URL = .init(string: "https://serpapi.com/search.json")! + let apiKey: String + + init(engine: WebSearchProvider.SerpAPIEngine, apiKey: String) { + self.engine = engine + self.apiKey = apiKey + } + + func search(query: String) async throws -> WebSearchResult { + var request = URLRequest(url: endpoint) + request.httpMethod = "GET" + var urlComponents = URLComponents(url: endpoint, resolvingAgainstBaseURL: true)! + urlComponents.queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "engine", value: engine.rawValue), + URLQueryItem(name: "api_key", value: apiKey) + ] + + guard let url = urlComponents.url else { + throw URLError(.badURL) + } + + request = URLRequest(url: url) + request.httpMethod = "GET" + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + // Parse the response into WebSearchResult + let decoder = JSONDecoder() + + do { + let searchResponse = try decoder.decode(SerpAPIResponse.self, from: data) + return searchResponse.toWebSearchResult() + } catch { + throw error + } + } +} + diff --git a/Tool/Sources/WebSearchService/WebSearchService.swift b/Tool/Sources/WebSearchService/WebSearchService.swift new file mode 100644 index 00000000..7eceade4 --- /dev/null +++ b/Tool/Sources/WebSearchService/WebSearchService.swift @@ -0,0 +1,75 @@ +import Foundation +import Preferences +import Keychain + +public enum WebSearchProvider { + public enum SerpAPIEngine: String { + case google + case baidu + case bing + case duckDuckGo = "duckduckgo" + } + + public enum HeadlessBrowserEngine: String { + case google + case baidu + case bing + case duckDuckGo = "duckduckgo" + } + + case serpAPI(SerpAPIEngine, apiKey: String) + case headlessBrowser(HeadlessBrowserEngine) + case appleDocumentation + + public static var userPreferred: WebSearchProvider { + switch UserDefaults.shared.value(for: \.searchProvider) { + case .headlessBrowser: + return .headlessBrowser(.init( + rawValue: UserDefaults.shared.value(for: \.headlessBrowserEngine).rawValue + ) ?? .google) + case .serpAPI: + let apiKeyName = UserDefaults.shared.value(for: \.serpAPIKeyName) + return .serpAPI(.init( + rawValue: UserDefaults.shared.value(for: \.serpAPIEngine).rawValue + ) ?? .google, apiKey: (try? Keychain.apiKey.get(apiKeyName)) ?? "") + } + } +} + +public struct WebSearchResult: Equatable { + public struct WebPage: Equatable { + public var urlString: String + public var title: String + public var snippet: String + } + + public var webPages: [WebPage] +} + +public protocol SearchService { + func search(query: String) async throws -> WebSearchResult +} + +public struct WebSearchService { + let service: SearchService + + init(service: SearchService) { + self.service = service + } + + public init(provider: WebSearchProvider) { + switch provider { + case let .serpAPI(engine, apiKey): + service = SerpAPISearchService(engine: engine, apiKey: apiKey) + case let .headlessBrowser(engine): + service = HeadlessBrowserSearchService(engine: engine) + case .appleDocumentation: + service = AppleDocumentationSearchService() + } + } + + public func search(query: String) async throws -> WebSearchResult { + return try await service.search(query: query) + } +} + diff --git a/Tool/Sources/Workspace/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift index 264a3dae..be508239 100644 --- a/Tool/Sources/Workspace/Filespace.swift +++ b/Tool/Sources/Workspace/Filespace.swift @@ -1,7 +1,7 @@ import Dependencies import Foundation import GitIgnoreCheck -import SuggestionModel +import SuggestionBasic public protocol FilespacePropertyKey { associatedtype Value @@ -62,7 +62,7 @@ public struct FilespaceCodeMetadata: Equatable { } @dynamicMemberLookup -public final class Filespace { +public final class Filespace: @unchecked Sendable { struct GitIgnoreStatus { var isIgnored: Bool var checkTime: Date @@ -88,17 +88,22 @@ public final class Filespace { } public var presentingSuggestion: CodeSuggestion? { - guard suggestions.endIndex > suggestionIndex, suggestionIndex >= 0 else { return nil } + guard suggestions.endIndex > suggestionIndex, suggestionIndex >= 0 else { + if suggestions.isEmpty { + return nil + } + return suggestions.first + } return suggestions[suggestionIndex] } // MARK: Life Cycle public var isExpired: Bool { - Environment.now().timeIntervalSince(lastUpdateTime) > 60 * 3 + Environment.now().timeIntervalSince(lastUpdateTime) > 60 * 60 } - public private(set) var lastUpdateTime: Date = Environment.now() + public internal(set) var lastUpdateTime: Date = Environment.now() private var additionalProperties = FilespacePropertyValues() let fileSaveWatcher: FileSaveWatcher let onClose: (URL) -> Void @@ -121,6 +126,9 @@ public final class Filespace { return isIgnored } } + + @WorkspaceActor + public private(set) var version: Int = 0 // MARK: Methods @@ -181,5 +189,10 @@ public final class Filespace { suggestionIndex = suggestions.endIndex - 1 } } + + @WorkspaceActor + public func bumpVersion() { + version += 1 + } } diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index 40c87261..e7dc9d0e 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -1,6 +1,6 @@ import Foundation import Preferences -import SuggestionModel +import SuggestionBasic import UserDefaultsObserver import XcodeInspector @@ -49,7 +49,7 @@ open class WorkspacePlugin { } @dynamicMemberLookup -public final class Workspace { +public final class Workspace: @unchecked Sendable { public struct UnsupportedFileError: Error, LocalizedError { public var extensionName: String public var errorDescription: String? { @@ -67,6 +67,13 @@ public final class Workspace { } } + public struct CantFindFileError: Error, LocalizedError { + public var fileURL: URL + public var errorDescription: String? { + "Can't find \(fileURL)." + } + } + private var additionalProperties = WorkspacePropertyValues() public internal(set) var plugins = [ObjectIdentifier: WorkspacePlugin]() public let workspaceURL: URL @@ -74,7 +81,7 @@ public final class Workspace { public let openedFileRecoverableStorage: OpenedFileRecoverableStorage public private(set) var lastLastUpdateTime = Environment.now() public var isExpired: Bool { - Environment.now().timeIntervalSince(lastLastUpdateTime) > 60 * 60 * 1 + Environment.now().timeIntervalSince(lastLastUpdateTime) > 30 * 60 * 1 } public private(set) var filespaces = [URL: Filespace]() @@ -107,7 +114,7 @@ public final class Workspace { let openedFiles = openedFileRecoverableStorage.openedFiles Task { @WorkspaceActor in for fileURL in openedFiles { - _ = createFilespaceIfNeeded(fileURL: fileURL) + _ = try? createFilespaceIfNeeded(fileURL: fileURL) } } } @@ -117,7 +124,25 @@ public final class Workspace { } @WorkspaceActor - public func createFilespaceIfNeeded(fileURL: URL) -> Filespace { + public func createFilespaceIfNeeded( + fileURL: URL, + checkIfFileExists: Bool = true + ) throws -> Filespace { + let extensionName = fileURL.pathExtension + if ["xcworkspace", "xcodeproj"].contains(extensionName) { + throw UnsupportedFileError(extensionName: extensionName) + } + var isDirectory: ObjCBool = false + if checkIfFileExists, !FileManager.default.fileExists( + atPath: fileURL.path, + isDirectory: &isDirectory + ) { + throw CantFindFileError(fileURL: fileURL) + } + if isDirectory.boolValue { + throw UnsupportedFileError(extensionName: extensionName) + } + let existedFilespace = filespaces[fileURL] let filespace = existedFilespace ?? .init( fileURL: fileURL, @@ -150,6 +175,7 @@ public final class Workspace { public func didUpdateFilespace(fileURL: URL, content: String) { refreshUpdateTime() guard let filespace = filespaces[fileURL] else { return } + filespace.bumpVersion() filespace.refreshUpdateTime() for plugin in plugins.values { plugin.didUpdateFilespace(filespace, content: content) diff --git a/Tool/Sources/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift index 2b9a0737..819f1ecc 100644 --- a/Tool/Sources/Workspace/WorkspacePool.swift +++ b/Tool/Sources/Workspace/WorkspacePool.swift @@ -61,6 +61,15 @@ public class WorkspacePool { } public func fetchFilespaceIfExisted(fileURL: URL) -> Filespace? { + // We prefer to get the filespace from the current active workspace. + // Just incase there are multiple workspaces opened with the same file. + if let currentWorkspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL { + if let workspace = workspaces[currentWorkspaceURL], + let filespace = workspace.filespaces[fileURL] + { + return filespace + } + } for workspace in workspaces.values { if let filespace = workspace.filespaces[fileURL] { return filespace @@ -68,6 +77,11 @@ public class WorkspacePool { } return nil } + + @WorkspaceActor + public func destroy() { + workspaces = [:] + } @WorkspaceActor public func fetchOrCreateWorkspace(workspaceURL: URL) async throws -> Workspace { @@ -85,20 +99,29 @@ public class WorkspacePool { } @WorkspaceActor - public func fetchOrCreateWorkspaceAndFilespace(fileURL: URL) async throws + public func fetchOrCreateWorkspaceAndFilespace( + fileURL: URL, + checkIfFileExists: Bool = true + ) async throws -> (workspace: Workspace, filespace: Filespace) { // If we can get the workspace URL directly. - if let currentWorkspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL { + if let currentWorkspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL { if let existed = workspaces[currentWorkspaceURL] { // Reuse the existed workspace. - let filespace = existed.createFilespaceIfNeeded(fileURL: fileURL) + let filespace = try existed.createFilespaceIfNeeded( + fileURL: fileURL, + checkIfFileExists: checkIfFileExists + ) return (existed, filespace) } let new = createNewWorkspace(workspaceURL: currentWorkspaceURL) workspaces[currentWorkspaceURL] = new - let filespace = new.createFilespaceIfNeeded(fileURL: fileURL) + let filespace = try new.createFilespaceIfNeeded( + fileURL: fileURL, + checkIfFileExists: checkIfFileExists + ) return (new, filespace) } @@ -132,12 +155,15 @@ public class WorkspacePool { return createNewWorkspace(workspaceURL: workspaceURL) }() - let filespace = workspace.createFilespaceIfNeeded(fileURL: fileURL) + let filespace = try workspace.createFilespaceIfNeeded( + fileURL: fileURL, + checkIfFileExists: checkIfFileExists + ) workspaces[workspaceURL] = workspace workspace.refreshUpdateTime() return (workspace, filespace) } - + throw Workspace.CantFindWorkspaceError() } diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift index 73897d0d..25afb616 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift @@ -1,24 +1,52 @@ import Foundation -import SuggestionModel +import SuggestionBasic +import SuggestionInjector import Workspace +/// The moment when a suggestion is generated. public struct FilespaceSuggestionSnapshot: Equatable { - #warning("TODO: Can we remove it?") - public var linesHash: Int + public var editingLine: String public var cursorPosition: CursorPosition + public var editingLinePrefix: String + public var editingLineSuffix: String + + public static func == ( + lhs: FilespaceSuggestionSnapshot, + rhs: FilespaceSuggestionSnapshot + ) -> Bool { + lhs.editingLine == rhs.editingLine + && lhs.cursorPosition == rhs.cursorPosition + } - public init(linesHash: Int, cursorPosition: CursorPosition) { - self.linesHash = linesHash + public init(lines: [String], cursorPosition: CursorPosition) { self.cursorPosition = cursorPosition + editingLine = if cursorPosition.line >= 0 && cursorPosition.line < lines.count { + lines[cursorPosition.line] + } else { + "" + } + let col = cursorPosition.character + let view = editingLine.utf16 + editingLinePrefix = if col >= 0 { + String(view.prefix(col)) ?? "" + } else { + "" + } + editingLineSuffix = if col >= 0, col < editingLine.utf16.count { + String(view[view.index(view.startIndex, offsetBy: col)...]) ?? "" + } else { + "" + } } } public struct FilespaceSuggestionSnapshotKey: FilespacePropertyKey { public static func createDefaultValue() - -> FilespaceSuggestionSnapshot { .init(linesHash: -1, cursorPosition: .outOfScope) } + -> FilespaceSuggestionSnapshot { .init(lines: [], cursorPosition: .outOfScope) } } public extension FilespacePropertyValues { + /// The state of the file when a suggestion is generated. @WorkspaceActor var suggestionSourceSnapshot: FilespaceSuggestionSnapshot { get { self[FilespaceSuggestionSnapshotKey.self] } @@ -29,102 +57,186 @@ public extension FilespacePropertyValues { public extension Filespace { @WorkspaceActor func resetSnapshot() { - // swiftformat:disable redundantSelf - self.suggestionSourceSnapshot = FilespaceSuggestionSnapshotKey.createDefaultValue() - // swiftformat:enable all + self[keyPath: \.suggestionSourceSnapshot] = FilespaceSuggestionSnapshotKey + .createDefaultValue() } /// Validate the suggestion is still valid. /// - Parameters: /// - lines: lines of the file /// - cursorPosition: cursor position + /// - alwaysTrueIfCursorNotMoved: for unit tests /// - Returns: `true` if the suggestion is still valid @WorkspaceActor - func validateSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { + func validateSuggestions( + lines: [String], + cursorPosition: CursorPosition, + alwaysTrueIfCursorNotMoved: Bool = true + ) -> Bool { guard let presentingSuggestion else { return false } - - // cursor has moved to another line - if cursorPosition.line != presentingSuggestion.position.line { + let snapshot = self[keyPath: \.suggestionSourceSnapshot] + if snapshot.cursorPosition == .outOfScope { return false } + + guard Self.validateSuggestion( + presentingSuggestion, + snapshot: snapshot, + lines: lines, + cursorPosition: cursorPosition, + alwaysTrueIfCursorNotMoved: alwaysTrueIfCursorNotMoved + ) else { reset() resetSnapshot() return false } + return true + } +} + +extension Filespace { + static func validateSuggestion( + _ suggestion: CodeSuggestion, + snapshot: FilespaceSuggestionSnapshot, + lines: [String], + cursorPosition: CursorPosition, + // For test + alwaysTrueIfCursorNotMoved: Bool = true + ) -> Bool { + // cursor is not even moved during the generation. + if alwaysTrueIfCursorNotMoved, cursorPosition == suggestion.position { return true } + + // cursor has moved to another line + if cursorPosition.line != suggestion.position.line { return false } + // the cursor position is valid - guard cursorPosition.line >= 0, cursorPosition.line < lines.count else { - reset() - resetSnapshot() + guard cursorPosition.line >= 0, cursorPosition.line < lines.count else { return false } + + let editingLine = lines[cursorPosition.line].dropLast(1) // dropping line ending + let suggestionLines = suggestion.text.breakLines(appendLineBreakToLastLine: true) + + if Self.validateThatIsNotTypingSuggestion( + suggestion, + snapshot: snapshot, + lines: lines, + suggestionLines: suggestionLines, + cursorPosition: cursorPosition + ) { return false } - let editingLine = lines[cursorPosition.line].dropLast(1) // dropping \n - let suggestionLines = presentingSuggestion.text.split(whereSeparator: \.isNewline) - let suggestionFirstLine = suggestionLines.first ?? "" + // if the line will not change after accepting the suggestion + if Self.validateThatSuggestionMakeNoDifferent( + suggestion, + lines: lines, + suggestionLines: suggestionLines + ) { + return false + } - /// For example: - /// ``` - /// ABCD012 // typed text - /// ^ - /// 0123456 // suggestion range 4-11, generated after `ABCD` - /// ``` - /// The suggestion should contain `012`, aka, the suggestion that is typed. - /// - /// Another case is that the suggestion may contain the whole line. - /// /// ``` - /// ABCD012 // typed text - /// ----^ - /// ABCD0123456 // suggestion range 0-11, generated after `ABCD` - /// The suggestion should contain `ABCD012`, aka, the suggestion that is typed. - /// ``` - let typedSuggestion = { - assert( - presentingSuggestion.range.start.character >= 0, - "Generating suggestion with invalid range" - ) - - let startIndex = editingLine.index( - editingLine.startIndex, - offsetBy: max(0, presentingSuggestion.range.start.character), - limitedBy: editingLine.endIndex - ) ?? editingLine.startIndex - - let endIndex = editingLine.index( - editingLine.startIndex, - offsetBy: cursorPosition.character, - limitedBy: editingLine.endIndex - ) ?? editingLine.endIndex - - if endIndex > startIndex { - return String(editingLine[startIndex.. 0, - !suggestionFirstLine.hasPrefix(typedSuggestion) - { - reset() - resetSnapshot() - return false + static func validateThatIsNotTypingSuggestion( + _ suggestion: CodeSuggestion, + snapshot: FilespaceSuggestionSnapshot, + lines: [String], + suggestionLines: [String], + cursorPosition: CursorPosition + ) -> Bool { + let lineIndex = suggestion.range.start.line + let typeStart = suggestion.position.character + let cursorColumn = cursorPosition.character + let suggestionStart = max( + 0, + suggestion.position.character - suggestion.range.start.character + ) + func contentBeforeCursor( + _ string: String, + start: Int + ) -> ArraySlice { + if start >= cursorColumn { return [] } + let elements = Array(string.utf16) + guard start >= 0, start < elements.endIndex else { return [] } + let endIndex = min(elements.endIndex, cursorColumn) + return elements[start..= 0, lineIndex < lines.endIndex else { return false } + let editingLine = lines[lineIndex] + let suggestionFirstLine = suggestionLines.first ?? "" + + let typed = contentBeforeCursor(editingLine, start: typeStart) + let expectedTyped = contentBeforeCursor(suggestionFirstLine, start: suggestionStart) + return typed != expectedTyped + } + + static func validateThatSuggestionMakeNoDifferent( + _ suggestion: CodeSuggestion, + lines: [String], + suggestionLines: [String] + ) -> Bool { + var editingRange = suggestion.range + let startLine = max(0, editingRange.start.line) + let endLine = max(startLine, min(editingRange.end.line, lines.count - 1)) + + // The editing range is out of the file + if startLine < 0 || endLine >= lines.count { return false } - // undo to a state before the suggestion was generated - if editingLine.count < presentingSuggestion.position.character { - reset() - resetSnapshot() + // The suggestion is apparently longer than the editing range + if endLine - startLine + 1 != suggestionLines.count { return false } - return true + let originalEditingLines = Array(lines[startLine...endLine]) + var editingLines = originalEditingLines + editingRange.end = .init( + line: editingRange.end.line - editingRange.start.line, + character: editingRange.end.character + ) + editingRange.start = .init(line: 0, character: editingRange.start.character) + var cursorPosition = CursorPosition( + line: suggestion.position.line - startLine, + character: suggestion.position.character + ) + let pseudoSuggestion = CodeSuggestion( + id: "", + text: suggestion.text, + position: cursorPosition, + range: editingRange + ) + var extraInfo = SuggestionInjector.ExtraInfo() + let injector = SuggestionInjector() + injector.acceptSuggestion( + intoContentWithoutSuggestion: &editingLines, + cursorPosition: &cursorPosition, + completion: pseudoSuggestion, + extraInfo: &extraInfo + ) + + // We want that finish typing a partial suggestion should also make no difference. + if let lastOriginalLine = originalEditingLines.last, + cursorPosition.character < lastOriginalLine.utf16.count, + // But we also want to separate this case from the case that the suggestion is + // shortening the last line. Which does make a difference. + suggestion.range.end.character < lastOriginalLine.utf16.count - 1 // for line ending + { + let editingLinesPrefix = editingLines.dropLast() + let originalEditingLinesPrefix = originalEditingLines.dropLast() + if editingLinesPrefix != originalEditingLinesPrefix { + return false + } + let lastEditingLine = editingLines.last ?? "\n" + return lastOriginalLine.hasPrefix(lastEditingLine.dropLast(1)) // for line ending + } + + return editingLines == originalEditingLines } } diff --git a/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift b/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift index 3e999628..ac53d042 100644 --- a/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift +++ b/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift @@ -1,29 +1,36 @@ +import BuiltinExtension import Foundation import Preferences -import SuggestionModel +import SuggestionBasic import SuggestionProvider import UserDefaultsObserver import Workspace +#if canImport(ProExtension) +import ProExtension +#endif + public final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { - public typealias SuggestionServiceFactory = ( - _ projectRootURL: URL, - _ onServiceLaunched: @escaping (any SuggestionServiceProvider) -> Void - ) -> any SuggestionServiceProvider - - let userDefaultsObserver = UserDefaultsObserver( + public typealias SuggestionServiceFactory = () -> any SuggestionServiceProvider + let suggestionServiceFactory: SuggestionServiceFactory + + let suggestionFeatureUsabilityObserver = UserDefaultsObserver( object: UserDefaults.shared, forKeyPaths: [ UserDefaultPreferenceKeys().suggestionFeatureEnabledProjectList.key, UserDefaultPreferenceKeys().disableSuggestionFeatureGlobally.key, ], context: nil ) + let providerChangeObserver = UserDefaultsObserver( + object: UserDefaults.shared, + forKeyPaths: [UserDefaultPreferenceKeys().suggestionFeatureProvider.key], + context: nil + ) + public var isRealtimeSuggestionEnabled: Bool { UserDefaults.shared.value(for: \.realtimeSuggestionToggle) } - let suggestionServiceFactory: SuggestionServiceFactory - private var _suggestionService: SuggestionServiceProvider? public var suggestionService: SuggestionServiceProvider? { @@ -40,13 +47,7 @@ public final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { } if _suggestionService == nil { - _suggestionService = suggestionServiceFactory(projectRootURL) { - [weak self] _ in - guard let self else { return } - for (_, filespace) in filespaces { - notifyOpenFile(filespace: filespace) - } - } + _suggestionService = suggestionServiceFactory() } return _suggestionService } @@ -62,77 +63,37 @@ public final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { } return true } - + public init( workspace: Workspace, suggestionProviderFactory: @escaping SuggestionServiceFactory ) { - self.suggestionServiceFactory = suggestionProviderFactory + suggestionServiceFactory = suggestionProviderFactory super.init(workspace: workspace) - userDefaultsObserver.onChange = { [weak self] in + suggestionFeatureUsabilityObserver.onChange = { [weak self] in guard let self else { return } _ = self.suggestionService } - } - 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 { - try await suggestionService?.notifyCloseTextDocument(fileURL: fileURL) - } - } - - public func notifyOpenFile(filespace: Filespace) { - Task { - guard filespace.isTextReadable else { return } - guard !(await filespace.isGitIgnored) else { return } - // check if file size is larger than 15MB, if so, return immediately - if let attrs = try? FileManager.default - .attributesOfItem(atPath: filespace.fileURL.path), - let fileSize = attrs[FileAttributeKey.size] as? UInt64, - fileSize > 15 * 1024 * 1024 - { return } - - try await suggestionService?.notifyOpenTextDocument( - fileURL: filespace.fileURL, - content: String(contentsOf: filespace.fileURL, encoding: .utf8) - ) - } - } - - public func notifyUpdateFile(filespace: Filespace, content: String) { - Task { - guard filespace.isTextReadable else { return } - guard !(await filespace.isGitIgnored) else { return } - try await suggestionService?.notifyChangeTextDocument( - fileURL: filespace.fileURL, - content: content - ) + providerChangeObserver.onChange = { [weak self] in + guard let self else { return } + self._suggestionService = nil } } - public func notifySaveFile(filespace: Filespace) { - Task { - guard filespace.isTextReadable else { return } - guard !(await filespace.isGitIgnored) else { return } - try await suggestionService?.notifySaveTextDocument(fileURL: filespace.fileURL) - } + func notifyAccepted(_ suggestion: CodeSuggestion) async { + await suggestionService?.notifyAccepted( + suggestion, + workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) + ) } - public func terminateSuggestionService() async { - await _suggestionService?.terminate() + func notifyRejected(_ suggestions: [CodeSuggestion]) async { + await suggestionService?.notifyRejected( + suggestions, + workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) + ) } } diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift index 850eb405..99abe305 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift @@ -1,5 +1,5 @@ import Foundation -import SuggestionModel +import SuggestionBasic import SuggestionProvider import Workspace import XPCShared @@ -33,7 +33,7 @@ public extension Workspace { ) async throws -> [CodeSuggestion] { refreshUpdateTime() - let filespace = createFilespaceIfNeeded(fileURL: fileURL) + let filespace = try createFilespaceIfNeeded(fileURL: fileURL) guard !(await filespace.isGitIgnored) else { return [] } @@ -47,26 +47,29 @@ public extension Workspace { filespace.codeMetadata.guessLineEnding(from: editor.lines.first) let snapshot = FilespaceSuggestionSnapshot( - linesHash: editor.lines.hashValue, + lines: editor.lines, cursorPosition: editor.cursorPosition ) filespace.suggestionSourceSnapshot = snapshot guard let suggestionService else { throw SuggestionFeatureDisabledError() } + let content = editor.lines.joined(separator: "") let completions = try await suggestionService.getSuggestions( .init( fileURL: fileURL, relativePath: fileURL.path.replacingOccurrences(of: projectRootURL.path, with: ""), - content: editor.lines.joined(separator: ""), + content: content, + originalContent: content, lines: editor.lines, cursorPosition: editor.cursorPosition, + cursorOffset: editor.cursorOffset, tabSize: editor.tabSize, indentSize: editor.indentSize, usesTabsForIndentation: editor.usesTabsForIndentation, - ignoreSpaceOnlySuggestions: true, relevantCodeSnippets: [] - ) + ), + workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) ) filespace.setSuggestions(completions) @@ -102,9 +105,15 @@ public extension Workspace { filespaces[fileURL]?.codeMetadata.indentSize = editor.indentSize filespaces[fileURL]?.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation } - + Task { - await suggestionService?.notifyRejected(filespaces[fileURL]?.suggestions ?? []) + await suggestionService?.notifyRejected( + filespaces[fileURL]?.suggestions ?? [], + workspaceInfo: .init( + workspaceURL: workspaceURL, + projectURL: projectRootURL + ) + ) } filespaces[fileURL]?.reset() } @@ -128,9 +137,14 @@ public extension Workspace { var allSuggestions = filespace.suggestions let suggestion = allSuggestions.remove(at: filespace.suggestionIndex) - Task { [allSuggestions] in - await suggestionService?.notifyAccepted(suggestion) - await suggestionService?.notifyRejected(allSuggestions) + Task { + await suggestionService?.notifyAccepted( + suggestion, + workspaceInfo: .init( + workspaceURL: workspaceURL, + projectURL: projectRootURL + ) + ) } filespaces[fileURL]?.reset() diff --git a/Tool/Sources/XPCShared/CommunicationBridgeXPCServiceProtocol.swift b/Tool/Sources/XPCShared/CommunicationBridgeXPCServiceProtocol.swift new file mode 100644 index 00000000..aaf8c05b --- /dev/null +++ b/Tool/Sources/XPCShared/CommunicationBridgeXPCServiceProtocol.swift @@ -0,0 +1,12 @@ +import Foundation + +@objc(CommunicationBridgeXPCServiceProtocol) +public protocol CommunicationBridgeXPCServiceProtocol { + func launchExtensionServiceIfNeeded(withReply reply: @escaping (NSXPCListenerEndpoint?) -> Void) + func quit(withReply reply: @escaping () -> Void) + func updateServiceEndpoint( + endpoint: NSXPCListenerEndpoint, + withReply reply: @escaping () -> Void + ) +} + diff --git a/Tool/Sources/XPCShared/Models.swift b/Tool/Sources/XPCShared/Models.swift index 7a17a951..82118502 100644 --- a/Tool/Sources/XPCShared/Models.swift +++ b/Tool/Sources/XPCShared/Models.swift @@ -1,5 +1,5 @@ -import SuggestionModel import Foundation +import SuggestionBasic public struct EditorContent: Codable { public struct Selection: Codable { @@ -17,6 +17,7 @@ public struct EditorContent: Codable { lines: [String], uti: String, cursorPosition: CursorPosition, + cursorOffset: Int, selections: [Selection], tabSize: Int, indentSize: Int, @@ -26,6 +27,7 @@ public struct EditorContent: Codable { self.lines = lines self.uti = uti self.cursorPosition = cursorPosition + self.cursorOffset = cursorOffset self.selections = selections self.tabSize = tabSize self.indentSize = indentSize @@ -37,6 +39,7 @@ public struct EditorContent: Codable { public var lines: [String] public var uti: String public var cursorPosition: CursorPosition + public var cursorOffset: Int public var selections: [Selection] public var tabSize: Int public var indentSize: Int @@ -50,63 +53,28 @@ public struct EditorContent: Codable { public struct UpdatedContent: Codable { public init(content: String, newSelection: CursorRange? = nil, modifications: [Modification]) { self.content = content - self.newSelection = newSelection + self.newSelections = if let newSelection { [newSelection] } else { [] } + self.modifications = modifications + } + + public init(content: String, newSelections: [CursorRange], modifications: [Modification]) { + self.content = content + self.newSelections = newSelections self.modifications = modifications } public var content: String - public var newSelection: CursorRange? + public var newSelections: [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 + return EditorInformation.code( + in: lines, + inside: .init( + start: .init(line: selection.start.line, character: selection.start.character), + end: .init(line: selection.end.line, character: selection.end.character) + ), + ignoreColumns: false + ).code } diff --git a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift new file mode 100644 index 00000000..610b6c53 --- /dev/null +++ b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift @@ -0,0 +1,81 @@ +import Foundation +import Logger + +public enum XPCCommunicationBridgeError: Swift.Error, LocalizedError { + case failedToCreateXPCConnection + case xpcServiceError(Error) + + public var errorDescription: String? { + switch self { + case .failedToCreateXPCConnection: + return "Failed to create XPC connection." + case let .xpcServiceError(error): + return "Connection to communication bridge error: \(error.localizedDescription)" + } + } +} + +public class XPCCommunicationBridge { + let service: XPCService + let logger: Logger + @XPCServiceActor + var serviceEndpoint: NSXPCListenerEndpoint? + + public init(logger: Logger) { + service = .init( + kind: .machService( + identifier: Bundle(for: XPCService.self) + .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String + + ".CommunicationBridge" + ), + interface: NSXPCInterface(with: CommunicationBridgeXPCServiceProtocol.self), + logger: logger + ) + self.logger = logger + } + + public func setDelegate(_ delegate: XPCServiceDelegate?) { + service.delegate = delegate + } + + @discardableResult + public func launchExtensionServiceIfNeeded() async throws -> NSXPCListenerEndpoint? { + try await withXPCServiceConnected { service, continuation in + service.launchExtensionServiceIfNeeded { endpoint in + continuation.resume(endpoint) + } + } + } + + public func quit() async throws { + try await withXPCServiceConnected { service, continuation in + service.quit { + continuation.resume(()) + } + } + } + + public func updateServiceEndpoint(_ endpoint: NSXPCListenerEndpoint) async throws { + try await withXPCServiceConnected { service, continuation in + service.updateServiceEndpoint(endpoint: endpoint) { + continuation.resume(()) + } + } + } +} + +extension XPCCommunicationBridge { + @XPCServiceActor + func withXPCServiceConnected( + _ fn: @escaping (CommunicationBridgeXPCServiceProtocol, AutoFinishContinuation) -> Void + ) async throws -> T { + guard let connection = service.connection + else { throw XPCCommunicationBridgeError.failedToCreateXPCConnection } + do { + return try await XPCShared.withXPCServiceConnected(connection: connection, fn) + } catch { + throw XPCCommunicationBridgeError.xpcServiceError(error) + } + } +} + diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift new file mode 100644 index 00000000..956e7bcb --- /dev/null +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -0,0 +1,310 @@ +import Foundation +import Logger + +public enum XPCExtensionServiceError: Swift.Error, LocalizedError { + case failedToGetServiceEndpoint + case failedToCreateXPCConnection + case xpcServiceError(Error) + + public var errorDescription: String? { + switch self { + case .failedToGetServiceEndpoint: + return "Waiting for service to connect to the communication bridge." + case .failedToCreateXPCConnection: + return "Failed to create XPC connection." + case let .xpcServiceError(error): + return "Connection to extension service error: \(error.localizedDescription)" + } + } +} + +public class XPCExtensionService { + @XPCServiceActor + var service: XPCService? + @XPCServiceActor + var connection: NSXPCConnection? { service?.connection } + let logger: Logger + let bridge: XPCCommunicationBridge + + public nonisolated + init(logger: Logger) { + self.logger = logger + bridge = XPCCommunicationBridge(logger: logger) + } + + /// Launches the extension service if it's not running, returns true if the service has finished + /// launching and the communication becomes available. + @XPCServiceActor + public func launchIfNeeded() async throws -> Bool { + try await bridge.launchExtensionServiceIfNeeded() != nil + } + + public func getXPCServiceVersion() async throws -> (version: String, build: String) { + try await withXPCServiceConnected { + service, continuation in + service.getXPCServiceVersion { version, build in + continuation.resume((version, build)) + } + } + } + + public func getXPCServiceAccessibilityPermission() async throws -> Bool { + try await withXPCServiceConnected { + service, continuation in + service.getXPCServiceAccessibilityPermission { isGranted in + continuation.resume(isGranted) + } + } + } + + public func getSuggestedCode(editorContent: EditorContent) async throws -> UpdatedContent? { + try await suggestionRequest( + editorContent, + { $0.getSuggestedCode } + ) + } + + public func getNextSuggestedCode(editorContent: EditorContent) async throws -> UpdatedContent? { + try await suggestionRequest( + editorContent, + { $0.getNextSuggestedCode } + ) + } + + public func getPreviousSuggestedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + editorContent, + { $0.getPreviousSuggestedCode } + ) + } + + public func getSuggestionAcceptedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + editorContent, + { $0.getSuggestionAcceptedCode } + ) + } + + public func getSuggestionRejectedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + editorContent, + { $0.getSuggestionRejectedCode } + ) + } + + public func getRealtimeSuggestedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + editorContent, + { $0.getRealtimeSuggestedCode } + ) + } + + public func getPromptToCodeAcceptedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + editorContent, + { $0.getPromptToCodeAcceptedCode } + ) + } + + public func toggleRealtimeSuggestion() async throws { + try await withXPCServiceConnected { + 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 { service, continuation in + service.prefetchRealtimeSuggestions(editorContent: data) { + continuation.resume(()) + } + } + } + + public func openChat(editorContent: EditorContent) async throws -> UpdatedContent? { + try await suggestionRequest( + editorContent, + { $0.openChat } + ) + } + + public func promptToCode(editorContent: EditorContent) async throws -> UpdatedContent? { + try await suggestionRequest( + editorContent, + { $0.promptToCode } + ) + } + + public func customCommand( + id: String, + editorContent: EditorContent + ) async throws -> UpdatedContent? { + try await suggestionRequest( + editorContent, + { service in { service.customCommand(id: id, editorContent: $0, withReply: $1) } } + ) + } + + public func quitService() async throws { + try await withXPCServiceConnectedWithoutLaunching { + service, continuation in + service.quit { + continuation.resume(()) + } + } + } + + public func postNotification(name: String) async throws { + try await withXPCServiceConnected { + service, continuation in + service.postNotification(name: name) { + continuation.resume(()) + } + } + } + + public func send( + requestBody: M + ) async throws -> M.ResponseBody { + try await withXPCServiceConnected { service, continuation in + do { + let requestBodyData = try JSONEncoder().encode(requestBody) + service.send(endpoint: M.endpoint, requestBody: requestBodyData) { data, error in + if let error { + continuation.reject(error) + } else { + do { + guard let data = data else { + continuation.reject(NoDataError()) + return + } + let responseBody = try JSONDecoder().decode( + M.ResponseBody.self, + from: data + ) + continuation.resume(responseBody) + } catch { + continuation.reject(error) + } + } + } + } catch { + continuation.reject(error) + } + } + } +} + +extension XPCExtensionService: XPCServiceDelegate { + public func connectionDidInterrupt() async { + Task { @XPCServiceActor in + service = nil + } + } + + public func connectionDidInvalidate() async { + Task { @XPCServiceActor in + service = nil + } + } +} + +extension XPCExtensionService { + @XPCServiceActor + private func updateEndpoint(_ endpoint: NSXPCListenerEndpoint) { + service = XPCService( + kind: .anonymous(endpoint: endpoint), + interface: NSXPCInterface(with: XPCServiceProtocol.self), + logger: logger, + delegate: self + ) + } + + @XPCServiceActor + private func withXPCServiceConnected( + _ fn: @escaping (XPCServiceProtocol, AutoFinishContinuation) -> Void + ) async throws -> T { + if let service, let connection = service.connection { + do { + return try await XPCShared.withXPCServiceConnected(connection: connection, fn) + } catch { + throw XPCExtensionServiceError.xpcServiceError(error) + } + } else { + guard let endpoint = try await bridge.launchExtensionServiceIfNeeded() + else { throw XPCExtensionServiceError.failedToGetServiceEndpoint } + updateEndpoint(endpoint) + + if let service, let connection = service.connection { + do { + return try await XPCShared.withXPCServiceConnected(connection: connection, fn) + } catch { + throw XPCExtensionServiceError.xpcServiceError(error) + } + } else { + throw XPCExtensionServiceError.failedToCreateXPCConnection + } + } + } + + @XPCServiceActor + private func withXPCServiceConnectedWithoutLaunching( + _ fn: @escaping (XPCServiceProtocol, AutoFinishContinuation) -> Void + ) async throws -> T { + if let service, let connection = service.connection { + do { + return try await XPCShared.withXPCServiceConnected(connection: connection, fn) + } catch { + throw XPCExtensionServiceError.xpcServiceError(error) + } + } + throw XPCExtensionServiceError.failedToCreateXPCConnection + } + + @XPCServiceActor + private func suggestionRequest( + _ 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 { + 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/Tool/Sources/XPCShared/XPCService.swift b/Tool/Sources/XPCShared/XPCService.swift new file mode 100644 index 00000000..f4d13db7 --- /dev/null +++ b/Tool/Sources/XPCShared/XPCService.swift @@ -0,0 +1,164 @@ +import Foundation +import Logger + +@globalActor +public enum XPCServiceActor { + public actor TheActor {} + public static let shared = TheActor() +} + +class XPCService { + enum Kind { + case machService(identifier: String) + case anonymous(endpoint: NSXPCListenerEndpoint) + } + + let kind: Kind + let interface: NSXPCInterface + let logger: Logger + weak var delegate: XPCServiceDelegate? + + @XPCServiceActor + private var isInvalidated = false + + @XPCServiceActor + private lazy var _connection: InvalidatingConnection? = buildConnection() + + @XPCServiceActor + var connection: NSXPCConnection? { + if isInvalidated { _connection = nil } + if _connection == nil { rebuildConnection() } + return _connection?.connection + } + + init( + kind: Kind, + interface: NSXPCInterface, + logger: Logger, + delegate: XPCServiceDelegate? = nil + ) { + self.kind = kind + self.interface = interface + self.logger = logger + self.delegate = delegate + } + + @XPCServiceActor + private func buildConnection() -> InvalidatingConnection { +// logger.info("Rebuilding connection") + let connection = switch kind { + case let .machService(name): + NSXPCConnection(machServiceName: name) + case let .anonymous(endpoint): + NSXPCConnection(listenerEndpoint: endpoint) + } + connection.remoteObjectInterface = interface + connection.invalidationHandler = { [weak self] in +// self?.logger.info("XPCService Invalidated") + Task { [weak self] in + self?.markAsInvalidated() + await self?.delegate?.connectionDidInvalidate() + } + } + connection.interruptionHandler = { [weak self] in +// self?.logger.info("XPCService interrupted") + Task { [weak self] in + await self?.delegate?.connectionDidInterrupt() + } + } + connection.resume() + return .init(connection) + } + + @XPCServiceActor + private func markAsInvalidated() { + isInvalidated = true + } + + @XPCServiceActor + private func rebuildConnection() { + _connection = buildConnection() + } +} + +public protocol XPCServiceDelegate: AnyObject { + func connectionDidInvalidate() async + func connectionDidInterrupt() async +} + +private class InvalidatingConnection { + let connection: NSXPCConnection + init(_ connection: NSXPCConnection) { + self.connection = connection + } + + deinit { + connection.invalidationHandler = {} + connection.interruptionHandler = {} + connection.invalidate() + } +} + +struct NoDataError: Error {} + +struct EmptyResponseError: Error, LocalizedError { + var errorDescription: String? { + "The server is not returning a response. The app may be installed in the wrong directory." + } +} + +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) + } + } +} + +@XPCServiceActor +func withXPCServiceConnected( + connection: NSXPCConnection, + _ fn: @escaping (P, AutoFinishContinuation) -> Void +) async throws -> T { + let stream: AsyncThrowingStream = AsyncThrowingStream { continuation in + let service = connection.remoteObjectProxyWithErrorHandler { + continuation.finish(throwing: $0) + } as! P + fn(service, .init(continuation: continuation)) + } + guard let result = try await stream.first(where: { _ in true }) else { + throw EmptyResponseError() + } + return result +} + +@XPCServiceActor +public func testXPCListenerEndpoint(_ endpoint: NSXPCListenerEndpoint) async -> Bool { + let connection = NSXPCConnection(listenerEndpoint: endpoint) + defer { connection.invalidate() } + let stream: AsyncThrowingStream = AsyncThrowingStream { continuation in + _ = connection.remoteObjectProxyWithErrorHandler { + continuation.finish(throwing: $0) + } + continuation.yield(()) + continuation.finish() + } + do { + guard let _ = try await stream.first(where: { _ in true }) else { + throw EmptyResponseError() + } + return true + } catch { + return false + } +} + diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift index 6ecd7ae5..ec5aea50 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -1,5 +1,5 @@ import Foundation -import SuggestionModel +import SuggestionBasic @objc(XPCServiceProtocol) public protocol XPCServiceProtocol { @@ -31,7 +31,7 @@ public protocol XPCServiceProtocol { editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void ) - func chatWithSelection( + func openChat( editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void ) @@ -56,6 +56,7 @@ public protocol XPCServiceProtocol { func getXPCServiceAccessibilityPermission(withReply reply: @escaping (Bool) -> Void) func postNotification(name: String, withReply reply: @escaping () -> Void) func send(endpoint: String, requestBody: Data, reply: @escaping (Data?, Error?) -> Void) + func quit(reply: @escaping () -> Void) } public struct NoResponse: Codable { @@ -79,7 +80,7 @@ public enum ExtensionServiceRequests { public struct ServiceInfo: Codable { public var bundleIdentifier: String public var name: String - + public init(bundleIdentifier: String, name: String) { self.bundleIdentifier = bundleIdentifier self.name = name @@ -91,6 +92,39 @@ public enum ExtensionServiceRequests { public init() {} } + + public struct GetExtensionOpenChatHandlers: ExtensionServiceRequestType { + public struct HandlerInfo: Codable { + public var bundleIdentifier: String + public var id: String + public var tabName: String + public var isBuiltIn: Bool + + public init(bundleIdentifier: String, id: String, tabName: String, isBuiltIn: Bool) { + self.bundleIdentifier = bundleIdentifier + self.id = id + self.tabName = tabName + self.isBuiltIn = isBuiltIn + } + } + + public typealias ResponseBody = [HandlerInfo] + public static let endpoint = "GetExtensionOpenChatHandlers" + + public init() {} + } + + public struct GetSuggestionLineAcceptedCode: ExtensionServiceRequestType { + public typealias ResponseBody = UpdatedContent? + + public static let endpoint = "GetSuggestionLineAcceptedCode" + + public let editorContent: EditorContent + + public init(editorContent: EditorContent) { + self.editorContent = editorContent + } + } } public struct XPCRequestHandlerHitError: Error, LocalizedError { diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift index 1245d98f..cd14dc13 100644 --- a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -1,11 +1,12 @@ import AppKit import Foundation -public class AppInstanceInspector: ObservableObject { - let runningApplication: NSRunningApplication +open class AppInstanceInspector: @unchecked Sendable { + public let runningApplication: NSRunningApplication public let processIdentifier: pid_t public let bundleURL: URL? public let bundleIdentifier: String? + public let name: String public var appElement: AXUIElement { let app = AXUIElementCreateApplication(runningApplication.processIdentifier) @@ -38,6 +39,7 @@ public class AppInstanceInspector: ObservableObject { init(runningApplication: NSRunningApplication) { self.runningApplication = runningApplication + name = runningApplication.localizedName ?? "Unknown" processIdentifier = runningApplication.processIdentifier bundleURL = runningApplication.bundleURL bundleIdentifier = runningApplication.bundleIdentifier diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 852a4de7..33291631 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -1,17 +1,20 @@ -import AppKit +@preconcurrency import AppKit import AsyncPassthroughSubject import AXExtension import AXNotificationStream import Combine import Foundation +import Perception -public final class XcodeAppInstanceInspector: AppInstanceInspector { - public struct AXNotification { +@XcodeInspectorActor +@Perceptible +public final class XcodeAppInstanceInspector: AppInstanceInspector, @unchecked Sendable { + public struct AXNotification: Sendable { public var kind: AXNotificationKind public var element: AXUIElement } - public enum AXNotificationKind { + public enum AXNotificationKind: Sendable { case titleChanged case applicationActivated case applicationDeactivated @@ -64,20 +67,70 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } } - @Published public fileprivate(set) var focusedWindow: XcodeWindowInspector? - @Published public fileprivate(set) var documentURL: URL? = nil - @Published public fileprivate(set) var workspaceURL: URL? = nil - @Published public fileprivate(set) var projectRootURL: URL? = nil - @Published public fileprivate(set) var workspaces = [WorkspaceIdentifier: Workspace]() - @Published public private(set) var completionPanel: AXUIElement? - public var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] { - updateWorkspaceInfo() - return workspaces.mapValues(\.info) + @MainActor + public fileprivate(set) var focusedWindow: XcodeWindowInspector? { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .focusedWindowDidChange, object: self) + } + } + } + + @MainActor + public fileprivate(set) var documentURL: URL? = nil { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .activeDocumentURLDidChange, object: self) + } + } + } + + @MainActor + public fileprivate(set) var workspaceURL: URL? = nil { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .activeWorkspaceURLDidChange, object: self) + } + } } - public let axNotifications = AsyncPassthroughSubject() + @MainActor + public fileprivate(set) var projectRootURL: URL? = nil { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .activeProjectRootURLDidChange, object: self) + } + } + } - public var realtimeDocumentURL: URL? { + @MainActor + public fileprivate(set) var workspaces = [WorkspaceIdentifier: Workspace]() { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .xcodeWorkspacesDidChange, object: self) + } + } + } + + @MainActor + public private(set) var completionPanel: AXUIElement? { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .completionPanelDidChange, object: self) + } + } + } + + private let observer = XcodeInspector.createObserver() + + public nonisolated var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] { + Self.fetchVisibleWorkspaces(runningApplication).mapValues { $0.info } + } + + public nonisolated let axNotifications = AsyncPassthroughSubject() + + public nonisolated + var realtimeDocumentURL: URL? { guard let window = appElement.focusedWindow, window.identifier == "Xcode.WorkspaceWindow" else { return nil } @@ -85,7 +138,8 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { return WorkspaceXcodeWindowInspector.extractDocumentURL(windowElement: window) } - public var realtimeWorkspaceURL: URL? { + public nonisolated + var realtimeWorkspaceURL: URL? { guard let window = appElement.focusedWindow, window.identifier == "Xcode.WorkspaceWindow" else { return nil } @@ -93,7 +147,8 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { return WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) } - public var realtimeProjectURL: URL? { + public nonisolated + var realtimeProjectURL: URL? { let workspaceURL = realtimeWorkspaceURL let documentURL = realtimeDocumentURL return WorkspaceXcodeWindowInspector.extractProjectURL( @@ -122,8 +177,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { return result } - private var longRunningTasks = Set>() - private var focusedWindowObservations = Set() + @PerceptionIgnored private var longRunningTasks = Set>() deinit { axNotifications.finish() @@ -134,27 +188,28 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { super.init(runningApplication: runningApplication) Task { @XcodeInspectorActor in - observeFocusedWindow() + await observeFocusedWindow() observeAXNotifications() try await Task.sleep(nanoseconds: 3_000_000_000) // Sometimes the focused window may not be ready on app launch. - if !(focusedWindow is WorkspaceXcodeWindowInspector) { - observeFocusedWindow() + if await !(focusedWindow is WorkspaceXcodeWindowInspector) { + await observeFocusedWindow() } } } - @XcodeInspectorActor func refresh() { - if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { - focusedWindow.refresh() - } else { - observeFocusedWindow() + Task { @MainActor in + if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { + await focusedWindow.refresh() + } else { + observeFocusedWindow() + } } } - @XcodeInspectorActor + @MainActor private func observeFocusedWindow() { if let window = appElement.focusedWindow { if window.identifier == "Xcode.WorkspaceWindow" { @@ -164,49 +219,40 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { axNotifications: axNotifications ) - focusedWindowObservations.forEach { $0.cancel() } - focusedWindowObservations.removeAll() + focusedWindow = window + documentURL = window.documentURL + workspaceURL = window.workspaceURL + projectRootURL = window.projectRootURL - Task { @MainActor in - focusedWindow = window - documentURL = window.documentURL - workspaceURL = window.workspaceURL - projectRootURL = window.projectRootURL + observer.observe { [weak self] in + let url = window.documentURL + if url != .init(fileURLWithPath: "/") { + self?.documentURL = url + } } - window.$documentURL - .filter { $0 != .init(fileURLWithPath: "/") } - .receive(on: DispatchQueue.main) - .sink { [weak self] url in - self?.documentURL = url - }.store(in: &focusedWindowObservations) - window.$workspaceURL - .filter { $0 != .init(fileURLWithPath: "/") } - .receive(on: DispatchQueue.main) - .sink { [weak self] url in + observer.observe { [weak self] in + let url = window.workspaceURL + if url != .init(fileURLWithPath: "/") { self?.workspaceURL = url - }.store(in: &focusedWindowObservations) - window.$projectRootURL - .filter { $0 != .init(fileURLWithPath: "/") } - .receive(on: DispatchQueue.main) - .sink { [weak self] url in - self?.projectRootURL = url - }.store(in: &focusedWindowObservations) + } + } - } else { - let window = XcodeWindowInspector(uiElement: window) - Task { @MainActor in - focusedWindow = window + observer.observe { [weak self] in + let url = window.projectRootURL + if url != .init(fileURLWithPath: "/") { + self?.projectRootURL = url + } } + } else { + let window = XcodeWindowInspector(app: runningApplication, uiElement: window) + focusedWindow = window } } else { - Task { @MainActor in - focusedWindow = nil - } + focusedWindow = nil } } - @XcodeInspectorActor func observeAXNotifications() { longRunningTasks.forEach { $0.cancel() } longRunningTasks = [] @@ -245,7 +291,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { self.axNotifications.send(.init(kind: event, element: notification.element)) if event == .focusedWindowChanged { - observeFocusedWindow() + await observeFocusedWindow() } if event == .focusedUIElementChanged || event == .applicationDeactivated { @@ -254,34 +300,38 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { guard let self else { return } try await Task.sleep(nanoseconds: 2_000_000_000) try Task.checkCancellation() - self.updateWorkspaceInfo() + await self.updateWorkspaceInfo() } } if event == .created || event == .uiElementDestroyed { - let isCompletionPanel = { - notification.element.identifier == "_XC_COMPLETION_TABLE_" - || notification.element.firstChild { element in - element.identifier == "_XC_COMPLETION_TABLE_" - } != nil - } - switch event { case .created: - if isCompletionPanel() { + if isCompletionPanel(notification.element) { await MainActor.run { self.completionPanel = notification.element self.completionPanel?.setMessagingTimeout(1) + } + self.axNotifications.send(.init( + kind: .xcodeCompletionPanelChanged, + element: notification.element + )) + } + case .uiElementDestroyed: + let completionPanel = await self.completionPanel + if let completionPanel { + if isCompletionPanel(notification.element) { + await MainActor.run { + self.completionPanel = nil + } self.axNotifications.send(.init( kind: .xcodeCompletionPanelChanged, element: notification.element )) - } - } - case .uiElementDestroyed: - if isCompletionPanel() { - await MainActor.run { - self.completionPanel = nil + } else if completionPanel.parent == nil { + await MainActor.run { + self.completionPanel = nil + } self.axNotifications.send(.init( kind: .xcodeCompletionPanelChanged, element: notification.element @@ -296,14 +346,16 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { longRunningTasks.insert(observeAXNotificationTask) - updateWorkspaceInfo() + Task { @MainActor in + updateWorkspaceInfo() + } } } // MARK: - Workspace Info extension XcodeAppInstanceInspector { - public enum WorkspaceIdentifier: Hashable { + public enum WorkspaceIdentifier: Hashable, Sendable { case url(URL) case unknown } @@ -325,7 +377,7 @@ extension XcodeAppInstanceInspector { } } - public struct WorkspaceInfo { + public struct WorkspaceInfo: Sendable { public let tabs: Set public func combined(with info: WorkspaceInfo) -> WorkspaceInfo { @@ -333,16 +385,31 @@ extension XcodeAppInstanceInspector { } } + @MainActor func updateWorkspaceInfo() { let workspaceInfoInVisibleSpace = Self.fetchVisibleWorkspaces(runningApplication) let workspaces = Self.updateWorkspace(workspaces, with: workspaceInfoInVisibleSpace) - Task { @MainActor in - self.workspaces = workspaces + self.workspaces = workspaces + } + + public func workspaceWindow( + forWorkspaceURL url: URL + ) -> AXUIElement? { + let windows = appElement.windows.filter { $0.identifier == "Xcode.WorkspaceWindow" } + + for window in windows { + if let workspaceURL = WorkspaceXcodeWindowInspector + .extractWorkspaceURL(windowElement: window), + workspaceURL == url + { + return window + } } + return nil } /// Use the project path as the workspace identifier. - static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier { + nonisolated static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier { if let url = WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) { return WorkspaceIdentifier.url(url) } @@ -350,7 +417,7 @@ extension XcodeAppInstanceInspector { } /// With Accessibility API, we can ONLY get the information of visible windows. - static func fetchVisibleWorkspaces( + nonisolated static func fetchVisibleWorkspaces( _ app: NSRunningApplication ) -> [WorkspaceIdentifier: Workspace] { let app = AXUIElementCreateApplication(app.processIdentifier) @@ -360,16 +427,21 @@ extension XcodeAppInstanceInspector { for window in windows { let workspaceIdentifier = workspaceIdentifier(window) + var traverseCount = 0 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" } + let tabBars = editArea.tabBars for tabBar in tabBars { - let tabs = tabBar.children { $0.roleDescription == "tab" } - for tab in tabs { - allTabs.insert(tab.title) + tabBar.traverse { element, _ in + traverseCount += 1 + if element.roleDescription == "tab" { + allTabs.insert(element.title) + return .skipDescendants + } + return .continueSearching(()) } } return allTabs @@ -382,7 +454,7 @@ extension XcodeAppInstanceInspector { return dict } - static func updateWorkspace( + nonisolated static func updateWorkspace( _ old: [WorkspaceIdentifier: Workspace], with new: [WorkspaceIdentifier: Workspace] ) -> [WorkspaceIdentifier: Workspace] { @@ -398,3 +470,131 @@ extension XcodeAppInstanceInspector { } } +private func isCompletionPanel(_ element: AXUIElement) -> Bool { + let matchXcode15CompletionPanel = + element.firstChild { element in + element.identifier == "_XC_COMPLETION_TABLE_" + } != nil + + if matchXcode15CompletionPanel { + return true + } + + let matchXcode16CompletionPanel = { + if element.parent?.parent != nil { return false } + if element.role != "AXWindow" { return false } + if element.roleDescription != "dialog" { return false } + guard let group = element.firstChild(where: { $0.role == "AXGroup" }), + let scrollArea = group.firstChild(where: { $0.role == "AXScrollArea" }), + let list = scrollArea.firstChild(where: { $0.role == "AXOpaqueProviderGroup" }), + let _ = list.children.first(where: { $0.value == "code completion" }) + else { return false } + return true + }() + + return matchXcode16CompletionPanel +} + +public extension AXUIElement { + var editorArea: AXUIElement? { + if description == "editor area" { return self } + var area: AXUIElement? + traverse { element, level in + if level > 10 { + return .skipDescendants + } + if element.description == "editor area" { + area = element + return .stopSearching + } + if element.description == "navigator" { + return .skipDescendants + } + + return .continueSearching(()) + } + return area + } + + var tabBars: [AXUIElement] { + guard let editorArea else { return [] } + + var tabBars = [AXUIElement]() + editorArea.traverse { element, _ in + let description = element.description + if description == "Tab Bar" { + element.traverse { element, _ in + if element.description == "tab bar" { + tabBars.append(element) + return .stopSearching + } + return .continueSearching(()) + } + + return .skipDescendantsAndSiblings + } + + if element.identifier == "editor context" { + return .skipDescendantsAndSiblings + } + + if element.isSourceEditor { + return .skipDescendantsAndSiblings + } + + if description == "Code Coverage Ribbon" { + return .skipDescendants + } + + if description == "Debug Area" { + return .skipDescendants + } + + if description == "debug bar" { + return .skipDescendants + } + + return .continueSearching(()) + } + + return tabBars + } + + var debugArea: AXUIElement? { + guard let editorArea else { return nil } + + var debugArea: AXUIElement? + editorArea.traverse { element, _ in + let description = element.description + if description == "Tab Bar" { + return .skipDescendants + } + + if element.identifier == "editor context" { + return .skipDescendantsAndSiblings + } + + if element.isSourceEditor { + return .skipDescendantsAndSiblings + } + + if description == "Code Coverage Ribbon" { + return .skipDescendants + } + + if description == "Debug Area" { + debugArea = element + return .skipDescendants + } + + if description == "debug bar" { + return .skipDescendants + } + + return .continueSearching(()) + } + + return debugArea + } +} + diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 2ccecb48..6ddd5b95 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -1,27 +1,28 @@ -import AppKit +@preconcurrency import AppKit import AsyncPassthroughSubject import AXNotificationStream import Foundation import Logger -import SuggestionModel +import SuggestionBasic /// Representing a source editor inside Xcode. -public class SourceEditor { +public class SourceEditor: @unchecked Sendable { public typealias Content = EditorInformation.SourceEditorContent - public struct AXNotification: Hashable { + public struct AXNotification: Hashable, Sendable { public var kind: AXNotificationKind public var element: AXUIElement - + public func hash(into hasher: inout Hasher) { kind.hash(into: &hasher) } } - public enum AXNotificationKind: Hashable, Equatable { + public enum AXNotificationKind: Hashable, Equatable, Sendable { case selectedTextChanged case valueChanged case scrollPositionChanged + case evaluatedContentChanged } let runningApplication: NSRunningApplication @@ -31,6 +32,22 @@ public class SourceEditor { /// To prevent expensive calculations in ``getContent()``. private let cache = Cache() + + public func getLatestEvaluatedContent() -> Content { + let selectionRange = element.selectedTextRange + let (content, lines, selections) = cache.latest() + let lineAnnotationElements = element.children.filter { $0.identifier == "Line Annotation" } + let lineAnnotations = lineAnnotationElements.map(\.description) + + return .init( + content: content, + lines: lines, + selections: selections, + cursorPosition: selections.first?.start ?? .outOfScope, + cursorOffset: selectionRange?.lowerBound ?? 0, + lineAnnotations: lineAnnotations + ) + } /// Get the content of the source editor. /// @@ -44,11 +61,14 @@ public class SourceEditor { let lineAnnotationElements = element.children.filter { $0.identifier == "Line Annotation" } let lineAnnotations = lineAnnotationElements.map(\.description) + axNotifications.send(.init(kind: .evaluatedContentChanged, element: element)) + return .init( content: content, lines: lines, selections: selections, cursorPosition: selections.first?.start ?? .outOfScope, + cursorOffset: selectionRange?.lowerBound ?? 0, lineAnnotations: lineAnnotations ) } @@ -62,7 +82,7 @@ public class SourceEditor { private func observeAXNotifications() { observeAXNotificationsTask?.cancel() - observeAXNotificationsTask = Task { @XcodeInspectorActor [weak self] in + observeAXNotificationsTask = Task { [weak self] in guard let self else { return } await withThrowingTaskGroup(of: Void.self) { [weak self] group in guard let self else { return } @@ -175,6 +195,12 @@ extension SourceEditor { return (lines, selections) } } + + func latest() -> (content: String, lines: [String], selections: [CursorRange]) { + Self.queue.sync { + (sourceContent ?? "", cachedLines, cachedSelections) + } + } } } @@ -198,8 +224,8 @@ public extension SourceEditor { range.length = max(countE - range.location, 0) break } - countS += line.count - countE += line.count + countS += line.utf16.count + countE += line.utf16.count } return range } @@ -221,24 +247,26 @@ public extension SourceEditor { var countE = 0 var cursorRange = CursorRange(start: .zero, end: .outOfScope) for (i, line) in lines.enumerated() { - // The range is counted in UTF8, which causes line endings like \r\n to be of length 2. - let lineEndingAddition = line.lineEnding.utf8.count - 1 if countS <= range.lowerBound, - range.lowerBound < countS + line.count + lineEndingAddition + range.lowerBound < countS + line.utf16.count { cursorRange.start = .init(line: i, character: range.lowerBound - countS) } if countE <= range.upperBound, - range.upperBound < countE + line.count + lineEndingAddition + range.upperBound < countE + line.utf16.count { cursorRange.end = .init(line: i, character: range.upperBound - countE) break } - countS += line.count + lineEndingAddition - countE += line.count + lineEndingAddition + countS += line.utf16.count + countE += line.utf16.count } if cursorRange.end == .outOfScope { - cursorRange.end = .init(line: lines.endIndex - 1, character: lines.last?.count ?? 0) + if range.lowerBound == range.upperBound { return .outOfScope } + cursorRange.end = .init( + line: lines.endIndex - 1, + character: lines.last?.utf16.count ?? 0 + ) } return cursorRange } diff --git a/Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift b/Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift index e6ea06eb..a7abe69a 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift @@ -30,11 +30,11 @@ public extension AppInstanceInspector { guard path.count >= 2 else { throw cantRunCommand("Path too short.") } if activateApp { - if !runningApplication.activate() { - Logger.service.error(""" - Trigger menu item \(sourcePath): \ - Xcode not activated. - """) + if !runningApplication.isActive { + // we prefer `.activate()` because it only brings the active window to the front + if !activate() { + appElement.isFrontmost = true + } } } else { if !runningApplication.isActive { @@ -104,6 +104,7 @@ public extension AppInstanceInspector { .joined(separator: " of ") return """ click menu item "\(button)" of \(list) \ + of menu 1 \ of menu bar item "\(menuBarItem)" \ of menu bar 1 """ diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index d7eaf53a..c26b7c7b 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -4,12 +4,40 @@ import AXExtension import Combine import Foundation import Logger +import Perception import Preferences -import SuggestionModel +import SuggestionBasic +import SwiftNavigation import Toast public extension Notification.Name { - static let accessibilityAPIMalfunctioning = Notification.Name("accessibilityAPIMalfunctioning") + static let accessibilityAPIMalfunctioning = Notification + .Name("XcodeInspector.accessibilityAPIMalfunctioning") + static let activeApplicationDidChange = Notification + .Name("XcodeInspector.activeApplicationDidChange") + static let previousActiveApplicationDidChange = Notification + .Name("XcodeInspector.previousActiveApplicationDidChange") + static let activeXcodeDidChange = Notification + .Name("XcodeInspector.activeXcodeDidChange") + static let latestActiveXcodeDidChange = Notification + .Name("XcodeInspector.latestActiveXcodeDidChange") + static let xcodesDidChange = Notification.Name("XcodeInspector.xcodesDidChange") + static let activeProjectRootURLDidChange = Notification + .Name("XcodeInspector.activeProjectRootURLDidChange") + static let activeDocumentURLDidChange = Notification + .Name("XcodeInspector.activeDocumentURLDidChange") + static let activeWorkspaceURLDidChange = Notification + .Name("XcodeInspector.activeWorkspaceURLDidChange") + static let focusedWindowDidChange = Notification + .Name("XcodeInspector.focusedWindowDidChange") + static let focusedEditorDidChange = Notification + .Name("XcodeInspector.focusedEditorDidChange") + static let focusedElementDidChange = Notification + .Name("XcodeInspector.focusedElementDidChange") + static let completionPanelDidChange = Notification + .Name("XcodeInspector.completionPanelDidChange") + static let xcodeWorkspacesDidChange = Notification + .Name("XcodeInspector.xcodeWorkspacesDidChange") } @globalActor @@ -18,54 +46,136 @@ public enum XcodeInspectorActor: GlobalActor { public static let shared = Actor() } -public final class XcodeInspector: ObservableObject { - public static let shared = XcodeInspector() - - @XcodeInspectorActor - @dynamicMemberLookup - public class Safe { - var inspector: XcodeInspector { .shared } - nonisolated init() {} - public subscript(dynamicMember member: KeyPath) -> T { - inspector[keyPath: member] +@XcodeInspectorActor +@Perceptible +public final class XcodeInspector: Sendable { + public final class PerceptionObserver: Sendable { + public struct Cancellable { + let token: ObserveToken + public func cancel() { + token.cancel() + } + } + + final class Object: NSObject, Sendable {} + + let object = Object() + + @MainActor + @discardableResult public func observe( + _ block: @Sendable @escaping @MainActor () -> Void + ) -> Cancellable { + let token = object.observe { block() } + return Cancellable(token: token) } } + public nonisolated static func createObserver() -> PerceptionObserver { + PerceptionObserver() + } + + public nonisolated static let shared = XcodeInspector() + private var toast: ToastController { ToastControllerDependencyKey.liveValue } - private var cancellable = Set() - private var activeXcodeObservations = Set>() - private var appChangeObservations = Set>() - private var activeXcodeCancellable = Set() + @PerceptionIgnored private var activeXcodeObservations = Set>() + @PerceptionIgnored private var appChangeObservations = Set>() + + @MainActor + public fileprivate(set) var activeApplication: AppInstanceInspector? { + didSet { + NotificationCenter.default.post(name: .activeApplicationDidChange, object: nil) + } + } + + @MainActor + public fileprivate(set) var previousActiveApplication: AppInstanceInspector? { + didSet { + NotificationCenter.default.post(name: .previousActiveApplicationDidChange, object: nil) + } + } + + @MainActor + public fileprivate(set) var activeXcode: XcodeAppInstanceInspector? { + didSet { + NotificationCenter.default.post(name: .activeXcodeDidChange, object: nil) + NotificationCenter.default.post(name: .focusedWindowDidChange, object: nil) + NotificationCenter.default.post(name: .activeDocumentURLDidChange, object: self) + NotificationCenter.default.post(name: .activeWorkspaceURLDidChange, object: self) + NotificationCenter.default.post(name: .activeProjectRootURLDidChange, object: self) + NotificationCenter.default.post(name: .completionPanelDidChange, object: self) + NotificationCenter.default.post(name: .xcodeWorkspacesDidChange, object: self) + } + } + + @MainActor + public fileprivate(set) var latestActiveXcode: XcodeAppInstanceInspector? { + didSet { + _nonIsolatedLatestActiveXcode = latestActiveXcode + NotificationCenter.default.post(name: .latestActiveXcodeDidChange, object: nil) + } + } + + @MainActor + public fileprivate(set) var xcodes: [XcodeAppInstanceInspector] = [] { + didSet { + NotificationCenter.default.post(name: .xcodesDidChange, object: nil) + } + } + + @MainActor + public var activeProjectRootURL: URL? { + (activeXcode ?? latestActiveXcode)?.projectRootURL + } + + @MainActor + public var activeDocumentURL: URL? { + (activeXcode ?? latestActiveXcode)?.documentURL + } + + @MainActor + public var activeWorkspaceURL: URL? { + (activeXcode ?? latestActiveXcode)?.workspaceURL + } + + @MainActor + public var focusedWindow: XcodeWindowInspector? { + (activeXcode ?? latestActiveXcode)?.focusedWindow + } + + @MainActor + public var completionPanel: AXUIElement? { + (activeXcode ?? latestActiveXcode)?.completionPanel + } + + @MainActor + public fileprivate(set) var focusedEditor: SourceEditor? { + didSet { + NotificationCenter.default.post(name: .focusedEditorDidChange, object: nil) + } + } - #warning("TODO: Find a good way to make XcodeInspector thread safe!") - public var safe = Safe() - - @Published public fileprivate(set) var activeApplication: AppInstanceInspector? - @Published public fileprivate(set) var previousActiveApplication: AppInstanceInspector? - @Published public fileprivate(set) var activeXcode: XcodeAppInstanceInspector? - @Published public fileprivate(set) var latestActiveXcode: XcodeAppInstanceInspector? - @Published public fileprivate(set) var xcodes: [XcodeAppInstanceInspector] = [] - @Published public fileprivate(set) var activeProjectRootURL: URL? = nil - @Published public fileprivate(set) var activeDocumentURL: URL? = nil - @Published public fileprivate(set) var activeWorkspaceURL: URL? = nil - @Published public fileprivate(set) var focusedWindow: XcodeWindowInspector? - @Published public fileprivate(set) var focusedEditor: SourceEditor? - @Published public fileprivate(set) var focusedElement: AXUIElement? - @Published public fileprivate(set) var completionPanel: AXUIElement? + @MainActor + public fileprivate(set) var latestFocusedEditor: SourceEditor? + + @MainActor + public fileprivate(set) var focusedElement: AXUIElement? { + didSet { + NotificationCenter.default.post(name: .focusedElementDidChange, object: nil) + } + } /// Get the content of the source editor. /// /// - note: This method is expensive. It needs to convert index based ranges to line based /// ranges. - @XcodeInspectorActor public func getFocusedEditorContent() async -> EditorInformation? { guard let documentURL = realtimeActiveDocumentURL, let workspaceURL = realtimeActiveWorkspaceURL, - let projectURL = activeProjectRootURL + let projectURL = realtimeActiveProjectURL else { return nil } - let editorContent = focusedEditor?.getContent() + let editorContent = await latestFocusedEditor?.getContent() let language = languageIdentifierFromFileURL(documentURL) let relativePath = documentURL.path.replacingOccurrences(of: projectURL.path, with: "") @@ -98,61 +208,61 @@ public final class XcodeInspector: ObservableObject { ) } - public var realtimeActiveDocumentURL: URL? { - latestActiveXcode?.realtimeDocumentURL ?? activeDocumentURL + @PerceptionIgnored + private nonisolated(unsafe) var _nonIsolatedLatestActiveXcode: XcodeAppInstanceInspector? + + public nonisolated var realtimeActiveDocumentURL: URL? { + _nonIsolatedLatestActiveXcode?.realtimeDocumentURL } - public var realtimeActiveWorkspaceURL: URL? { - latestActiveXcode?.realtimeWorkspaceURL ?? activeWorkspaceURL + public nonisolated var realtimeActiveWorkspaceURL: URL? { + _nonIsolatedLatestActiveXcode?.realtimeWorkspaceURL } - public var realtimeActiveProjectURL: URL? { - latestActiveXcode?.realtimeProjectURL ?? activeProjectRootURL + public nonisolated var realtimeActiveProjectURL: URL? { + _nonIsolatedLatestActiveXcode?.realtimeProjectURL } - init() { + nonisolated init() { AXUIElement.setGlobalMessagingTimeout(3) - Task { @XcodeInspectorActor in - restart() - } + Task { await restart() } } - @XcodeInspectorActor - public func restart(cleanUp: Bool = false) { + public func restart(cleanUp: Bool = false) async { if cleanUp { activeXcodeObservations.forEach { $0.cancel() } activeXcodeObservations.removeAll() - activeXcodeCancellable.forEach { $0.cancel() } - activeXcodeCancellable.removeAll() - activeXcode = nil - latestActiveXcode = nil - activeApplication = nil - activeProjectRootURL = nil - activeDocumentURL = nil - activeWorkspaceURL = nil - focusedWindow = nil - focusedEditor = nil - focusedElement = nil - completionPanel = nil + await MainActor.run { + self.activeXcode = nil + latestActiveXcode = nil + activeApplication = nil + focusedEditor = nil + latestFocusedEditor = nil + focusedElement = nil + } } let runningApplications = NSWorkspace.shared.runningApplications - xcodes = runningApplications - .filter { $0.isXcode } - .map(XcodeAppInstanceInspector.init(runningApplication:)) - let activeXcode = xcodes.first(where: \.isActive) - latestActiveXcode = activeXcode ?? xcodes.first - activeApplication = activeXcode ?? runningApplications - .first(where: \.isActive) - .map(AppInstanceInspector.init(runningApplication:)) + + await MainActor.run { + xcodes = runningApplications + .filter { $0.isXcode } + .map(XcodeAppInstanceInspector.init(runningApplication:)) + let activeXcode = xcodes.first(where: \.isActive) + latestActiveXcode = activeXcode ?? xcodes.first + activeApplication = activeXcode ?? runningApplications + .first(where: \.isActive) + .map(AppInstanceInspector.init(runningApplication:)) + self.activeXcode = activeXcode + } appChangeObservations.forEach { $0.cancel() } appChangeObservations.removeAll() let appChangeTask = Task(priority: .utility) { [weak self] in guard let self else { return } - if let activeXcode { - setActiveXcode(activeXcode) + if let activeXcode = await self.activeXcode { + await setActiveXcode(activeXcode) } await withThrowingTaskGroup(of: Void.self) { [weak self] group in @@ -166,22 +276,22 @@ public final class XcodeInspector: ObservableObject { .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { continue } if app.isXcode { - if let existed = xcodes.first(where: { + if let existed = await self.xcodes.first(where: { $0.processIdentifier == app.processIdentifier && !$0.isTerminated }) { - Task { @XcodeInspectorActor in - self.setActiveXcode(existed) + Task { + await self.setActiveXcode(existed) } } else { let new = XcodeAppInstanceInspector(runningApplication: app) - Task { @XcodeInspectorActor in + Task { @MainActor in self.xcodes.append(new) - self.setActiveXcode(new) + await self.setActiveXcode(new) } } } else { let appInspector = AppInstanceInspector(runningApplication: app) - Task { @XcodeInspectorActor in + Task { @MainActor in self.previousActiveApplication = self.activeApplication self.activeApplication = appInspector } @@ -200,7 +310,7 @@ public final class XcodeInspector: ObservableObject { else { continue } if app.isXcode { let processIdentifier = app.processIdentifier - Task { @XcodeInspectorActor in + Task { @MainActor in self.xcodes.removeAll { $0.processIdentifier == processIdentifier || $0.isTerminated } @@ -211,7 +321,7 @@ public final class XcodeInspector: ObservableObject { } if let activeXcode = self.xcodes.first(where: \.isActive) { - self.setActiveXcode(activeXcode) + await self.setActiveXcode(activeXcode) } } } @@ -231,8 +341,8 @@ public final class XcodeInspector: ObservableObject { } try await Task.sleep(nanoseconds: 10_000_000_000) - Task { @XcodeInspectorActor in - self.checkForAccessibilityMalfunction("Timer") + Task { + await self.checkForAccessibilityMalfunction("Timer") } } } @@ -254,60 +364,56 @@ public final class XcodeInspector: ObservableObject { appChangeObservations.insert(appChangeTask) } - public func reactivateObservationsToXcode() { - Task { @XcodeInspectorActor in - if let activeXcode { - setActiveXcode(activeXcode) - activeXcode.observeAXNotifications() - } + public func reactivateObservationsToXcode() async { + if let activeXcode = await activeXcode { + await setActiveXcode(activeXcode) + activeXcode.observeAXNotifications() } } - @XcodeInspectorActor - private func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { - previousActiveApplication = activeApplication - activeApplication = xcode + private func setActiveXcode(_ xcode: XcodeAppInstanceInspector) async { + await MainActor.run { + previousActiveApplication = activeApplication + activeApplication = xcode + } xcode.refresh() for task in activeXcodeObservations { task.cancel() } - for cancellable in activeXcodeCancellable { cancellable.cancel() } activeXcodeObservations.removeAll() - activeXcodeCancellable.removeAll() - - activeXcode = xcode - latestActiveXcode = xcode - activeDocumentURL = xcode.documentURL - focusedWindow = xcode.focusedWindow - completionPanel = xcode.completionPanel - activeProjectRootURL = xcode.projectRootURL - activeWorkspaceURL = xcode.workspaceURL - focusedWindow = xcode.focusedWindow - - let setFocusedElement = { @XcodeInspectorActor [weak self] in + await MainActor.run { + activeXcode = xcode + latestActiveXcode = xcode + } + + 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 if let element = focusedElement, - let editorElement = element.firstParent(where: \.isSourceEditor) - { - focusedEditor = .init( - runningApplication: xcode.runningApplication, - element: editorElement - ) - } else { - focusedEditor = nil + await MainActor.run { + self.focusedElement = xcode.appElement.focusedElement + if let editorElement = self.focusedElement, editorElement.isSourceEditor { + self.focusedEditor = .init( + runningApplication: xcode.runningApplication, + element: editorElement + ) + self.latestFocusedEditor = self.focusedEditor + } else if let element = self.focusedElement, + let editorElement = element.firstParent(where: \.isSourceEditor) + { + self.focusedEditor = .init( + runningApplication: xcode.runningApplication, + element: editorElement + ) + self.latestFocusedEditor = self.focusedEditor + } else { + self.focusedEditor = nil + } } } - setFocusedElement() - let focusedElementChanged = Task { @XcodeInspectorActor in + await setFocusedElement() + let focusedElementChanged = Task { for await notification in await xcode.axNotifications.notifications() { if notification.kind == .focusedUIElementChanged { try Task.checkCancellation() - setFocusedElement() + await setFocusedElement() } } } @@ -317,7 +423,7 @@ public final class XcodeInspector: ObservableObject { if UserDefaults.shared .value(for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) { - let malfunctionCheck = Task { @XcodeInspectorActor [weak self] in + let malfunctionCheck = Task { [weak self] in if #available(macOS 13.0, *) { let notifications = await xcode.axNotifications.notifications().filter { $0.kind == .uiElementDestroyed @@ -325,50 +431,30 @@ public final class XcodeInspector: ObservableObject { for await _ in notifications { guard let self else { return } try Task.checkCancellation() - self.checkForAccessibilityMalfunction("Element Destroyed") + await self.checkForAccessibilityMalfunction("Element Destroyed") } } } activeXcodeObservations.insert(malfunctionCheck) - checkForAccessibilityMalfunction("Reactivate Xcode") + await checkForAccessibilityMalfunction("Reactivate Xcode") } - - xcode.$completionPanel.sink { [weak self] element in - Task { @XcodeInspectorActor in self?.completionPanel = element } - }.store(in: &activeXcodeCancellable) - - xcode.$documentURL.sink { [weak self] url in - Task { @XcodeInspectorActor in self?.activeDocumentURL = url } - }.store(in: &activeXcodeCancellable) - - xcode.$workspaceURL.sink { [weak self] url in - Task { @XcodeInspectorActor in self?.activeWorkspaceURL = url } - }.store(in: &activeXcodeCancellable) - - xcode.$projectRootURL.sink { [weak self] url in - Task { @XcodeInspectorActor in self?.activeProjectRootURL = url } - }.store(in: &activeXcodeCancellable) - - xcode.$focusedWindow.sink { [weak self] window in - Task { @XcodeInspectorActor in self?.focusedWindow = window } - }.store(in: &activeXcodeCancellable) } private var lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() - @XcodeInspectorActor - private func checkForAccessibilityMalfunction(_ source: String) { + private func checkForAccessibilityMalfunction(_ source: String) async { guard Date().timeIntervalSince(lastRecoveryFromAccessibilityMalfunctioningTimeStamp) > 5 else { return } - if let editor = focusedEditor, !editor.element.isSourceEditor { + if let editor = await focusedEditor, !editor.element.isSourceEditor { NotificationCenter.default.post( name: .accessibilityAPIMalfunctioning, object: "Source Editor Element Corrupted: \(source)" ) - } else if let element = activeXcode?.appElement.focusedElement { + } else if let element = await activeXcode?.appElement.focusedElement { + let focusedElement = await focusedElement if element.description != focusedElement?.description || element.role != focusedElement?.role { @@ -380,8 +466,7 @@ public final class XcodeInspector: ObservableObject { } } - @XcodeInspectorActor - private func recoverFromAccessibilityMalfunctioning(_ source: String?) { + private func recoverFromAccessibilityMalfunctioning(_ source: String?) async { let message = """ Accessibility API malfunction detected: \ \(source ?? ""). @@ -393,9 +478,9 @@ public final class XcodeInspector: ObservableObject { } else { Logger.service.info(message) } - if let activeXcode { + if let activeXcode = await activeXcode { lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() - setActiveXcode(activeXcode) + await setActiveXcode(activeXcode) activeXcode.observeAXNotifications() } } diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index d3178781..c63b3f71 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -2,45 +2,75 @@ import AppKit import AsyncPassthroughSubject import AXExtension import Combine +import CoreGraphics import Foundation import Logger +import Perception -public class XcodeWindowInspector: ObservableObject { - public let uiElement: AXUIElement +public class XcodeWindowInspector { + public let app: NSRunningApplication + public let windowID: CGWindowID + public var uiElement: AXUIElement { + let windowID = self.windowID + if _uiElement.parent == nil { + let app = AXUIElementCreateApplication(app.processIdentifier) + app.setMessagingTimeout(2) + if let newWindowElement = app.windows.first(where: { $0.windowID == windowID }) { + self._uiElement = newWindowElement + newWindowElement.setMessagingTimeout(2) + } + } + return _uiElement + } + + var _uiElement: AXUIElement - init(uiElement: AXUIElement) { - self.uiElement = uiElement + init( + app: NSRunningApplication, + uiElement: AXUIElement + ) { + self.app = app + _uiElement = uiElement uiElement.setMessagingTimeout(2) + windowID = uiElement.windowID ?? 0 + } + + public var isInvalid: Bool { + uiElement.parent == nil } } -public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { - let app: NSRunningApplication - @Published public internal(set) var documentURL: URL = .init(fileURLWithPath: "/") - @Published public internal(set) var workspaceURL: URL = .init(fileURLWithPath: "/") - @Published public internal(set) var projectRootURL: URL = .init(fileURLWithPath: "/") - private var focusedElementChangedTask: Task? +@XcodeInspectorActor +@Perceptible +public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector, Sendable { + @MainActor + public private(set) var documentURL: URL = .init(fileURLWithPath: "/") + @MainActor + public private(set) var workspaceURL: URL = .init(fileURLWithPath: "/") + @MainActor + public private(set) var projectRootURL: URL = .init(fileURLWithPath: "/") + @PerceptionIgnored private var focusedElementChangedTask: Task? public func refresh() { - Task { @XcodeInspectorActor in updateURLs() } + Task { @MainActor in updateURLs() } } + @MainActor public init( app: NSRunningApplication, uiElement: AXUIElement, axNotifications: AsyncPassthroughSubject ) { - self.app = app - super.init(uiElement: uiElement) + super.init(app: app, uiElement: uiElement) - focusedElementChangedTask = Task { [weak self, axNotifications] in - await self?.updateURLs() + focusedElementChangedTask = Task { @MainActor [weak self, axNotifications] in + self?.updateURLs() await withThrowingTaskGroup(of: Void.self) { [weak self] group in group.addTask { [weak self] in // prevent that documentURL may not be available yet try await Task.sleep(nanoseconds: 500_000_000) - if self?.documentURL == .init(fileURLWithPath: "/") { + if await self?.documentURL == .init(fileURLWithPath: "/") { await self?.updateURLs() } } @@ -60,32 +90,26 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { } } - @XcodeInspectorActor + @MainActor func updateURLs() { let documentURL = Self.extractDocumentURL(windowElement: uiElement) if let documentURL { - Task { @MainActor in - self.documentURL = documentURL - } + self.documentURL = documentURL } let workspaceURL = Self.extractWorkspaceURL(windowElement: uiElement) if let workspaceURL { - Task { @MainActor in - self.workspaceURL = workspaceURL - } + self.workspaceURL = workspaceURL } let projectURL = Self.extractProjectURL( workspaceURL: workspaceURL, documentURL: documentURL ) if let projectURL { - Task { @MainActor in - self.projectRootURL = projectURL - } + projectRootURL = projectURL } } - static func extractDocumentURL( + nonisolated static func extractDocumentURL( windowElement: AXUIElement ) -> URL? { // fetch file path of the frontmost window of Xcode through Accessibility API. @@ -95,12 +119,12 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { fileURLWithPath: path .replacingOccurrences(of: "file://", with: "") ) - return url + return adjustFileURL(url) } return nil } - static func extractWorkspaceURL( + nonisolated static func extractWorkspaceURL( windowElement: AXUIElement ) -> URL? { for child in windowElement.children { @@ -114,7 +138,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return nil } - public static func extractProjectURL( + public nonisolated static func extractProjectURL( workspaceURL: URL?, documentURL: URL? ) -> URL? { @@ -142,5 +166,14 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return lastGitDirectoryURL ?? firstDirectoryURL ?? workspaceURL } + + nonisolated static func adjustFileURL(_ url: URL) -> URL { + if url.pathExtension == "playground", + FileManager.default.fileIsDirectory(atPath: url.path) + { + return url.appendingPathComponent("Contents.swift") + } + return url + } } diff --git a/Tool/Tests/CodeDiffTests/CodeDiffTests.swift b/Tool/Tests/CodeDiffTests/CodeDiffTests.swift new file mode 100644 index 00000000..27c629e2 --- /dev/null +++ b/Tool/Tests/CodeDiffTests/CodeDiffTests.swift @@ -0,0 +1,739 @@ +import Foundation +import XCTest + +@testable import CodeDiff + +class CodeDiffTests: XCTestCase { + func test_diff_snippets_empty_snippets() { + XCTAssertEqual( + CodeDiff().diff(snippet: "", from: ""), + .init(sections: [ + .init( + oldOffset: 0, + newOffset: 0, + oldSnippet: [.init(text: "")], + newSnippet: [.init(text: "")] + ), + ]) + ) + } + + func test_diff_snippets_from_empty_to_content() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + let foo = Foo() + foo.bar() + """, + from: "" + ), + .init(sections: [ + .init( + oldOffset: 0, + newOffset: 0, + oldSnippet: [.init(text: "", diff: .mutated(changes: []))], + newSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [.init( + offset: 0, + element: "let foo = Foo()" + )]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [.init(offset: 0, element: "foo.bar()")]) + ), + ] + ), + ]) + ) + } + + func test_diff_snippets_insert_at_top() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + let foo = Foo() + foo.bar() + """, + from: """ + foo.bar() + """ + ), + .init(sections: [ + .init( + oldOffset: 0, + newOffset: 0, + oldSnippet: [], + newSnippet: [.init( + text: "let foo = Foo()", + diff: .mutated(changes: [CodeDiff.SnippetDiff.Change( + offset: 0, + element: "let foo = Foo()" + )]) + )] + ), + + .init( + oldOffset: 0, + newOffset: 1, + oldSnippet: [.init(text: "foo.bar()", diff: .unchanged)], + newSnippet: [.init(text: "foo.bar()", diff: .unchanged)] + ), + ]) + ) + } + + func test_diff_snippets_from_one_line_to_content() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + let foo = Foo() + foo.bar() + """, + from: """ + // comment + """ + ), + .init(sections: [ + .init( + oldOffset: 0, + newOffset: 0, + oldSnippet: [.init(text: "// comment", diff: .mutated(changes: [ + .init(offset: 0, element: "// comm"), + .init(offset: 8, element: "n"), + ]))], + newSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [ + .init(offset: 0, element: "l"), + .init(offset: 3, element: " foo = Foo()"), + ]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [.init(offset: 0, element: "foo.bar()")]) + ), + ] + ), + ]) + ) + } + + func test_diff_snippets_from_content_to_empty() { + XCTAssertEqual( + CodeDiff().diff( + snippet: "", + from: """ + let foo = Foo() + foo.bar() + """ + ), + .init(sections: [ + .init( + oldOffset: 0, + newOffset: 0, + oldSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [.init( + offset: 0, + element: "let foo = Foo()" + )]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [.init(offset: 0, element: "foo.bar()")]) + ), + ], + newSnippet: [.init(text: "", diff: .mutated(changes: []))] + ), + ]) + ) + } + + func test_diff_snippets_from_content_to_one_line() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + // comment + let foo = Bar() + print(bar) + print(foo) + """, + from: """ + let foo = Bar() + """ + ), + .init(sections: [ + .init( + oldOffset: 0, + newOffset: 0, + oldSnippet: [], + newSnippet: [ + .init( + text: "// comment", + diff: .mutated(changes: [.init(offset: 0, element: "// comment")]) + ), + ] + ), + .init( + oldOffset: 0, + newOffset: 1, + oldSnippet: [ + .init(text: " let foo = Bar()"), + ], + newSnippet: [ + .init(text: " let foo = Bar()"), + ] + ), + .init(oldOffset: 1, newOffset: 2, oldSnippet: [], newSnippet: [ + .init( + text: " print(bar)", + diff: .mutated(changes: [.init(offset: 0, element: " print(bar)")]) + ), + .init( + text: " print(foo)", + diff: .mutated(changes: [.init(offset: 0, element: " print(foo)")]) + ), + ]), + ]) + ) + } + + func test_diff_snippets_mutation() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + var foo = Bar() + foo.baz() + print(foo) + """, + from: """ + let foo = Foo() + foo.bar() + """ + ), + .init(sections: [ + .init( + oldOffset: 0, + newOffset: 0, + oldSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [ + .init(offset: 0, element: "let"), + .init(offset: 10, element: "Foo"), + ]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [ + .init(offset: 6, element: "r"), + ]) + ), + ], + newSnippet: [ + .init( + text: "var foo = Bar()", + diff: .mutated(changes: [ + .init(offset: 0, element: "var"), + .init(offset: 10, element: "Bar"), + ]) + ), + .init( + text: "foo.baz()", + diff: .mutated(changes: [ + .init(offset: 6, element: "z"), + ]) + ), + .init( + text: "print(foo)", + diff: .mutated(changes: [ + .init(offset: 0, element: "print(foo)"), + ]) + ), + ] + ), + ]) + ) + } + + func test_diff_snippets_multiple_sections() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + var foo = Bar() + foo.baz() + // divider a + print(foo) + // divider b + // divider c + func bar() { + print(foo) + } + """, + from: """ + let foo = Foo() + foo.bar() + // divider a + // divider b + // divider c + func bar() {} + """ + ), + .init(sections: [ + .init( + oldOffset: 0, + newOffset: 0, + oldSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [ + .init(offset: 0, element: "let"), + .init(offset: 10, element: "Foo"), + ]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [ + .init(offset: 6, element: "r"), + ]) + ), + ], + newSnippet: [ + .init( + text: "var foo = Bar()", + diff: .mutated(changes: [ + .init(offset: 0, element: "var"), + .init(offset: 10, element: "Bar"), + ]) + ), + .init( + text: "foo.baz()", + diff: .mutated(changes: [ + .init(offset: 6, element: "z"), + ]) + ), + ] + ), + .init( + oldOffset: 2, + newOffset: 2, + oldSnippet: [.init(text: "// divider a")], + newSnippet: [.init(text: "// divider a")] + ), + .init( + oldOffset: 3, + newOffset: 3, + oldSnippet: [], + newSnippet: [ + .init( + text: "print(foo)", + diff: .mutated(changes: [ + .init(offset: 0, element: "print(foo)"), + ]) + ), + ] + ), + .init( + oldOffset: 3, + newOffset: 4, + oldSnippet: [.init(text: "// divider b"), .init(text: "// divider c")], + newSnippet: [.init(text: "// divider b"), .init(text: "// divider c")] + ), + .init( + oldOffset: 5, + newOffset: 6, + oldSnippet: [ + .init( + text: "func bar() {}", + diff: .mutated(changes: [ + .init(offset: 12, element: "}"), + ]) + ), + ], + newSnippet: [ + .init( + text: "func bar() {", + diff: .mutated(changes: []) + ), + .init( + text: " print(foo)", + diff: .mutated(changes: [.init(offset: 0, element: " print(foo)")]) + ), + .init( + text: "}", + diff: .mutated(changes: [.init(offset: 0, element: "}")]) + ), + ] + ), + ]) + ) + } + + func test_diff_snippets_multiple_sections_beginning_unchanged() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + // unchanged + // unchanged + var foo = Bar() + foo.baz() + // divider a + print(foo) + """, + from: """ + // unchanged + // unchanged + let foo = Foo() + foo.bar() + // divider a + """ + ), + .init(sections: [ + .init( + oldOffset: 0, + newOffset: 0, + oldSnippet: [.init(text: "// unchanged"), .init(text: "// unchanged")], + newSnippet: [.init(text: "// unchanged"), .init(text: "// unchanged")] + ), + .init( + oldOffset: 2, + newOffset: 2, + oldSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [ + .init(offset: 0, element: "let"), + .init(offset: 10, element: "Foo"), + ]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [ + .init(offset: 6, element: "r"), + ]) + ), + ], + newSnippet: [ + .init( + text: "var foo = Bar()", + diff: .mutated(changes: [ + .init(offset: 0, element: "var"), + .init(offset: 10, element: "Bar"), + ]) + ), + .init( + text: "foo.baz()", + diff: .mutated(changes: [ + .init(offset: 6, element: "z"), + ]) + ), + ] + ), + .init( + oldOffset: 4, + newOffset: 4, + oldSnippet: [.init(text: "// divider a")], + newSnippet: [.init(text: "// divider a")] + ), + .init( + oldOffset: 5, + newOffset: 5, + oldSnippet: [], + newSnippet: [ + .init( + text: "print(foo)", + diff: .mutated(changes: [ + .init(offset: 0, element: "print(foo)"), + ]) + ), + ] + ), + ]) + ) + } + + func test_diff_snippets_multiple_sections_beginning_unchanged_reversed() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + // unchanged + // unchanged + let foo = Foo() + foo.bar() + // divider a + """, + from: """ + // unchanged + // unchanged + var foo = Bar() + foo.baz() + // divider a + print(foo) + """ + ), + .init(sections: [ + .init( + oldOffset: 0, + newOffset: 0, + oldSnippet: [.init(text: "// unchanged"), .init(text: "// unchanged")], + newSnippet: [.init(text: "// unchanged"), .init(text: "// unchanged")] + ), + .init( + oldOffset: 2, + newOffset: 2, + oldSnippet: [ + .init( + text: "var foo = Bar()", + diff: .mutated(changes: [ + .init(offset: 0, element: "var"), + .init(offset: 10, element: "Bar"), + ]) + ), + .init( + text: "foo.baz()", + diff: .mutated(changes: [ + .init(offset: 6, element: "z"), + ]) + ), + ], + newSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [ + .init(offset: 0, element: "let"), + .init(offset: 10, element: "Foo"), + ]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [ + .init(offset: 6, element: "r"), + ]) + ), + ] + ), + .init( + oldOffset: 4, + newOffset: 4, + oldSnippet: [.init(text: "// divider a")], + newSnippet: [.init(text: "// divider a")] + ), + .init( + oldOffset: 5, + newOffset: 5, + oldSnippet: [.init( + text: "print(foo)", + diff: .mutated(changes: [ + .init(offset: 0, element: "print(foo)"), + ]) + )], + newSnippet: [] + ), + ]) + ) + } + + func test_diff_snippets_multiple_sections_more_unbalanced_sections_reversed() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + let foo = Foo() + foo.bar() + // divider a + // divider b + // divider c + func bar() {} + """, + from: """ + var foo = Bar() + foo.baz() + // divider a + print(foo) + // divider b + print(foo) + // divider c + func bar() { + print(foo) + } + """ + ), + .init(sections: [ + .init( + oldOffset: 0, + newOffset: 0, + oldSnippet: [ + .init( + text: "var foo = Bar()", + diff: .mutated(changes: [ + .init(offset: 0, element: "var"), + .init(offset: 10, element: "Bar"), + ]) + ), + .init( + text: "foo.baz()", + diff: .mutated(changes: [ + .init(offset: 6, element: "z"), + ]) + ), + ], + newSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [ + .init(offset: 0, element: "let"), + .init(offset: 10, element: "Foo"), + ]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [ + .init(offset: 6, element: "r"), + ]) + ), + ] + ), + .init( + oldOffset: 2, + newOffset: 2, + oldSnippet: [.init(text: "// divider a")], + newSnippet: [.init(text: "// divider a")] + ), + .init( + oldOffset: 3, + newOffset: 3, + oldSnippet: [.init( + text: "print(foo)", + diff: .mutated(changes: [ + .init(offset: 0, element: "print(foo)"), + ]) + )], + newSnippet: [] + ), + .init( + oldOffset: 4, + newOffset: 3, + oldSnippet: [.init(text: "// divider b")], + newSnippet: [.init(text: "// divider b")] + ), + .init( + oldOffset: 5, + newOffset: 4, + oldSnippet: [.init( + text: "print(foo)", + diff: .mutated(changes: [ + .init(offset: 0, element: "print(foo)"), + ]) + )], + newSnippet: [] + ), + .init( + oldOffset: 6, + newOffset: 4, + oldSnippet: [.init(text: "// divider c")], + newSnippet: [.init(text: "// divider c")] + ), + .init( + oldOffset: 7, + newOffset: 5, + oldSnippet: [ + .init( + text: "func bar() {", + diff: .mutated(changes: []) + ), + .init( + text: " print(foo)", + diff: .mutated(changes: [.init(offset: 0, element: " print(foo)")]) + ), + .init( + text: "}", + diff: .mutated(changes: [.init(offset: 0, element: "}")]) + ), + ], + newSnippet: [ + .init( + text: "func bar() {}", + diff: .mutated(changes: [ + .init(offset: 12, element: "}"), + ]) + ), + ] + ), + ]) + ) + } + + func test_removing_last_line() { + let originalCode = """ + 1 + 2 + 3 + """ + let newCode = """ + 1 + 2 + """ + + let diff = CodeDiff().diff(snippet: newCode, from: originalCode) + XCTAssertEqual(diff, .init(sections: [ + .init(oldOffset: 0, newOffset: 0, oldSnippet: [ + .init(text: "1", diff: .unchanged), + .init(text: "2", diff: .unchanged), + ], newSnippet: [ + .init(text: "1", diff: .unchanged), + .init(text: "2", diff: .unchanged), + ]), + .init(oldOffset: 2, newOffset: 2, oldSnippet: [ + .init(text: "3", diff: .mutated(changes: [.init(offset: 0, element: "3")])), + ], newSnippet: [ + ]), + ])) + } + + func test_removing_multiple_sections() { + let originalCode = """ + 1 + 2 + 3 + 4 + 5 + """ + let newCode = """ + 1 + 3 + 5 + """ + + let diff = CodeDiff().diff(snippet: newCode, from: originalCode) + XCTAssertEqual(diff, .init(sections: [ + .init(oldOffset: 0, newOffset: 0, oldSnippet: [ + .init(text: "1", diff: .unchanged), + ], newSnippet: [ + .init(text: "1", diff: .unchanged), + ]), + .init(oldOffset: 1, newOffset: 1, oldSnippet: [ + .init(text: "2", diff: .mutated(changes: [.init(offset: 0, element: "2")])), + ], newSnippet: [ + ]), + .init(oldOffset: 2, newOffset: 1, oldSnippet: [ + .init(text: "3", diff: .unchanged), + ], newSnippet: [ + .init(text: "3", diff: .unchanged), + ]), + .init(oldOffset: 3, newOffset: 2, oldSnippet: [ + .init(text: "4", diff: .mutated(changes: [.init(offset: 0, element: "4")])), + ], newSnippet: [ + ]), + .init(oldOffset: 4, newOffset: 2, oldSnippet: [ + .init(text: "5", diff: .unchanged), + ], newSnippet: [ + .init(text: "5", diff: .unchanged), + ]), + ])) + } +} + diff --git a/Tool/Tests/FocusedCodeFinderTests/ObjectiveCFocusedCodeFinderTests.swift b/Tool/Tests/FocusedCodeFinderTests/ObjectiveCFocusedCodeFinderTests.swift index c9b55764..af9d3c02 100644 --- a/Tool/Tests/FocusedCodeFinderTests/ObjectiveCFocusedCodeFinderTests.swift +++ b/Tool/Tests/FocusedCodeFinderTests/ObjectiveCFocusedCodeFinderTests.swift @@ -1,5 +1,5 @@ import Foundation -import SuggestionModel +import SuggestionBasic import XCTest @testable import FocusedCodeFinder diff --git a/Tool/Tests/FocusedCodeFinderTests/SwiftFocusedCodeFinderTests.swift b/Tool/Tests/FocusedCodeFinderTests/SwiftFocusedCodeFinderTests.swift index 5cebe53b..c8c2d1da 100644 --- a/Tool/Tests/FocusedCodeFinderTests/SwiftFocusedCodeFinderTests.swift +++ b/Tool/Tests/FocusedCodeFinderTests/SwiftFocusedCodeFinderTests.swift @@ -1,5 +1,5 @@ import Foundation -import SuggestionModel +import SuggestionBasic import XCTest @testable import FocusedCodeFinder @@ -220,7 +220,7 @@ final class SwiftFocusedCodeFinder_Selection_Tests: XCTestCase { func test_selecting_a_static_function_from_an_actor_the_scope_should_be_the_actor() { let code = """ - @gloablActor + @globalActor public actor A { static func f() {} static func g() {} @@ -240,7 +240,7 @@ final class SwiftFocusedCodeFinder_Selection_Tests: XCTestCase { XCTAssertEqual(context, .init( scope: .scope(signature: [ .init( - signature: "@gloablActor public actor A", + signature: "@globalActor public actor A", name: "A", range: .init(startPair: (0, 0), endPair: (7, 1)) ), diff --git a/Tool/Tests/FocusedCodeFinderTests/UnknownLanguageFocusedCodeFinderTests.swift b/Tool/Tests/FocusedCodeFinderTests/UnknownLanguageFocusedCodeFinderTests.swift index bdc00be9..fa975db9 100644 --- a/Tool/Tests/FocusedCodeFinderTests/UnknownLanguageFocusedCodeFinderTests.swift +++ b/Tool/Tests/FocusedCodeFinderTests/UnknownLanguageFocusedCodeFinderTests.swift @@ -1,5 +1,5 @@ import Foundation -import SuggestionModel +import SuggestionBasic import XCTest @testable import FocusedCodeFinder diff --git a/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift b/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift index c500e34b..7c462176 100644 --- a/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift +++ b/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift @@ -1,134 +1,113 @@ +import CopilotForXcodeKit import LanguageServerProtocol import XCTest @testable import GitHubCopilotService +struct TestServiceLocator: ServiceLocatorType { + let server: GitHubCopilotLSP + func getService(from workspace: WorkspaceInfo) async -> GitHubCopilotService? { + .init(designatedServer: server) + } +} + final class FetchSuggestionTests: XCTestCase { func test_process_suggestions_from_server() async throws { struct TestServer: GitHubCopilotLSP { func sendNotification(_: LanguageServerProtocol.ClientNotification) async throws { - fatalError() + return } - func sendRequest(_: E) async throws -> E.Response where E: GitHubCopilotRequestType { - return GitHubCopilotRequest.GetCompletionsCycling.Response(completions: [ + func sendRequest(_: E, timeout: TimeInterval?) async throws -> E.Response + where E: GitHubCopilotRequestType + { + return GitHubCopilotRequest.InlineCompletion.Response(items: [ .init( - text: "Hello World\n", - position: .init((0, 0)), - uuid: "uuid", + insertText: "Hello World\n", + filterText: nil, range: .init(start: .init((0, 0)), end: .init((0, 4))), - displayText: "" + command: nil ), .init( - text: " ", - position: .init((0, 0)), - uuid: "uuid", + insertText: " ", + filterText: nil, range: .init(start: .init((0, 0)), end: .init((0, 1))), - displayText: "" + command: nil ), .init( - text: " \n", - position: .init((0, 0)), - uuid: "uuid", + insertText: " \n", + filterText: nil, range: .init(start: .init((0, 0)), end: .init((0, 2))), - displayText: "" + command: nil ), ]) 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, - ignoreTrailingNewLinesAndSpaces: false + let service = + GitHubCopilotSuggestionService(serviceLocator: TestServiceLocator(server: TestServer())) + let completions = try await service.getSuggestions( + .init( + fileURL: .init(fileURLWithPath: "/file.swift"), + relativePath: "", + language: .builtIn(.swift), + content: "", + originalContent: "", + cursorPosition: .outOfScope, + tabSize: 4, + indentSize: 4, + usesTabsForIndentation: false, + relevantCodeSnippets: [] + ), + workspace: .init( + workspaceURL: .init(fileURLWithPath: "/"), + projectURL: .init(fileURLWithPath: "/file.swift") + ) ) XCTAssertEqual(completions.count, 3) } - func test_ignore_empty_suggestions() async throws { - struct TestServer: GitHubCopilotLSP { - func sendNotification(_: 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: "" - ), - .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: "" - ), - ]) 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, - ignoreTrailingNewLinesAndSpaces: false - ) - XCTAssertEqual(completions.count, 1) - XCTAssertEqual(completions.first?.text, "Hello World\n") - } - func test_if_language_identifier_is_unknown_returns_correctly() async throws { class TestServer: GitHubCopilotLSP { func sendNotification(_: LanguageServerProtocol.ClientNotification) async throws { // unimplemented } - func sendRequest(_: E) async throws -> E.Response where E: GitHubCopilotRequestType { - return GitHubCopilotRequest.GetCompletionsCycling.Response(completions: [ + func sendRequest(_: E, timeout: TimeInterval?) async throws -> E.Response + where E: GitHubCopilotRequestType + { + return GitHubCopilotRequest.InlineCompletion.Response(items: [ .init( - text: "Hello World\n", - position: .init((0, 0)), - uuid: "uuid", + insertText: "Hello World\n", + filterText: nil, range: .init(start: .init((0, 0)), end: .init((0, 4))), - displayText: "" + command: nil ), ]) 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, - ignoreTrailingNewLinesAndSpaces: true + let service = + GitHubCopilotSuggestionService(serviceLocator: TestServiceLocator(server: testServer)) + let completions = try await service.getSuggestions( + .init( + fileURL: .init(fileURLWithPath: "/"), + relativePath: "", + language: .builtIn(.swift), + content: "", + originalContent: "", + cursorPosition: .outOfScope, + tabSize: 4, + indentSize: 4, + usesTabsForIndentation: false, + relevantCodeSnippets: [] + ), + workspace: .init( + workspaceURL: .init(fileURLWithPath: "/"), + projectURL: .init(fileURLWithPath: "/file.swift") + ) ) XCTAssertEqual(completions.count, 1) - XCTAssertEqual(completions.first?.text, "Hello World") + XCTAssertEqual(completions.first?.text, "Hello World\n") } } diff --git a/Tool/Tests/JoinJSONTests/JoinJSONTests.swift b/Tool/Tests/JoinJSONTests/JoinJSONTests.swift new file mode 100644 index 00000000..05cbf3e6 --- /dev/null +++ b/Tool/Tests/JoinJSONTests/JoinJSONTests.swift @@ -0,0 +1,74 @@ +import Foundation + +import XCTest +@testable import JoinJSON + +final class JoinJSONTests: XCTestCase { + var sut: JoinJSON! + + override func setUp() { + super.setUp() + sut = JoinJSON() + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + func test_join_two_valid_json_strings() throws { + let json1 = """ + {"name": "John"} + """ + let json2 = """ + {"age": 30} + """ + + let result = sut.join(json1, with: json2) + let dict = try JSONSerialization.jsonObject(with: result) as? [String: Any] + + XCTAssertEqual(dict?["name"] as? String, "John") + XCTAssertEqual(dict?["age"] as? Int, 30) + } + + func test_join_with_invalid_json_returns_first_data() { + let json1 = """ + {"name": "John"} + """ + let invalidJSON = "invalid json" + + let result = sut.join(json1, with: invalidJSON) + XCTAssertEqual(result, json1.data(using: .utf8)) + } + + func test_join_with_overlapping_keys_prefers_second_value() throws { + let json1 = """ + {"name": "John", "age": 25} + """ + let json2 = """ + {"age": 30} + """ + + let result = sut.join(json1, with: json2) + let dict = try JSONSerialization.jsonObject(with: result) as? [String: Any] + + XCTAssertEqual(dict?["name"] as? String, "John") + XCTAssertEqual(dict?["age"] as? Int, 30) + } + + func test_join_with_data_input() throws { + let data1 = """ + {"name": "John"} + """.data(using: .utf8)! + + let data2 = """ + {"age": 30} + """.data(using: .utf8)! + + let result = sut.join(data1, with: data2) + let dict = try JSONSerialization.jsonObject(with: result) as? [String: Any] + + XCTAssertEqual(dict?["name"] as? String, "John") + XCTAssertEqual(dict?["age"] as? Int, 30) + } +} diff --git a/Tool/Tests/LangChainTests/VectorStoreTests/TemporaryUSearchTests.swift b/Tool/Tests/LangChainTests/VectorStoreTests/TemporaryUSearchTests.swift index 3736100d..02f86c5d 100644 --- a/Tool/Tests/LangChainTests/VectorStoreTests/TemporaryUSearchTests.swift +++ b/Tool/Tests/LangChainTests/VectorStoreTests/TemporaryUSearchTests.swift @@ -26,7 +26,7 @@ class TemporaryUSearchTests: XCTestCase { func test_setting_data() async throws { let identifier = "hello-world" - let store = TemporaryUSearch(identifier: identifier) + let store = TemporaryUSearch(identifier: identifier, dimensions: 4) try await store.set(EmbeddingData.data.map { datum in .init( document: .init(pageContent: datum.text, metadata: [:]), diff --git a/Tool/Tests/ModificationBasicTests/ExplanationThenCodeStreamParserTests.swift b/Tool/Tests/ModificationBasicTests/ExplanationThenCodeStreamParserTests.swift new file mode 100644 index 00000000..1597c553 --- /dev/null +++ b/Tool/Tests/ModificationBasicTests/ExplanationThenCodeStreamParserTests.swift @@ -0,0 +1,289 @@ +import Foundation +import XCTest +@testable import ModificationBasic + +class ExplanationThenCodeStreamParserTests: XCTestCase { + func collectFragments(_ fragments: [ExplanationThenCodeStreamParser.Fragment]) -> ( + code: String, + explanation: String + ) { + var code = "" + var explanation = "" + for fragment in fragments { + switch fragment { + case let .code(c): + code += c + case let .explanation(e): + explanation += e + } + } + return (code: code, explanation: explanation) + } + + func process(_ code: String) async -> (code: String, explanation: String) { + let parser = ExplanationThenCodeStreamParser() + var allFragments: [ExplanationThenCodeStreamParser.Fragment] = [] + + func chunks(from code: String, chunkSize: Int) -> [String] { + var chunks: [String] = [] + var currentIndex = code.startIndex + + while currentIndex < code.endIndex { + let endIndex = code.index( + currentIndex, + offsetBy: chunkSize, + limitedBy: code.endIndex + ) ?? code.endIndex + let chunk = String(code[currentIndex.. ChatMessage.Reference { .init( title: "", - subTitle: "", content: text, - uri: "", - startLine: nil, - endLine: nil, kind: .text ) } func test_retrieved_content_when_the_context_window_is_large_enough() async { let strategy = Strategy() - + let memory = AutoManagedChatGPTMemory( systemPrompt: "", - configuration: UserPreferenceChatGPTConfiguration(), + configuration: UserPreferenceChatGPTConfiguration().overriding { + $0.maxTokens = 999999 + }, functionProvider: EmptyFunctionProvider() ) @@ -57,7 +56,7 @@ class AutoManagedChatGPTMemoryRetrievedContentTests: XCTestCase { """ let maxTokenCount = await strategy.countToken(.init(role: .user, content: fullContent)) - + let result = await memory.generateRetrievedContentMessage( maxTokenCount: maxTokenCount, strategy: strategy @@ -96,7 +95,9 @@ class AutoManagedChatGPTMemoryRetrievedContentTests: XCTestCase { let memory = AutoManagedChatGPTMemory( systemPrompt: "", - configuration: UserPreferenceChatGPTConfiguration(), + configuration: UserPreferenceChatGPTConfiguration().overriding { + $0.maxTokens = 999999 + }, functionProvider: EmptyFunctionProvider() ) @@ -159,13 +160,15 @@ class AutoManagedChatGPTMemoryRetrievedContentTests: XCTestCase { D """) } - + func test_retrieved_content_when_the_context_window_can_take_only_one_document() async { let strategy = Strategy() let memory = AutoManagedChatGPTMemory( systemPrompt: "", - configuration: UserPreferenceChatGPTConfiguration(), + configuration: UserPreferenceChatGPTConfiguration().overriding { + $0.maxTokens = 999999 + }, functionProvider: EmptyFunctionProvider() ) @@ -200,13 +203,15 @@ class AutoManagedChatGPTMemoryRetrievedContentTests: XCTestCase { A """) } - + func test_retrieved_content_when_the_context_window_empty() async { let strategy = Strategy() let memory = AutoManagedChatGPTMemory( systemPrompt: "", - configuration: UserPreferenceChatGPTConfiguration(), + configuration: UserPreferenceChatGPTConfiguration().overriding { + $0.maxTokens = 999999 + }, functionProvider: EmptyFunctionProvider() ) diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift deleted file mode 100644 index 99646988..00000000 --- a/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift +++ /dev/null @@ -1,40 +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 skip to true.") - } - - func test_calling_the_api_with_function_calling() 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 skip to true.") - } -} diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift new file mode 100644 index 00000000..75c20b96 --- /dev/null +++ b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift @@ -0,0 +1,592 @@ +import AIModel +import ChatBasic +import Dependencies +import Foundation +import XCTest + +@testable import OpenAIService + +class ChatGPTServiceTests: XCTestCase { + func test_send_memory_and_handles_responses_with_chunks() async throws { + let api = ChunksChatCompletionsStreamAPI(chunks: [ + .token("hello"), + .token(" "), + .token("world"), + .token("!"), + .finish(reason: "finished"), + ]) + let builder = APIBuilder(api: api) + let memory = EmptyChatGPTMemory() + let stream = withDependencies { values in + values.uuid = .incrementing + values.date = .constant(Date()) + values.chatCompletionsAPIBuilder = builder + } operation: { + let service = ChatGPTService( + configuration: EmptyConfiguration(), + functionProvider: NoChatGPTFunctionProvider() + ) + return service.send(memory) + } + + let response = try await stream.asArray() + XCTAssertEqual(response, [ + .partialText("hello"), + .partialText(" "), + .partialText("world"), + .partialText("!"), + .usage(promptTokens: 0, completionTokens: 0, cachedTokens: 0, otherUsage: [:]), + ]) + + let history = await memory.history + XCTAssertEqual(history, [ + .init( + id: "00000000-0000-0000-0000-000000000000", + role: .assistant, + content: "hello world!" + ), + ]) + } + + func test_send_memory_returns_tool_calls() async throws { + let api = ChunksChatCompletionsStreamAPI( + chunks: [ + .partialToolCalls([ + .init(index: 0, id: "1", type: "function", function: .init(name: "foo")), + .init(index: 1, id: "2", type: "function", function: .init(name: "bar")), + ]), + .partialToolCalls([ + .init( + index: 0, + id: "1", + type: "function", + function: .init(arguments: "{\"foo\": \"hi\"}") + ), + .init( + index: 1, + id: "2", + type: "function", + function: .init(arguments: "{\"bar\": \"bye\"}") + ), + ]), + ] + ) + let builder = APIBuilder(api: api) + let memory = EmptyChatGPTMemory() + let stream = withDependencies { values in + values.uuid = .incrementing + values.date = .constant(Date()) + values.chatCompletionsAPIBuilder = builder + } operation: { + let service = ChatGPTService( + configuration: EmptyConfiguration(), + functionProvider: FunctionProvider() + ) + return service.send(memory) + } + + let response = try await stream.asArray() + XCTAssertEqual(response, [ + .usage(promptTokens: 0, completionTokens: 0, cachedTokens: 0, otherUsage: [:]), + .toolCalls([ + .init( + id: "1", + type: "function", + function: .init(name: "foo", arguments: "{\"foo\": \"hi\"}"), + response: nil + ), + .init( + id: "2", + type: "function", + function: .init(name: "bar", arguments: "{\"bar\": \"bye\"}"), + response: nil + ), + ]), + ]) + + let history = await memory.history + XCTAssertEqual(history, [ + .init( + id: "00000000-0000-0000-0000-000000000000", + role: .assistant, + content: nil, + toolCalls: [ + .init( + id: "1", + type: "function", + function: .init(name: "foo", arguments: "{\"foo\": \"hi\"}"), + response: nil + ), + .init( + id: "2", + type: "function", + function: .init(name: "bar", arguments: "{\"bar\": \"bye\"}"), + response: nil + ), + ] + ), + ]) + } + + func test_send_memory_and_automatically_handles_multiple_tool_calls() async throws { + let api = ChunksChatCompletionsStreamAPI(chunks: [[ + .partialToolCalls([ + .init(index: 0, id: "1", type: "function", function: .init(name: "foo")), + .init(index: 1, id: "2", type: "function", function: .init(name: "bar")), + ]), + .partialToolCalls([ + .init( + index: 0, + id: "1", + type: "function", + function: .init(arguments: "{\"foo\": \"hi\"}") + ), + .init( + index: 1, + id: "2", + type: "function", + function: .init(arguments: "{\"bar\": \"bye\"}") + ), + ]), + ], + [ + .token("hello"), + .token(" "), + .token("world"), + .token("!"), + .finish(reason: "finished"), + ], + ]) + let builder = APIBuilder(api: api) + let memory = EmptyChatGPTMemory() + let stream = withDependencies { values in + values.uuid = .incrementing + values.date = .constant(Date()) + values.chatCompletionsAPIBuilder = builder + } operation: { + let service = ChatGPTService( + configuration: EmptyConfiguration().overriding { + $0.runFunctionsAutomatically = true + }, + functionProvider: FunctionProvider() + ) + return service.send(memory) + } + + let response = try await stream.asArray() + XCTAssertEqual(response, [ + .status(["start foo 1"]), + .status(["start foo 2"]), + .status(["start foo 3"]), + .status(["start bar 1", "start foo 3"]), + .status(["start bar 2", "start foo 3"]), + .status(["start bar 3", "start foo 3"]), + .usage(promptTokens: 0, completionTokens: 0, cachedTokens: 0, otherUsage: [:]), + .status(["foo hi"]), + .status([]), + .status(["bar bye"]), + .status([]), + .partialText("hello"), + .partialText(" "), + .partialText("world"), + .partialText("!"), + .usage(promptTokens: 0, completionTokens: 0, cachedTokens: 0, otherUsage: [:]), + ]) + + let history = await memory.history + XCTAssertEqual(history, [ + .init( + id: "00000000-0000-0000-0000-000000000000", + role: .assistant, + content: nil, + toolCalls: [ + .init( + id: "1", + type: "function", + function: .init(name: "foo", arguments: "{\"foo\": \"hi\"}"), + response: .init(content: "foo hi", summary: "foo hi") + ), + .init( + id: "2", + type: "function", + function: .init(name: "bar", arguments: "{\"bar\": \"bye\"}"), + response: .init(content: "Error: bar error", summary: "Error: bar error") + ), + ] + ), + .init( + id: "00000000-0000-0000-0000-000000000001", + role: .assistant, + content: "hello world!" + ), + ]) + } + + func test_send_memory_and_automatically_handles_unknown_tool_call() async throws { + let api = ChunksChatCompletionsStreamAPI(chunks: [[ + .partialToolCalls([ + .init(index: 0, id: "1", type: "function", function: .init(name: "python")), + .init(index: 1, id: "2", type: "function", function: .init(name: "unknown")), + ]), + .partialToolCalls([ + .init( + index: 0, + id: "1", + type: "function", + function: .init(arguments: "{\"foo\": \"hi\"}") + ), + .init( + index: 1, + id: "2", + type: "function", + function: .init(arguments: "{\"foo\": \"hi\"}") + ), + ]), + ], + [ + .token("result a"), + ], + [ + .token("result b"), + ], + [ + .token("hello"), + .token(" "), + .token("world"), + .token("!"), + .finish(reason: "finished"), + ], + ]) + let builder = APIBuilder(api: api) + let memory = EmptyChatGPTMemory() + let stream = withDependencies { values in + values.uuid = .incrementing + values.date = .constant(Date()) + values.chatCompletionsAPIBuilder = builder + } operation: { + let service = ChatGPTService( + configuration: EmptyConfiguration().overriding { + $0.runFunctionsAutomatically = true + }, + utilityConfiguration: EmptyConfiguration(), + functionProvider: FunctionProvider() + ) + return service.send(memory) + } + + let response = try await stream.asArray() + XCTAssertEqual(response, [ + .usage(promptTokens: 0, completionTokens: 0, cachedTokens: 0, otherUsage: [:]), + .partialText("hello"), + .partialText(" "), + .partialText("world"), + .partialText("!"), + .usage(promptTokens: 0, completionTokens: 0, cachedTokens: 0, otherUsage: [:]), + ]) + + let history = await memory.history + XCTAssertEqual(history, [ + .init( + id: "00000000-0000-0000-0000-000000000000", + role: .assistant, + content: nil, + toolCalls: [ + .init( + id: "1", + type: "function", + function: .init(name: "python", arguments: "{\"foo\": \"hi\"}"), + response: .init(content: "result a", summary: "Finished running function.") + ), + .init( + id: "2", + type: "function", + function: .init(name: "unknown", arguments: "{\"foo\": \"hi\"}"), + response: .init(content: "result b", summary: "Finished running function.") + ), + ] + ), + .init( + id: "00000000-0000-0000-0000-000000000003", + role: .assistant, + content: "hello world!" + ), + ]) + } + + func test_send_memory_and_handles_error() async throws { + struct E: Error, LocalizedError { + var errorDescription: String? { "error happens" } + } + let api = ChunksChatCompletionsStreamAPI(chunks: [ + .token("hello"), + .token(" "), + .failure(E()), + ]) + let builder = APIBuilder(api: api) + let memory = EmptyChatGPTMemory() + let stream = withDependencies { values in + values.uuid = .incrementing + values.date = .constant(Date()) + values.chatCompletionsAPIBuilder = builder + } operation: { + let service = ChatGPTService( + configuration: EmptyConfiguration(), + functionProvider: NoChatGPTFunctionProvider() + ) + return service.send(memory) + } + + var results = [ChatGPTResponse]() + let expectError = expectation(description: "error") + do { + for try await item in stream { + results.append(item) + } + } catch is E { + expectError.fulfill() + } catch { + XCTFail("Incorrect Error") + } + + await fulfillment(of: [expectError], timeout: 1) + let history = await memory.history + XCTAssertEqual(history, [ + .init( + id: "00000000-0000-0000-0000-000000000000", + role: .assistant, + content: "hello " + ), + .init( + id: "00000000-0000-0000-0000-000000000001", + role: .assistant, + content: "error happens" + ), + ]) + } + + func test_send_memory_and_handles_cancellation() async throws { + let api = ChunksChatCompletionsStreamAPI(chunks: [ + .token("hello"), + .token(" "), + .failure(CancellationError()), + ]) + let builder = APIBuilder(api: api) + let memory = EmptyChatGPTMemory() + let stream = withDependencies { values in + values.uuid = .incrementing + values.date = .constant(Date()) + values.chatCompletionsAPIBuilder = builder + } operation: { + let service = ChatGPTService( + configuration: EmptyConfiguration(), + functionProvider: NoChatGPTFunctionProvider() + ) + return service.send(memory) + } + + var results = [ChatGPTResponse]() + let expectError = expectation(description: "error") + do { + for try await item in stream { + results.append(item) + } + } catch is CancellationError { + expectError.fulfill() + } catch { + XCTFail("Incorrect Error") + } + + await fulfillment(of: [expectError], timeout: 1) + let history = await memory.history + XCTAssertEqual(history, [ + .init( + id: "00000000-0000-0000-0000-000000000000", + role: .assistant, + content: "hello " + ), + ]) + } +} + +private struct APIBuilder: ChatCompletionsAPIBuilder { + let api: ChatCompletionsStreamAPI + + func buildStreamAPI( + model: ChatModel, + endpoint: URL, + apiKey: String, + requestBody: ChatCompletionsRequestBody + ) -> any ChatCompletionsStreamAPI { + api + } + + func buildNonStreamAPI( + model: ChatModel, + endpoint: URL, + apiKey: String, + requestBody: ChatCompletionsRequestBody + ) -> any ChatCompletionsAPI { + fatalError() + } +} + +private struct EmptyConfiguration: ChatGPTConfiguration { + var model: AIModel.ChatModel? { .init(id: "", name: "", format: .openAI, info: .init()) } + var temperature: Double { 0 } + var stop: [String] { [] } + var maxTokens: Int { 99999 } + var minimumReplyTokens: Int { 99999 } + var runFunctionsAutomatically: Bool { false } + var shouldEndTextWindow: (String) -> Bool = { _ in true } +} + +private class ChunksChatCompletionsStreamAPI: ChatCompletionsStreamAPI { + private(set) var chunks: [[Result]] + init(chunks: [Result]) { + self.chunks = [chunks] + } + + init(chunks: [[Result]]) { + self.chunks = chunks + } + + func callAsFunction() async throws + -> AsyncThrowingStream + { + let chunks = self.chunks.removeFirst() + return .init { + for chunk in chunks { + switch chunk { + case let .success(chunk): + $0.yield(chunk) + case let .failure(error): + $0.finish(throwing: error) + return + } + } + $0.finish() + } + } +} + +private struct ThrowingChatCompletionsStreamAPI: ChatCompletionsStreamAPI { + let error: any Error + func callAsFunction() async throws + -> AsyncThrowingStream + { + throw error + } +} + +private extension Result { + static func token(_ string: String) -> Result { + .success(.init( + id: "1", + object: "object", + model: "model", + message: .some(.init(role: .assistant, content: string)), + finishReason: nil + )) + } + + static func partialToolCalls(_ toolCalls: [ChatCompletionsStreamDataChunk.Delta.ToolCall]) + -> Result + { + .success(.init( + id: "1", + object: "object", + model: "model", + message: .some(.init( + role: .assistant, + content: nil, + toolCalls: toolCalls + )), + finishReason: nil + )) + } + + static func finish(reason: String) -> Result { + .success(.init( + id: "1", + object: "object", + model: "model", + message: .some(.init(role: .assistant, content: nil)), + finishReason: reason + )) + } +} + +private struct FunctionProvider: ChatGPTFunctionProvider { + struct Foo: ChatGPTFunction { + struct Arguments: Codable { + var foo: String + } + + struct Result: ChatGPTFunctionResult { + var userReadableContent: ChatBasic.ChatGPTFunctionResultUserReadableContent = .text("") + var result: String + var botReadableContent: String { result } + } + + var name: String { "foo" } + + var description: String { "foo" } + + var argumentSchema: ChatBasic.JSONSchemaValue = .string("") + + func prepare(reportProgress: @escaping ReportProgress) async { + await reportProgress("start foo 1") + await reportProgress("start foo 2") + await reportProgress("start foo 3") + } + + func call( + arguments: Arguments, + reportProgress: @escaping ReportProgress + ) async throws -> Result { + await reportProgress("foo \(arguments.foo)") + return .init(result: "foo \(arguments.foo)") + } + } + + struct Bar: ChatGPTFunction { + struct Arguments: Codable { + var bar: String + } + + struct Result: ChatGPTFunctionResult { + var userReadableContent: ChatBasic.ChatGPTFunctionResultUserReadableContent = .text("") + + var result: String + var botReadableContent: String { result } + } + + var name: String { "bar" } + + var description: String { "bar" } + + var argumentSchema: ChatBasic.JSONSchemaValue = .string("") + + func prepare(reportProgress: @escaping ReportProgress) async { + await reportProgress("start bar 1") + await reportProgress("start bar 2") + await reportProgress("start bar 3") + } + + func call( + arguments: Arguments, + reportProgress: @escaping ReportProgress + ) async throws -> Result { + await reportProgress("bar \(arguments.bar)") + struct E: Error, LocalizedError { + var errorDescription: String? { "bar error" } + } + throw E() + } + } + + var functions: [any ChatGPTFunction] = [Foo(), Bar()] + + var functionCallStrategy: OpenAIService.FunctionCallStrategy? { nil } +} + diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift deleted file mode 100644 index 38bebbbe..00000000 --- a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift +++ /dev/null @@ -1,547 +0,0 @@ -import Dependencies -import XCTest -@testable import OpenAIService - -final class ChatGPTStreamTests: XCTestCase { - func test_sending_message() async throws { - let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s") - let configuration = UserPreferenceChatGPTConfiguration().overriding { - $0.model = .init(id: "id", name: "name", format: .openAI, info: .init()) - } - let functionProvider = NoChatGPTFunctionProvider() - let service = ChatGPTService( - memory: memory, - configuration: configuration, - functionProvider: functionProvider - ) - var requestBody: ChatCompletionsRequestBody? - service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in - requestBody = _requestBody - return MockCompletionStreamAPI_Message() - } - - try await withDependencies { values in - values.uuid = .incrementing - values.date = .constant(.init(timeIntervalSince1970: 0)) - } operation: { - let stream = try await service.send(content: "Hello") - var all = [String]() - for try await text in stream { - all.append(text) - let history = await memory.history - XCTAssertTrue( - history.last?.content?.hasPrefix(all.joined()) ?? false, - "History is not updated" - ) - } - - XCTAssertEqual(requestBody?.messages, [ - .init(role: .system, content: "system"), - .init(role: .user, content: "Hello"), - ], "System prompt is not included") - - XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is not correct") - - var history = await memory.history - for (i, _) in history.enumerated() { - history[i].tokensCount = nil - } - XCTAssertEqual(history, [ - .init( - id: "s", - role: .system, - content: "system" - ), - .init(id: "00000000-0000-0000-0000-000000000000", role: .user, content: "Hello"), - .init( - id: "00000000-0000-0000-0000-0000000000010.0", - role: .assistant, - content: "hellomyfriends" - ), - ], "History is not updated") - - XCTAssertEqual(requestBody?.tools, nil, "Function schema is not submitted") - } - } - - func test_handling_function_call() async throws { - let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s") - let configuration = UserPreferenceChatGPTConfiguration().overriding { - $0.model = .init(id: "id", name: "name", format: .openAI, info: .init()) - } - let functionProvider = FunctionProvider() - let service = ChatGPTService( - memory: memory, - configuration: configuration, - functionProvider: functionProvider - ) - var requestBody: ChatCompletionsRequestBody? - service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in - requestBody = _requestBody - if _requestBody.messages.count <= 2 { - return MockCompletionStreamAPI_Function() - } - return MockCompletionStreamAPI_Message() - } - - try await withDependencies { values in - values.uuid = .incrementing - values.date = .constant(.init(timeIntervalSince1970: 0)) - } operation: { - let stream = try await service.send(content: "Hello") - var all = [String]() - for try await text in stream { - all.append(text) - let history = await memory.history - XCTAssertEqual(history.last?.id, "00000000-0000-0000-0000-0000000000030.0") - XCTAssertTrue( - history.last?.content?.hasPrefix(all.joined()) ?? false, - "History is not updated" - ) - } - - XCTAssertEqual(requestBody?.messages, [ - .init(role: .system, content: "system"), - .init(role: .user, content: "Hello"), - .init( - role: .assistant, content: "", - toolCalls: [ - .init( - id: "id", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}") - )] - ), - .init(role: .tool, content: "Function is called.", toolCallId: "id"), - ], "System prompt is not included") - - XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is not correct") - - var history = await memory.history - for (i, _) in history.enumerated() { - history[i].tokensCount = nil - } - XCTAssertEqual(history, [ - .init(id: "s", role: .system, content: "system"), - .init(id: "00000000-0000-0000-0000-000000000000", role: .user, content: "Hello"), - .init( - id: "00000000-0000-0000-0000-0000000000010.0", - role: .assistant, - content: nil, - toolCalls: [ - .init( - id: "id", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"), - response: .init(content: "Function is called.", summary: nil) - ), - ] - ), - .init( - id: "00000000-0000-0000-0000-0000000000030.0", - role: .assistant, - content: "hellomyfriends" - ), - ], "History is not updated") - - XCTAssertEqual(requestBody?.tools, [ - EmptyFunction(), - ].map { - .init( - type: "function", - function: .init( - name: $0.name, - description: $0.description, - parameters: $0.argumentSchema - ) - ) - }, "Function schema is not submitted") - } - } - - func test_handling_multiple_function_call() async throws { - let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s") - let configuration = UserPreferenceChatGPTConfiguration().overriding { - $0.model = .init(id: "id", name: "name", format: .openAI, info: .init()) - } - let functionProvider = FunctionProvider() - let service = ChatGPTService( - memory: memory, - configuration: configuration, - functionProvider: functionProvider - ) - var requestBody: ChatCompletionsRequestBody? - - service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in - requestBody = _requestBody - if _requestBody.messages.count <= 4 { - return MockCompletionStreamAPI_Function(count: 3) - } - return MockCompletionStreamAPI_Message() - } - - try await withDependencies { values in - values.uuid = .incrementing - values.date = .constant(.init(timeIntervalSince1970: 0)) - } operation: { - let stream = try await service.send(content: "Hello") - var all = [String]() - for try await text in stream { - all.append(text) - let history = await memory.history - XCTAssertEqual(history.last?.id, "00000000-0000-0000-0000-0000000000030.0") - XCTAssertTrue( - history.last?.content?.hasPrefix(all.joined()) ?? false, - "History is not updated" - ) - } - - XCTAssertEqual(requestBody?.messages, [ - .init(role: .system, content: "system"), - .init(role: .user, content: "Hello"), - .init( - role: .assistant, content: "", - toolCalls: [ - .init( - id: "id", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}") - ), - .init( - id: "id2", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}") - ), - .init( - id: "id3", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}") - ), - ] - ), - .init( - role: .tool, - content: "Function is called.", - toolCallId: "id" - ), - .init( - role: .tool, - content: "Function is called.", - toolCallId: "id2" - ), - .init( - role: .tool, - content: "Function is called.", - toolCallId: "id3" - ), - ], "System prompt is not included") - - XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is not correct") - - var history = await memory.history - for (i, _) in history.enumerated() { - history[i].tokensCount = nil - } - XCTAssertEqual(history, [ - .init(id: "s", role: .system, content: "system"), - .init(id: "00000000-0000-0000-0000-000000000000", role: .user, content: "Hello"), - .init( - id: "00000000-0000-0000-0000-0000000000010.0", - role: .assistant, - content: nil, - toolCalls: [ - .init( - id: "id", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"), - response: .init(content: "Function is called.", summary: nil) - ), - .init( - id: "id2", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"), - response: .init(content: "Function is called.", summary: nil) - ), - .init( - id: "id3", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"), - response: .init(content: "Function is called.", summary: nil) - ), - ] - ), - .init( - id: "00000000-0000-0000-0000-0000000000030.0", - role: .assistant, - content: "hellomyfriends" - ), - ], "History is not updated") - - XCTAssertEqual(requestBody?.tools, [ - EmptyFunction(), - ].map { - .init( - type: "function", - function: .init( - name: $0.name, - description: $0.description, - parameters: $0.argumentSchema - ) - ) - }, "Function schema is not submitted") - } - } - - func test_function_calling_unsupported() async throws { - let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s") - let configuration = UserPreferenceChatGPTConfiguration().overriding { - $0.model = .init( - id: "id", - name: "name", - format: .openAI, - info: .init(supportsFunctionCalling: false) - ) - } - let functionProvider = FunctionProvider() - let service = ChatGPTService( - memory: memory, - configuration: configuration, - functionProvider: functionProvider - ) - var requestBody: ChatCompletionsRequestBody? - service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in - requestBody = _requestBody - if _requestBody.messages.count <= 2 { - return MockCompletionStreamAPI_Function() - } - return MockCompletionStreamAPI_Message() - } - - try await withDependencies { values in - values.uuid = .incrementing - values.date = .constant(.init(timeIntervalSince1970: 0)) - } operation: { - let stream = try await service.send(content: "Hello") - var all = [String]() - for try await text in stream { - all.append(text) - let history = await memory.history - XCTAssertEqual(history.last?.id, "00000000-0000-0000-0000-0000000000030.0") - XCTAssertTrue( - history.last?.content?.hasPrefix(all.joined()) ?? false, - "History is not updated" - ) - } - - XCTAssertEqual(requestBody?.messages, [ - .init(role: .system, content: "system"), - .init(role: .user, content: "Hello"), - .init( - role: .assistant, content: "" - ), - .init(role: .user, content: "Function is called."), - ], "System prompt is not included") - - XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is not correct") - - var history = await memory.history - for (i, _) in history.enumerated() { - history[i].tokensCount = nil - } - XCTAssertEqual(history, [ - .init(id: "s", role: .system, content: "system"), - .init(id: "00000000-0000-0000-0000-000000000000", role: .user, content: "Hello"), - .init( - id: "00000000-0000-0000-0000-0000000000010.0", - role: .assistant, - content: nil, - toolCalls: [ - .init( - id: "id", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"), - response: .init(content: "Function is called.", summary: nil) - ), - ] - ), - .init( - id: "00000000-0000-0000-0000-0000000000030.0", - role: .assistant, - content: "hellomyfriends" - ), - ], "History is not updated") - - XCTAssertEqual(requestBody?.tools, nil, "Functions should be nil") - } - } -} - -extension ChatGPTStreamTests { - struct MockCompletionStreamAPI_Message: ChatCompletionsStreamAPI { - @Dependency(\.uuid) var uuid - func callAsFunction() async throws - -> AsyncThrowingStream - { - let id = uuid().uuidString - return AsyncThrowingStream { continuation in - let chunks: [ChatCompletionsStreamDataChunk] = [ - .init( - id: id, - object: "", - model: "", - message: .init(role: .assistant), - finishReason: "" - ), - .init( - id: id, - object: "", - model: "", - message: .init(content: "hello"), - finishReason: "" - ), - .init( - id: id, - object: "", - model: "", - message: .init(content: "my"), - finishReason: "" - ), - .init( - id: id, - object: "", - model: "", - message: .init(content: "friends"), - finishReason: "" - ), - ] - for chunk in chunks { - continuation.yield(chunk) - } - continuation.finish() - } - } - } - - struct MockCompletionStreamAPI_Function: ChatCompletionsStreamAPI { - @Dependency(\.uuid) var uuid - var count: Int = 1 - func callAsFunction() async throws - -> AsyncThrowingStream - { - let id = uuid().uuidString - return AsyncThrowingStream { continuation in - for i in 0.. String { - "Function is called." - } - } - - struct FunctionProvider: ChatGPTFunctionProvider { - var functionCallStrategy: OpenAIService.FunctionCallStrategy? { nil } - - var functions: [any ChatGPTFunction] { [EmptyFunction()] } - } -} - diff --git a/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift b/Tool/Tests/OpenAIServiceTests/GoogleAIChatCompletionsAPITests.swift similarity index 57% rename from Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift rename to Tool/Tests/OpenAIServiceTests/GoogleAIChatCompletionsAPITests.swift index e92d5079..7044fa74 100644 --- a/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift +++ b/Tool/Tests/OpenAIServiceTests/GoogleAIChatCompletionsAPITests.swift @@ -1,18 +1,21 @@ import Foundation +import GoogleGenerativeAI import XCTest @testable import OpenAIService -class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase { +class GoogleAIChatCompletionsAPITests: XCTestCase { + let convert = GoogleAIChatCompletionsService.convertMessages + func test_top_system_prompt_should_convert_to_user_message_that_does_not_merge_with_others() { - let prompt = ChatGPTPrompt(history: [ + let prompt: [ChatCompletionsRequestBody.Message] = [ .init(role: .system, content: "SystemPrompt"), .init(role: .user, content: "A"), .init(role: .assistant, content: "B"), .init(role: .user, content: "Hello"), - ]).googleAICompatible + ] - let expected = ChatGPTPrompt(history: [ + let expected: [ChatCompletionsRequestBody.Message] = [ .init(role: .user, content: """ System Prompt: SystemPrompt @@ -21,14 +24,22 @@ class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase { .init(role: .user, content: "A"), .init(role: .assistant, content: "B"), .init(role: .user, content: "Hello"), - ]) - - XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) - XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) + ] + + let converted = convert(prompt) + + XCTAssertEqual( + converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } }, + expected.map(\.content) + ) + XCTAssertEqual( + converted.map(\.role), + expected.map(\.role).map(ModelContent.convertRole(_:)) + ) } func test_adjacent_same_role_messages_should_be_merged_except_for_the_last_user_message() { - let prompt = ChatGPTPrompt(history: [ + let prompt: [ChatCompletionsRequestBody.Message] = [ .init(role: .system, content: "SystemPrompt"), .init(role: .user, content: "A"), .init(role: .user, content: "B"), @@ -37,9 +48,9 @@ class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase { .init(role: .assistant, content: "E"), .init(role: .assistant, content: "F"), .init(role: .user, content: "World"), - ]).googleAICompatible + ] - let expected = ChatGPTPrompt(history: [ + let expected: [ChatCompletionsRequestBody.Message] = [ .init(role: .user, content: """ System Prompt: SystemPrompt @@ -68,21 +79,29 @@ class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase { F """), .init(role: .user, content: "World"), - ]) - - XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) - XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) + ] + + let converted = convert(prompt) + + XCTAssertEqual( + converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } }, + expected.map(\.content) + ) + XCTAssertEqual( + converted.map(\.role), + expected.map(\.role).map(ModelContent.convertRole(_:)) + ) } func test_non_top_system_prompt_should_merge_as_user_prompt() { - let prompt = ChatGPTPrompt(history: [ + let prompt: [ChatCompletionsRequestBody.Message] = [ .init(role: .user, content: "A"), .init(role: .system, content: "SystemPrompt"), .init(role: .assistant, content: "B"), .init(role: .user, content: "Hello"), - ]).googleAICompatible + ] - let expected = ChatGPTPrompt(history: [ + let expected: [ChatCompletionsRequestBody.Message] = [ .init(role: .user, content: """ A @@ -93,46 +112,55 @@ class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase { """), .init(role: .assistant, content: "B"), .init(role: .user, content: "Hello"), - ]) - - XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) - XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) + ] + + let converted = convert(prompt) + + XCTAssertEqual( + converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } }, + expected.map(\.content) + ) + XCTAssertEqual( + converted.map(\.role), + expected.map(\.role).map(ModelContent.convertRole(_:)) + ) } func test_function_call_should_convert_assistant_and_user_message_with_text_content() { - let prompt = ChatGPTPrompt(history: [ + let prompt: [ChatCompletionsRequestBody.Message] = [ .init(role: .user, content: "A"), .init( role: .assistant, - content: nil, + content: "", toolCalls: [ .init( id: "id", type: "function", - function: .init(name: "ping", arguments: "{ \"ip\": \"127.0.0.1\" }"), - response: .init(content: "42ms", summary: nil) + function: .init(name: "ping", arguments: "{ \"ip\": \"127.0.0.1\" }") ), ] ), + .init(role: .tool, content: "42ms", toolCallId: "id"), .init(role: .assistant, content: "Merge me"), .init(role: .user, content: "Merge me"), .init(role: .user, content: "Merge me"), .init(role: .assistant, content: "B"), .init(role: .user, content: "Hello"), - ]).googleAICompatible + ] - let expected = ChatGPTPrompt(history: [ + let expected: [ChatCompletionsRequestBody.Message] = [ .init(role: .user, content: "A"), .init(role: .assistant, content: """ + Function ID: id Call function: ping Arguments: { "ip": "127.0.0.1" } - Result: 42ms - - ====== - - Merge me """), .init(role: .user, content: """ + Result of function ID: id + 42ms + """), + .init(role: .assistant, content: "Merge me"), + .init(role: .user, content: """ Merge me ====== @@ -141,26 +169,42 @@ class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase { """), .init(role: .assistant, content: "B"), .init(role: .user, content: "Hello"), - ]) - - XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) - XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) + ] + + let converted = convert(prompt) + + XCTAssertEqual( + converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } }, + expected.map(\.content) + ) + XCTAssertEqual( + converted.map(\.role), + expected.map(\.role).map(ModelContent.convertRole(_:)) + ) } func test_if_the_second_last_message_is_from_user_add_a_dummy() { - let prompt = ChatGPTPrompt(history: [ + let prompt: [ChatCompletionsRequestBody.Message] = [ .init(role: .user, content: "A"), .init(role: .user, content: "Hello"), - ]).googleAICompatible + ] - let expected = ChatGPTPrompt(history: [ + let expected: [ChatCompletionsRequestBody.Message] = [ .init(role: .user, content: "A"), .init(role: .assistant, content: "OK"), .init(role: .user, content: "Hello"), - ]) - - XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) - XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) + ] + + let converted = convert(prompt) + + XCTAssertEqual( + converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } }, + expected.map(\.content) + ) + XCTAssertEqual( + converted.map(\.role), + expected.map(\.role).map(ModelContent.convertRole(_:)) + ) } } diff --git a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift index 1173494a..da9cd557 100644 --- a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift +++ b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift @@ -1,3 +1,4 @@ +import ChatBasic import Foundation import TokenEncoder import XCTest @@ -25,7 +26,7 @@ final class AutoManagedChatGPTMemoryLimitTests: XCTestCase { ]) // XCTAssertEqual(remainingTokens, 10000 - 12 - 6) - let history = await memory.history +// let history = await memory.history // token count caching is removed // XCTAssertEqual(history.map(\.tokensCount), [ @@ -103,11 +104,11 @@ class MockEncoder: TokenEncoder { struct MockStrategy: AutoManagedChatGPTMemoryStrategy { let encoder = MockEncoder() - func countToken(_ message: OpenAIService.ChatMessage) async -> Int { + func countToken(_ message: ChatBasic.ChatMessage) async -> Int { await encoder.countToken(message) } - func countToken(_: F) async -> Int where F: OpenAIService.ChatGPTFunction { + func countToken(_: F) async -> Int where F: ChatBasic.ChatGPTFunction { 0 } @@ -130,7 +131,8 @@ private func runService( let memory = AutoManagedChatGPTMemory( systemPrompt: systemPrompt, configuration: configuration, - functionProvider: NoChatGPTFunctionProvider() + functionProvider: NoChatGPTFunctionProvider(), + maxNumberOfMessages: maxNumberOfMessages ) for message in messages { @@ -138,7 +140,6 @@ private func runService( } let messages = await memory.generateSendingHistory( - maxNumberOfMessages: maxNumberOfMessages, strategy: MockStrategy() ) diff --git a/Tool/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift b/Tool/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift index 351e3a6b..f9eadb92 100644 --- a/Tool/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift +++ b/Tool/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift @@ -8,12 +8,13 @@ final class ConvertToCodeLinesTests: XCTestCase { struct Cat { } """ - let (result, spaceCount) = highlighted( + let (result, spaceCount) = CodeHighlighting.highlighted( code: code, language: "swift", + scenario: "a", brightMode: true, droppingLeadingSpaces: false, - fontSize: 14 + font: .systemFont(ofSize: 14) ) XCTAssertEqual(spaceCount, 0) @@ -29,12 +30,13 @@ final class ConvertToCodeLinesTests: XCTestCase { struct Cat { } """ - let (result, spaceCount) = highlighted( + let (result, spaceCount) = CodeHighlighting.highlighted( code: code, language: "md", + scenario: "a", brightMode: true, droppingLeadingSpaces: true, - fontSize: 14 + font: .systemFont(ofSize: 14) ) XCTAssertEqual(spaceCount, 0) @@ -49,12 +51,13 @@ final class ConvertToCodeLinesTests: XCTestCase { struct Cat { } """ - let (result, spaceCount) = highlighted( + let (result, spaceCount) = CodeHighlighting.highlighted( code: code, language: "md", + scenario: "a", brightMode: true, droppingLeadingSpaces: true, - fontSize: 14 + font: .systemFont(ofSize: 14) ) XCTAssertEqual(spaceCount, 4) @@ -69,12 +72,13 @@ final class ConvertToCodeLinesTests: XCTestCase { struct Cat { } """ - let (result, spaceCount) = highlighted( + let (result, spaceCount) = CodeHighlighting.highlighted( code: code, language: "md", + scenario: "a", brightMode: true, droppingLeadingSpaces: true, - fontSize: 14 + font: .systemFont(ofSize: 14) ) XCTAssertEqual(spaceCount, 8) @@ -90,12 +94,13 @@ final class ConvertToCodeLinesTests: XCTestCase { } """ - let (result, spaceCount) = highlighted( + let (result, spaceCount) = CodeHighlighting.highlighted( code: code, language: "md", + scenario: "a", brightMode: true, droppingLeadingSpaces: true, - fontSize: 14 + font: .systemFont(ofSize: 14) ) XCTAssertEqual(spaceCount, 4) @@ -112,12 +117,13 @@ final class ConvertToCodeLinesTests: XCTestCase { // } """ - let (result, spaceCount) = highlighted( + let (result, spaceCount) = CodeHighlighting.highlighted( code: code, language: "md", + scenario: "a", brightMode: true, droppingLeadingSpaces: true, - fontSize: 14 + font: .systemFont(ofSize: 14) ) XCTAssertEqual(spaceCount, 0) @@ -134,12 +140,13 @@ final class ConvertToCodeLinesTests: XCTestCase { // } """ - let (result, spaceCount) = highlighted( + let (result, spaceCount) = CodeHighlighting.highlighted( code: code, language: "md", + scenario: "a", brightMode: true, droppingLeadingSpaces: true, - fontSize: 14 + font: .systemFont(ofSize: 14) ) XCTAssertEqual(spaceCount, 4) diff --git a/Tool/Tests/SuggestionModelTests/BreakLinePerformanceTests.swift b/Tool/Tests/SuggestionBasicTests/BreakLinePerformanceTests.swift similarity index 90% rename from Tool/Tests/SuggestionModelTests/BreakLinePerformanceTests.swift rename to Tool/Tests/SuggestionBasicTests/BreakLinePerformanceTests.swift index 62683392..6f2ff5a7 100644 --- a/Tool/Tests/SuggestionModelTests/BreakLinePerformanceTests.swift +++ b/Tool/Tests/SuggestionBasicTests/BreakLinePerformanceTests.swift @@ -1,6 +1,6 @@ import Foundation import XCTest -@testable import SuggestionModel +@testable import SuggestionBasic final class BreakLinePerformanceTests: XCTestCase { func test_breakLines() { diff --git a/Tool/Tests/SuggestionModelTests/LineAnnotationParsingTests.swift b/Tool/Tests/SuggestionBasicTests/LineAnnotationParsingTests.swift similarity index 92% rename from Tool/Tests/SuggestionModelTests/LineAnnotationParsingTests.swift rename to Tool/Tests/SuggestionBasicTests/LineAnnotationParsingTests.swift index 1471aa33..8ff71f9c 100644 --- a/Tool/Tests/SuggestionModelTests/LineAnnotationParsingTests.swift +++ b/Tool/Tests/SuggestionBasicTests/LineAnnotationParsingTests.swift @@ -1,7 +1,7 @@ import Foundation import XCTest -@testable import SuggestionModel +@testable import SuggestionBasic class LineAnnotationParsingTests: XCTestCase { func test_parse_line_annotation() { diff --git a/Tool/Tests/SuggestionModelTests/ModificationTests.swift b/Tool/Tests/SuggestionBasicTests/ModificationTests.swift similarity index 97% rename from Tool/Tests/SuggestionModelTests/ModificationTests.swift rename to Tool/Tests/SuggestionBasicTests/ModificationTests.swift index a2296824..0de61490 100644 --- a/Tool/Tests/SuggestionModelTests/ModificationTests.swift +++ b/Tool/Tests/SuggestionBasicTests/ModificationTests.swift @@ -1,6 +1,6 @@ import XCTest -@testable import SuggestionModel +@testable import SuggestionBasic final class ModificationTests: XCTestCase { func test_nsmutablearray_deleting_an_element() { diff --git a/Tool/Tests/SuggestionBasicTests/TextExtrationFromCodeTests.swift b/Tool/Tests/SuggestionBasicTests/TextExtrationFromCodeTests.swift new file mode 100644 index 00000000..451ea274 --- /dev/null +++ b/Tool/Tests/SuggestionBasicTests/TextExtrationFromCodeTests.swift @@ -0,0 +1,157 @@ +import Foundation +import XCTest +@testable import SuggestionBasic + +final class TextExtractionFromCodeTests: XCTestCase { + func test_empty_selection() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 0), + end: CursorPosition(line: 0, character: 0) + ) + let lines = ["let foo = 1\n", "let bar = 2\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, "") + XCTAssertEqual(result.lines, ["let foo = 1\n"]) + } + + func test_single_line_selection() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 0, character: 10) + ) + let lines = ["let foo = 1\n", "let bar = 2\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, "foo = ") + XCTAssertEqual(result.lines, ["let foo = 1\n"]) + } + + func test_single_line_selection_with_emoji() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 0, character: 10) + ) + let lines = ["let 🎆🎆o = 1\n", "let bar = 2\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, "🎆🎆o ") + XCTAssertEqual(result.lines, ["let 🎆🎆o = 1\n"]) + } + + func test_single_line_selection_cutting_emoji() { + // undefined behavior + + let selection = CursorRange( + start: CursorPosition(line: 0, character: 5), + end: CursorPosition(line: 0, character: 10) + ) + let lines = ["let 🎆🎆o = 1\n", "let bar = 2\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.lines, ["let 🎆🎆o = 1\n"]) + } + + func test_single_line_selection_at_line_end() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 8), + end: CursorPosition(line: 0, character: 11) + ) + let lines = ["let foo = 1\n", "let bar = 2\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, "= 1") + XCTAssertEqual(result.lines, ["let foo = 1\n"]) + } + + func test_multi_line_selection() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 1, character: 11) + ) + let lines = ["let foo = 1\n", "let bar = 2\n", "let baz = 3\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, "foo = 1\nlet bar = 2") + XCTAssertEqual(result.lines, ["let foo = 1\n", "let bar = 2\n"]) + } + + func test_multi_line_selection_with_emoji() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 1, character: 11) + ) + let lines = ["🎆🎆 foo = 1\n", "let bar = 2\n", "let baz = 3\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, " foo = 1\nlet bar = 2") + XCTAssertEqual(result.lines, ["🎆🎆 foo = 1\n", "let bar = 2\n"]) + } + + func test_invalid_selection() { + let selection = CursorRange( + start: CursorPosition(line: 1, character: 4), + end: CursorPosition(line: 0, character: 10) + ) + let lines = ["let foo = 1", "let bar = 2"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, "") + XCTAssertEqual(result.lines, []) + } + + func test_single_line_selection_ignoring_column() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 0, character: 10) + ) + let lines = ["let foo = 1\n", "let bar = 2\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: true + ) + XCTAssertEqual(result.code, "let foo = 1\n") + XCTAssertEqual(result.lines, ["let foo = 1\n"]) + } + + func test_multi_line_selection_ignoring_column() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 1, character: 11) + ) + let lines = ["let foo = 1\n", "let bar = 2\n", "let baz = 3\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: true + ) + XCTAssertEqual(result.code, "let foo = 1\nlet bar = 2\n") + XCTAssertEqual(result.lines, ["let foo = 1\n", "let bar = 2\n"]) + } +} + diff --git a/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift new file mode 100644 index 00000000..f8f8b909 --- /dev/null +++ b/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift @@ -0,0 +1,1246 @@ +import SuggestionBasic +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( + id: "", + text: text, + position: .init(line: 0, character: 1), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 0) + ), + replacingLines: "".breakLines(appendLineBreakToLastLine: true) + ) + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 1) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 1, character: 0), + end: .init(line: 2, character: 19)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 2, character: 19)) + XCTAssertEqual( + lines.joined(separator: ""), + """ + struct Cat { + var name: String + var age: String + } + + """, + "There is always a new line at the end of each line! When you join them, it will look like this" + ) + } + + 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( + id: "", + text: text, + position: .init(line: 0, character: 12), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 12) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 12) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 2, character: 19)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().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( + id: "", + text: text, + position: .init(line: 1, character: 12), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 12) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 1, character: 12) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 1, character: 0), + end: .init(line: 2, character: 19)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().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( + id: "", + text: text, + position: .init(line: 1, character: 12), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 12) + ), + replacingLines: """ + struct Cat { + var name + } + """.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 1, character: 12) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 1, character: 0), + end: .init(line: 2, character: 19)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().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_has_suffix_typed() async throws { + let content = """ + print("") + """ // typed ") + let text = """ + print("Hello World!") + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 7), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 7) + ), + replacingLines: """ + print(" + """.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 0, character: 21)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 21)) + XCTAssertEqual(lines.joined(separator: ""), """ + print("Hello World!") + + """) + } + + func test_accept_suggestion_overlap_continue_typing_suggestion_in_the_middle() async throws { + let content = """ + print("He") + """ + let text = """ + print("Hello World! + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 7), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 7) + ), + replacingLines: """ + print("") + """.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 0, character: 19)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 19)) + XCTAssertEqual(lines.joined(separator: ""), """ + print("Hello World!") + + """) + } + + func test_accept_suggestion_overlap_continue_typing_has_suffix_typed_suggestion_has_multiple_lines( + ) async throws { + let content = """ + struct Cat {} + """ + let text = """ + struct Cat { + var name: String + var kind: String + } + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 6), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 6) + ), + replacingLines: """ + struct + """.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 12) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 3, character: 1)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 3, character: 1)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct Cat { + var name: String + var kind: String + } + + """) + } + + func test_propose_suggestion_partial_overlap() async throws { + let content = "func quickSort() {}}" + 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( + id: "", + text: text, + position: .init(line: 0, character: 18), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 20) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 18) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 6, character: 1)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().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() {" + 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( + id: "", + text: text, + position: .init(line: 0, character: 18), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 0) + ), + replacingLines: "".breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 18) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 1, character: 0), + end: .init(line: 6, character: 1)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().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( + id: "", + text: text, + position: .init(line: 0, character: 7), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 2, character: 1) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 4, character: 1)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 4, character: 1)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct Dog { + func speak() { + print("woof") + } + } + + """) + } + + 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( + id: "", + text: text, + position: .init(line: 5, character: 34), + range: .init( + start: .init(line: 4, character: 7), + end: .init(line: 5, character: 34) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 5, character: 34) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 4, character: 7), + end: .init(line: 7, character: 5)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().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() {} + + """) + } + + func test_replacing_single_line_in_the_middle_should_not_remove_the_next_character( + ) async throws { + let content = """ + apiKeyName: ,, + """ + + let suggestion = CodeSuggestion( + id: "", + text: "apiKeyName: azureOpenAIAPIKeyName", + position: .init(line: 0, character: 12), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 12) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var lines = content.breakIntoEditorStyleLines() + var extraInfo = SuggestionInjector.ExtraInfo() + var cursor = CursorPosition(line: 5, character: 34) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertEqual(cursor, .init(line: 0, character: 33)) + XCTAssertEqual(lines.joined(separator: ""), """ + apiKeyName: azureOpenAIAPIKeyName,, + + """) + } + + func test_remove_the_first_adjacent_placeholder_in_the_last_line( + ) async throws { + let content = """ + apiKeyName: <#T##value: BinaryInteger##BinaryInteger#> <#Hello#>, + """ + + let suggestion = CodeSuggestion( + id: "", + text: "apiKeyName: azureOpenAIAPIKeyName", + position: .init(line: 0, character: 12), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 12) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var lines = content.breakIntoEditorStyleLines() + var extraInfo = SuggestionInjector.ExtraInfo() + var cursor = CursorPosition(line: 5, character: 34) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertEqual(cursor, .init(line: 0, character: 33)) + XCTAssertEqual(lines.joined(separator: ""), """ + apiKeyName: azureOpenAIAPIKeyName <#Hello#>, + + """) + } + + func test_accept_suggestion_start_from_previous_line_has_emoji_inside() async throws { + let content = """ + struct 😹😹 { + } + """ + let text = """ + struct 😹😹 { + var name: String + var age: String + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 13), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 13) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 13) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 2, character: 19)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 2, character: 19)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct 😹😹 { + var name: String + var age: String + } + + """) + } + + func test_accept_suggestion_overlap_with_emoji_in_the_previous_code() async throws { + let content = """ + struct 😹😹 { + var name + } + """ + let text = """ + var name: String + var age: String + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 1, character: 13), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 13) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 1, character: 13) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 1, character: 0), + end: .init(line: 2, character: 19)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 2, character: 19)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct 😹😹 { + var name: String + var age: String + } + + """) + } + + func test_accept_suggestion_overlap_continue_typing_has_emoji_inside() async throws { + let content = """ + struct 😹😹 { + var name: Str + } + """ + let text = """ + var name: String + var age: String + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 1, character: 13), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 13) + ), + replacingLines: """ + struct 😹😹 { + var name: + } + """.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 1, character: 13) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 1, character: 0), + end: .init(line: 2, character: 19)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 2, character: 19)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct 😹😹 { + var name: String + var age: String + } + + """) + } + + func test_replacing_multiple_lines_with_emoji() async throws { + let content = """ + struct 😹😹 { + func speak() { print("meow") } + } + """ + let text = """ + struct 🐶🐶 { + func speak() { + print("woof") + } + } + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 7), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 2, character: 1) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 4, character: 1)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 4, character: 1)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct 🐶🐶 { + func speak() { + print("woof") + } + } + + """) + } + + func test_accept_suggestion_overlap_continue_typing_suggestion_with_emoji_in_the_middle( + ) async throws { + let content = """ + print("🐶") + """ + let text = """ + print("🐶llo 🐶rld! + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 6), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 6) + ), + replacingLines: """ + print(") + """.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 0, character: 19)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 19)) + XCTAssertEqual(lines.joined(separator: ""), """ + print("🐶llo 🐶rld!") + + """) + } + + func test_replacing_single_line_in_the_middle_should_not_remove_the_next_character_with_emoji( + ) async throws { + let content = """ + 🐶KeyName: ,, + """ + + let suggestion = CodeSuggestion( + id: "", + text: "🐶KeyName: azure👩‍❤️‍👨AIAPIKeyName", + position: .init(line: 0, character: 11), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 11) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var lines = content.breakIntoEditorStyleLines() + var extraInfo = SuggestionInjector.ExtraInfo() + var cursor = CursorPosition(line: 5, character: 34) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertEqual(cursor, .init(line: 0, character: 36)) + XCTAssertEqual(lines.joined(separator: ""), """ + 🐶KeyName: azure👩‍❤️‍👨AIAPIKeyName,, + + """) + } + + func test_accept_suggestion_in_the_middle_single_line() async throws { + let content = """ + let foobar = 1 + """ + let text = """ + let fooBar + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 7), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 10) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 0, character: 10)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 10)) + XCTAssertEqual(lines.joined(separator: ""), """ + let fooBar = 1 + + """) + } + + func test_accept_suggestion_in_the_middle_single_line_case_2() async throws { + let content = """ + let pikachecker = 1 + """ + let text = """ + let pikaChecker + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 16), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 23) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 16) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 0, character: 23)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 23)) + XCTAssertEqual(lines.joined(separator: ""), """ + let pikaChecker = 1 + + """) + } + + func test_accept_suggestion_rewriting_the_single_line() async throws { + let content = """ + let foobar = + """ + let text = """ + let zooKoo = 2 + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 12), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 12) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 0, character: 14)), + ] + ) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 14)) + XCTAssertEqual(lines.joined(separator: ""), """ + let zooKoo = 2 + + """) + } + + func test_accepting_multiple_suggestions_at_a_time() async throws { + let content = """ + protocol Definition { + var id: String + var name: String + } + + struct Foo { + + } + + struct Bar { + + } + + let foo = Foo() + + struct Baz {} + """ + let text1 = """ + struct Foo: Definition { + var id: String + var name: String + } + """ + let suggestion1 = CodeSuggestion( + id: "1", + text: text1, + position: .init(line: 5, character: 0), + range: .init( + start: .init(line: 5, character: 0), + end: .init(line: 7, character: 1) + ), + replacingLines: Array(content.breakLines(appendLineBreakToLastLine: true)[5...7]) + ) + + let text2 = """ + struct Bar: Definition { + var id: String + var name: String + } + """ + let suggestion2 = CodeSuggestion( + id: "2", + text: text2, + position: .init(line: 9, character: 0), + range: .init( + start: .init(line: 9, character: 0), + end: .init(line: 11, character: 1) + ), + replacingLines: Array(content.breakLines(appendLineBreakToLastLine: true)[9...11]) + ) + + let text3 = """ + struct Baz: Definition { + var id: String + var name: String + } + """ + let suggestion3 = CodeSuggestion( + id: "3", + text: text3, + position: .init(line: 15, character: 0), + range: .init( + start: .init(line: 15, character: 0), + end: .init(line: 15, character: 13) + ), + replacingLines: Array(content.breakLines(appendLineBreakToLastLine: true)[15...15]) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 14) + SuggestionInjector().acceptSuggestions( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completions: [suggestion1, suggestion2, suggestion3], + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 20, character: 1)) + XCTAssertEqual(lines.joined(separator: ""), """ + protocol Definition { + var id: String + var name: String + } + + struct Foo: Definition { + var id: String + var name: String + } + + struct Bar: Definition { + var id: String + var name: String + } + + let foo = Foo() + + struct Baz: Definition { + var id: String + var name: String + } + + """) + XCTAssertEqual(extraInfo.modificationRanges, [ + "1": .init(start: .init(line: 5, character: 0), end: .init(line: 8, character: 1)), + "2": .init(start: .init(line: 10, character: 0), end: .init(line: 13, character: 1)), + "3": .init(start: .init(line: 17, character: 0), end: .init(line: 20, character: 1)), + ]) + } + +// Not supported yet +// func test_accepting_multiple_same_line_suggestions_at_a_time() async throws { +// let content = "let foo = 1\n" +// let text1 = "berry" +// let suggestion1 = CodeSuggestion( +// id: "1", +// text: text1, +// position: .init(line: 0, character: 4), +// range: .init( +// start: .init(line: 0, character: 4), +// end: .init(line: 0, character: 7) +// ), +// replacingLines: [content] +// ) +// +// let text2 = """ +// 200 +// """ +// let suggestion2 = CodeSuggestion( +// id: "2", +// text: text2, +// position: .init(line: 0, character: 10), +// range: .init( +// start: .init(line: 0, character: 10), +// end: .init(line: 0, character: 11) +// ), +// replacingLines: [content] +// ) +// +// var extraInfo = SuggestionInjector.ExtraInfo() +// var lines = content.breakIntoEditorStyleLines() +// var cursor = CursorPosition(line: 0, character: 0) +// SuggestionInjector().acceptSuggestions( +// intoContentWithoutSuggestion: &lines, +// cursorPosition: &cursor, +// completions: [suggestion1, suggestion2], +// extraInfo: &extraInfo +// ) +// XCTAssertTrue(extraInfo.didChangeContent) +// XCTAssertTrue(extraInfo.didChangeCursorPosition) +// XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) +// XCTAssertEqual(cursor, .init(line: 0, character: 15)) +// XCTAssertEqual(lines.joined(separator: ""), "let berry = 200\n") +// XCTAssertEqual(extraInfo.modificationRanges, [ +// "1": .init(start: .init(line: 0, character: 4), end: .init(line: 0, character: 9)), +// "2": .init(start: .init(line: 0, character: 12), end: .init(line: 0, character: 15)), +// ]) +// } +} + +extension String { + func breakIntoEditorStyleLines() -> [String] { + split(separator: "\n", omittingEmptySubsequences: false).map { $0 + "\n" } + } +} + diff --git a/Core/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift b/Tool/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift similarity index 99% rename from Core/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift rename to Tool/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift index 754eb90f..5a2173cf 100644 --- a/Core/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift +++ b/Tool/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift @@ -1,4 +1,4 @@ -//import SuggestionModel +//import SuggestionBasic //import XCTest // //@testable import SuggestionInjector @@ -47,7 +47,7 @@ // *///======== End of Copilot Suggestion // } // """, -// "The user may want to keep typing on the empty line, so suggestion is addded to the next line" +// "The user may want to keep typing on the empty line, so suggestion is added to the next line" // ) // } // diff --git a/Core/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift b/Tool/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift similarity index 99% rename from Core/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift rename to Tool/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift index 54b70d3c..dd6915df 100644 --- a/Core/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift +++ b/Tool/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift @@ -1,4 +1,4 @@ -//import SuggestionModel +//import SuggestionBasic //import XCTest // //@testable import SuggestionInjector diff --git a/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift b/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift new file mode 100644 index 00000000..214e6ad8 --- /dev/null +++ b/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift @@ -0,0 +1,390 @@ +import Foundation +import SuggestionBasic +import XCTest + +@testable import SuggestionProvider + +class PostProcessingSuggestionServiceMiddlewareTests: XCTestCase { + func createRequest( + _ code: String = "", + _ cursorPosition: CursorPosition = .zero + ) -> SuggestionRequest { + let lines = code.breakLines() + return SuggestionRequest( + fileURL: URL(fileURLWithPath: "/path/to/file.swift"), + relativePath: "file.swift", + content: code, + originalContent: code, + lines: lines, + cursorPosition: cursorPosition, + cursorOffset: { + if cursorPosition == .outOfScope { return 0 } + let prefixLines = if cursorPosition.line > 0 { + lines[0.. + } + let offset = prefixLines.reduce(0) { $0 + $1.utf8.count } + return offset + + lines[cursorPosition.line].prefix(cursorPosition.character).utf8.count + }(), + tabSize: 4, + indentSize: 4, + usesTabsForIndentation: false, + relevantCodeSnippets: [] + ) + } + + func test_empty() async throws { + let middleware = PostProcessingSuggestionServiceMiddleware() + + let handler: PostProcessingSuggestionServiceMiddleware.Next = { _ in + [ + .init( + id: "1", + text: "", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + ] + } + + let suggestions = try await middleware.getSuggestion( + createRequest("", .init(line: 0, character: 0)), + configuration: .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ), + next: handler + ) + + XCTAssertEqual(suggestions, []) + } + + func test_trailing_whitespaces_and_new_lines_should_be_removed() async throws { + let middleware = PostProcessingSuggestionServiceMiddleware() + + let handler: PostProcessingSuggestionServiceMiddleware.Next = { _ in + [ + .init( + id: "1", + text: "hello world \n \n", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "2", + text: " \n hello world \n \n", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + ] + } + + let suggestions = try await middleware.getSuggestion( + createRequest(), + configuration: .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ), + next: handler + ) + + XCTAssertEqual(suggestions, [ + .init( + id: "1", + text: "hello world", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "2", + text: " \n hello world", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + ]) + } + + func test_remove_suggestions_that_contains_only_whitespaces_and_new_lines() async throws { + let middleware = PostProcessingSuggestionServiceMiddleware() + + let handler: PostProcessingSuggestionServiceMiddleware.Next = { _ in + [ + .init( + id: "1", + text: "hello world \n \n", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "2", + text: " \n\n\r", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "3", + text: " ", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "4", + text: "\n\n\n", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + ] + } + + let suggestions = try await middleware.getSuggestion( + createRequest(), + configuration: .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ), + next: handler + ) + + XCTAssertEqual(suggestions, [ + .init( + id: "1", + text: "hello world", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + ]) + } + + func test_remove_suggestion_that_takes_no_effect_after_being_accepted() async throws { + let middleware = PostProcessingSuggestionServiceMiddleware() + + let handler: PostProcessingSuggestionServiceMiddleware.Next = { _ in + [ + .init( + id: "1", + text: "hello world \n \n", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "2", + text: "let cat = 100", + position: .init(line: 0, character: 13), + range: .init(startPair: (0, 0), endPair: (0, 13)) + ), + .init( + id: "3", + text: "let cat = 10", + position: .init(line: 0, character: 13), + range: .init(startPair: (0, 0), endPair: (0, 13)) + ), + ] + } + + let suggestions = try await middleware.getSuggestion( + createRequest("let cat = 100", .init(line: 0, character: 3)), + configuration: .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ), + next: handler + ) + + XCTAssertEqual(suggestions, [ + .init( + id: "1", + text: "hello world", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "3", + text: "let cat = 10", + position: .init(line: 0, character: 13), + range: .init(startPair: (0, 0), endPair: (0, 13)) + ), + ]) + } + + func test_remove_duplicated_trailing_closing_parenthesis_single_parenthesis() async throws { + let middleware = PostProcessingSuggestionServiceMiddleware() + + let handler: PostProcessingSuggestionServiceMiddleware.Next = { _ in + [ + .init( + id: "1", + text: "hello world\n}", + position: .init(line: 0, character: 1), + range: .init(startPair: (0, 0), endPair: (0, 1)) + ), + ] + } + + let suggestions = try await middleware.getSuggestion( + createRequest("h\n}\n", .init(line: 0, character: 1)), + configuration: .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ), + next: handler + ) + + XCTAssertEqual(suggestions, [ + .init( + id: "1", + text: "hello world", + position: .init(line: 0, character: 1), + range: .init(startPair: (0, 0), endPair: (0, 1)), + middlewareComments: ["Removed redundant closing parenthesis."] + ), + ]) + } + + func test_remove_duplicated_trailing_closing_parenthesis_single_line() async throws { + let middleware = PostProcessingSuggestionServiceMiddleware() + + let handler: PostProcessingSuggestionServiceMiddleware.Next = { _ in + [ + .init( + id: "1", + text: "}", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + ] + } + + let suggestions = try await middleware.getSuggestion( + createRequest("\n}\n", .init(line: 0, character: 0)), + configuration: .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ), + next: handler + ) + + XCTAssertEqual(suggestions, [ + .init( + id: "1", + text: "", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)), + middlewareComments: ["Removed redundant closing parenthesis."] + ), + ]) + } + + func test_remove_duplicated_trailing_closing_parenthesis_leading_space() async throws { + let middleware = PostProcessingSuggestionServiceMiddleware() + + let handler: PostProcessingSuggestionServiceMiddleware.Next = { _ in + [ + .init( + id: "1", + text: "hello world\n }", + position: .init(line: 0, character: 1), + range: .init(startPair: (0, 0), endPair: (0, 1)) + ), + ] + } + + let suggestions = try await middleware.getSuggestion( + createRequest("h\n }\n", .init(line: 0, character: 1)), + configuration: .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ), + next: handler + ) + + XCTAssertEqual(suggestions, [ + .init( + id: "1", + text: "hello world", + position: .init(line: 0, character: 1), + range: .init(startPair: (0, 0), endPair: (0, 1)), + middlewareComments: ["Removed redundant closing parenthesis."] + ), + ]) + } + + func test_remove_duplicated_trailing_closing_parenthesis_commas() async throws { + let middleware = PostProcessingSuggestionServiceMiddleware() + + let handler: PostProcessingSuggestionServiceMiddleware.Next = { _ in + [ + .init( + id: "1", + text: "hello world\n,},", + position: .init(line: 0, character: 1), + range: .init(startPair: (0, 0), endPair: (0, 1)) + ), + ] + } + + let suggestions = try await middleware.getSuggestion( + createRequest("h\n,},\n", .init(line: 0, character: 1)), + configuration: .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ), + next: handler + ) + + XCTAssertEqual(suggestions, [ + .init( + id: "1", + text: "hello world", + position: .init(line: 0, character: 1), + range: .init(startPair: (0, 0), endPair: (0, 1)), + middlewareComments: ["Removed redundant closing parenthesis."] + ), + ]) + } + + func test_remove_duplicated_trailing_closing_parenthesis_multiple_parenthesis() async throws { + let middleware = PostProcessingSuggestionServiceMiddleware() + + let handler: PostProcessingSuggestionServiceMiddleware.Next = { _ in + [ + .init( + id: "1", + text: "hello world\n}))>}}", + position: .init(line: 0, character: 1), + range: .init(startPair: (0, 0), endPair: (0, 1)) + ), + ] + } + + let suggestions = try await middleware.getSuggestion( + createRequest("h\n}))>}}\n", .init(line: 0, character: 1)), + configuration: .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ), + next: handler + ) + + XCTAssertEqual(suggestions, [ + .init( + id: "1", + text: "hello world", + position: .init(line: 0, character: 1), + range: .init(startPair: (0, 0), endPair: (0, 1)), + middlewareComments: ["Removed redundant closing parenthesis."] + ), + ]) + } +} + diff --git a/Tool/Tests/TokenEncoderTests/TiktokenCl100kBaseTokenEncoderTests.swift b/Tool/Tests/TokenEncoderTests/TiktokenCl100kBaseTokenEncoderTests.swift index 918014c9..2d565639 100644 --- a/Tool/Tests/TokenEncoderTests/TiktokenCl100kBaseTokenEncoderTests.swift +++ b/Tool/Tests/TokenEncoderTests/TiktokenCl100kBaseTokenEncoderTests.swift @@ -4,20 +4,20 @@ import XCTest @testable import TokenEncoder class TiktokenCl100kBaseTokenEncoderTests: XCTestCase { - func test_encoding() async throws { - let encoder = TiktokenCl100kBaseTokenEncoder() - let encoded = encoder.encode(text: """ - 我可以吞下玻璃而不伤身体 - The quick brown fox jumps over the lazy dog - """) - XCTAssertEqual(encoded.count, 26) - XCTAssertEqual( - encoded, - [ - 37046, 74770, 7305, 252, 17297, 29207, 119, 163, 240, 225, 69636, 16937, 17885, 97, - 96356, 33014, 198, 791, 4062, 14198, 39935, 35308, 927, 279, 16053, 5679, - ] - ) - } +// func test_encoding() async throws { +// let encoder = TiktokenCl100kBaseTokenEncoder() +// let encoded = encoder.encode(text: """ +// 我可以吞下玻璃而不伤身体 +// The quick brown fox jumps over the lazy dog +// """) +// XCTAssertEqual(encoded.count, 26) +// XCTAssertEqual( +// encoded, +// [ +// 37046, 74770, 7305, 252, 17297, 29207, 119, 163, 240, 225, 69636, 16937, 17885, 97, +// 96356, 33014, 198, 791, 4062, 14198, 39935, 35308, 927, 279, 16053, 5679, +// ] +// ) +// } } diff --git a/Tool/Tests/WebSearchServiceTests/HeadlessBrowserSearchServiceTests.swift b/Tool/Tests/WebSearchServiceTests/HeadlessBrowserSearchServiceTests.swift new file mode 100644 index 00000000..3a66f0e9 --- /dev/null +++ b/Tool/Tests/WebSearchServiceTests/HeadlessBrowserSearchServiceTests.swift @@ -0,0 +1,50 @@ +import Foundation +import XCTest + +@testable import WebSearchService + +class HeadlessBrowserSearchServiceTests: XCTestCase { + func test_search_on_google() async throws { + let search = HeadlessBrowserSearchService(engine: .google) + + do { + let result = try await search.search(query: "Snoopy") + XCTAssertFalse(result.webPages.isEmpty, "Expected non-empty search result") + } catch { + XCTFail("Search failed with error: \(error)") + } + } + + func test_search_on_baidu() async throws { + let search = HeadlessBrowserSearchService(engine: .baidu) + + do { + let result = try await search.search(query: "Snoopy") + XCTAssertFalse(result.webPages.isEmpty, "Expected non-empty search result") + } catch { + XCTFail("Search failed with error: \(error)") + } + } + + func test_search_on_duckDuckGo() async throws { + let search = HeadlessBrowserSearchService(engine: .duckDuckGo) + + do { + let result = try await search.search(query: "Snoopy") + XCTAssertFalse(result.webPages.isEmpty, "Expected non-empty search result") + } catch { + XCTFail("Search failed with error: \(error)") + } + } + + func test_search_on_bing() async throws { + let search = HeadlessBrowserSearchService(engine: .bing) + + do { + let result = try await search.search(query: "Snoopy") + XCTAssertFalse(result.webPages.isEmpty, "Expected non-empty search result") + } catch { + XCTFail("Search failed with error: \(error)") + } + } +} diff --git a/Tool/Tests/XcodeInspectorTests/EditorRangeConversionTests.swift b/Tool/Tests/XcodeInspectorTests/EditorRangeConversionTests.swift new file mode 100644 index 00000000..d62d0c0b --- /dev/null +++ b/Tool/Tests/XcodeInspectorTests/EditorRangeConversionTests.swift @@ -0,0 +1,231 @@ +import Foundation +import SuggestionBasic +import XCTest + +@testable import XcodeInspector + +class SourceEditorRangeConversionTests: XCTestCase { + // MARK: - Convert to CursorRange + + func test_convert_multiline_range() { + let code = """ + import Foundation + import XCTest + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let range = 21...39 + let cursorRange = SourceEditor.convertRangeToCursorRange(range, in: code) + + XCTAssertEqual(cursorRange.start, .init(line: 1, character: 3)) + XCTAssertEqual(cursorRange.end, .init(line: 3, character: 6)) + } + + func test_convert_multiline_range_with_special_line_endings() { + let code = """ + import Foundation + import XCTest + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """.replacingOccurrences(of: "\n", with: "\r\n") + + let range = 21...39 + let cursorRange = SourceEditor.convertRangeToCursorRange(range, in: code) + + XCTAssertEqual(cursorRange.start, .init(line: 1, character: 2)) + XCTAssertEqual(cursorRange.end, .init(line: 3, character: 3)) + } + + func test_convert_multiline_range_with_emoji() { + let code = """ + import Foundation + import 🎆🎆🎆🎆🎆🎆 + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let range = 21...42 + let cursorRange = SourceEditor.convertRangeToCursorRange(range, in: code) + + XCTAssertEqual(cursorRange.start, .init(line: 1, character: 3)) + XCTAssertEqual(cursorRange.end, .init(line: 3, character: 3)) + } + + func test_convert_multiline_range_cutting_emoji() { + // undefined behavior + + let code = """ + import Foundation + import 🎆🎆🎆🎆🎆🎆 + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let range = 26...42 // in the middle of the emoji + let cursorRange = SourceEditor.convertRangeToCursorRange(range, in: code) + + XCTAssertEqual(cursorRange.start, .init(line: 1, character: 8)) + XCTAssertEqual(cursorRange.end, .init(line: 3, character: 3)) + } + + func test_convert_range_with_no_code() { + let code = "" + let range = 21...39 + let cursorRange = SourceEditor.convertRangeToCursorRange(range, in: code) + + XCTAssertEqual(cursorRange.start, .zero) + XCTAssertEqual(cursorRange.end, .zero) + } + + func test_convert_multiline_range_with_out_of_range_cursor() { + let code = """ + import Foundation + import XCTest + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let range = 999...1000 + let cursorRange = SourceEditor.convertRangeToCursorRange(range, in: code) + + // undefined behavior + + XCTAssertEqual(cursorRange.start, .zero) + XCTAssertEqual(cursorRange.end, .init(line: 8, character: 0)) + } + + // MARK: - Convert to CFRange + + func test_back_convert_multiline_cursor_range() { + let code = """ + import Foundation + import XCTest + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let cursorRange = CursorRange( + start: .init(line: 1, character: 3), + end: .init(line: 3, character: 6) + ) + let range = SourceEditor.convertCursorRangeToRange(cursorRange, in: code) + + XCTAssertEqual(range.range, 21...39) + } + + func test_back_convert_multiline_range_with_out_of_range_cursor() { + let code = """ + import Foundation + import XCTest + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let cursorRange = CursorRange( + start: .init(line: 999, character: 0), + end: .init(line: 1000, character: 0) + ) + let range = SourceEditor.convertCursorRangeToRange(cursorRange, in: code) + + // undefined behavior + + XCTAssertEqual(range.range, 0...0) + } + + func test_back_convert_multiline_range_with_special_line_endings() { + let code = """ + import Foundation + import XCTest + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """.replacingOccurrences(of: "\n", with: "\r\n") + + let cursorRange = CursorRange( + start: .init(line: 1, character: 2), + end: .init(line: 3, character: 3) + ) + let range = SourceEditor.convertCursorRangeToRange(cursorRange, in: code) + + XCTAssertEqual(range.range, 21...39) + } + + func test_back_convert_multiline_range_with_emoji() { + let code = """ + import Foundation + import 🎆🎆🎆🎆🎆🎆 + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let cursorRange = CursorRange( + start: .init(line: 1, character: 3), + end: .init(line: 3, character: 3) + ) + let range = SourceEditor.convertCursorRangeToRange(cursorRange, in: code) + XCTAssertEqual(range.range, 21...42) + } + + func test_back_convert_range_with_no_code() { + let code = "" + let range = 21...39 + let cursorRange = SourceEditor.convertCursorRangeToRange( + SourceEditor.convertRangeToCursorRange(range, in: code), + in: code + ) + + XCTAssertEqual(cursorRange.range, 0...0) + } +} + +private extension CFRange { + var range: ClosedRange { + return location...(location + length) + } +} + diff --git a/Version.xcconfig b/Version.xcconfig index fcbdaf4d..9fbbef26 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,4 @@ -APP_VERSION = 0.32.2 -APP_BUILD = 363 - +APP_VERSION = 0.38.0 +APP_BUILD = 504 +RELEASE_CHANNEL = +RELEASE_NUMBER = 1 diff --git a/appcast.xml b/appcast.xml index 0bf89f3a..58cb484f 100644 --- a/appcast.xml +++ b/appcast.xml @@ -1,805 +1,43 @@ - - Copilot for Xcode - - - 0.32.2 - Sat, 20 Apr 2024 20:31:36 +0800 - 363 - 0.32.2 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.32.2 - - - - - - 0.32.0 - Mon, 15 Apr 2024 23:48:22 +0800 - 360 - 0.32.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.32.0 - - - - - - 0.32.0 - Thu, 11 Apr 2024 11:31:29 +0800 - 358 - beta - 0.32.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.32.0.beta - - - - - - 0.32.0 - Thu, 04 Apr 2024 22:34:37 +0800 - 353 - beta - 0.32.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.32.0.beta - - - - - - 0.31.3 - Fri, 29 Mar 2024 18:52:57 +0800 - 343 - 0.31.3 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.31.3 - - - - - - 0.31.2 - Thu, 14 Mar 2024 22:26:51 +0800 - 340 - 0.31.2 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.31.2 - - - - - - 0.31.1 - Wed, 13 Mar 2024 21:24:28 +0800 - 334 - 0.31.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.31.1 - - - - - - 0.31.0 - Fri, 08 Mar 2024 14:48:32 +0800 - 333 - 0.31.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.31.0 - - - - - - 0.31.0 - Mon, 04 Mar 2024 15:27:04 +0800 - 331 - beta - 0.31.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.31.0.beta - - - - - - 0.30.5 - Thu, 22 Feb 2024 17:05:45 +0800 - 328 - 0.30.5 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.30.5 - - - - - - 0.30.5 - Wed, 21 Feb 2024 23:18:59 +0800 - 326 - beta - 0.30.5 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.30.5.beta - - - - - - 0.30.5 - Tue, 20 Feb 2024 18:38:09 +0800 - 322 - beta - 0.30.5 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.30.5.beta - - - - - - 0.30.4 - Sat, 17 Feb 2024 16:25:04 +0800 - 320 - 0.30.4 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.30.4 - - - - - - 0.30.4 - Fri, 16 Feb 2024 01:49:49 +0800 - 318 - beta - 0.30.4 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.30.4.beta - - - - - - 0.30.4 - Thu, 15 Feb 2024 01:37:49 +0800 - 317 - beta - 0.30.4 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.30.4.beta - - - - - - 0.30.3 - Wed, 07 Feb 2024 22:13:53 +0800 - 316 - 0.30.3 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.30.3 - - - - - - 0.30.2 - Thu, 01 Feb 2024 15:09:51 +0800 - 314 - 0.30.2 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.30.2 - - - - - - 0.30.1 - Sun, 28 Jan 2024 17:12:35 +0800 - 313 - 0.30.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.30.1 - - - - - - 0.30.0 - Mon, 22 Jan 2024 16:01:13 +0800 - 311 - 0.30.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.30.0 - - - - - - 0.29.1 - Tue, 16 Jan 2024 01:12:50 +0800 - 301 - 0.29.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.29.1 - - - - - - 0.29.0 - Tue, 09 Jan 2024 00:36:06 +0800 - 300 - 0.29.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.29.0 - - - - - - 0.28.3 - Tue, 12 Dec 2023 14:29:58 +0800 - 293 - 0.28.3 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.28.3 - - - - - - 0.28.2 - Mon, 04 Dec 2023 23:11:26 +0800 - 292 - 0.28.2 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.28.2 - - - - - - 0.28.0 - Mon, 04 Dec 2023 16:04:23 +0800 - 290 - 0.28.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.28.0 - - - - - - 0.27.1 - Sat, 18 Nov 2023 12:46:36 +0800 - 281 - 0.27.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.27.1 - - - - - - 0.27.0 - Fri, 10 Nov 2023 02:34:25 +0800 - 280 - 0.27.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.27.0 - - - - - - 0.26.0 - Sun, 22 Oct 2023 18:58:49 +0800 - 272 - 0.26.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.26.0 - - - - - - 0.25.0 - Wed, 11 Oct 2023 23:08:08 +0800 - 261 - 0.25.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.25.0 - - - - - - 0.24.1 - Fri, 29 Sep 2023 14:35:35 +0800 - 251 - 0.24.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.24.1 - - - - - - 0.24.0 - Thu, 28 Sep 2023 01:35:21 +0800 - 250 - 0.24.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.24.0 - - - - - - 0.23.2 - Sat, 09 Sep 2023 22:07:35 +0800 - 241 - 0.23.2 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.23.2 - - - - - - 0.23.1 - Wed, 06 Sep 2023 21:08:26 +0800 - 240 - 0.23.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.23.1 - - - - - - 0.22.3 - Sat, 02 Sep 2023 15:51:16 +0800 - 233 - 0.22.3 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.22.3 - - - - - - 0.22.2 - Sat, 19 Aug 2023 10:48:38 +0800 - 232 - 0.22.2 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.22.2 - - - - - - 0.22.1 - Fri, 18 Aug 2023 21:14:04 +0800 - 231 - 0.22.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.22.1 - - - - - - 0.22.0 - Fri, 18 Aug 2023 17:30:27 +0800 - 230 - 0.22.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.22.0 - - - - - - 0.21.2 - Mon, 14 Aug 2023 21:20:30 +0800 - 222 - 0.21.2 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.21.2 - - - - - - 0.21.1 - Sun, 13 Aug 2023 17:02:38 +0800 - 221 - 0.21.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.21.1 - - - - - - 0.21.0 - Wed, 09 Aug 2023 15:45:24 +0800 - 220 - 0.21.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.21.0 - - - - - - 0.20.1 - Fri, 21 Jul 2023 16:00:42 +0800 - 210 - 0.20.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.20.1 - - - - - - 0.20.0 - Tue, 11 Jul 2023 13:32:57 +0800 - 200 - 0.20.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.20.0 - - - - - - 0.19.2 - Mon, 26 Jun 2023 21:56:13 +0800 - 192 - 0.19.2 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.19.2 - - - - - - 0.19.1 - Sat, 24 Jun 2023 22:34:24 +0800 - 191 - 0.19.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.19.1 - - - - - - 0.19.0 - Sat, 24 Jun 2023 00:44:54 +0800 - 190 - 0.19.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.19.0 - - - - - - 0.18.2 - Wed, 14 Jun 2023 18:45:02 +0800 - 182 - 0.18.2 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.18.2 - - - - - - 0.18.1 - Sat, 10 Jun 2023 17:03:06 +0800 - 181 - 0.18.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.18.1 - - - - - - 0.18.0 - Thu, 08 Jun 2023 14:34:14 +0800 - 180 - 0.18.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.18.0 - - - - - - 0.17.1 - Wed, 31 May 2023 12:30:21 +0800 - 171 - 0.17.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.17.1 - - - - - - 0.17.0 - Sat, 27 May 2023 15:33:25 +0800 - 170 - 0.17.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.17.0 - - - - - - 0.16.1 - Tue, 23 May 2023 11:06:14 +0800 - 161 - 0.16.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.16.1 - - - - - - 0.16.0 - Thu, 18 May 2023 18:42:47 +0800 - 160 - 0.16.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.16.0 - - - - - - 0.15.4 - Mon, 15 May 2023 22:13:11 +0800 - 154 - 0.15.4 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.15.4 - - - - - - 0.15.2 - Sun, 14 May 2023 20:58:20 +0800 - 152 - 0.15.2 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.15.2 - - - - - - 0.15.1 - Sun, 14 May 2023 17:18:15 +0800 - 151 - 0.15.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.15.1 - - - - - - 0.15.0 - Sun, 14 May 2023 16:00:43 +0800 - 150 - 0.15.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.15.0 - - - - - - 0.14.1 - Tue, 02 May 2023 21:32:45 +0800 - 135 - 0.14.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.14.1 - - - - - - 0.14.0 - Sun, 30 Apr 2023 02:05:16 +0800 - 130 - 0.14.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.14.0 - - - - - - 0.13.4 - Thu, 27 Apr 2023 10:54:46 +0800 - 110 - 0.13.4 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.13.4 - - - - - - 0.13.2 - Sun, 23 Apr 2023 18:03:30 +0800 - 106 - 0.13.2 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.13.2 - - - - - - 0.13.1 - Sat, 22 Apr 2023 23:07:44 +0800 - 105 - 0.13.1 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.13.1 - - - - - - 0.13.0 - Sat, 22 Apr 2023 12:20:16 +0800 - 101 - 0.13.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.13.0 - - - - - - 0.12.0 - Sat, 15 Apr 2023 14:43:37 +0800 - 90 - 0.12.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.12.0 - - - - - - 0.11.2 - Wed, 12 Apr 2023 12:23:29 +0800 - 84 - 0.11.2 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.11.2 - - - - - - 0.11.0 - Sat, 08 Apr 2023 23:32:22 +0800 - 80 - 0.11.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.11.0 - - - - - - 0.10.0 - Fri, 31 Mar 2023 23:12:28 +0800 - 70 - 0.10.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.10.0 - - - - - - 0.9.0 - Sat, 25 Mar 2023 23:43:42 +0800 - 62 - 0.9.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.9.0 - - - - + + Copilot for Xcode + + 0.33.5 + Tue, 09 Jul 2024 01:29:35 +0800 + 396 + 0.33.5 + 12.0 + https://copilotforxcode.intii.com/changelog/0.33.5 + + + + 0.33.5 + Mon, 08 Jul 2024 23:53:53 +0800 + 394 + 0.33.5 + 12.0 + https://copilotforxcode.intii.com/changelog/0.33.5 + + + + 0.33.5 + Tue, 02 Jul 2024 23:07:42 +0800 + beta + 393 + 0.33.5 + 12.0 + https://github.com/intitni/CopilotForXcode/releases/tag/0.33.5.beta + + + + 0.33.4 + Tue, 18 Jun 2024 14:55:47 +0800 + 390 + 0.33.4 + 12.0 + https://github.com/intitni/CopilotForXcode/releases/tag/0.33.4 + + + diff --git a/bridgeLaunchAgent.plist b/bridgeLaunchAgent.plist new file mode 100644 index 00000000..87565369 --- /dev/null +++ b/bridgeLaunchAgent.plist @@ -0,0 +1,15 @@ + + + + + Label + com.intii.CopilotForXcode.CommunicationBridge + Program + /Applications/Copilot for Xcode.app/Contents/Applications/CommunicationBridge + MachServices + + com.intii.CopilotForXcode.CommunicationBridge + + + +