diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 63235d66..9d688e1b 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "cgeventoverride", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/CGEventOverride", + "state" : { + "revision" : "40b29e804204c461253a52b77adea9c055184aad", + "version" : "1.2.0" + } + }, { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", @@ -30,10 +39,10 @@ { "identity" : "highlightr", "kind" : "remoteSourceControl", - "location" : "https://github.com/raspu/Highlightr", + "location" : "https://github.com/intitni/Highlightr", "state" : { - "revision" : "93199b9e434f04bda956a613af8f571933f9f037", - "version" : "2.1.2" + "branch" : "bump-highlight-js-version", + "revision" : "4ffbb1b0b721378263297cafea6f2838044eb1eb" } }, { @@ -90,15 +99,6 @@ "version" : "2.4.2" } }, - { - "identity" : "splash", - "kind" : "remoteSourceControl", - "location" : "https://github.com/JohnSundell/Splash", - "state" : { - "branch" : "master", - "revision" : "2e3f17c2d09689c8bf175c4a84ff7f2ad3353301" - } - }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", diff --git a/Core/Package.swift b/Core/Package.swift index 73e215ec..32611501 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -15,7 +15,6 @@ let package = Package( "FileChangeChecker", "LaunchAgentManager", "UpdateChecker", - "UserDefaultsObserver", ] ), .library( @@ -72,14 +71,15 @@ let package = Package( "SuggestionService", "GitHubCopilotService", "XPCShared", - "CGEventObserver", "DisplayLink", "SuggestionWidget", "ChatService", "PromptToCodeService", "ServiceUpdateMigration", - "UserDefaultsObserver", "ChatGPTChatTab", + .product(name: "CGEventObserver", package: "Tool"), + .product(name: "Workspace", package: "Tool"), + .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), .product(name: "Environment", package: "Tool"), .product(name: "SuggestionModel", package: "Tool"), @@ -91,7 +91,7 @@ let package = Package( .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Dependencies", package: "swift-dependencies"), ].pro([ - "ProChatTabs", + "ProService", ]) ), .testTarget( @@ -149,7 +149,7 @@ let package = Package( .target(name: "SuggestionService", dependencies: [ "GitHubCopilotService", "CodeiumService", - "UserDefaultsObserver", + .product(name: "UserDefaultsObserver", package: "Tool"), ]), // MARK: - Prompt To Code @@ -180,6 +180,7 @@ let package = Package( // context collectors "WebChatContextCollector", "ActiveDocumentChatContextCollector", + "SystemInfoChatContextCollector", .product(name: "AppMonitoring", package: "Tool"), .product(name: "Environment", package: "Tool"), @@ -226,7 +227,7 @@ let package = Package( name: "SuggestionWidget", dependencies: [ "ChatGPTChatTab", - "UserDefaultsObserver", + .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), .product(name: "Environment", package: "Tool"), @@ -241,12 +242,6 @@ let package = Package( // MARK: - Helpers - .target( - name: "CGEventObserver", - dependencies: [ - .product(name: "Logger", package: "Tool"), - ] - ), .target(name: "FileChangeChecker"), .target(name: "LaunchAgentManager"), .target(name: "DisplayLink"), @@ -264,7 +259,6 @@ let package = Package( .product(name: "Preferences", package: "Tool"), ] ), - .target(name: "UserDefaultsObserver"), .target( name: "PlusFeatureFlag", dependencies: [ @@ -353,6 +347,15 @@ let package = Package( path: "Sources/ChatContextCollectors/WebChatContextCollector" ), + .target( + name: "SystemInfoChatContextCollector", + dependencies: [ + "ChatContextCollector", + .product(name: "OpenAIService", package: "Tool"), + ], + path: "Sources/ChatContextCollectors/SystemInfoChatContextCollector" + ), + .target( name: "ActiveDocumentChatContextCollector", dependencies: [ diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index 8dc96526..5e29ed6a 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -89,9 +89,15 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { let start = """ ## File and Code Scope - You can use the following context to answer user's questions about the editing document or code. The context shows only a part of the code in the editing document, and will change during the conversation, so it may not match our conversation. + You can use the following context to answer my questions about the editing document or code. The context shows only a part of the code in the editing document, and will change during the conversation, so it may not match our conversation. - User Editing Document Context: ### + \( + context.focusedContext == nil + ? "" + : "When you don't known what I am asking, I am probably referring to the code." + ) + + Editing Document Context: ### """ let end = "###" let relativePath = "Document Relative Path: \(context.relativePath)" @@ -110,10 +116,7 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { let codeRange = "Focused Range [line, character]: \(focusedContext.codeRange)" let code = """ - Focused Code (start from line \( - focusedContext.codeRange.start - .line - )): + Focused Code (start from line \(focusedContext.codeRange.start.line + 1)): ```\(context.language.rawValue) \(focusedContext.code) ``` @@ -282,7 +285,7 @@ struct ActiveDocumentContext { selectionRange = info.editorContent?.selections.first ?? .zero lineAnnotations = info.editorContent?.lineAnnotations ?? [] imports = [] - + if changed { moveToFocusedCode() } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift index 9ceb23f8..136c35a7 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift @@ -10,7 +10,7 @@ struct ExpandFocusRangeFunction: ChatGPTFunction { var range: CursorRange var botReadableContent: String { - "User Editing Document Context is updated to display code at \(range)." + "Editing Document Context is updated to display code at \(range)." } } @@ -25,7 +25,7 @@ struct ExpandFocusRangeFunction: ChatGPTFunction { } var description: String { - "Call when User Editing Document Context provides too little context to answer a question." + "Call when Editing Document Context provides too little context to answer a question." } var argumentSchema: JSONSchemaValue { [ diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift index ec3a8310..952b9b61 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift @@ -12,7 +12,7 @@ struct MoveToCodeAroundLineFunction: ChatGPTFunction { var range: CursorRange var botReadableContent: String { - "User Editing Document Context is updated to display code at \(range)." + "Editing Document Context is updated to display code at \(range)." } } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift index 8f094d66..af667524 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift @@ -10,7 +10,7 @@ struct MoveToFocusedCodeFunction: ChatGPTFunction { var range: CursorRange var botReadableContent: String { - "User Editing Document Context is updated to display code at \(range)." + "Editing Document Context is updated to display code at \(range)." } } @@ -25,7 +25,7 @@ struct MoveToFocusedCodeFunction: ChatGPTFunction { } var description: String { - "Move user editing document context to the selected or focused code" + "Move editing document context to the selected or focused code" } var argumentSchema: JSONSchemaValue { [ diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift index a54bbfba..43460b22 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift @@ -68,9 +68,9 @@ public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { return """ Selected Code Not Available: ''' - User has disabled default scope. \ - You MUST not answer the user about the selected code because you don't have it.\ - Ask user to prepend message with `@selection` to enable selected code to be \ + I have disabled default scope. \ + You MUST not answer about the selected code because you don't have it.\ + Ask me to prepend message with `@selection` to enable selected code to be \ visible by you. ''' """ diff --git a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift new file mode 100644 index 00000000..fd137a24 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift @@ -0,0 +1,27 @@ +import ChatContextCollector +import Foundation +import OpenAIService + +public final class SystemInfoChatContextCollector: ChatContextCollector { + static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, yyyy-MM-dd HH:mm:ssZ" + return formatter + }() + + public init() {} + + public func generateContext( + history: [ChatMessage], + scopes: Set, + content: String + ) -> ChatContext? { + return .init( + systemPrompt: """ + Current Time: \(Self.dateFormatter.string(from: Date())) (You can use it to calculate time in another time zone) + """, + functions: [] + ) + } +} + diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift index 7eaaf89b..8bb360dd 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift @@ -51,7 +51,7 @@ struct SearchFunction: ChatGPTFunction { "freshness": [ .type: "string", .description: .string( - "limit the search result to a specific range, use only when user ask the question about current events. Today is \(today). Format: yyyy-MM-dd..yyyy-MM-dd" + "limit the search result to a specific range, use only when I ask the question about current events. Today is \(today). Format: yyyy-MM-dd..yyyy-MM-dd" ), .examples: ["1919-10-20..1988-10-20"], ], diff --git a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift index ba115bef..238f1b01 100644 --- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift +++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift @@ -3,7 +3,7 @@ import SharedUIComponents import SwiftUI struct ChatContextMenu: View { - let chat: ChatProvider + @ObservedObject var chat: ChatProvider @AppStorage(\.customCommands) var customCommands var body: some View { diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index a13d2b6f..85413186 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -1,7 +1,9 @@ import ChatService import ChatTab import Combine +import ComposableArchitecture import Foundation +import OpenAIService import Preferences import SwiftUI @@ -13,18 +15,24 @@ public class ChatGPTChatTab: ChatTab { public let provider: ChatProvider private var cancellable = Set() + struct RestorableState: Codable { + var history: [OpenAIService.ChatMessage] + var configuration: OverridingChatGPTConfiguration.Overriding + var systemPrompt: String + var extraSystemPrompt: String + } + struct Builder: ChatTabBuilder { var title: String - var buildable: Bool { true } var customCommand: CustomCommand? + var afterBuild: (ChatGPTChatTab) async -> Void = { _ in } - func build() -> any ChatTab { - let tab = ChatGPTChatTab() - Task { - if let customCommand { - try await tab.service.handleCustomCommand(customCommand) - } + func build(store: StoreOf) async -> (any ChatTab)? { + let tab = ChatGPTChatTab(store: store) + if let customCommand { + try? await tab.service.handleCustomCommand(customCommand) } + await afterBuild(tab) return tab } } @@ -37,6 +45,32 @@ public class ChatGPTChatTab: ChatTab { ChatContextMenu(chat: provider) } + public func restorableState() async -> Data { + let state = RestorableState( + history: await service.memory.history, + configuration: service.configuration.overriding, + systemPrompt: service.systemPrompt, + extraSystemPrompt: service.extraSystemPrompt + ) + return (try? JSONEncoder().encode(state)) ?? Data() + } + + public static func restore( + from data: Data, + externalDependency: Void + ) async throws -> any ChatTabBuilder { + let state = try JSONDecoder().decode(RestorableState.self, from: data) + let builder = Builder(title: "Chat") { @MainActor tab in + tab.service.configuration.overriding = state.configuration + tab.service.mutateSystemPrompt(state.systemPrompt) + tab.service.mutateExtraSystemPrompt(state.extraSystemPrompt) + await tab.service.memory.mutateHistory { history in + history = state.history + } + } + return builder + } + public static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] { let customCommands = UserDefaults.shared.value(for: \.customCommands).compactMap { command in @@ -49,14 +83,33 @@ public class ChatGPTChatTab: ChatTab { return [Builder(title: "New Chat", customCommand: nil)] + customCommands } - public init(service: ChatService = .init()) { + public init(service: ChatService = .init(), store: StoreOf) { self.service = service provider = .init(service: service) - super.init(id: "Chat-" + provider.id.uuidString, title: "Chat") + super.init(store: store) + } + public func start() { + chatTabViewStore.send(.updateTitle("Chat")) + + service.$systemPrompt.removeDuplicates().sink { _ in + Task { @MainActor [weak self] in + self?.chatTabViewStore.send(.tabContentUpdated) + } + }.store(in: &cancellable) + + service.$extraSystemPrompt.removeDuplicates().sink { _ in + Task { @MainActor [weak self] in + self?.chatTabViewStore.send(.tabContentUpdated) + } + }.store(in: &cancellable) + provider.$history.sink { [weak self] _ in - if let title = self?.provider.title { - self?.title = title + Task { @MainActor [weak self] in + if let title = self?.provider.title { + self?.chatTabViewStore.send(.updateTitle(title)) + } + self?.chatTabViewStore.send(.tabContentUpdated) } }.store(in: &cancellable) } diff --git a/Core/Sources/ChatService/AllContextCollector.swift b/Core/Sources/ChatService/AllContextCollector.swift index ef4b3b79..600c8f74 100644 --- a/Core/Sources/ChatService/AllContextCollector.swift +++ b/Core/Sources/ChatService/AllContextCollector.swift @@ -1,8 +1,10 @@ import ActiveDocumentChatContextCollector import ChatContextCollector +import SystemInfoChatContextCollector import WebChatContextCollector let allContextCollectors: [any ChatContextCollector] = [ + SystemInfoChatContextCollector(), ActiveDocumentChatContextCollector(), WebChatContextCollector(), ] diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 91bdcebb..57b6bbec 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -7,7 +7,7 @@ import Preferences public final class ChatService: ObservableObject { public let memory: ContextAwareAutoManagedChatGPTMemory - public let configuration: OverridingChatGPTConfiguration + public let configuration: OverridingChatGPTConfiguration public let chatGPTService: any ChatGPTServiceType public var allPluginCommands: [String] { allPlugins.map { $0.command } } @Published public internal(set) var isReceivingMessage = false @@ -20,7 +20,7 @@ public final class ChatService: ObservableObject { init( memory: ContextAwareAutoManagedChatGPTMemory, - configuration: OverridingChatGPTConfiguration, + configuration: OverridingChatGPTConfiguration, chatGPTService: T ) { self.memory = memory diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift b/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift index c49f8907..d531f866 100644 --- a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift +++ b/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift @@ -178,13 +178,15 @@ public class GitHubCopilotBaseService { executableURL: URL, supportURL: URL ) { - let supportURL = FileManager.default.urls( + guard let supportURL = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask - ).first!.appendingPathComponent( + ).first?.appendingPathComponent( Bundle.main .object(forInfoDictionaryKey: "APPLICATION_SUPPORT_FOLDER") as! String - ) + ) else { + throw CancellationError() + } if !FileManager.default.fileExists(atPath: supportURL.path) { try? FileManager.default diff --git a/Core/Sources/HostApp/AccountSettings/CopilotView.swift b/Core/Sources/HostApp/AccountSettings/CopilotView.swift index dbfafea7..dcb27929 100644 --- a/Core/Sources/HostApp/AccountSettings/CopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/CopilotView.swift @@ -20,19 +20,19 @@ struct CopilotView: View { @AppStorage(\.gitHubCopilotUseStrictSSL) var gitHubCopilotUseStrictSSL @AppStorage(\.gitHubCopilotIgnoreTrailingNewLines) var gitHubCopilotIgnoreTrailingNewLines + @AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear) + var disableGitHubCopilotSettingsAutoRefreshOnAppear init() {} } class ViewModel: ObservableObject { let installationManager = GitHubCopilotInstallationManager() - @Published var installationStatus: GitHubCopilotInstallationManager.InstallationStatus + @Published var installationStatus: GitHubCopilotInstallationManager.InstallationStatus? @Published var installationStep: GitHubCopilotInstallationManager.InstallationStep? - init() { - installationStatus = installationManager.checkInstallation() - } - + init() {} + init( installationStatus: GitHubCopilotInstallationManager.InstallationStatus, installationStep: GitHubCopilotInstallationManager.InstallationStep? @@ -172,6 +172,8 @@ struct CopilotView: View { VStack(alignment: .leading) { HStack { switch viewModel.installationStatus { + case .none: + Text("Copilot.Vim Version: Loading..") case .notInstalled: Text("Copilot.Vim Version: Not Installed") installButton @@ -194,7 +196,10 @@ struct CopilotView: View { Text("Status: \(status?.description ?? "Loading..")") HStack(alignment: .center) { - Button("Refresh") { checkStatus() } + Button("Refresh") { + viewModel.refreshInstallationStatus() + checkStatus() + } if status == .notSignedIn { Button("Sign In") { signIn() } .alert(isPresented: $isUserCodeCopiedAlertPresented) { @@ -261,6 +266,8 @@ struct CopilotView: View { Spacer() }.onAppear { if isPreview { return } + if settings.disableGitHubCopilotSettingsAutoRefreshOnAppear { return } + viewModel.refreshInstallationStatus() checkStatus() }.onChange(of: settings.runNodeWith) { _ in Self.copilotAuthService = nil diff --git a/Core/Sources/HostApp/DebugView.swift b/Core/Sources/HostApp/DebugView.swift index c49221de..96a3844a 100644 --- a/Core/Sources/HostApp/DebugView.swift +++ b/Core/Sources/HostApp/DebugView.swift @@ -12,6 +12,8 @@ final class DebugSettings: ObservableObject { var alwaysAcceptSuggestionWithAccessibilityAPI @AppStorage(\.enableXcodeInspectorDebugMenu) var enableXcodeInspectorDebugMenu @AppStorage(\.disableFunctionCalling) var disableFunctionCalling + @AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear) + var disableGitHubCopilotSettingsAutoRefreshOnAppear init() {} } @@ -48,6 +50,9 @@ struct DebugSettingsView: View { Toggle(isOn: $settings.disableFunctionCalling) { Text("Disable function calling for chat feature") } + Toggle(isOn: $settings.disableGitHubCopilotSettingsAutoRefreshOnAppear) { + Text("Disable GitHub Copilot settings auto refresh status on appear") + } } .padding() } @@ -60,3 +65,4 @@ struct DebugSettingsView_Preview: PreviewProvider { } } + diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift index 75efee3a..8218f95a 100644 --- a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift @@ -1,6 +1,10 @@ import Preferences import SwiftUI +#if canImport(ProHostApp) +import ProHostApp +#endif + struct SuggestionSettingsView: View { final class Settings: ObservableObject { @AppStorage(\.realtimeSuggestionToggle) @@ -19,6 +23,10 @@ struct SuggestionSettingsView: View { var suggestionCodeFontSize @AppStorage(\.suggestionFeatureProvider) var suggestionFeatureProvider + @AppStorage(\.suggestionDisplayCompactMode) + var suggestionDisplayCompactMode + @AppStorage(\.acceptSuggestionWithTab) + var acceptSuggestionWithTab init() {} } @@ -56,8 +64,16 @@ struct SuggestionSettingsView: View { } Toggle(isOn: $settings.realtimeSuggestionToggle) { - Text("Real-time suggestion") + Text("Real-time Suggestion") + } + + #if canImport(ProHostApp) + WithFeatureEnabled(\.tabToAcceptSuggestion) { + Toggle(isOn: $settings.acceptSuggestionWithTab) { + Text("Accept Suggestion with Tab") + } } + #endif HStack { Toggle(isOn: $settings.disableSuggestionFeatureGlobally) { @@ -72,7 +88,7 @@ struct SuggestionSettingsView: View { isOpen: $isSuggestionFeatureEnabledListPickerOpen ) } - + HStack { Button("Disabled Language List") { isSuggestionFeatureDisabledLanguageListViewOpen = true @@ -105,6 +121,10 @@ struct SuggestionSettingsView: View { } Group { + Toggle(isOn: $settings.suggestionDisplayCompactMode) { + Text("Hide Buttons") + } + Toggle(isOn: $settings.hideCommonPrecedingSpacesInSuggestion) { Text("Hide Common Preceding Spaces") } diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift index 96b86319..cc8616c4 100644 --- a/Core/Sources/HostApp/FeatureSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettingsView.swift @@ -6,9 +6,8 @@ struct FeatureSettingsView: View { var body: some View { SidebarTabView(tag: $tag) { ScrollView { - SuggestionSettingsView() + SuggestionSettingsView().padding() } - .padding() .sidebarItem( tag: 0, title: "Suggestion", @@ -17,9 +16,8 @@ struct FeatureSettingsView: View { ) ScrollView { - ChatSettingsView() + ChatSettingsView().padding() } - .padding() .sidebarItem( tag: 1, title: "Chat", @@ -28,9 +26,8 @@ struct FeatureSettingsView: View { ) ScrollView { - PromptToCodeSettingsView() + PromptToCodeSettingsView().padding() } - .padding() .sidebarItem( tag: 2, title: "Prompt to Code", diff --git a/Core/Sources/Service/DependencyUpdater.swift b/Core/Sources/Service/DependencyUpdater.swift index a98444db..31ab8b7f 100644 --- a/Core/Sources/Service/DependencyUpdater.swift +++ b/Core/Sources/Service/DependencyUpdater.swift @@ -2,10 +2,10 @@ import CodeiumService import GitHubCopilotService import Logger -public struct DependencyUpdater { - public init() {} +struct DependencyUpdater { + init() {} - public func update() { + func update() { Task { await withTaskGroup(of: Void.self) { taskGroup in let gitHubCopilot = GitHubCopilotInstallationManager() diff --git a/Core/Sources/Service/GUI/ChatTabFactory.swift b/Core/Sources/Service/GUI/ChatTabFactory.swift index 6804c389..843133bc 100644 --- a/Core/Sources/Service/GUI/ChatTabFactory.swift +++ b/Core/Sources/Service/GUI/ChatTabFactory.swift @@ -11,7 +11,7 @@ import XcodeInspector import ProChatTabs enum ChatTabFactory { - static var chatTabBuilderCollection: [ChatTabBuilderCollection] { + static func chatTabBuilderCollection() -> [ChatTabBuilderCollection] { func folderIfNeeded( _ builders: [any ChatTabBuilder], title: String @@ -25,79 +25,88 @@ enum ChatTabFactory { let collection = [ folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name), - folderIfNeeded(BrowserChatTab.chatBuilders(externalDependency: .init( - getEditorContent: { - guard let editor = XcodeInspector.shared.focusedEditor else { - return .init(selectedText: "", language: "", fileContent: "") - } - let content = editor.content - return .init( - selectedText: content.selectedContent, - language: languageIdentifierFromFileURL( - XcodeInspector.shared - .activeDocumentURL - ) - .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 = PromptToCodeService( - code: prompt, - selectionRange: .outOfScope, - language: .plaintext, - identSize: 4, - usesTabsForIndentation: true, - projectRootURL: .init(fileURLWithPath: "/"), - fileURL: .init(fileURLWithPath: "/"), - allCode: prompt, - extraSystemPrompt: extraSystemPrompt, - generateDescriptionRequirement: false - ) - try await service.modifyCode(prompt: instruction ?? "Modify content.") - return service.code - } - } - )), title: BrowserChatTab.name), + folderIfNeeded( + BrowserChatTab.chatBuilders( + externalDependency: externalDependenciesForBrowserChatTab() + ), + title: BrowserChatTab.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.content + return .init( + selectedText: content.selectedContent, + language: languageIdentifierFromFileURL( + XcodeInspector.shared + .activeDocumentURL + ) + .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 = PromptToCodeService( + code: prompt, + selectionRange: .outOfScope, + language: .plaintext, + identSize: 4, + usesTabsForIndentation: true, + projectRootURL: .init(fileURLWithPath: "/"), + fileURL: .init(fileURLWithPath: "/"), + allCode: prompt, + extraSystemPrompt: extraSystemPrompt, + generateDescriptionRequirement: false + ) + try await service.modifyCode(prompt: instruction ?? "Modify content.") + return service.code + } + } + ) + } } #else enum ChatTabFactory { - static var chatTabBuilderCollection: [ChatTabBuilderCollection] { + static func chatTabBuilderCollection() -> [ChatTabBuilderCollection] { func folderIfNeeded( _ builders: [any ChatTabBuilder], title: String diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index bd4ea831..0fe8896d 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -7,24 +7,50 @@ import Environment import Preferences import SuggestionWidget +#if canImport(ProChatTabs) +import ProChatTabs +#endif + +#if canImport(ChatTabPersistent) +import ChatTabPersistent +#endif + struct GUI: ReducerProtocol { struct State: Equatable { var suggestionWidgetState = WidgetFeature.State() var chatTabGroup: ChatPanelFeature.ChatTabGroup { - get { suggestionWidgetState.chatPanelState.chatTapGroup } - set { suggestionWidgetState.chatPanelState.chatTapGroup = newValue } + get { suggestionWidgetState.chatPanelState.chatTabGroup } + set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue } } + + #if canImport(ChatTabPersistent) + var persistentState: ChatTabPersistent.State { + get { + .init(chatTabInfo: chatTabGroup.tabInfo) + } + set { + chatTabGroup.tabInfo = newValue.chatTabInfo + } + } + #endif } enum Action { + case start case openChatPanel(forceDetach: Bool) case createChatGPTChatTabIfNeeded case sendCustomCommandToActiveChat(CustomCommand) case suggestionWidget(WidgetFeature.Action) + + #if canImport(ChatTabPersistent) + case persistent(ChatTabPersistent.Action) + #endif } + @Dependency(\.chatTabPool) var chatTabPool: ChatTabPool + var body: some ReducerProtocol { Scope(state: \.suggestionWidgetState, action: /Action.suggestionWidget) { WidgetFeature() @@ -37,16 +63,24 @@ struct GUI: ReducerProtocol { Reduce { _, action in switch action { case let .createNewTapButtonClicked(kind): - guard let builder = kind?.builder else { - let chatTap = ChatGPTChatTab() - return .run { send in - await send(.appendAndSelectTab(chatTap)) + return .run { send in + if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) { + await send(.appendAndSelectTab(chatTabInfo)) } } - guard builder.buildable else { return .none } - let chatTap = builder.build() + + case let .closeTabButtonClicked(id): + return .run { _ in + chatTabPool.removeTab(of: id) + } + + case let .chatTab(_, .openNewTab(builder)): return .run { send in - await send(.appendAndSelectTab(chatTap)) + if let (_, chatTabInfo) = await chatTabPool + .createTab(from: builder.chatTabBuilder) + { + await send(.appendAndSelectTab(chatTabInfo)) + } } default: @@ -55,8 +89,21 @@ struct GUI: ReducerProtocol { } } + #if canImport(ChatTabPersistent) + Scope(state: \.persistentState, action: /Action.persistent) { + ChatTabPersistent() + } + #endif + Reduce { state, action in switch action { + case .start: + return .run { send in + #if canImport(ChatTabPersistent) + await send(.persistent(.restoreChatTabs)) + #endif + } + case let .openChatPanel(forceDetach): return .run { send in await send( @@ -65,12 +112,16 @@ struct GUI: ReducerProtocol { } case .createChatGPTChatTabIfNeeded: - if state.chatTabGroup.tabs.contains(where: { $0 is ChatGPTChatTab }) { + if state.chatTabGroup.tabInfo.contains(where: { + chatTabPool.getTab(of: $0.id) is ChatGPTChatTab + }) { return .none } - let chatTab = ChatGPTChatTab() - state.chatTabGroup.tabs.append(chatTab) - return .none + return .run { send in + if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil) { + await send(.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))) + } + } case let .sendCustomCommandToActiveChat(command): @Sendable func stopAndHandleCommand(_ tab: ChatGPTChatTab) async { @@ -80,32 +131,65 @@ struct GUI: ReducerProtocol { try? await tab.service.handleCustomCommand(command) } - if let activeTab = state.chatTabGroup.activeChatTab as? ChatGPTChatTab { + if let info = state.chatTabGroup.selectedTabInfo, + let activeTab = chatTabPool.getTab(of: info.id) as? ChatGPTChatTab + { return .run { send in await send(.openChatPanel(forceDetach: false)) await stopAndHandleCommand(activeTab) } } - if let chatTab = state.chatTabGroup.tabs.first(where: { - guard $0 is ChatGPTChatTab else { return false } - return true - }) as? ChatGPTChatTab { + 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) } } - let chatTab = ChatGPTChatTab() - state.chatTabGroup.tabs.append(chatTab) + return .run { send in + guard let (chatTab, chatTabInfo) = await chatTabPool.createTab(for: nil) else { + return + } + await send(.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))) await send(.openChatPanel(forceDetach: false)) - await stopAndHandleCommand(chatTab) + if let chatTab = chatTab as? ChatGPTChatTab { + await stopAndHandleCommand(chatTab) + } } + case let .suggestionWidget(.chatPanel(.chatTab(id, .tabContentUpdated))): + #if canImport(ChatTabPersistent) + // when a tab is updated, persist it. + return .run { send in + await send(.persistent(.chatTabUpdated(id: id))) + } + #else + return .none + #endif + + case let .suggestionWidget(.chatPanel(.closeTabButtonClicked(id))): + #if canImport(ChatTabPersistent) + // when a tab is closed, remove it from persistence. + return .run { send in + await send(.persistent(.chatTabClosed(id: id))) + } + #else + return .none + #endif + case .suggestionWidget: return .none + + #if canImport(ChatTabPersistent) + case .persistent: + return .none + #endif } } } @@ -113,20 +197,30 @@ struct GUI: ReducerProtocol { @MainActor public final class GraphicalUserInterfaceController { - public static let shared = GraphicalUserInterfaceController() private let store: StoreOf let widgetController: SuggestionWidgetController let widgetDataSource: WidgetDataSource let viewStore: ViewStoreOf + let chatTabPool: ChatTabPool + + class WeakStoreHolder { + weak var store: StoreOf? + } - private init() { + init() { + let chatTabPool = ChatTabPool() let suggestionDependency = SuggestionWidgetControllerDependency() let setupDependency: (inout DependencyValues) -> Void = { dependencies in dependencies.suggestionWidgetControllerDependency = suggestionDependency dependencies.suggestionWidgetUserDefaultsObservers = .init() - dependencies.chatTabBuilderCollection = { - ChatTabFactory.chatTabBuilderCollection + dependencies.chatTabPool = chatTabPool + dependencies.chatTabBuilderCollection = ChatTabFactory.chatTabBuilderCollection + + #if canImport(ChatTabPersistent) && canImport(ProChatTabs) + dependencies.restoreChatTabInPool = { + await chatTabPool.restore($0) } + #endif } let store = StoreOf( initialState: .init(), @@ -134,6 +228,7 @@ public final class GraphicalUserInterfaceController { prepareDependencies: setupDependency ) self.store = store + self.chatTabPool = chatTabPool viewStore = ViewStore(store) widgetDataSource = .init() @@ -142,9 +237,22 @@ public final class GraphicalUserInterfaceController { state: \.suggestionWidgetState, action: GUI.Action.suggestionWidget ), + chatTabPool: chatTabPool, dependency: suggestionDependency ) + chatTabPool.createStore = { id in + store.scope( + state: { state in + state.chatTabGroup.tabInfo[id: id] + ?? .init(id: id, title: "") + }, + action: { childAction in + .suggestionWidget(.chatPanel(.chatTab(id: id, action: childAction))) + } + ) + } + suggestionDependency.suggestionWidgetDataSource = widgetDataSource suggestionDependency.onOpenChatClicked = { [weak self] in Task { [weak self] in @@ -159,6 +267,10 @@ public final class GraphicalUserInterfaceController { } } } + + func start() { + store.send(.start) + } public func openGlobalChat() { Task { @@ -168,3 +280,71 @@ public final class GraphicalUserInterfaceController { } } +extension ChatTabPool { + @MainActor + func createTab( + id: String = UUID().uuidString, + from builder: ChatTabBuilder + ) async -> (any ChatTab, ChatTabInfo)? { + let id = id + let info = ChatTabInfo(id: id, title: "") + guard let chatTap = await builder.build(store: createStore(id)) else { return nil } + setTab(chatTap) + return (chatTap, info) + } + + @MainActor + func createTab( + for kind: ChatTabKind? + ) 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) + } + + guard let chatTap = await builder.build(store: createStore(id)) else { return nil } + setTab(chatTap) + return (chatTap, info) + } + + #if canImport(ChatTabPersistent) && canImport(ProChatTabs) + @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) + default: + break + } + + guard let builder = try? await EmptyChatTab.restore( + from: data.data, externalDependency: () + ) else { + return nil + } + return await createTab(from: builder) + } + #endif +} + diff --git a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift index fdb3e15c..73187619 100644 --- a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift +++ b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift @@ -55,11 +55,14 @@ extension PromptToCodeProvider { service.stopResponding() } - onAcceptSuggestionTapped = { - Task { @ServiceActor in + onAcceptSuggestionTapped = { [weak self] in + Task { [weak self] in let handler = PseudoCommandHandler() await handler.acceptSuggestion() - if let app = ActiveApplicationMonitor.previousActiveApplication, app.isXcode { + if let app = ActiveApplicationMonitor.shared.previousApp, + app.isXcode, + !(self?.isContinuous ?? false) + { try await Task.sleep(nanoseconds: 200_000_000) app.activate() } diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index d8ed98df..edd72bd2 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -21,7 +21,7 @@ final class WidgetDataSource { self.provider = provider } } - + private(set) var promptToCodes = [URL: PromptToCode]() init() {} @@ -60,7 +60,7 @@ final class WidgetDataSource { self?.removePromptToCode(for: url) let presenter = PresentInWindowSuggestionPresenter() presenter.closePromptToCode(fileURL: url) - if let app = ActiveApplicationMonitor.previousActiveApplication, app.isXcode { + if let app = ActiveApplicationMonitor.shared.previousApp, app.isXcode { Task { @MainActor in try await Task.sleep(nanoseconds: 200_000_000) app.activate() @@ -87,33 +87,33 @@ final class WidgetDataSource { extension WidgetDataSource: SuggestionWidgetDataSource { func suggestionForFile(at url: URL) async -> SuggestionProvider? { - for workspace in await workspaces.values { - if let filespace = await workspace.filespaces[url], - let suggestion = await filespace.presentingSuggestion + for workspace in Service.shared.workspacePool.workspaces.values { + if let filespace = workspace.filespaces[url], + let suggestion = filespace.presentingSuggestion { - return await .init( + return .init( code: suggestion.text, language: filespace.language, startLineIndex: suggestion.position.line, suggestionCount: filespace.suggestions.count, currentSuggestionIndex: filespace.suggestionIndex, onSelectPreviousSuggestionTapped: { - Task { @ServiceActor in + Task { let handler = PseudoCommandHandler() await handler.presentPreviousSuggestion() } }, onSelectNextSuggestionTapped: { - Task { @ServiceActor in + Task { let handler = PseudoCommandHandler() await handler.presentNextSuggestion() } }, onRejectSuggestionTapped: { - Task { @ServiceActor in + Task { let handler = PseudoCommandHandler() await handler.rejectSuggestions() - if let app = ActiveApplicationMonitor.previousActiveApplication, + if let app = ActiveApplicationMonitor.shared.previousApp, app.isXcode { try await Task.sleep(nanoseconds: 200_000_000) @@ -122,10 +122,10 @@ extension WidgetDataSource: SuggestionWidgetDataSource { } }, onAcceptSuggestionTapped: { - Task { @ServiceActor in + Task { let handler = PseudoCommandHandler() await handler.acceptSuggestion() - if let app = ActiveApplicationMonitor.previousActiveApplication, + if let app = ActiveApplicationMonitor.shared.previousApp, app.isXcode { try await Task.sleep(nanoseconds: 200_000_000) diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index d3f28fd8..84a2ed5f 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -9,17 +9,11 @@ import Foundation import Logger import Preferences import QuartzCore +import Workspace import XcodeInspector -@ServiceActor public class RealtimeSuggestionController { - public nonisolated static let shared = RealtimeSuggestionController() - var eventObserver: CGEventObserverType = CGEventObserver(eventsOfInterest: [ - .keyUp, - .keyDown, - .rightMouseDown, - .leftMouseDown, - ]) + let eventObserver: CGEventObserverType = CGEventObserver(eventsOfInterest: [.keyDown]) private var task: Task? private var inflightPrefetchTask: Task? private var windowChangeObservationTask: Task? @@ -28,35 +22,34 @@ public class RealtimeSuggestionController { private var focusedUIElement: AXUIElement? private var sourceEditor: SourceEditor? - private nonisolated init() { + init() {} + + func start() { Task { [weak self] in - - if let app = ActiveApplicationMonitor.activeXcode { - await self?.handleXcodeChanged(app) - await self?.startHIDObservation(by: 1) + if let app = ActiveApplicationMonitor.shared.activeXcode { + self?.handleXcodeChanged(app) + self?.startHIDObservation() } - var previousApp = ActiveApplicationMonitor.activeXcode - for await app in ActiveApplicationMonitor.createStream() { + var previousApp = ActiveApplicationMonitor.shared.activeXcode + for await app in ActiveApplicationMonitor.shared.createStream() { guard let self else { return } try Task.checkCancellation() defer { previousApp = app } - if let app = ActiveApplicationMonitor.activeXcode, app != previousApp { - await self.handleXcodeChanged(app) + if let app = ActiveApplicationMonitor.shared.activeXcode, app != previousApp { + self.handleXcodeChanged(app) } - if ActiveApplicationMonitor.activeXcode != nil { - await startHIDObservation(by: 1) + if ActiveApplicationMonitor.shared.activeXcode != nil { + startHIDObservation() } else { - await stopHIDObservation(by: 1) + stopHIDObservation() } } } } - private func startHIDObservation(by listener: AnyHashable) { - Logger.service.info("Add auto trigger listener: \(listener).") - + private func startHIDObservation() { if task == nil { task = Task { [weak self, eventObserver] in for await event in eventObserver.createStream() { @@ -68,8 +61,7 @@ public class RealtimeSuggestionController { eventObserver.activateIfPossible() } - private func stopHIDObservation(by listener: AnyHashable) { - Logger.service.info("Remove auto trigger listener: \(listener).") + private func stopHIDObservation() { task?.cancel() task = nil eventObserver.deactivate() @@ -99,16 +91,17 @@ public class RealtimeSuggestionController { } private func handleFocusElementChange() { - guard let activeXcode = ActiveApplicationMonitor.activeXcode else { return } + guard let activeXcode = ActiveApplicationMonitor.shared.activeXcode else { return } let application = AXUIElementCreateApplication(activeXcode.processIdentifier) guard let focusElement = application.focusedElement else { return } let focusElementType = focusElement.description focusedUIElement = focusElement - + Task { // Notify suggestion service for open file. try await Task.sleep(nanoseconds: 500_000_000) let fileURL = try await Environment.fetchCurrentFileURL() - _ = try await Workspace.fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + _ = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) } guard focusElementType == "Source Editor" else { return } @@ -154,21 +147,21 @@ public class RealtimeSuggestionController { } } - Task { // Get cache ready for real-time suggestions. + Task { @WorkspaceActor in // Get cache ready for real-time suggestions. guard UserDefaults.shared.value(for: \.preCacheOnFileOpen) else { return } let fileURL = try await Environment.fetchCurrentFileURL() - let (_, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + let (_, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) - if filespace.uti == nil { + if filespace.codeMetadata.uti == nil { Logger.service.info("Generate cache for file.") // avoid the command get called twice - filespace.uti = "" + filespace.codeMetadata.uti = "" do { try await Environment.triggerAction("Real-time Suggestions") } catch { - if filespace.uti?.isEmpty ?? true { - filespace.uti = nil + if filespace.codeMetadata.uti?.isEmpty ?? true { + filespace.codeMetadata.uti = nil } } } @@ -191,7 +184,7 @@ public class RealtimeSuggestionController { } func triggerPrefetchDebounced(force: Bool = false) { - inflightPrefetchTask = Task { @ServiceActor in + inflightPrefetchTask = Task { @WorkspaceActor in try? await Task.sleep(nanoseconds: UInt64(( UserDefaults.shared.value(for: \.realtimeSuggestionDebounce) ) * 1_000_000_000)) @@ -201,8 +194,8 @@ public class RealtimeSuggestionController { if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally), let fileURL = try? await Environment.fetchCurrentFileURL(), - let (workspace, _) = try? await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + let (workspace, _) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) { let isEnabled = workspace.isSuggestionFeatureEnabled if !isEnabled { return } @@ -221,7 +214,7 @@ public class RealtimeSuggestionController { // cancel in-flight tasks await withTaskGroup(of: Void.self) { group in - for (_, workspace) in workspaces { + for (_, workspace) in Service.shared.workspacePool.workspaces { group.addTask { await workspace.cancelInFlightRealtimeSuggestionRequests() } @@ -233,17 +226,17 @@ public class RealtimeSuggestionController { /// Looks like the Xcode will keep the panel around until content is changed, /// not sure how to observe that it's hidden. func isCompletionPanelPresenting() -> Bool { - guard let activeXcode = ActiveApplicationMonitor.activeXcode else { return false } + guard let activeXcode = ActiveApplicationMonitor.shared.activeXcode else { return false } let application = AXUIElementCreateApplication(activeXcode.processIdentifier) return application.focusedWindow?.child(identifier: "_XC_COMPLETION_TABLE_") != nil } func notifyEditingFileChange(editor: AXUIElement) async { guard let fileURL = try? await Environment.fetchCurrentFileURL(), - let (workspace, filespace) = try? await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + let (workspace, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } - workspace.notifyUpdateFile(filespace: filespace, content: editor.value) + workspace.suggestionPlugin?.notifyUpdateFile(filespace: filespace, content: editor.value) } } diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 4a29ac25..1f971e05 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -3,10 +3,22 @@ import AppKit import AXExtension import Foundation import Logger +import Workspace import XcodeInspector public final class ScheduledCleaner { - public init() { + let workspacePool: WorkspacePool + let guiController: GraphicalUserInterfaceController + + init( + workspacePool: WorkspacePool, + guiController: GraphicalUserInterfaceController + ) { + self.workspacePool = workspacePool + self.guiController = guiController + } + + func start() { // occasionally cleanup workspaces. Task { @ServiceActor in while !Task.isCancelled { @@ -17,7 +29,7 @@ public final class ScheduledCleaner { // cleanup when Xcode becomes inactive Task { @ServiceActor in - for await app in ActiveApplicationMonitor.createStream() { + for await app in ActiveApplicationMonitor.shared.createStream() { try Task.checkCancellation() if let app, !app.isXcode { await cleanUp() @@ -43,14 +55,14 @@ public final class ScheduledCleaner { } } } - for (url, workspace) in workspaces { + for (url, workspace) in workspacePool.workspaces { if workspace.isExpired, workspaceInfos[.url(url)] == nil { Logger.service.info("Remove idle workspace") for url in workspace.filespaces.keys { - await GraphicalUserInterfaceController.shared.widgetDataSource.cleanup(for: url) + await guiController.widgetDataSource.cleanup(for: url) } - workspace.cleanUp(availableTabs: []) - workspaces[url] = nil + await workspace.cleanUp(availableTabs: []) + workspacePool.removeWorkspace(url: url) } else { let tabs = (workspaceInfos[.url(url)]?.tabs ?? []) .union(workspaceInfos[.unknown]?.tabs ?? []) @@ -62,19 +74,18 @@ public final class ScheduledCleaner { availableTabs: tabs ) { Logger.service.info("Remove idle filespace") - await GraphicalUserInterfaceController.shared.widgetDataSource - .cleanup(for: url) + await guiController.widgetDataSource.cleanup(for: url) } } // cleanup workspace - workspace.cleanUp(availableTabs: tabs) + await workspace.cleanUp(availableTabs: tabs) } } } @ServiceActor public func closeAllChildProcesses() async { - for (_, workspace) in workspaces { + for (_, workspace) in workspacePool.workspaces { await workspace.terminateSuggestionService() } } diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift new file mode 100644 index 00000000..c1c9b2eb --- /dev/null +++ b/Core/Sources/Service/Service.swift @@ -0,0 +1,50 @@ +import Foundation +import KeyBindingManager +import Workspace + +@globalActor public enum ServiceActor { + public actor TheActor {} + public static let shared = TheActor() +} + +/// The running extension service. +public final class Service { + public static let shared = Service() + + @WorkspaceActor + let workspacePool = { + let it = WorkspacePool() + it.registerPlugin { + SuggestionServiceWorkspacePlugin(workspace: $0) + } + return it + }() + + @MainActor + public let guiController = GraphicalUserInterfaceController() + public let realtimeSuggestionController = RealtimeSuggestionController() + public let scheduledCleaner: ScheduledCleaner + let keyBindingManager: KeyBindingManager + + private init() { + scheduledCleaner = .init(workspacePool: workspacePool, guiController: guiController) + keyBindingManager = .init( + workspacePool: workspacePool, + acceptSuggestion: { + Task { + await PseudoCommandHandler().acceptSuggestion() + } + } + ) + } + + @MainActor + public func start() { + scheduledCleaner.start() + realtimeSuggestionController.start() + guiController.start() + keyBindingManager.start() + DependencyUpdater().update() + } +} + diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 0a6356ad..e40f8261 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -4,6 +4,7 @@ import Environment import Preferences import SuggestionInjector import SuggestionModel +import Workspace import XcodeInspector import XPCShared @@ -39,13 +40,14 @@ struct PseudoCommandHandler { )) } + @WorkspaceActor func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async { // Can't use handler if content is not available. guard let editor = await getEditorContent(sourceEditor: sourceEditor), let filespace = await getFilespace(), - let (workspace, _) = try? await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: filespace.fileURL) else { return } + let (workspace, _) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return } let fileURL = filespace.fileURL let presenter = PresentInWindowSuggestionPresenter() @@ -54,7 +56,7 @@ struct PseudoCommandHandler { defer { presenter.markAsProcessing(false) } // Check if the current suggestion is still valid. - if await filespace.validateSuggestions( + if filespace.validateSuggestions( lines: editor.lines, cursorPosition: editor.cursorPosition ) { @@ -62,13 +64,13 @@ struct PseudoCommandHandler { } else { presenter.discardSuggestion(fileURL: filespace.fileURL) } - - let snapshot = Filespace.Snapshot( + + let snapshot = FilespaceSuggestionSnapshot( linesHash: editor.lines.hashValue, cursorPosition: editor.cursorPosition ) - - guard await filespace.suggestionSourceSnapshot != snapshot else { return } + + guard filespace.suggestionSourceSnapshot != snapshot else { return } do { try await workspace.generateSuggestions( @@ -76,12 +78,12 @@ struct PseudoCommandHandler { editor: editor ) if let sourceEditor { - _ = await filespace.validateSuggestions( + _ = filespace.validateSuggestions( lines: sourceEditor.content.lines, cursorPosition: sourceEditor.content.cursorPosition ) } - if await filespace.presentingSuggestion != nil { + if filespace.presentingSuggestion != nil { presenter.presentSuggestion(fileURL: fileURL) } else { presenter.discardSuggestion(fileURL: fileURL) @@ -91,11 +93,12 @@ struct PseudoCommandHandler { } } + @WorkspaceActor func invalidateRealtimeSuggestionsIfNeeded(fileURL: URL, sourceEditor: SourceEditor) async { - guard let (_, filespace) = try? await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) else { return } + guard let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } - if await !filespace.validateSuggestions( + if !filespace.validateSuggestions( lines: sourceEditor.content.lines, cursorPosition: sourceEditor.content.cursorPosition ) { @@ -165,8 +168,8 @@ struct PseudoCommandHandler { } try await Environment.triggerAction("Accept Suggestion") } catch { - guard let xcode = ActiveApplicationMonitor.activeXcode ?? ActiveApplicationMonitor - .latestXcode else { return } + 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" @@ -257,8 +260,8 @@ extension PseudoCommandHandler { cursorPosition: CursorPosition )? { - guard let xcode = ActiveApplicationMonitor.activeXcode - ?? ActiveApplicationMonitor.latestXcode else { return nil } + guard let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode else { return nil } let application = AXUIElementCreateApplication(xcode.processIdentifier) guard let focusElement = sourceEditor ?? application.focusedElement, focusElement.description == "Source Editor" @@ -274,24 +277,24 @@ extension PseudoCommandHandler { try? await Environment.fetchCurrentFileURL() } - @ServiceActor + @WorkspaceActor func getFilespace() async -> Filespace? { guard let fileURL = await getFileURL(), - let (_, filespace) = try? await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return nil } return filespace } - @ServiceActor + @WorkspaceActor func getEditorContent(sourceEditor: SourceEditor?) async -> EditorContent? { guard let filespace = await getFilespace(), let sourceEditor else { return nil } let content = sourceEditor.content - let uti = filespace.uti ?? "" - let tabSize = filespace.tabSize ?? 4 - let indentSize = filespace.indentSize ?? 4 - let usesTabsForIndentation = filespace.usesTabsForIndentation ?? false + let uti = filespace.codeMetadata.uti ?? "" + let tabSize = filespace.codeMetadata.tabSize ?? 4 + let indentSize = filespace.codeMetadata.indentSize ?? 4 + let usesTabsForIndentation = filespace.codeMetadata.usesTabsForIndentation ?? false return .init( content: content.content, lines: content.lines, diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 1343115c..5707ed32 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -9,9 +9,9 @@ import SuggestionInjector import SuggestionModel import SuggestionWidget import UserNotifications +import Workspace import XPCShared -@ServiceActor struct WindowBaseCommandHandler: SuggestionCommandHandler { nonisolated init() {} @@ -31,18 +31,19 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + @WorkspaceActor private func _presentSuggestions(editor: EditorContent) async throws { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + let (workspace, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) try Task.checkCancellation() - let snapshot = Filespace.Snapshot( + let snapshot = FilespaceSuggestionSnapshot( linesHash: editor.lines.hashValue, cursorPosition: editor.cursorPosition ) @@ -75,12 +76,13 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + @WorkspaceActor private func _presentNextSuggestion(editor: EditorContent) async throws { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + let (workspace, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) workspace.selectNextSuggestion(forFileAt: fileURL) if filespace.presentingSuggestion != nil { @@ -101,12 +103,13 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + @WorkspaceActor private func _presentPreviousSuggestion(editor: EditorContent) async throws { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + let (workspace, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) workspace.selectPreviousSuggestion(forFileAt: fileURL) if filespace.presentingSuggestion != nil { @@ -127,12 +130,13 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + @WorkspaceActor private func _rejectSuggestion(editor: EditorContent) async throws { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } let fileURL = try await Environment.fetchCurrentFileURL() - let dataSource = GraphicalUserInterfaceController.shared.widgetDataSource + let dataSource = Service.shared.guiController.widgetDataSource if await dataSource.promptToCodes[fileURL]?.promptToCodeService != nil { await dataSource.removePromptToCode(for: fileURL) @@ -140,24 +144,27 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return } - let (workspace, _) = try await Workspace.fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) workspace.rejectSuggestion(forFileAt: fileURL, editor: editor) presenter.discardSuggestion(fileURL: fileURL) } + @WorkspaceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, _) = try await Workspace.fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) let injector = SuggestionInjector() var lines = editor.lines var cursorPosition = editor.cursorPosition var extraInfo = SuggestionInjector.ExtraInfo() - let dataSource = GraphicalUserInterfaceController.shared.widgetDataSource + let dataSource = Service.shared.guiController.widgetDataSource if let service = await dataSource.promptToCodes[fileURL]?.promptToCodeService { let suggestion = CodeSuggestion( @@ -221,14 +228,15 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + @WorkspaceActor func prepareCache(editor: EditorContent) async throws -> UpdatedContent? { let fileURL = try await Environment.fetchCurrentFileURL() - let (_, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) - filespace.uti = editor.uti - filespace.tabSize = editor.tabSize - filespace.indentSize = editor.indentSize - filespace.usesTabsForIndentation = editor.usesTabsForIndentation + let (_, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + filespace.codeMetadata.uti = editor.uti + filespace.codeMetadata.tabSize = editor.tabSize + filespace.codeMetadata.indentSize = editor.indentSize + filespace.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation return nil } @@ -238,7 +246,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { func chatWithSelection(editor: EditorContent) async throws -> UpdatedContent? { Task { @MainActor in - let viewStore = GraphicalUserInterfaceController.shared.viewStore + let viewStore = Service.shared.guiController.viewStore viewStore.send(.createChatGPTChatTabIfNeeded) viewStore.send(.openChatPanel(forceDetach: false)) } @@ -288,7 +296,7 @@ extension WindowBaseCommandHandler { switch command.feature { case .chatWithSelection, .customChat: Task { @MainActor in - GraphicalUserInterfaceController.shared.viewStore + Service.shared.guiController.viewStore .send(.sendCustomCommandToActiveChat(command)) } case let .promptToCode(extraSystemPrompt, prompt, continuousMode, generateDescription): @@ -315,6 +323,7 @@ extension WindowBaseCommandHandler { } } + @WorkspaceActor func presentPromptToCode( editor: EditorContent, extraSystemPrompt: String?, @@ -326,9 +335,9 @@ extension WindowBaseCommandHandler { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, _) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) - guard workspace.isSuggestionFeatureEnabled else { + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + guard workspace.suggestionPlugin?.isSuggestionFeatureEnabled ?? false else { presenter.presentErrorMessage("Prompt to code is disabled for this project") return } @@ -369,7 +378,7 @@ extension WindowBaseCommandHandler { ) }() as (String, CursorRange) - let dataSource = GraphicalUserInterfaceController.shared.widgetDataSource + let dataSource = Service.shared.guiController.widgetDataSource let promptToCode = await dataSource.createPromptToCode( for: fileURL, diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInCommentSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInCommentSuggestionPresenter.swift deleted file mode 100644 index 39d111e1..00000000 --- a/Core/Sources/Service/SuggestionPresenter/PresentInCommentSuggestionPresenter.swift +++ /dev/null @@ -1,72 +0,0 @@ -import SuggestionModel -import Foundation -import SuggestionInjector -import XPCShared - -struct PresentInCommentSuggestionPresenter { - func presentSuggestion( - for filespace: Filespace, - in workspace: Workspace, - originalContent: String, - lines: [String], - cursorPosition: CursorPosition - ) async throws -> UpdatedContent? { - let injector = SuggestionInjector() - var lines = lines - var cursorPosition = cursorPosition - var extraInfo = SuggestionInjector.ExtraInfo() - - injector.rejectCurrentSuggestions( - from: &lines, - cursorPosition: &cursorPosition, - extraInfo: &extraInfo - ) - - guard let completion = await filespace.presentingSuggestion else { - return .init( - content: originalContent, - newSelection: .cursor(cursorPosition), - modifications: extraInfo.modifications - ) - } - - await injector.proposeSuggestion( - intoContentWithoutSuggestion: &lines, - completion: completion, - index: filespace.suggestionIndex, - count: filespace.suggestions.count, - extraInfo: &extraInfo - ) - - return .init( - content: String(lines.joined(separator: "")), - newSelection: .cursor(cursorPosition), - modifications: extraInfo.modifications - ) - } - - func discardSuggestion( - for filespace: Filespace, - in workspace: Workspace, - originalContent: String, - lines: [String], - cursorPosition: CursorPosition - ) async throws -> UpdatedContent? { - let injector = SuggestionInjector() - var lines = lines - var cursorPosition = cursorPosition - var extraInfo = SuggestionInjector.ExtraInfo() - - injector.rejectCurrentSuggestions( - from: &lines, - cursorPosition: &cursorPosition, - extraInfo: &extraInfo - ) - - return .init( - content: String(lines.joined(separator: "")), - newSelection: .cursor(cursorPosition), - modifications: extraInfo.modifications - ) - } -} diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift index 9cb45971..9215551f 100644 --- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -7,21 +7,21 @@ import SuggestionWidget struct PresentInWindowSuggestionPresenter { func presentSuggestion(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.widgetController + let controller = Service.shared.guiController.widgetController controller.suggestCode() } } func discardSuggestion(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.widgetController + let controller = Service.shared.guiController.widgetController controller.discardSuggestion() } } func markAsProcessing(_ isProcessing: Bool) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.widgetController + let controller = Service.shared.guiController.widgetController controller.markAsProcessing(isProcessing) } } @@ -30,42 +30,42 @@ struct PresentInWindowSuggestionPresenter { if error is CancellationError { return } if let urlError = error as? URLError, urlError.code == URLError.cancelled { return } Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.widgetController + let controller = Service.shared.guiController.widgetController controller.presentError(error.localizedDescription) } } func presentErrorMessage(_ message: String) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.widgetController + let controller = Service.shared.guiController.widgetController controller.presentError(message) } } func closeChatRoom(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.widgetController + let controller = Service.shared.guiController.widgetController controller.closeChatRoom() } } func presentChatRoom(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.widgetController + let controller = Service.shared.guiController.widgetController controller.presentChatRoom() } } func presentPromptToCode(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.widgetController + let controller = Service.shared.guiController.widgetController controller.presentPromptToCode() } } func closePromptToCode(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.widgetController + let controller = Service.shared.guiController.widgetController controller.discardPromptToCode() } } diff --git a/Core/Sources/Service/Workspace.swift b/Core/Sources/Service/Workspace.swift deleted file mode 100644 index 9d18c0f1..00000000 --- a/Core/Sources/Service/Workspace.swift +++ /dev/null @@ -1,481 +0,0 @@ -import ChatService -import Environment -import Foundation -import GitHubCopilotService -import Logger -import Preferences -import SuggestionInjector -import SuggestionModel -import SuggestionService -import UserDefaultsObserver -import XcodeInspector -import XPCShared - -// MARK: - Filespace - -@ServiceActor -final class Filespace { - struct Snapshot: Equatable { - var linesHash: Int - var cursorPosition: CursorPosition - } - - let fileURL: URL - private(set) lazy var language: String = languageIdentifierFromFileURL(fileURL).rawValue - var suggestions: [CodeSuggestion] = [] { - didSet { refreshUpdateTime() } - } - - // stored for pseudo command handler - var uti: String? - var tabSize: Int? - var indentSize: Int? - var usesTabsForIndentation: Bool? - // --------------------------------- - - var suggestionIndex: Int = 0 - var suggestionSourceSnapshot: Snapshot = .init(linesHash: -1, cursorPosition: .outOfScope) - var presentingSuggestion: CodeSuggestion? { - guard suggestions.endIndex > suggestionIndex, suggestionIndex >= 0 else { return nil } - return suggestions[suggestionIndex] - } - - private(set) var lastSuggestionUpdateTime: Date = Environment.now() - var isExpired: Bool { - Environment.now().timeIntervalSince(lastSuggestionUpdateTime) > 60 * 3 - } - - let fileSaveWatcher: FileSaveWatcher - - fileprivate init(fileURL: URL, onSave: @escaping (Filespace) -> Void) { - self.fileURL = fileURL - fileSaveWatcher = .init(fileURL: fileURL) - fileSaveWatcher.changeHandler = { [weak self] in - guard let self else { return } - onSave(self) - } - } - - func reset(resetSnapshot: Bool = true) { - suggestions = [] - suggestionIndex = 0 - if resetSnapshot { - suggestionSourceSnapshot = .init(linesHash: -1, cursorPosition: .outOfScope) - } - } - - func refreshUpdateTime() { - lastSuggestionUpdateTime = Environment.now() - } - - /// Validate the suggestion is still valid. - /// - Parameters: - /// - lines: lines of the file - /// - cursorPosition: cursor position - /// - Returns: `true` if the suggestion is still valid - func validateSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { - guard let presentingSuggestion else { return false } - - // cursor has moved to another line - if cursorPosition.line != presentingSuggestion.position.line { - reset() - return false - } - - // the cursor position is valid - guard cursorPosition.line >= 0, cursorPosition.line < lines.count else { - reset() - return false - } - - let editingLine = lines[cursorPosition.line].dropLast(1) // dropping \n - let suggestionLines = presentingSuggestion.text.split(separator: "\n") - let suggestionFirstLine = suggestionLines.first ?? "" - - // the line content doesn't match the suggestion - if cursorPosition.character > 0, - !suggestionFirstLine.hasPrefix(editingLine[..<(editingLine.index( - editingLine.startIndex, - offsetBy: cursorPosition.character, - limitedBy: editingLine.endIndex - ) ?? editingLine.endIndex)]) - { - reset() - return false - } - - // finished typing the whole suggestion when the suggestion has only one line - if editingLine.hasPrefix(suggestionFirstLine), suggestionLines.count <= 1 { - reset() - return false - } - - // undo to a state before the suggestion was generated - if editingLine.count < presentingSuggestion.position.character { - reset() - return false - } - - return true - } -} - -// MARK: - Workspace - -@ServiceActor -final class Workspace { - struct SuggestionFeatureDisabledError: Error, LocalizedError { - var errorDescription: String? { - "Suggestion feature is disabled for this project." - } - } - - struct UnsupportedFileError: Error, LocalizedError { - var extensionName: String - var errorDescription: String? { - "File type \(extensionName) unsupported." - } - } - - let projectRootURL: URL - let openedFileRecoverableStorage: OpenedFileRecoverableStorage - var lastSuggestionUpdateTime = Environment.now() - var isExpired: Bool { - Environment.now().timeIntervalSince(lastSuggestionUpdateTime) > 60 * 60 * 1 - } - - private(set) var filespaces = [URL: Filespace]() - var isRealtimeSuggestionEnabled: Bool { - UserDefaults.shared.value(for: \.realtimeSuggestionToggle) - } - - let userDefaultsObserver = UserDefaultsObserver( - object: UserDefaults.shared, forKeyPaths: [ - UserDefaultPreferenceKeys().suggestionFeatureEnabledProjectList.key, - UserDefaultPreferenceKeys().disableSuggestionFeatureGlobally.key, - ], context: nil - ) - - private var _suggestionService: SuggestionServiceType? - - private var suggestionService: SuggestionServiceType? { - // Check if the workspace is disabled. - let isSuggestionDisabledGlobally = UserDefaults.shared - .value(for: \.disableSuggestionFeatureGlobally) - if isSuggestionDisabledGlobally { - let enabledList = UserDefaults.shared.value(for: \.suggestionFeatureEnabledProjectList) - if !enabledList.contains(where: { path in projectRootURL.path.hasPrefix(path) }) { - // If it's disable, remove the service - _suggestionService = nil - return nil - } - } - - if _suggestionService == nil { - _suggestionService = SuggestionService(projectRootURL: projectRootURL) { - [weak self] _ in - guard let self else { return } - for (_, filespace) in filespaces { - notifyOpenFile(filespace: filespace) - } - } - } - return _suggestionService - } - - var isSuggestionFeatureEnabled: Bool { - let isSuggestionDisabledGlobally = UserDefaults.shared - .value(for: \.disableSuggestionFeatureGlobally) - if isSuggestionDisabledGlobally { - let enabledList = UserDefaults.shared.value(for: \.suggestionFeatureEnabledProjectList) - if !enabledList.contains(where: { path in projectRootURL.path.hasPrefix(path) }) { - return false - } - } - return true - } - - private init(projectRootURL: URL) { - self.projectRootURL = projectRootURL - openedFileRecoverableStorage = .init(projectRootURL: projectRootURL) - - userDefaultsObserver.onChange = { [weak self] in - guard let self else { return } - _ = self.suggestionService - } - - let openedFiles = openedFileRecoverableStorage.openedFiles - for fileURL in openedFiles { - _ = createFilespaceIfNeeded(fileURL: fileURL) - } - } - - func refreshUpdateTime() { - lastSuggestionUpdateTime = Environment.now() - } - - func canAutoTriggerGetSuggestions( - forFileAt fileURL: URL, - lines: [String], - cursorPosition: CursorPosition - ) -> Bool { - guard isRealtimeSuggestionEnabled else { return false } - guard let filespace = filespaces[fileURL] else { return true } - if lines.hashValue != filespace.suggestionSourceSnapshot.linesHash { return true } - if cursorPosition != filespace.suggestionSourceSnapshot.cursorPosition { return true } - return false - } - - /// This is the only way to create a workspace and a filespace. - static func fetchOrCreateWorkspaceIfNeeded(fileURL: URL) async throws - -> (workspace: Workspace, filespace: Filespace) - { - let ignoreFileExtensions = ["mlmodel"] - if ignoreFileExtensions.contains(fileURL.pathExtension) { - throw UnsupportedFileError(extensionName: fileURL.pathExtension) - } - - // If we know which project is opened. - if let currentProjectURL = try await Environment.fetchCurrentProjectRootURLFromXcode() { - if let existed = workspaces[currentProjectURL] { - let filespace = existed.createFilespaceIfNeeded(fileURL: fileURL) - return (existed, filespace) - } - - let new = Workspace(projectRootURL: currentProjectURL) - workspaces[currentProjectURL] = new - let filespace = new.createFilespaceIfNeeded(fileURL: fileURL) - return (new, filespace) - } - - // If not, we try to reuse a filespace if found. - // - // Sometimes, we can't get the project root path from Xcode window, for example, when the - // quick open window in displayed. - for workspace in workspaces.values { - if let filespace = workspace.filespaces[fileURL] { - return (workspace, filespace) - } - } - - // If we can't find an existed one, we will try to guess it. - // Most of the time we won't enter this branch, just incase. - - let workspaceURL = try await Environment.guessProjectRootURLForFile(fileURL) - - let workspace = { - if let existed = workspaces[workspaceURL] { - return existed - } - // Reuse existed workspace if possible - for (_, workspace) in workspaces { - if fileURL.path.hasPrefix(workspace.projectRootURL.path) { - return workspace - } - } - return Workspace(projectRootURL: workspaceURL) - }() - - let filespace = workspace.createFilespaceIfNeeded(fileURL: fileURL) - workspaces[workspaceURL] = workspace - workspace.refreshUpdateTime() - return (workspace, filespace) - } - - private func createFilespaceIfNeeded(fileURL: URL) -> Filespace { - let existedFilespace = filespaces[fileURL] - let filespace = existedFilespace ?? .init(fileURL: fileURL, onSave: { [weak self] - filespace in - guard let self else { return } - notifySaveFile(filespace: filespace) - }) - if filespaces[fileURL] == nil { - filespaces[fileURL] = filespace - } - if existedFilespace == nil { - notifyOpenFile(filespace: filespace) - } else { - filespace.refreshUpdateTime() - } - return filespace - } -} - -// MARK: - Suggestion - -extension Workspace { - @discardableResult - func generateSuggestions( - forFileAt fileURL: URL, - editor: EditorContent - ) async throws -> [CodeSuggestion] { - refreshUpdateTime() - - let filespace = createFilespaceIfNeeded(fileURL: fileURL) - - if !editor.uti.isEmpty { - filespace.uti = editor.uti - filespace.tabSize = editor.tabSize - filespace.indentSize = editor.indentSize - filespace.usesTabsForIndentation = editor.usesTabsForIndentation - } - - let snapshot = Filespace.Snapshot( - linesHash: editor.lines.hashValue, - cursorPosition: editor.cursorPosition - ) - - filespace.suggestionSourceSnapshot = snapshot - - guard let suggestionService else { throw SuggestionFeatureDisabledError() } - let completions = try await suggestionService.getSuggestions( - fileURL: fileURL, - content: editor.lines.joined(separator: ""), - cursorPosition: editor.cursorPosition, - tabSize: editor.tabSize, - indentSize: editor.indentSize, - usesTabsForIndentation: editor.usesTabsForIndentation, - ignoreSpaceOnlySuggestions: true - ) - - filespace.suggestions = completions - filespace.suggestionIndex = 0 - - return completions - } - - func selectNextSuggestion(forFileAt fileURL: URL) { - refreshUpdateTime() - guard let filespace = filespaces[fileURL], - filespace.suggestions.count > 1 - else { return } - filespace.suggestionIndex += 1 - if filespace.suggestionIndex >= filespace.suggestions.endIndex { - filespace.suggestionIndex = 0 - } - } - - func selectPreviousSuggestion(forFileAt fileURL: URL) { - refreshUpdateTime() - guard let filespace = filespaces[fileURL], - filespace.suggestions.count > 1 - else { return } - filespace.suggestionIndex -= 1 - if filespace.suggestionIndex < 0 { - filespace.suggestionIndex = filespace.suggestions.endIndex - 1 - } - } - - func rejectSuggestion(forFileAt fileURL: URL, editor: EditorContent?) { - refreshUpdateTime() - - if let editor, !editor.uti.isEmpty { - filespaces[fileURL]?.uti = editor.uti - filespaces[fileURL]?.tabSize = editor.tabSize - filespaces[fileURL]?.indentSize = editor.indentSize - filespaces[fileURL]?.usesTabsForIndentation = editor.usesTabsForIndentation - } - Task { - await suggestionService?.notifyRejected(filespaces[fileURL]?.suggestions ?? []) - } - filespaces[fileURL]?.reset(resetSnapshot: false) - } - - func acceptSuggestion(forFileAt fileURL: URL, editor: EditorContent?) -> CodeSuggestion? { - refreshUpdateTime() - guard let filespace = filespaces[fileURL], - !filespace.suggestions.isEmpty, - filespace.suggestionIndex >= 0, - filespace.suggestionIndex < filespace.suggestions.endIndex - else { return nil } - - if let editor, !editor.uti.isEmpty { - filespaces[fileURL]?.uti = editor.uti - filespaces[fileURL]?.tabSize = editor.tabSize - filespaces[fileURL]?.indentSize = editor.indentSize - filespaces[fileURL]?.usesTabsForIndentation = editor.usesTabsForIndentation - } - - var allSuggestions = filespace.suggestions - let suggestion = allSuggestions.remove(at: filespace.suggestionIndex) - - Task { - await suggestionService?.notifyAccepted(suggestion) - await suggestionService?.notifyRejected(allSuggestions) - } - - filespaces[fileURL]?.reset() - - return suggestion - } - - func notifyOpenFile(filespace: Filespace) { - refreshUpdateTime() - openedFileRecoverableStorage.openFile(fileURL: filespace.fileURL) - Task { - // 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: try String(contentsOf: filespace.fileURL, encoding: .utf8) - ) - } - } - - func notifyUpdateFile(filespace: Filespace, content: String) { - filespace.refreshUpdateTime() - refreshUpdateTime() - Task { - try await suggestionService?.notifyChangeTextDocument( - fileURL: filespace.fileURL, - content: content - ) - } - } - - func notifySaveFile(filespace: Filespace) { - filespace.refreshUpdateTime() - refreshUpdateTime() - Task { - try await suggestionService?.notifySaveTextDocument(fileURL: filespace.fileURL) - } - } -} - -// MARK: - Cleanup - -extension Workspace { - func cleanUp(availableTabs: Set) { - for (fileURL, _) in filespaces { - if isFilespaceExpired(fileURL: fileURL, availableTabs: availableTabs) { - Task { - try await suggestionService?.notifyCloseTextDocument(fileURL: fileURL) - } - openedFileRecoverableStorage.closeFile(fileURL: fileURL) - filespaces[fileURL] = nil - } - } - } - - func isFilespaceExpired(fileURL: URL, availableTabs: Set) -> Bool { - let filename = fileURL.lastPathComponent - if availableTabs.contains(filename) { return false } - guard let filespace = filespaces[fileURL] else { return true } - return filespace.isExpired - } - - func cancelInFlightRealtimeSuggestionRequests() async { - guard let suggestionService else { return } - await suggestionService.cancelRequest() - } - - func terminateSuggestionService() async { - await _suggestionService?.terminate() - } -} - diff --git a/Core/Sources/Service/WorkspaceExtension/Filespace+SuggestionService.swift b/Core/Sources/Service/WorkspaceExtension/Filespace+SuggestionService.swift new file mode 100644 index 00000000..a9382dd4 --- /dev/null +++ b/Core/Sources/Service/WorkspaceExtension/Filespace+SuggestionService.swift @@ -0,0 +1,86 @@ +import Foundation +import SuggestionModel +import Workspace + +struct FilespaceSuggestionSnapshot: Equatable { + var linesHash: Int + var cursorPosition: CursorPosition +} + +struct FilespaceSuggestionSnapshotKey: FilespacePropertyKey { + static func createDefaultValue() + -> FilespaceSuggestionSnapshot { .init(linesHash: -1, cursorPosition: .outOfScope) } +} + +extension FilespacePropertyValues { + var suggestionSourceSnapshot: FilespaceSuggestionSnapshot { + get { self[FilespaceSuggestionSnapshotKey.self] } + set { self[FilespaceSuggestionSnapshotKey.self] = newValue } + } +} + +extension Filespace { + func resetSnapshot() { + // swiftformat:disable redundantSelf + self.suggestionSourceSnapshot = FilespaceSuggestionSnapshotKey.createDefaultValue() + // swiftformat:enable all + } + + /// Validate the suggestion is still valid. + /// - Parameters: + /// - lines: lines of the file + /// - cursorPosition: cursor position + /// - Returns: `true` if the suggestion is still valid + @WorkspaceActor + func validateSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { + guard let presentingSuggestion else { return false } + + // cursor has moved to another line + if cursorPosition.line != presentingSuggestion.position.line { + reset() + resetSnapshot() + return false + } + + // the cursor position is valid + guard cursorPosition.line >= 0, cursorPosition.line < lines.count else { + reset() + resetSnapshot() + return false + } + + let editingLine = lines[cursorPosition.line].dropLast(1) // dropping \n + let suggestionLines = presentingSuggestion.text.split(separator: "\n") + let suggestionFirstLine = suggestionLines.first ?? "" + + // the line content doesn't match the suggestion + if cursorPosition.character > 0, + !suggestionFirstLine.hasPrefix(editingLine[..<(editingLine.index( + editingLine.startIndex, + offsetBy: cursorPosition.character, + limitedBy: editingLine.endIndex + ) ?? editingLine.endIndex)]) + { + reset() + resetSnapshot() + return false + } + + // finished typing the whole suggestion when the suggestion has only one line + if editingLine.hasPrefix(suggestionFirstLine), suggestionLines.count <= 1 { + reset() + resetSnapshot() + return false + } + + // undo to a state before the suggestion was generated + if editingLine.count < presentingSuggestion.position.character { + reset() + resetSnapshot() + return false + } + + return true + } +} + diff --git a/Core/Sources/Service/WorkspaceExtension/SuggestionWorkspacePlugin.swift b/Core/Sources/Service/WorkspaceExtension/SuggestionWorkspacePlugin.swift new file mode 100644 index 00000000..94f54db4 --- /dev/null +++ b/Core/Sources/Service/WorkspaceExtension/SuggestionWorkspacePlugin.swift @@ -0,0 +1,136 @@ +import Environment +import Foundation +import UserDefaultsObserver +import Workspace +import SuggestionService +import SuggestionModel +import Preferences + +final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { + let userDefaultsObserver = UserDefaultsObserver( + object: UserDefaults.shared, forKeyPaths: [ + UserDefaultPreferenceKeys().suggestionFeatureEnabledProjectList.key, + UserDefaultPreferenceKeys().disableSuggestionFeatureGlobally.key, + ], context: nil + ) + + var isRealtimeSuggestionEnabled: Bool { + UserDefaults.shared.value(for: \.realtimeSuggestionToggle) + } + + private var _suggestionService: SuggestionServiceType? + + var suggestionService: SuggestionServiceType? { + // Check if the workspace is disabled. + let isSuggestionDisabledGlobally = UserDefaults.shared + .value(for: \.disableSuggestionFeatureGlobally) + if isSuggestionDisabledGlobally { + let enabledList = UserDefaults.shared.value(for: \.suggestionFeatureEnabledProjectList) + if !enabledList.contains(where: { path in projectRootURL.path.hasPrefix(path) }) { + // If it's disable, remove the service + _suggestionService = nil + return nil + } + } + + if _suggestionService == nil { + _suggestionService = SuggestionService(projectRootURL: projectRootURL) { + [weak self] _ in + guard let self else { return } + for (_, filespace) in filespaces { + notifyOpenFile(filespace: filespace) + } + } + } + return _suggestionService + } + + var isSuggestionFeatureEnabled: Bool { + let isSuggestionDisabledGlobally = UserDefaults.shared + .value(for: \.disableSuggestionFeatureGlobally) + if isSuggestionDisabledGlobally { + let enabledList = UserDefaults.shared.value(for: \.suggestionFeatureEnabledProjectList) + if !enabledList.contains(where: { path in projectRootURL.path.hasPrefix(path) }) { + return false + } + } + return true + } + + func canAutoTriggerGetSuggestions( + forFileAt fileURL: URL, + lines: [String], + cursorPosition: CursorPosition + ) -> Bool { + guard isRealtimeSuggestionEnabled else { return false } + guard let filespace = filespaces[fileURL] else { return true } + if lines.hashValue != filespace.suggestionSourceSnapshot.linesHash { return true } + if cursorPosition != filespace.suggestionSourceSnapshot.cursorPosition { return true } + return false + } + + override init(workspace: Workspace) { + super.init(workspace: workspace) + + userDefaultsObserver.onChange = { [weak self] in + guard let self else { return } + _ = self.suggestionService + } + } + + override func didOpenFilespace(_ filespace: Filespace) { + notifyOpenFile(filespace: filespace) + } + + override func didSaveFilespace(_ filespace: Filespace) { + notifySaveFile(filespace: filespace) + } + + override func didCloseFilespace(_ fileURL: URL) { + Task { + try await suggestionService?.notifyCloseTextDocument(fileURL: fileURL) + } + } + + func notifyOpenFile(filespace: Filespace) { + workspace?.refreshUpdateTime() + workspace?.openedFileRecoverableStorage.openFile(fileURL: filespace.fileURL) + Task { + // 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: try String(contentsOf: filespace.fileURL, encoding: .utf8) + ) + } + } + + func notifyUpdateFile(filespace: Filespace, content: String) { + filespace.refreshUpdateTime() + workspace?.refreshUpdateTime() + Task { + try await suggestionService?.notifyChangeTextDocument( + fileURL: filespace.fileURL, + content: content + ) + } + } + + func notifySaveFile(filespace: Filespace) { + filespace.refreshUpdateTime() + workspace?.refreshUpdateTime() + Task { + try await suggestionService?.notifySaveTextDocument(fileURL: filespace.fileURL) + } + } + + func terminateSuggestionService() async { + await _suggestionService?.terminate() + } +} + diff --git a/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift new file mode 100644 index 00000000..3a5475da --- /dev/null +++ b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift @@ -0,0 +1,34 @@ +import Foundation +import Workspace +import SuggestionService + +extension Workspace { + @WorkspaceActor + 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) + } + } + } + + func isFilespaceExpired(fileURL: URL, availableTabs: Set) -> Bool { + let filename = fileURL.lastPathComponent + if availableTabs.contains(filename) { return false } + guard let filespace = filespaces[fileURL] else { return true } + return filespace.isExpired + } + + func cancelInFlightRealtimeSuggestionRequests() async { + guard let suggestionService else { return } + await suggestionService.cancelRequest() + } + + func terminateSuggestionService() async { + await suggestionPlugin?.terminateSuggestionService() + } +} diff --git a/Core/Sources/Service/WorkspaceExtension/Workspace+SuggestionService.swift b/Core/Sources/Service/WorkspaceExtension/Workspace+SuggestionService.swift new file mode 100644 index 00000000..de9df342 --- /dev/null +++ b/Core/Sources/Service/WorkspaceExtension/Workspace+SuggestionService.swift @@ -0,0 +1,132 @@ +import Foundation +import SuggestionModel +import SuggestionService +import Workspace +import XPCShared + +extension Workspace { + var suggestionPlugin: SuggestionServiceWorkspacePlugin? { + plugin(for: SuggestionServiceWorkspacePlugin.self) + } + + var suggestionService: SuggestionServiceType? { + suggestionPlugin?.suggestionService + } + + var isSuggestionFeatureEnabled: Bool { + suggestionPlugin?.isSuggestionFeatureEnabled ?? false + } + + struct SuggestionFeatureDisabledError: Error, LocalizedError { + var errorDescription: String? { + "Suggestion feature is disabled for this project." + } + } +} + +extension Workspace { + @WorkspaceActor + @discardableResult + func generateSuggestions( + forFileAt fileURL: URL, + editor: EditorContent + ) async throws -> [CodeSuggestion] { + refreshUpdateTime() + + let filespace = createFilespaceIfNeeded(fileURL: fileURL) + + if !editor.uti.isEmpty { + filespace.codeMetadata.uti = editor.uti + filespace.codeMetadata.tabSize = editor.tabSize + filespace.codeMetadata.indentSize = editor.indentSize + filespace.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + } + + let snapshot = FilespaceSuggestionSnapshot( + linesHash: editor.lines.hashValue, + cursorPosition: editor.cursorPosition + ) + + filespace.suggestionSourceSnapshot = snapshot + + guard let suggestionService else { throw SuggestionFeatureDisabledError() } + let completions = try await suggestionService.getSuggestions( + fileURL: fileURL, + content: editor.lines.joined(separator: ""), + cursorPosition: editor.cursorPosition, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation, + ignoreSpaceOnlySuggestions: true + ) + + filespace.setSuggestions(completions) + + return completions + } + + @WorkspaceActor + func selectNextSuggestion(forFileAt fileURL: URL) { + refreshUpdateTime() + guard let filespace = filespaces[fileURL], + filespace.suggestions.count > 1 + else { return } + filespace.nextSuggestion() + } + + @WorkspaceActor + func selectPreviousSuggestion(forFileAt fileURL: URL) { + refreshUpdateTime() + guard let filespace = filespaces[fileURL], + filespace.suggestions.count > 1 + else { return } + filespace.previousSuggestion() + } + + @WorkspaceActor + func rejectSuggestion(forFileAt fileURL: URL, editor: EditorContent?) { + refreshUpdateTime() + + if let editor, !editor.uti.isEmpty { + filespaces[fileURL]?.codeMetadata.uti = editor.uti + filespaces[fileURL]?.codeMetadata.tabSize = editor.tabSize + filespaces[fileURL]?.codeMetadata.indentSize = editor.indentSize + filespaces[fileURL]?.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + } + Task { + await suggestionService?.notifyRejected(filespaces[fileURL]?.suggestions ?? []) + } + filespaces[fileURL]?.reset() + } + + @WorkspaceActor + func acceptSuggestion(forFileAt fileURL: URL, editor: EditorContent?) -> CodeSuggestion? { + refreshUpdateTime() + guard let filespace = filespaces[fileURL], + !filespace.suggestions.isEmpty, + filespace.suggestionIndex >= 0, + filespace.suggestionIndex < filespace.suggestions.endIndex + else { return nil } + + if let editor, !editor.uti.isEmpty { + filespaces[fileURL]?.codeMetadata.uti = editor.uti + filespaces[fileURL]?.codeMetadata.tabSize = editor.tabSize + filespaces[fileURL]?.codeMetadata.indentSize = editor.indentSize + filespaces[fileURL]?.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + } + + var allSuggestions = filespace.suggestions + let suggestion = allSuggestions.remove(at: filespace.suggestionIndex) + + Task { [allSuggestions] in + await suggestionService?.notifyAccepted(suggestion) + await suggestionService?.notifyRejected(allSuggestions) + } + + filespaces[fileURL]?.reset() + filespaces[fileURL]?.resetSnapshot() + + return suggestion + } +} + diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 13ab09cc..07af3b57 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -7,14 +7,6 @@ import Logger import Preferences import XPCShared -@globalActor public enum ServiceActor { - public actor TheActor {} - public static let shared = TheActor() -} - -@ServiceActor -var workspaces = [URL: Workspace]() - public class XPCService: NSObject, XPCServiceProtocol { // MARK: - Service @@ -61,7 +53,7 @@ public class XPCService: NSObject, XPCServiceProtocol { } Task { - await RealtimeSuggestionController.shared.cancelInFlightTasks(excluding: task) + await Service.shared.realtimeSuggestionController.cancelInFlightTasks(excluding: task) } return task } @@ -176,7 +168,7 @@ public class XPCService: NSObject, XPCServiceProtocol { return } Task { @ServiceActor in - await RealtimeSuggestionController.shared.cancelInFlightTasks() + await Service.shared.realtimeSuggestionController.cancelInFlightTasks() UserDefaults.shared.set( !UserDefaults.shared.value(for: \.realtimeSuggestionToggle), for: \.realtimeSuggestionToggle @@ -184,13 +176,17 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(nil) } } - + public func postNotification(name: String, withReply reply: @escaping () -> Void) { reply() NSWorkspace.shared.notificationCenter.post(name: .init(name), object: nil) } - - public func performAction(name: String, arguments: String, withReply reply: @escaping (String) -> Void) { + + public func performAction( + name: String, + arguments: String, + withReply reply: @escaping (String) -> Void + ) { reply("None") } } diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index ff8f2f72..247f8f00 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -23,7 +23,7 @@ struct ChatWindowView: View { OverallState( isPanelDisplayed: $0.isPanelDisplayed, colorScheme: $0.colorScheme, - selectedTabId: $0.chatTapGroup.selectedTabId + selectedTabId: $0.chatTabGroup.selectedTabId ) } ) { viewStore in @@ -155,19 +155,19 @@ struct ChatTabBar: View { let store: StoreOf struct TabBarState: Equatable { - var tabs: [BaseChatTab] - var tabInfo: [ChatTabInfo] + var tabInfo: IdentifiedArray var selectedTabId: String } + @Environment(\.chatTabPool) var chatTabPool + var body: some View { WithViewStore( store, observe: { TabBarState( - tabs: $0.chatTapGroup.tabs, - tabInfo: $0.chatTapGroup.tabInfo, - selectedTabId: $0.chatTapGroup.selectedTabId - ?? $0.chatTapGroup.tabInfo.first?.id ?? "" + tabInfo: $0.chatTabGroup.tabInfo, + selectedTabId: $0.chatTabGroup.selectedTabId + ?? $0.chatTabGroup.tabInfo.first?.id ?? "" ) } ) { viewStore in HStack(spacing: 0) { @@ -182,10 +182,10 @@ struct ChatTabBar: View { ) .id(info.id) .contextMenu { - if let tab = viewStore.state.tabs - .first(where: { $0.id == info.id }) - { + if let tab = chatTabPool.getTab(of: info.id) { tab.menu + } else { + EmptyView() } } } @@ -217,7 +217,7 @@ struct ChatTabBar: View { @ViewBuilder var createButton: some View { Menu { - WithViewStore(store, observe: { $0.chatTapGroup.tabCollection }) { viewStore in + WithViewStore(store, observe: { $0.chatTabGroup.tabCollection }) { viewStore in ForEach(0.. struct TabContainerState: Equatable { - var tabs: [BaseChatTab] + var tabInfo: IdentifiedArray var selectedTabId: String? } + @Environment(\.chatTabPool) var chatTabPool + var body: some View { WithViewStore( store, observe: { TabContainerState( - tabs: $0.chatTapGroup.tabs, - selectedTabId: $0.chatTapGroup.selectedTabId - ?? $0.chatTapGroup.tabInfo.first?.id ?? "" + tabInfo: $0.chatTabGroup.tabInfo, + selectedTabId: $0.chatTabGroup.selectedTabId + ?? $0.chatTabGroup.tabInfo.first?.id ?? "" ) } ) { viewStore in ZStack { - if viewStore.state.tabs.isEmpty { + if viewStore.state.tabInfo.isEmpty { Text("Empty") } else { - ForEach(viewStore.state.tabs, id: \.id) { tab in - tab.body - .opacity(tab.id == viewStore.state.selectedTabId ? 1 : 0) - .frame(maxWidth: .infinity, maxHeight: .infinity) + ForEach(viewStore.state.tabInfo) { tabInfo in + if let tab = chatTabPool.getTab(of: tabInfo.id) { + tab.body + .opacity(tab.id == viewStore.state.selectedTabId ? 1 : 0) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + EmptyView() + } } } } } - .onPreferenceChange(ChatTabInfoPreferenceKey.self) { items in - store.send(.updateChatTabInfo(items)) - } } } @@ -361,10 +364,9 @@ class FakeChatTab: ChatTab { struct Builder: ChatTabBuilder { var title: String = "Title" - var buildable: Bool { true } - func build() -> any ChatTab { - return FakeChatTab(id: "id", title: "Title") + func build(store: StoreOf) async -> (any ChatTab)? { + return FakeChatTab(store: store) } } @@ -386,25 +388,51 @@ class FakeChatTab: ChatTab { ) } - override init(id: String, title: String) { - super.init(id: id, title: title) + func restorableState() async -> Data { + return Data() + } + + static func restore( + from data: Data, + externalDependency: () + ) async throws -> any ChatTabBuilder { + return Builder() + } + + convenience init(id: String, title: String) { + self.init(store: .init( + initialState: .init(id: id, title: title), + reducer: ChatTabItem() + )) } + + func start() {} } struct ChatWindowView_Previews: PreviewProvider { + static let pool = ChatTabPool([ + "1": FakeChatTab(id: "1", title: "Hello I am a chatbot"), + "2": EmptyChatTab(id: "2"), + "3": EmptyChatTab(id: "3"), + "4": EmptyChatTab(id: "4"), + "5": EmptyChatTab(id: "5"), + "6": EmptyChatTab(id: "6"), + "7": EmptyChatTab(id: "7"), + ]) + static var previews: some View { ChatWindowView( store: .init( initialState: .init( - chatTapGroup: .init( - tabs: [ - FakeChatTab(id: "1", title: "Hello I am a chatbot"), - EmptyChatTab(id: "2"), - EmptyChatTab(id: "3"), - EmptyChatTab(id: "4"), - EmptyChatTab(id: "5"), - EmptyChatTab(id: "6"), - EmptyChatTab(id: "7"), + chatTabGroup: .init( + tabInfo: [ + .init(id: "1", title: "Fake"), + .init(id: "2", title: "Empty-2"), + .init(id: "3", title: "Empty-3"), + .init(id: "4", title: "Empty-4"), + .init(id: "5", title: "Empty-5"), + .init(id: "6", title: "Empty-6"), + .init(id: "7", title: "Empty-7"), ], selectedTabId: "1" ), @@ -415,6 +443,7 @@ struct ChatWindowView_Previews: PreviewProvider { ) .xcodeStyleFrame() .padding() + .environment(\.chatTabPool, pool) } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index 1267b902..e6f2a517 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -24,32 +24,28 @@ public struct ChatTabKind: Equatable { public struct ChatPanelFeature: ReducerProtocol { public struct ChatTabGroup: Equatable { - public var tabs: [BaseChatTab] - public var tabInfo: [ChatTabInfo] + public var tabInfo: IdentifiedArray public var tabCollection: [ChatTabBuilderCollection] public var selectedTabId: String? + + public var selectedTabInfo: ChatTabInfo? { + guard let id = selectedTabId else { return tabInfo.first } + return tabInfo[id: id] + } init( - tabs: [BaseChatTab] = [], - tabInfo: [ChatTabInfo] = [], + tabInfo: IdentifiedArray = [], tabCollection: [ChatTabBuilderCollection] = [], selectedTabId: String? = nil ) { - self.tabs = tabs self.tabInfo = tabInfo self.tabCollection = tabCollection self.selectedTabId = selectedTabId } - - public var activeChatTab: BaseChatTab? { - guard let id = selectedTabId else { return tabs.first } - guard let tab = tabs.first(where: { $0.id == id }) else { return tabs.first } - return tab - } } public struct State: Equatable { - public var chatTapGroup = ChatTabGroup() + public var chatTabGroup = ChatTabGroup() var colorScheme: ColorScheme = .light var isPanelDisplayed = false var chatPanelInASeparateWindow = false @@ -65,14 +61,16 @@ public struct ChatPanelFeature: ReducerProtocol { case presentChatPanel(forceDetach: Bool) // Tabs - case updateChatTabInfo([ChatTabInfo]) + case updateChatTabInfo(IdentifiedArray) case createNewTapButtonHovered case closeTabButtonClicked(id: String) case createNewTapButtonClicked(kind: ChatTabKind?) case tabClicked(id: String) - case appendAndSelectTab(BaseChatTab) + case appendAndSelectTab(ChatTabInfo) case switchToNextTab case switchToPreviousTab + + case chatTab(id: String, action: ChatTabItem.Action) } @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @@ -92,7 +90,7 @@ public struct ChatPanelFeature: ReducerProtocol { } case .closeActiveTabClicked: - if let id = state.chatTapGroup.selectedTabId { + if let id = state.chatTabGroup.selectedTabId { return .run { send in await send(.closeTabButtonClicked(id: id)) } @@ -123,80 +121,96 @@ public struct ChatPanelFeature: ReducerProtocol { } case let .updateChatTabInfo(chatTabInfo): - let previousSelectedIndex = state.chatTapGroup.tabInfo - .firstIndex(where: { $0.id == state.chatTapGroup.selectedTabId }) - state.chatTapGroup.tabInfo = chatTabInfo - if !chatTabInfo.contains(where: { $0.id == state.chatTapGroup.selectedTabId }) { + let previousSelectedIndex = state.chatTabGroup.tabInfo + .firstIndex(where: { $0.id == state.chatTabGroup.selectedTabId }) + state.chatTabGroup.tabInfo = chatTabInfo + if !chatTabInfo.contains(where: { $0.id == state.chatTabGroup.selectedTabId }) { if let previousSelectedIndex { let proposedSelectedIndex = previousSelectedIndex - 1 if proposedSelectedIndex >= 0, proposedSelectedIndex < chatTabInfo.endIndex { - state.chatTapGroup.selectedTabId = chatTabInfo[proposedSelectedIndex].id + state.chatTabGroup.selectedTabId = chatTabInfo[proposedSelectedIndex].id } else { - state.chatTapGroup.selectedTabId = chatTabInfo.first?.id + state.chatTabGroup.selectedTabId = chatTabInfo.first?.id } } else { - state.chatTapGroup.selectedTabId = nil + state.chatTabGroup.selectedTabId = nil } } return .none case let .closeTabButtonClicked(id): - state.chatTapGroup.tabs.removeAll { $0.id == id } - if state.chatTapGroup.tabs.isEmpty { + let firstIndex = state.chatTabGroup.tabInfo.firstIndex { $0.id == id } + let nextIndex = { + guard let firstIndex else { return 0 } + let nextIndex = firstIndex - 1 + return max(nextIndex, 0) + }() + state.chatTabGroup.tabInfo.removeAll { $0.id == id } + if state.chatTabGroup.tabInfo.isEmpty { state.isPanelDisplayed = false } + if nextIndex < state.chatTabGroup.tabInfo.count { + state.chatTabGroup.selectedTabId = state.chatTabGroup.tabInfo[nextIndex].id + } else { + state.chatTabGroup.selectedTabId = nil + } return .none case .createNewTapButtonHovered: - state.chatTapGroup.tabCollection = chatTabBuilderCollection() + state.chatTabGroup.tabCollection = chatTabBuilderCollection() return .none case .createNewTapButtonClicked: return .none // handled elsewhere case let .tabClicked(id): - guard state.chatTapGroup.tabInfo.contains(where: { $0.id == id }) else { - state.chatTapGroup.selectedTabId = nil + guard state.chatTabGroup.tabInfo.contains(where: { $0.id == id }) else { + state.chatTabGroup.selectedTabId = nil return .none } - state.chatTapGroup.selectedTabId = id + state.chatTabGroup.selectedTabId = id return .none case let .appendAndSelectTab(tab): - guard !state.chatTapGroup.tabInfo.contains(where: { $0.id == tab.id }) + guard !state.chatTabGroup.tabInfo.contains(where: { $0.id == tab.id }) else { return .none } - state.chatTapGroup.tabs.append(tab) - state.chatTapGroup.selectedTabId = tab.id + state.chatTabGroup.tabInfo.append(tab) + state.chatTabGroup.selectedTabId = tab.id return .none case .switchToNextTab: - let selectedId = state.chatTapGroup.selectedTabId - guard let index = state.chatTapGroup.tabInfo + let selectedId = state.chatTabGroup.selectedTabId + guard let index = state.chatTabGroup.tabInfo .firstIndex(where: { $0.id == selectedId }) else { return .none } let nextIndex = index + 1 - if nextIndex >= state.chatTapGroup.tabInfo.endIndex { + if nextIndex >= state.chatTabGroup.tabInfo.endIndex { return .none } - let targetId = state.chatTapGroup.tabInfo[nextIndex].id - state.chatTapGroup.selectedTabId = targetId + let targetId = state.chatTabGroup.tabInfo[nextIndex].id + state.chatTabGroup.selectedTabId = targetId return .none case .switchToPreviousTab: - let selectedId = state.chatTapGroup.selectedTabId - guard let index = state.chatTapGroup.tabInfo + let selectedId = state.chatTabGroup.selectedTabId + guard let index = state.chatTabGroup.tabInfo .firstIndex(where: { $0.id == selectedId }) else { return .none } let previousIndex = index - 1 - if previousIndex < 0 || previousIndex >= state.chatTapGroup.tabInfo.endIndex { + if previousIndex < 0 || previousIndex >= state.chatTabGroup.tabInfo.endIndex { return .none } - let targetId = state.chatTapGroup.tabInfo[previousIndex].id - state.chatTapGroup.selectedTabId = targetId + let targetId = state.chatTabGroup.tabInfo[previousIndex].id + state.chatTabGroup.selectedTabId = targetId + return .none + + case .chatTab: return .none } + }.forEach(\.chatTabGroup.tabInfo, action: /Action.chatTab) { + ChatTabItem() } } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 9b117e23..42537284 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -23,6 +23,7 @@ public struct WidgetFeature: ReducerProtocol { } public struct State: Equatable { + var focusingDocumentURL: URL? public var colorScheme: ColorScheme = .light // MARK: Panels @@ -63,7 +64,7 @@ public struct WidgetFeature: ReducerProtocol { } return false }(), - isContentEmpty: chatPanelState.chatTapGroup.tabs.isEmpty + isContentEmpty: chatPanelState.chatTabGroup.tabInfo.isEmpty && panelState.sharedPanelState.content == nil, isChatPanelDetached: chatPanelState.chatPanelInASeparateWindow, isChatOpen: chatPanelState.isPanelDisplayed, @@ -107,6 +108,7 @@ public struct WidgetFeature: ReducerProtocol { case updateWindowLocation(animated: Bool) case updateWindowOpacity + case updateFocusingDocumentURL case panel(PanelFeature.Action) case chatPanel(ChatPanelFeature.Action) @@ -121,6 +123,11 @@ public struct WidgetFeature: ReducerProtocol { @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @Dependency(\.activeApplicationMonitor) var activeApplicationMonitor @Dependency(\.xcodeInspector) var xcodeInspector + @Dependency(\.mainQueue) var mainQueue + + public enum DebounceKey: Hashable { + case updateWindowOpacity + } public init() {} @@ -149,7 +156,7 @@ public struct WidgetFeature: ReducerProtocol { } return .run { _ in guard isDisplayingContent else { return } - if let app = activeApplicationMonitor.previousActiveApplication, app.isXcode { + if let app = activeApplicationMonitor.previousApp, app.isXcode { try await Task.sleep(nanoseconds: 200_000_000) app.activate() } @@ -301,6 +308,8 @@ public struct WidgetFeature: ReducerProtocol { case .observeWindowChange: guard let app = activeApplicationMonitor.activeApplication else { return .none } guard app.isXcode else { return .none } + + let documentURL = state.focusingDocumentURL return .run { send in await send(.observeEditorChange) @@ -319,17 +328,31 @@ public struct WidgetFeature: ReducerProtocol { kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification ) + for await notification in notifications { try Task.checkCancellation() + // Hide the widgets before switching to another window/editor + // so the transition looks better. + if [ + kAXFocusedUIElementChangedNotification, + kAXFocusedWindowChangedNotification, + ].contains(notification.name) { + let newDocumentURL = xcodeInspector.realtimeActiveDocumentURL + if documentURL != newDocumentURL { + await send(.panel(.removeDisplayedContent)) + await hidePanelWindows() + } + await send(.updateFocusingDocumentURL) + } + + // update widgets. if [ kAXFocusedUIElementChangedNotification, kAXApplicationActivatedNotification, kAXMainWindowChangedNotification, kAXFocusedWindowChangedNotification, ].contains(notification.name) { - await hidePanelWindows() - await send(.panel(.removeDisplayedContent)) await send(.updateWindowLocation(animated: false)) await send(.updateWindowOpacity) await send(.observeEditorChange) @@ -421,6 +444,10 @@ public struct WidgetFeature: ReducerProtocol { state.panelState.suggestionPanelState.colorScheme = scheme state.chatPanelState.colorScheme = scheme return .none + + case .updateFocusingDocumentURL: + state.focusingDocumentURL = xcodeInspector.realtimeActiveDocumentURL + return .none case let .updateWindowLocation(animated): guard let widgetLocation = generateWidgetLocation() else { return .none } @@ -485,9 +512,9 @@ public struct WidgetFeature: ReducerProtocol { case .updateWindowOpacity: let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow - let hasChat = !state.chatPanelState.chatTapGroup.tabs.isEmpty - + let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty return .run { _ in + try await mainQueue.sleep(for: .seconds(0.2)) Task { @MainActor in if let app = activeApplicationMonitor.activeApplication, app.isXcode { let application = AXUIElementCreateApplication(app.processIdentifier) @@ -538,16 +565,10 @@ public struct WidgetFeature: ReducerProtocol { } } } + .cancellable(id: DebounceKey.updateWindowOpacity, cancelInFlight: true) - case let .circularWidget(action): - switch action { - case .openChatButtonClicked: - suggestionWidgetControllerDependency.onOpenChatClicked() - return .none - - default: - return .none - } + case .circularWidget: + return .none case .panel: return .none diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift index b7a9c16f..304b4620 100644 --- a/Core/Sources/SuggestionWidget/ModuleDependency.swift +++ b/Core/Sources/SuggestionWidget/ModuleDependency.swift @@ -1,9 +1,11 @@ import ActiveApplicationMonitor import AppKit +import ChatTab import ComposableArchitecture import Dependencies import Foundation import Preferences +import SwiftUI import UserDefaultsObserver import XcodeInspector @@ -69,7 +71,7 @@ struct XcodeInspectorKey: DependencyKey { } struct ActiveApplicationMonitorKey: DependencyKey { - static let liveValue = ActiveApplicationMonitor.self + static let liveValue = ActiveApplicationMonitor.shared } struct ChatTabBuilderCollectionKey: DependencyKey { @@ -81,7 +83,7 @@ struct ChatTabBuilderCollectionKey: DependencyKey { struct ActivatePreviouslyActiveXcodeKey: DependencyKey { static let liveValue = { @MainActor in @Dependency(\.activeApplicationMonitor) var activeApplicationMonitor - if let app = activeApplicationMonitor.previousActiveApplication, app.isXcode { + if let app = activeApplicationMonitor.previousApp, app.isXcode { try? await Task.sleep(nanoseconds: 200_000_000) app.activate() } @@ -118,7 +120,7 @@ extension DependencyValues { set { self[XcodeInspectorKey.self] = newValue } } - var activeApplicationMonitor: ActiveApplicationMonitor.Type { + var activeApplicationMonitor: ActiveApplicationMonitor { get { self[ActiveApplicationMonitorKey.self] } set { self[ActiveApplicationMonitorKey.self] = newValue } } @@ -133,4 +135,3 @@ extension DependencyValues { set { self[ActivateExtensionServiceKey.self] = newValue } } } - diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index 57c3cca9..d520702b 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -8,7 +8,7 @@ enum Style { static let panelWidth: Double = 454 static let inlineSuggestionMinWidth: Double = 540 static let inlineSuggestionMaxHeight: Double = 400 - static let widgetHeight: Double = 24 + static let widgetHeight: Double = 20 static var widgetWidth: Double { widgetHeight } static let widgetPadding: Double = 4 } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index 6babe4f2..6904ed5f 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -5,6 +5,7 @@ struct CodeBlockSuggestionPanel: View { @ObservedObject var suggestion: SuggestionProvider @Environment(\.colorScheme) var colorScheme @AppStorage(\.suggestionCodeFontSize) var fontSize + @AppStorage(\.suggestionDisplayCompactMode) var suggestionDisplayCompactMode struct ToolBar: View { @ObservedObject var suggestion: SuggestionProvider @@ -48,6 +49,37 @@ struct CodeBlockSuggestionPanel: View { } } + struct CompactToolBar: View { + @ObservedObject var suggestion: SuggestionProvider + + var body: some View { + HStack { + Button(action: { + suggestion.selectPreviousSuggestion() + }) { + Image(systemName: "chevron.left") + }.buttonStyle(.plain) + + Text( + "\(suggestion.currentSuggestionIndex + 1) / \(suggestion.suggestionCount)" + ) + .monospacedDigit() + + Button(action: { + suggestion.selectNextSuggestion() + }) { + Image(systemName: "chevron.right") + }.buttonStyle(.plain) + + Spacer() + } + .padding(4) + .font(.caption) + .foregroundColor(.secondary) + .background(.regularMaterial) + } + } + var body: some View { VStack(spacing: 0) { CustomScrollView { @@ -62,7 +94,11 @@ struct CodeBlockSuggestionPanel: View { } .background(Color.contentBackground) - ToolBar(suggestion: suggestion) + if suggestionDisplayCompactMode { + CompactToolBar(suggestion: suggestion) + } else { + ToolBar(suggestion: suggestion) + } } .xcodeStyleFrame() } @@ -126,6 +162,44 @@ struct CodeBlockSuggestionPanel_Bright_Preview: PreviewProvider { } } +struct CodeBlockSuggestionPanel_CompactToolBar_Preview: PreviewProvider { + static let userDefault = { + let userDefault = UserDefaults(suiteName: "CodeBlockSuggestionPanel_CompactToolBar_Preview") + userDefault?.set(true, for: \.suggestionDisplayCompactMode) + return userDefault! + }() + + static var previews: some View { + CodeBlockSuggestionPanel(suggestion: SuggestionProvider( + code: """ + LazyVGrid(columns: [GridItem(.fixed(30)), GridItem(.flexible())]) { + ForEach(0.. let viewStore: ViewStoreOf + let chatTabPool: ChatTabPool private var cancellable = Set() public let dependency: SuggestionWidgetControllerDependency public init( store: StoreOf, + chatTabPool: ChatTabPool, dependency: SuggestionWidgetControllerDependency ) { self.dependency = dependency self.store = store + self.chatTabPool = chatTabPool viewStore = .init(store, observe: { $0 }) super.init() diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index eae75bbc..5db67131 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -103,23 +103,23 @@ enum UpdateLocationStrategy { .widgetPadding ) - let proposedAnchorFrameOnTheRightSide = { - if hideCircularWidget { - return CGRect( - x: editorFrame.maxX, - y: y, - width: 0, - height: 0 - ) - } else { - return CGRect( - x: editorFrame.maxX - Style.widgetPadding - Style.widgetWidth, - y: y, - width: Style.widgetWidth, - height: Style.widgetHeight - ) - } - }() + var proposedAnchorFrameOnTheRightSide = CGRect( + x: editorFrame.maxX - Style.widgetPadding, + y: y, + width: 0, + height: 0 + ) + + let widgetFrameOnTheRightSide = CGRect( + x: editorFrame.maxX - Style.widgetPadding - Style.widgetWidth, + y: y, + width: Style.widgetWidth, + height: Style.widgetHeight + ) + + if !hideCircularWidget { + proposedAnchorFrameOnTheRightSide = widgetFrameOnTheRightSide + } let proposedPanelX = proposedAnchorFrameOnTheRightSide.maxX + Style .widgetPadding * 2 @@ -148,7 +148,7 @@ enum UpdateLocationStrategy { ) return .init( - widgetFrame: anchorFrame, + widgetFrame: widgetFrameOnTheRightSide, tabFrame: tabFrame, defaultPanelLocation: .init( frame: panelFrame, @@ -157,23 +157,24 @@ enum UpdateLocationStrategy { suggestionPanelLocation: nil ) } else { - let proposedAnchorFrameOnTheLeftSide = { - if hideCircularWidget { - return CGRect( - x: editorFrame.minX, - y: proposedAnchorFrameOnTheRightSide.origin.y, - width: 0, - height: 0 - ) - } else { - return CGRect( - x: editorFrame.minX + Style.widgetPadding, - y: proposedAnchorFrameOnTheRightSide.origin.y, - width: Style.widgetWidth, - height: Style.widgetHeight - ) - } - }() + var proposedAnchorFrameOnTheLeftSide = CGRect( + x: editorFrame.minX + Style.widgetPadding, + y: proposedAnchorFrameOnTheRightSide.origin.y, + width: 0, + height: 0 + ) + + let widgetFrameOnTheLeftSide = CGRect( + x: editorFrame.minX + Style.widgetPadding, + y: proposedAnchorFrameOnTheRightSide.origin.y, + width: Style.widgetWidth, + height: Style.widgetHeight + ) + + if !hideCircularWidget { + proposedAnchorFrameOnTheLeftSide = widgetFrameOnTheLeftSide + } + let proposedPanelX = proposedAnchorFrameOnTheLeftSide.minX - Style .widgetPadding * 2 - Style.panelWidth let putAnchorToTheLeft = { @@ -204,7 +205,7 @@ enum UpdateLocationStrategy { height: Style.widgetHeight ) return .init( - widgetFrame: anchorFrame, + widgetFrame: widgetFrameOnTheLeftSide, tabFrame: tabFrame, defaultPanelLocation: .init( frame: panelFrame, @@ -229,7 +230,7 @@ enum UpdateLocationStrategy { height: Style.widgetHeight ) return .init( - widgetFrame: anchorFrame, + widgetFrame: widgetFrameOnTheRightSide, tabFrame: tabFrame, defaultPanelLocation: .init( frame: panelFrame, diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index aaea57e6..11dbf5f8 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -10,23 +10,36 @@ struct WidgetView: View { @State var isHovering: Bool = false var onOpenChatClicked: () -> Void = {} var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } + + @AppStorage(\.hideCircularWidget) var hideCircularWidget var body: some View { - Circle() - .fill(isHovering ? .white.opacity(0.8) : .white.opacity(0.3)) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - store.send(.widgetClicked) + WithViewStore(store, observe: { $0.isProcessing }) { viewStore in + Circle() + .fill(isHovering ? .white.opacity(0.5) : .white.opacity(0.15)) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + store.send(.widgetClicked) + } } - } - .overlay { overlayCircle } - .onHover { yes in - withAnimation(.easeInOut(duration: 0.2)) { - isHovering = yes + .overlay { overlayCircle } + .onHover { yes in + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = yes + } + }.contextMenu { + WidgetContextMenu(store: store) } - }.contextMenu { - WidgetContextMenu(store: store) - } + .opacity({ + if !hideCircularWidget { return 1 } + return viewStore.state ? 1 : 0 + }()) + .animation( + featureFlag: \.animationCCrashSuggestion, + .easeInOut(duration: 0.2), + value: viewStore.state + ) + } } struct OverlayCircleState: Equatable { diff --git a/Core/Tests/ServiceTests/Environment.swift b/Core/Tests/ServiceTests/Environment.swift index 7f414962..3bf19a99 100644 --- a/Core/Tests/ServiceTests/Environment.swift +++ b/Core/Tests/ServiceTests/Environment.swift @@ -1,32 +1,15 @@ import AppKit import Client -import SuggestionModel -import GitHubCopilotService import Environment import Foundation +import GitHubCopilotService +import SuggestionModel +import Workspace import XCTest import XPCShared @testable import Service -@ServiceActor func clearEnvironment() { - workspaces = [:] - - Environment.now = { Date() } - - Environment.fetchCurrentProjectRootURLFromXcode = { - URL(fileURLWithPath: "/path/to/project") - } - - Environment.fetchCurrentFileURL = { - URL(fileURLWithPath: "/path/to/project/file.swift") - } - - Environment.triggerAction = { _ in } - - Environment.guessProjectRootURLForFile = { $0 } -} - func completion(text: String, range: CursorRange, uuid: String = "") -> CodeSuggestion { .init(text: text, position: range.start, uuid: uuid, range: range, displayText: text) } @@ -35,27 +18,27 @@ class MockSuggestionService: GitHubCopilotSuggestionServiceType { func terminate() async { fatalError() } - + func cancelRequest() async { fatalError() } - + func notifyOpenTextDocument(fileURL: URL, content: String) async throws { fatalError() } - + func notifyChangeTextDocument(fileURL: URL, content: String) async throws { fatalError() } - + func notifyCloseTextDocument(fileURL: URL) async throws { fatalError() } - + func notifySaveTextDocument(fileURL: URL) async throws { fatalError() } - + var completions = [CodeSuggestion]() var accepted: String? var rejected: [String] = [] @@ -85,3 +68,4 @@ class MockSuggestionService: GitHubCopilotSuggestionServiceType { rejected = completions.map(\.uuid) } } + diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift index c0b3f80f..dd7bca69 100644 --- a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift +++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift @@ -1,14 +1,16 @@ import Foundation -import XCTest import SuggestionModel +import Workspace +import XCTest @testable import Service class FilespaceSuggestionInvalidationTests: XCTestCase { - @ServiceActor + @WorkspaceActor func prepare(suggestionText: String, cursorPosition: CursorPosition) async throws -> Filespace { - let (_, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: URL(fileURLWithPath: "file/path/to.swift")) + let pool = WorkspacePool() + let (_, filespace) = try await pool + .fetchOrCreateWorkspaceAndFilespace(fileURL: URL(fileURLWithPath: "file/path/to.swift")) filespace.suggestions = [ .init( text: suggestionText, @@ -16,11 +18,11 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { uuid: "", range: .outOfScope, displayText: "" - ) + ), ] return filespace } - + func test_text_typing_suggestion_should_be_valid() async throws { let filespace = try await prepare( suggestionText: "hello man", @@ -34,7 +36,7 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { let suggestion = await filespace.presentingSuggestion XCTAssertNotNil(suggestion) } - + func test_text_typing_suggestion_in_the_middle_should_be_valid() async throws { let filespace = try await prepare( suggestionText: "hello man", @@ -48,7 +50,7 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { let suggestion = await filespace.presentingSuggestion XCTAssertNotNil(suggestion) } - + func test_text_cursor_moved_to_another_line_should_invalidate() async throws { let filespace = try await prepare( suggestionText: "hello man", @@ -62,7 +64,7 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { let suggestion = await filespace.presentingSuggestion XCTAssertNil(suggestion) } - + func test_text_cursor_is_invalid_should_invalidate() async throws { let filespace = try await prepare( suggestionText: "hello man", @@ -76,7 +78,7 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { let suggestion = await filespace.presentingSuggestion XCTAssertNil(suggestion) } - + func test_line_content_does_not_match_input_should_invalidate() async throws { let filespace = try await prepare( suggestionText: "hello man", @@ -90,7 +92,7 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { let suggestion = await filespace.presentingSuggestion XCTAssertNil(suggestion) } - + func test_line_content_does_not_match_input_should_invalidate_index_out_of_scope() async throws { let filespace = try await prepare( suggestionText: "hello man", @@ -104,7 +106,7 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { let suggestion = await filespace.presentingSuggestion XCTAssertNil(suggestion) } - + func test_finish_typing_the_whole_single_line_suggestion_should_invalidate() async throws { let filespace = try await prepare( suggestionText: "hello man", @@ -118,8 +120,9 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { let suggestion = await filespace.presentingSuggestion XCTAssertNil(suggestion) } - - func test_finish_typing_the_whole_single_line_suggestion_suggestion_is_incomplete_should_invalidate() async throws { + + func test_finish_typing_the_whole_single_line_suggestion_suggestion_is_incomplete_should_invalidate( + ) async throws { let filespace = try await prepare( suggestionText: "hello man", cursorPosition: .init(line: 1, character: 0) @@ -132,7 +135,7 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { let suggestion = await filespace.presentingSuggestion XCTAssertNil(suggestion) } - + func test_finish_typing_the_whole_multiple_line_suggestion_should_be_valid() async throws { let filespace = try await prepare( suggestionText: "hello man\nhow are you?", @@ -146,8 +149,9 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { let suggestion = await filespace.presentingSuggestion XCTAssertNotNil(suggestion) } - - func test_undo_text_to_a_state_before_the_suggestion_was_generated_should_invalidate() async throws { + + func test_undo_text_to_a_state_before_the_suggestion_was_generated_should_invalidate( + ) async throws { let filespace = try await prepare( suggestionText: "hello man", cursorPosition: .init(line: 1, character: 5) // generating man from hello diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 0ee34dcc..243c0bee 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -18,7 +18,7 @@ let serviceIdentifier = bundleIdentifierBase + ".ExtensionService" @main class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { - let scheduledCleaner = ScheduledCleaner() + let service = Service.shared var statusBarItem: NSStatusItem! var xpcListener: (NSXPCListener, ServiceDelegate)? let updateChecker = @@ -29,9 +29,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func applicationDidFinishLaunching(_: Notification) { if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } - _ = GraphicalUserInterfaceController.shared - _ = RealtimeSuggestionController.shared _ = XcodeInspector.shared + service.start() AXIsProcessTrustedWithOptions([ kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true, ] as CFDictionary) @@ -41,7 +40,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { Logger.service.info("XPC Service started.") NSApp.setActivationPolicy(.accessory) buildStatusBarMenu() - DependencyUpdater().update() Task { do { @@ -54,7 +52,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { @objc func quit() { Task { @MainActor in - await scheduledCleaner.closeAllChildProcesses() + await service.scheduledCleaner.closeAllChildProcesses() exit(0) } } @@ -71,7 +69,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { @objc func openGlobalChat() { Task { @MainActor in - let serviceGUI = GraphicalUserInterfaceController.shared + let serviceGUI = Service.shared.guiController serviceGUI.openGlobalChat() } } diff --git a/Pro b/Pro index 6e61d887..4f1a4e91 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 6e61d88739c7c3fadc450c04d87d46d72c2924f8 +Subproject commit 4f1a4e91996e6ad6afe185c70efb0e88cd8e5092 diff --git a/README.md b/README.md index d2475525..c7fede5c 100644 --- a/README.md +++ b/README.md @@ -105,14 +105,14 @@ It looks like there is no way to add default key bindings to commands, but you c A [recommended setup](https://github.com/intitni/CopilotForXcode/issues/14) that should cause no conflict is -| Command | Key Binding | -| ------------------- | ----------- | -| Accept Suggestions | `⌥}` | -| Reject Suggestion | `⌥{` | -| Next Suggestion | `⌥>` | -| Previous Suggestion | `⌥<` | -| Open Chat | `⌥"` | -| Explain Selection | `⌥\|` | +| Command | Key Binding | +| ------------------- | ------------------------------------------------------ | +| Accept Suggestions | `⌥}` (Or accept with Tab if Plus license is available) | +| Reject Suggestion | `⌥{` | +| Next Suggestion | `⌥>` | +| Previous Suggestion | `⌥<` | +| Open Chat | `⌥"` | +| Explain Selection | `⌥\|` | Essentially using `⌥⇧` as the "access" key combination for all bindings. @@ -310,13 +310,16 @@ These features are included in another repo, and are not open sourced. The currently available Plus features include: -- Browser tap in chat panel. +- 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 opened the host app or the service app if a license key is available +- 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. diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 88bbb7a9..d45389dd 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -119,6 +119,13 @@ "identifier" : "ASTParserTests", "name" : "ASTParserTests" } + }, + { + "target" : { + "containerPath" : "container:Pro", + "identifier" : "ChatTabPersistentTests", + "name" : "ChatTabPersistentTests" + } } ], "version" : 1 diff --git a/Tool/Package.swift b/Tool/Package.swift index 2c819037..b595a4de 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -20,6 +20,9 @@ let package = Package( .library(name: "Toast", targets: ["Toast"]), .library(name: "Keychain", targets: ["Keychain"]), .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), + .library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]), + .library(name: "CGEventObserver", targets: ["CGEventObserver"]), + .library(name: "Workspace", targets: ["Workspace"]), .library( name: "AppMonitoring", targets: [ @@ -39,8 +42,7 @@ let package = Package( .package(url: "https://github.com/ChimeHQ/JSONRPC", exact: "0.6.0"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), .package(url: "https://github.com/unum-cloud/usearch", from: "0.19.1"), - .package(url: "https://github.com/raspu/Highlightr", from: "2.1.0"), - .package(url: "https://github.com/JohnSundell/Splash", branch: "master"), + .package(url: "https://github.com/intitni/Highlightr", branch: "bump-highlight-js-version"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.55.0" @@ -143,12 +145,13 @@ let package = Package( .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] ), - + + .target(name: "UserDefaultsObserver"), + .target( name: "SharedUIComponents", dependencies: [ "Highlightr", - "Splash", "Preferences", ] ), @@ -163,6 +166,24 @@ let package = Package( .testTarget(name: "ASTParserTests", dependencies: ["ASTParser"]), + .target( + name: "Workspace", + dependencies: [ + "UserDefaultsObserver", + "SuggestionModel", + "Environment", + "Logger", + "Preferences", + ] + ), + + .target( + name: "CGEventObserver", + dependencies: [ + "Logger", + ] + ), + // MARK: - Services .target( diff --git a/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift b/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift index c2910807..9dc7e6ee 100644 --- a/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift +++ b/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift @@ -1,11 +1,14 @@ import AppKit public final class ActiveApplicationMonitor { - static let shared = ActiveApplicationMonitor() - var latestXcode: NSRunningApplication? = NSWorkspace.shared.runningApplications + public static let shared = ActiveApplicationMonitor() + public private(set) var latestXcode: NSRunningApplication? = NSWorkspace.shared + .runningApplications .first(where: \.isXcode) - var previousApp: NSRunningApplication? - var activeApplication = NSWorkspace.shared.runningApplications.first(where: \.isActive) { + public private(set) var previousApp: NSRunningApplication? + public private(set) var activeApplication = NSWorkspace.shared.runningApplications + .first(where: \.isActive) + { didSet { if activeApplication?.isXcode ?? false { latestXcode = activeApplication @@ -36,27 +39,23 @@ public final class ActiveApplicationMonitor { } } - public static var activeApplication: NSRunningApplication? { shared.activeApplication } - - public static var previousActiveApplication: NSRunningApplication? { shared.previousApp } - - public static var activeXcode: NSRunningApplication? { + public var activeXcode: NSRunningApplication? { if activeApplication?.isXcode ?? false { return activeApplication } return nil } - public static var latestXcode: NSRunningApplication? { shared.latestXcode } - - public static func createStream() -> AsyncStream { + public func createStream() -> AsyncStream { .init { continuation in let id = UUID() - ActiveApplicationMonitor.shared.addContinuation(continuation, id: id) - continuation.onTermination = { _ in - ActiveApplicationMonitor.shared.removeContinuation(id: id) + Task { @MainActor in + continuation.onTermination = { _ in + self.removeContinuation(id: id) + } + addContinuation(continuation, id: id) + continuation.yield(activeApplication) } - continuation.yield(activeApplication) } } diff --git a/Tool/Sources/BingSearchService/BingSearchService.swift b/Tool/Sources/BingSearchService/BingSearchService.swift index 13fe2b18..4cc4b88c 100644 --- a/Tool/Sources/BingSearchService/BingSearchService.swift +++ b/Tool/Sources/BingSearchService/BingSearchService.swift @@ -37,7 +37,7 @@ enum BingSearchError: Error, LocalizedError { case let .searchURLFormatIncorrect(url): return "The search URL format is incorrect: \(url)" case .subscriptionKeyNotAvailable: - return "The user doesn't provide a subscription key to use Bing search." + return "The I didn't provide a subscription key to use Bing search." } } } diff --git a/Core/Sources/CGEventObserver/CGEventObserver.swift b/Tool/Sources/CGEventObserver/CGEventObserver.swift similarity index 100% rename from Core/Sources/CGEventObserver/CGEventObserver.swift rename to Tool/Sources/CGEventObserver/CGEventObserver.swift diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index f44617ce..4103f0e4 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import Foundation import SwiftUI +/// The information of a tab. public struct ChatTabInfo: Identifiable, Equatable { public var id: String public var title: String @@ -12,63 +13,70 @@ public struct ChatTabInfo: Identifiable, Equatable { } } -public struct ChatTabInfoPreferenceKey: PreferenceKey { - public static var defaultValue: [ChatTabInfo] = [] - public static func reduce(value: inout [ChatTabInfo], nextValue: () -> [ChatTabInfo]) { - value.append(contentsOf: nextValue()) - } -} - /// Every chat tab should conform to this type. public typealias ChatTab = BaseChatTab & ChatTabType -/// The base class for all chat tabs. -open class BaseChatTab: Equatable { - /// To support dynamic update of title in view. - final class InfoObservable: ObservableObject { - @Published var id: String - @Published var title: String - init(id: String, title: String) { - self.title = title - self.id = id - } - } +/// 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 + /// Build the menu for this chat tab. + @ViewBuilder + func buildMenu() -> any View + /// The name of this chat tab type. + static var name: String { get } + /// Available builders for this chat tab. + /// It's used to generate a list of tab types for user to create. + static func chatBuilders(externalDependency: ExternalDependency) -> [ChatTabBuilder] + /// Restorable state + func restorableState() async -> Data + /// Restore state + static func restore( + from data: Data, + externalDependency: ExternalDependency + ) 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() +} +/// The base class for all chat tabs. +open class BaseChatTab { /// A wrapper to support dynamic update of title in view. struct ContentView: View { - @ObservedObject var info: InfoObservable var buildView: () -> any View var body: some View { AnyView(buildView()) - .preference( - key: ChatTabInfoPreferenceKey.self, - value: [ChatTabInfo( - id: info.id, - title: info.title - )] - ) } } - public let id: String - public var title: String { - didSet { info.title = title } - } - - let info: InfoObservable + public var id: String { chatTabViewStore.id } + public var title: String { chatTabViewStore.title } + /// 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 - public init(id: String, title: String) { - self.id = id - self.title = title - info = InfoObservable(id: id, title: title) + public init(store: StoreOf) { + chatTabStore = store + chatTabViewStore = ViewStore(store) } /// The view for this chat tab. @ViewBuilder public var body: some View { - let id = "ChatTabBody\(info.id)" + let id = "ChatTabBody\(id)" if let tab = self as? (any ChatTabType) { - ContentView(info: info, buildView: tab.buildView).id(id) + ContentView(buildView: tab.buildView).id(id) + .onAppear { + Task { @MainActor in self.startIfNotStarted() } + } } else { EmptyView().id(id) } @@ -77,16 +85,25 @@ open class BaseChatTab: Equatable { /// The menu for this chat tab. @ViewBuilder public var menu: some View { - let id = "ChatTabMenu\(info.id)" + let id = "ChatTabMenu\(id)" if let tab = self as? (any ChatTabType) { - ContentView(info: info, buildView: tab.buildMenu).id(id) + ContentView(buildView: tab.buildMenu).id(id) + .onAppear { + Task { @MainActor in self.startIfNotStarted() } + } } else { EmptyView().id(id) } } - public static func == (lhs: BaseChatTab, rhs: BaseChatTab) -> Bool { - lhs.id == rhs.id + @MainActor + func startIfNotStarted() { + guard !didStart else { return } + didStart = true + + if let tab = self as? (any ChatTabType) { + tab.start() + } } } @@ -94,17 +111,15 @@ open class BaseChatTab: Equatable { public protocol ChatTabBuilder { /// A visible title for user. var title: String { get } - /// whether the chat tab is buildable. - var buildable: Bool { get } /// Build the chat tab. - func build() -> any ChatTab + func build(store: StoreOf) async -> (any ChatTab)? } +/// A chat tab builder that doesn't build. public struct DisabledChatTabBuilder: ChatTabBuilder { public var title: String - public var buildable: Bool { false } - public func build() -> any ChatTab { - EmptyChatTab(id: UUID().uuidString) + public func build(store: StoreOf) async -> (any ChatTab)? { + return nil } public init(title: String) { @@ -112,20 +127,9 @@ public struct DisabledChatTabBuilder: ChatTabBuilder { } } -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 - /// Build the menu for this chat tab. - @ViewBuilder - func buildMenu() -> any View - /// The name of this chat tab. - 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] +public extension ChatTabType { + /// The name of this chat tab type. + var name: String { Self.name } } public extension ChatTabType where ExternalDependency == Void { @@ -136,14 +140,14 @@ public extension ChatTabType where ExternalDependency == Void { } } +/// A chat tab that does nothing. public class EmptyChatTab: ChatTab { public static var name: String { "Empty" } struct Builder: ChatTabBuilder { let title: String - var buildable: Bool { true } - func build() -> any ChatTab { - EmptyChatTab() + func build(store: StoreOf) async -> (any ChatTab)? { + EmptyChatTab(store: store) } } @@ -162,8 +166,26 @@ public class EmptyChatTab: ChatTab { EmptyView() } - public init(id: String = UUID().uuidString) { - super.init(id: id, title: "Empty") + public func restorableState() async -> Data { + return Data() + } + + public static func restore( + from data: Data, + externalDependency: Void + ) async throws -> any ChatTabBuilder { + return Builder(title: "Empty") + } + + public convenience init(id: String) { + self.init(store: .init( + initialState: .init(id: id, title: "Empty-\(id)"), + reducer: ChatTabItem() + )) + } + + public func start() { + chatTabViewStore.send(.updateTitle("Empty-\(id)")) } } diff --git a/Tool/Sources/ChatTab/ChatTabItem.swift b/Tool/Sources/ChatTab/ChatTabItem.swift new file mode 100644 index 00000000..b74063cf --- /dev/null +++ b/Tool/Sources/ChatTab/ChatTabItem.swift @@ -0,0 +1,41 @@ +import ComposableArchitecture +import Foundation + +public struct AnyChatTabBuilder: Equatable { + public static func == (lhs: AnyChatTabBuilder, rhs: AnyChatTabBuilder) -> Bool { + true + } + + public let chatTabBuilder: any ChatTabBuilder + + public init(_ chatTabBuilder: any ChatTabBuilder) { + self.chatTabBuilder = chatTabBuilder + } +} + +public struct ChatTabItem: ReducerProtocol { + public typealias State = ChatTabInfo + + public enum Action: Equatable { + case updateTitle(String) + case openNewTab(AnyChatTabBuilder) + case tabContentUpdated + } + + public init() {} + + public var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case let .updateTitle(title): + state.title = title + return .none + case .openNewTab: + return .none + case .tabContentUpdated: + return .none + } + } + } +} + diff --git a/Tool/Sources/ChatTab/ChatTabPool.swift b/Tool/Sources/ChatTab/ChatTabPool.swift new file mode 100644 index 00000000..db5424a2 --- /dev/null +++ b/Tool/Sources/ChatTab/ChatTabPool.swift @@ -0,0 +1,54 @@ +import ComposableArchitecture +import Dependencies +import Foundation +import SwiftUI + +/// A pool that stores all the available tabs. +public final class ChatTabPool { + public var createStore: (String) -> StoreOf = { id in + .init( + initialState: .init(id: id, title: ""), + reducer: ChatTabItem() + ) + } + + private var pool: [String: any ChatTab] + + public init(_ pool: [String: any ChatTab] = [:]) { + self.pool = pool + } + + public func getTab(of id: String) -> (any ChatTab)? { + pool[id] + } + + public func setTab(_ tab: any ChatTab) { + pool[tab.id] = tab + } + + public func removeTab(of id: String) { + pool.removeValue(forKey: id) + } +} + +public struct ChatTabPoolDependencyKey: DependencyKey { + public static let liveValue = ChatTabPool() +} + +public extension DependencyValues { + var chatTabPool: ChatTabPool { + get { self[ChatTabPoolDependencyKey.self] } + set { self[ChatTabPoolDependencyKey.self] = newValue } + } +} + +public struct ChatTabPoolEnvironmentKey: EnvironmentKey { + public static let defaultValue = ChatTabPool() +} + +public extension EnvironmentValues { + var chatTabPool: ChatTabPool { + get { self[ChatTabPoolEnvironmentKey.self] } + set { self[ChatTabPoolEnvironmentKey.self] = newValue } + } +} diff --git a/Tool/Sources/Environment/Environment.swift b/Tool/Sources/Environment/Environment.swift index b36fc35c..97b762fe 100644 --- a/Tool/Sources/Environment/Environment.swift +++ b/Tool/Sources/Environment/Environment.swift @@ -25,7 +25,7 @@ public enum Environment { public static var now = { Date() } public static var isXcodeActive: () async -> Bool = { - ActiveApplicationMonitor.activeXcode != nil + ActiveApplicationMonitor.shared.activeXcode != nil } public static var frontmostXcodeWindowIsEditor: () async -> Bool = { @@ -43,8 +43,8 @@ public enum Environment { } public static var fetchCurrentProjectRootURLFromXcode: () async throws -> URL? = { - if let xcode = ActiveApplicationMonitor.activeXcode - ?? ActiveApplicationMonitor.latestXcode + if let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode { let application = AXUIElementCreateApplication(xcode.processIdentifier) let focusedWindow = application.focusedWindow @@ -84,8 +84,8 @@ public enum Environment { } public static var fetchCurrentFileURL: () async throws -> URL = { - guard let xcode = ActiveApplicationMonitor.activeXcode - ?? ActiveApplicationMonitor.latestXcode + guard let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode else { throw FailedToFetchFileURLError() } @@ -111,8 +111,8 @@ public enum Environment { } public static var fetchFocusedElementURI: () async throws -> URL = { - guard let xcode = ActiveApplicationMonitor.activeXcode - ?? ActiveApplicationMonitor.latestXcode + guard let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode else { return URL(fileURLWithPath: "/global") } let application = AXUIElementCreateApplication(xcode.processIdentifier) @@ -134,8 +134,8 @@ public enum Environment { } public static var triggerAction: (_ name: String) async throws -> Void = { name in - guard let activeXcode = ActiveApplicationMonitor.activeXcode - ?? ActiveApplicationMonitor.latestXcode + guard let activeXcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode else { return } let bundleName = Bundle.main .object(forInfoDictionaryKey: "EXTENSION_BUNDLE_NAME") as! String diff --git a/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift b/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift index 4af4b73e..41829694 100644 --- a/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift +++ b/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift @@ -102,7 +102,7 @@ public final class RefineDocumentChain: Chain { content: { if let previousAnswer = input.previousAnswer { return """ - The user will send you a question about a document, you must refine your previous answer to it only according to the document. + I will send you a question about a document, you must refine your previous answer to it only according to the document. Previous answer:### \(previousAnswer) ### @@ -112,7 +112,7 @@ public final class RefineDocumentChain: Chain { """ } else { return """ - The user will send you a question about a document, you must answer it only according to the document. + I will send you a question about a document, you must answer it only according to the document. Page \(input.index) of \(input.totalCount) of the document:### \(input.document) ### diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 6a6f5709..406dafe4 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -182,7 +182,7 @@ extension ChatGPTService { func sendMemory() async throws -> AsyncThrowingStream { guard let url = URL(string: configuration.endpoint) else { throw ChatGPTServiceError.endpointIncorrect } - + await memory.refresh() let messages = await memory.messages.map { @@ -282,7 +282,7 @@ extension ChatGPTService { func sendMemoryAndWait() async throws -> ChatMessage? { guard let url = URL(string: configuration.endpoint) else { throw ChatGPTServiceError.endpointIncorrect } - + await memory.refresh() let messages = await memory.messages.map { @@ -368,16 +368,7 @@ extension ChatGPTService { let messageId = messageId ?? uuidGenerator() guard var function = functionProvider.function(named: call.name) else { - let content = "Error: function not found" - let responseMessage = ChatMessage( - id: messageId, - role: .function, - content: content, - name: call.name, - summary: "Function `\(call.name)` not found." - ) - await memory.appendMessage(responseMessage) - return content + return await fallbackFunctionCall(call, messageId: messageId) } // Insert the chat message into memory to indicate the start of the function. @@ -414,6 +405,56 @@ extension ChatGPTService { return content } } + + /// Mock a function call result when the bot is calling a function that is not implemented. + func fallbackFunctionCall( + _ call: ChatMessage.FunctionCall, + messageId: String + ) async -> String { + let memory = ConversationChatGPTMemory(systemPrompt: { + if call.name == "python" { + return """ + Act like a Python interpreter. + I will give you Python code and you will execute it. + Reply with output of the code and tell me it's an answer generated by LLM. + """ + } else { + return """ + You are a function simulator. Your name is \(call.name). + Act like a function. + I will send you the arguments. + Reply with output of the function and tell me it's an answer generated by LLM. + """ + } + }()) + + let service = ChatGPTService( + memory: memory, + configuration: OverridingChatGPTConfiguration(overriding: configuration, with: .init( + temperature: 0 + )), + functionProvider: NoChatGPTFunctionProvider() + ) + + let content: String = await { + do { + return try await service.sendAndWait(content: """ + \(call.arguments) + """) ?? "No result." + } catch { + return "No result." + } + }() + let responseMessage = ChatMessage( + id: messageId, + role: .function, + content: content, + name: call.name, + summary: "Finished running function." + ) + await memory.appendMessage(responseMessage) + return content + } } extension ChatGPTService { @@ -430,5 +471,5 @@ func maxTokenForReply(model: String, remainingTokens: Int?) -> Int? { guard let remainingTokens else { return nil } guard let model = ChatGPTModel(rawValue: model) else { return remainingTokens } return min(model.maxToken / 2, remainingTokens) -} +} diff --git a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift index f446ea2d..aa93a61e 100644 --- a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift @@ -39,15 +39,15 @@ public extension ChatGPTConfiguration { } func overriding( - _ overrides: OverridingChatGPTConfiguration.Overriding - ) -> OverridingChatGPTConfiguration { + _ overrides: OverridingChatGPTConfiguration.Overriding + ) -> OverridingChatGPTConfiguration { .init(overriding: self, with: overrides) } func overriding( - _ update: (inout OverridingChatGPTConfiguration.Overriding) -> Void = { _ in } - ) -> OverridingChatGPTConfiguration { - var overrides = OverridingChatGPTConfiguration.Overriding() + _ update: (inout OverridingChatGPTConfiguration.Overriding) -> Void = { _ in } + ) -> OverridingChatGPTConfiguration { + var overrides = OverridingChatGPTConfiguration.Overriding() update(&overrides) return .init(overriding: self, with: overrides) } diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift index c600de55..d4ce6ed2 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift @@ -43,10 +43,8 @@ public struct UserPreferenceChatGPTConfiguration: ChatGPTConfiguration { public init() {} } -public class OverridingChatGPTConfiguration< - Configuration: ChatGPTConfiguration ->: ChatGPTConfiguration { - public struct Overriding { +public class OverridingChatGPTConfiguration: ChatGPTConfiguration { + public struct Overriding: Codable { public var featureProvider: ChatFeatureProvider? public var temperature: Double? public var model: String? @@ -80,10 +78,13 @@ public class OverridingChatGPTConfiguration< } } - private let configuration: Configuration + private let configuration: ChatGPTConfiguration public var overriding = Overriding() - public init(overriding configuration: Configuration, with overrides: Overriding = .init()) { + public init( + overriding configuration: any ChatGPTConfiguration, + with overrides: Overriding = .init() + ) { overriding = overrides self.configuration = configuration } @@ -123,7 +124,7 @@ public class OverridingChatGPTConfiguration< public var minimumReplyTokens: Int { overriding.minimumReplyTokens ?? configuration.minimumReplyTokens } - + public var runFunctionsAutomatically: Bool { overriding.runFunctionsAutomatically ?? configuration.runFunctionsAutomatically } diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 218bf5fe..57a4490a 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -217,6 +217,10 @@ public extension UserDefaultPreferenceKeys { var realtimeSuggestionToggle: PreferenceKey { .init(defaultValue: true, key: "RealtimeSuggestionToggle") } + + var suggestionDisplayCompactMode: PreferenceKey { + .init(defaultValue: false, key: "SuggestionDisplayCompactMode") + } var suggestionCodeFontSize: PreferenceKey { .init(defaultValue: 13, key: "SuggestionCodeFontSize") @@ -245,6 +249,10 @@ public extension UserDefaultPreferenceKeys { var realtimeSuggestionDebounce: PreferenceKey { .init(defaultValue: 0, key: "RealtimeSuggestionDebounce") } + + var acceptSuggestionWithTab: PreferenceKey { + .init(defaultValue: false, key: "AcceptSuggestionWithTab") + } } // MARK: - Chat @@ -405,5 +413,9 @@ public extension UserDefaultPreferenceKeys { var disableFunctionCalling: FeatureFlag { .init(defaultValue: false, key: "FeatureFlag-DisableFunctionCalling") } + + var disableGitHubCopilotSettingsAutoRefreshOnAppear: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-DisableGitHubCopilotSettingsAutoRefreshOnAppear") + } } diff --git a/Tool/Sources/Preferences/Types/ChatFeatureProvider.swift b/Tool/Sources/Preferences/Types/ChatFeatureProvider.swift index d97a4238..ac23afb3 100644 --- a/Tool/Sources/Preferences/Types/ChatFeatureProvider.swift +++ b/Tool/Sources/Preferences/Types/ChatFeatureProvider.swift @@ -1,4 +1,4 @@ -public enum ChatFeatureProvider: String, CaseIterable { +public enum ChatFeatureProvider: String, CaseIterable, Codable { case openAI case azureOpenAI } diff --git a/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift index eca3bb89..db01ac70 100644 --- a/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift +++ b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift @@ -1,7 +1,6 @@ import AppKit import Foundation import Highlightr -import Splash import SwiftUI public func highlightedCodeBlock( @@ -10,98 +9,32 @@ public func highlightedCodeBlock( brightMode: Bool, fontSize: Double ) -> NSAttributedString { - switch language { - case "swift": - let plainTextColor = brightMode - ? .black - : #colorLiteral(red: 0.6509803922, green: 0.6980392157, blue: 0.7529411765, alpha: 1) - let highlighter = - SyntaxHighlighter( - format: AttributedStringOutputFormat(theme: .init( - font: .init(size: fontSize), - plainTextColor: plainTextColor, - tokenColors: brightMode - ? [ - .keyword: #colorLiteral(red: 0.6078431373, green: 0.137254902, blue: 0.5764705882, alpha: 1), - .string: #colorLiteral(red: 0.1371159852, green: 0.3430536985, blue: 0.362406373, alpha: 1), - .type: #colorLiteral(red: 0.2456904352, green: 0.5002114773, blue: 0.5297455192, alpha: 1), - .call: #colorLiteral(red: 0.1960784314, green: 0.4274509804, blue: 0.4549019608, alpha: 1), - .number: #colorLiteral(red: 0.4385872483, green: 0.4995297194, blue: 0.5483990908, alpha: 1), - .comment: #colorLiteral(red: 0.3647058824, green: 0.4235294118, blue: 0.4745098039, alpha: 1), - .property: #colorLiteral(red: 0.1960784314, green: 0.4274509804, blue: 0.4549019608, alpha: 1), - .dotAccess: #colorLiteral(red: 0.1960784314, green: 0.4274509804, blue: 0.4549019608, alpha: 1), - .preprocessing: #colorLiteral(red: 0.3921568627, green: 0.2196078431, blue: 0.1254901961, alpha: 1), - ] : [ - .keyword: #colorLiteral(red: 0.8258609176, green: 0.5708742738, blue: 0.8922662139, alpha: 1), - .string: #colorLiteral(red: 0.6253595352, green: 0.7963448763, blue: 0.5427476764, alpha: 1), - .type: #colorLiteral(red: 0.9221783876, green: 0.7978314757, blue: 0.5575165749, alpha: 1), - .call: #colorLiteral(red: 0.4466812611, green: 0.742190659, blue: 0.9515134692, alpha: 1), - .number: #colorLiteral(red: 0.8620631099, green: 0.6468816996, blue: 0.4395158887, alpha: 1), - .comment: #colorLiteral(red: 0.4233166873, green: 0.4612616301, blue: 0.5093258619, alpha: 1), - .property: #colorLiteral(red: 0.906378448, green: 0.5044228435, blue: 0.5263597369, alpha: 1), - .dotAccess: #colorLiteral(red: 0.906378448, green: 0.5044228435, blue: 0.5263597369, alpha: 1), - .preprocessing: #colorLiteral(red: 0.3776347041, green: 0.8792117238, blue: 0.4709561467, alpha: 1), - ] - )) - ) - let formatted = NSMutableAttributedString(attributedString: highlighter.highlight(code)) - formatted.addAttributes( - [.font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)], - range: NSRange(location: 0, length: formatted.length) + var language = language + // Workaround: Highlightr uses a different identifier for Objective-C. + if language.lowercased().hasPrefix("objective"), language.lowercased().hasSuffix("c") { + language = "objectivec" + } + func unhighlightedCode() -> NSAttributedString { + return NSAttributedString( + string: code, + attributes: [ + .foregroundColor: brightMode ? NSColor.black : NSColor.white, + .font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular), + ] ) - func leadingSpacesInCode(_ code: String) -> Int { - var leadingSpaces = 0 - for char in code { - if char == " " { - leadingSpaces += 1 - } else { - break - } - } - return leadingSpaces - } - - // Workaround: Splash has a bug that will insert an extra space at the beginning. - let leadingSpaces = leadingSpacesInCode(code) - let leadingSpacesFormatted = leadingSpacesInCode(formatted.string) - let diff = leadingSpacesFormatted - leadingSpaces - if diff > 0 { - formatted.mutableString.replaceCharacters( - in: .init(location: 0, length: diff), - with: "" - ) - } - // End of workaround. - - return formatted - default: - var language = language - // Workaround: Highlightr uses a different identifier for Objective-C. - if language.lowercased().hasPrefix("objective"), language.lowercased().hasSuffix("c") { - language = "objectivec" - } - func unhighlightedCode() -> NSAttributedString { - return NSAttributedString( - string: code, - attributes: [ - .foregroundColor: brightMode ? NSColor.black : NSColor.white, - .font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular), - ] - ) - } - guard let highlighter = Highlightr() else { - return unhighlightedCode() - } - highlighter.setTheme(to: brightMode ? "xcode" : "atom-one-dark") - highlighter.theme.setCodeFont(.monospacedSystemFont(ofSize: fontSize, weight: .regular)) - guard let formatted = highlighter.highlight(code, as: language) else { - return unhighlightedCode() - } - if formatted.string == "undefined" { - return unhighlightedCode() - } - return formatted } + guard let highlighter = Highlightr() else { + return unhighlightedCode() + } + highlighter.setTheme(to: brightMode ? "xcode" : "atom-one-dark") + highlighter.theme.setCodeFont(.monospacedSystemFont(ofSize: fontSize, weight: .regular)) + guard let formatted = highlighter.highlight(code, as: language) else { + return unhighlightedCode() + } + if formatted.string == "undefined" { + return unhighlightedCode() + } + return formatted } public func highlighted( diff --git a/Tool/Sources/SuggestionModel/ExportedFromLSP.swift b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift index ac49e250..9d7d364b 100644 --- a/Tool/Sources/SuggestionModel/ExportedFromLSP.swift +++ b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift @@ -7,7 +7,7 @@ public extension CursorPosition { static var outOfScope: CursorPosition { .init(line: -1, character: -1) } var readableText: String { - return "[\(line), \(character)]" + return "[\(line + 1), \(character)]" } } diff --git a/Tool/Sources/UserDefaultsObserver/UserDefaultsObserver.swift b/Tool/Sources/UserDefaultsObserver/UserDefaultsObserver.swift new file mode 100644 index 00000000..62ecce3f --- /dev/null +++ b/Tool/Sources/UserDefaultsObserver/UserDefaultsObserver.swift @@ -0,0 +1,36 @@ +import Foundation + +public final class UserDefaultsObserver: NSObject { + public var onChange: (() -> Void)? + private weak var object: NSObject? + private let keyPaths: [String] + + public init( + object: NSObject, + forKeyPaths keyPaths: [String], + context: UnsafeMutableRawPointer? + ) { + self.object = object + self.keyPaths = keyPaths + super.init() + for keyPath in keyPaths { + object.addObserver(self, forKeyPath: keyPath, options: .new, context: context) + } + } + + deinit { + for keyPath in keyPaths { + object?.removeObserver(self, forKeyPath: keyPath) + } + } + + public override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, + context: UnsafeMutableRawPointer? + ) { + onChange?() + } +} + diff --git a/Core/Sources/Service/FileSaveWatcher.swift b/Tool/Sources/Workspace/FileSaveWatcher.swift similarity index 100% rename from Core/Sources/Service/FileSaveWatcher.swift rename to Tool/Sources/Workspace/FileSaveWatcher.swift diff --git a/Tool/Sources/Workspace/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift new file mode 100644 index 00000000..deb5b90a --- /dev/null +++ b/Tool/Sources/Workspace/Filespace.swift @@ -0,0 +1,129 @@ +import Environment +import Foundation +import SuggestionModel + +public protocol FilespacePropertyKey { + associatedtype Value + static func createDefaultValue() -> Value +} + +public final class FilespacePropertyValues { + var storage: [ObjectIdentifier: Any] = [:] + + public subscript(_ key: K.Type) -> K.Value { + get { + if let value = storage[ObjectIdentifier(key)] as? K.Value { + return value + } + let value = key.createDefaultValue() + storage[ObjectIdentifier(key)] = value + return value + } + set { + storage[ObjectIdentifier(key)] = newValue + } + } +} + +public struct FilespaceCodeMetadata: Equatable { + public var uti: String? + public var tabSize: Int? + public var indentSize: Int? + public var usesTabsForIndentation: Bool? + + init( + uti: String? = nil, + tabSize: Int? = nil, + indentSize: Int? = nil, + usesTabsForIndentation: Bool? = nil + ) { + self.uti = uti + self.tabSize = tabSize + self.indentSize = indentSize + self.usesTabsForIndentation = usesTabsForIndentation + } +} + +@dynamicMemberLookup +public final class Filespace { + public let fileURL: URL + public private(set) lazy var language: String = languageIdentifierFromFileURL(fileURL).rawValue + public var codeMetadata: FilespaceCodeMetadata = .init() + public private(set) var suggestions: [CodeSuggestion] = [] { + didSet { refreshUpdateTime() } + } + + public private(set) var suggestionIndex: Int = 0 + + public var presentingSuggestion: CodeSuggestion? { + guard suggestions.endIndex > suggestionIndex, suggestionIndex >= 0 else { return nil } + return suggestions[suggestionIndex] + } + + public var isExpired: Bool { + Environment.now().timeIntervalSince(lastSuggestionUpdateTime) > 60 * 3 + } + + private(set) var lastSuggestionUpdateTime: Date = Environment.now() + var additionalProperties = FilespacePropertyValues() + let fileSaveWatcher: FileSaveWatcher + let onClose: (URL) -> Void + + deinit { + onClose(fileURL) + } + + init( + fileURL: URL, + onSave: @escaping (Filespace) -> Void, + onClose: @escaping (URL) -> Void + ) { + self.fileURL = fileURL + self.onClose = onClose + fileSaveWatcher = .init(fileURL: fileURL) + fileSaveWatcher.changeHandler = { [weak self] in + guard let self else { return } + onSave(self) + } + } + + public subscript( + dynamicMember dynamicMember: WritableKeyPath + ) -> K { + get { additionalProperties[keyPath: dynamicMember] } + set { additionalProperties[keyPath: dynamicMember] = newValue } + } + + @WorkspaceActor + public func reset() { + suggestions = [] + suggestionIndex = 0 + } + + public func refreshUpdateTime() { + lastSuggestionUpdateTime = Environment.now() + } + + @WorkspaceActor + public func setSuggestions(_ suggestions: [CodeSuggestion]) { + self.suggestions = suggestions + suggestionIndex = 0 + } + + @WorkspaceActor + public func nextSuggestion() { + suggestionIndex += 1 + if suggestionIndex >= suggestions.endIndex { + suggestionIndex = 0 + } + } + + @WorkspaceActor + public func previousSuggestion() { + suggestionIndex -= 1 + if suggestionIndex < 0 { + suggestionIndex = suggestions.endIndex - 1 + } + } +} + diff --git a/Core/Sources/Service/OpenedFileRocoverableStorage.swift b/Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift similarity index 85% rename from Core/Sources/Service/OpenedFileRocoverableStorage.swift rename to Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift index ad715141..d5661941 100644 --- a/Core/Sources/Service/OpenedFileRocoverableStorage.swift +++ b/Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift @@ -1,8 +1,7 @@ import Foundation import Preferences -@ServiceActor -final class OpenedFileRecoverableStorage { +public final class OpenedFileRecoverableStorage { let projectRootURL: URL let userDefault = UserDefaults.shared let key = "OpenedFileRecoverableStorage" @@ -11,7 +10,7 @@ final class OpenedFileRecoverableStorage { self.projectRootURL = projectRootURL } - func openFile(fileURL: URL) { + public func openFile(fileURL: URL) { var dict = userDefault.dictionary(forKey: key) ?? [:] var openedFiles = Set(dict[projectRootURL.path] as? [String] ?? []) openedFiles.insert(fileURL.path) @@ -19,7 +18,7 @@ final class OpenedFileRecoverableStorage { userDefault.set(dict, forKey: key) } - func closeFile(fileURL: URL) { + public func closeFile(fileURL: URL) { var dict = userDefault.dictionary(forKey: key) ?? [:] var openedFiles = dict[projectRootURL.path] as? [String] ?? [] openedFiles.removeAll(where: { $0 == fileURL.path }) @@ -27,7 +26,7 @@ final class OpenedFileRecoverableStorage { userDefault.set(dict, forKey: key) } - var openedFiles: [URL] { + public var openedFiles: [URL] { let dict = userDefault.dictionary(forKey: key) ?? [:] let openedFiles = dict[projectRootURL.path] as? [String] ?? [] return openedFiles.map { URL(fileURLWithPath: $0) } diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift new file mode 100644 index 00000000..e6ffb14b --- /dev/null +++ b/Tool/Sources/Workspace/Workspace.swift @@ -0,0 +1,137 @@ +import Environment +import Foundation +import Preferences +import SuggestionModel +import UserDefaultsObserver + +public protocol WorkspacePropertyKey { + associatedtype Value + static func createDefaultValue() -> Value +} + +public class WorkspacePropertyValues { + var storage: [ObjectIdentifier: Any] = [:] + + public subscript(_ key: K.Type) -> K.Value { + get { + if let value = storage[ObjectIdentifier(key)] as? K.Value { + return value + } + let value = key.createDefaultValue() + storage[ObjectIdentifier(key)] = value + return value + } + set { + storage[ObjectIdentifier(key)] = newValue + } + } +} + +open class WorkspacePlugin { + public private(set) weak var workspace: Workspace? + public var projectRootURL: URL { workspace?.projectRootURL ?? URL(fileURLWithPath: "/") } + public var filespaces: [URL: Filespace] { workspace?.filespaces ?? [:] } + + public init(workspace: Workspace) { + self.workspace = workspace + } + + open func didOpenFilespace(_: Filespace) {} + open func didSaveFilespace(_: Filespace) {} + open func didCloseFilespace(_: URL) {} +} + +@dynamicMemberLookup +public final class Workspace { + public struct UnsupportedFileError: Error, LocalizedError { + public var extensionName: String + public var errorDescription: String? { + "File type \(extensionName) unsupported." + } + + public init(extensionName: String) { + self.extensionName = extensionName + } + } + + var additionalProperties = WorkspacePropertyValues() + public internal(set) var plugins = [ObjectIdentifier: WorkspacePlugin]() + public let projectRootURL: URL + public let openedFileRecoverableStorage: OpenedFileRecoverableStorage + public private(set) var lastSuggestionUpdateTime = Environment.now() + public var isExpired: Bool { + Environment.now().timeIntervalSince(lastSuggestionUpdateTime) > 60 * 60 * 1 + } + + public private(set) var filespaces = [URL: Filespace]() + + let userDefaultsObserver = UserDefaultsObserver( + object: UserDefaults.shared, forKeyPaths: [ + UserDefaultPreferenceKeys().suggestionFeatureEnabledProjectList.key, + UserDefaultPreferenceKeys().disableSuggestionFeatureGlobally.key, + ], context: nil + ) + + public subscript( + dynamicMember dynamicMember: WritableKeyPath + ) -> K { + get { additionalProperties[keyPath: dynamicMember] } + set { additionalProperties[keyPath: dynamicMember] = newValue } + } + + public func plugin(for type: P.Type) -> P? { + plugins[ObjectIdentifier(type)] as? P + } + + init(projectRootURL: URL) { + self.projectRootURL = projectRootURL + openedFileRecoverableStorage = .init(projectRootURL: projectRootURL) + let openedFiles = openedFileRecoverableStorage.openedFiles + Task { @WorkspaceActor in + for fileURL in openedFiles { + _ = createFilespaceIfNeeded(fileURL: fileURL) + } + } + } + + public func refreshUpdateTime() { + lastSuggestionUpdateTime = Environment.now() + } + + @WorkspaceActor + public func createFilespaceIfNeeded(fileURL: URL) -> Filespace { + let existedFilespace = filespaces[fileURL] + let filespace = existedFilespace ?? .init( + fileURL: fileURL, + onSave: { [weak self] filespace in + guard let self else { return } + for plugin in self.plugins.values { + plugin.didSaveFilespace(filespace) + } + }, + onClose: { [weak self] url in + guard let self else { return } + for plugin in self.plugins.values { + plugin.didCloseFilespace(url) + } + } + ) + if filespaces[fileURL] == nil { + filespaces[fileURL] = filespace + } + if existedFilespace == nil { + for plugin in plugins.values { + plugin.didOpenFilespace(filespace) + } + } else { + filespace.refreshUpdateTime() + } + return filespace + } + + @WorkspaceActor + public func closeFilespace(fileURL: URL) { + filespaces[fileURL] = nil + } +} + diff --git a/Tool/Sources/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift new file mode 100644 index 00000000..4f17941a --- /dev/null +++ b/Tool/Sources/Workspace/WorkspacePool.swift @@ -0,0 +1,132 @@ +import Environment +import Foundation + +@globalActor public enum WorkspaceActor { + public actor TheActor {} + public static let shared = TheActor() +} + +public class WorkspacePool { + public internal(set) var workspaces: [URL: Workspace] = [:] + var plugins = [ObjectIdentifier: (Workspace) -> WorkspacePlugin]() + + public init( + workspaces: [URL: Workspace] = [:], + plugins: [ObjectIdentifier: (Workspace) -> WorkspacePlugin] = [:] + ) { + self.workspaces = workspaces + self.plugins = plugins + } + + public func registerPlugin(_ plugin: @escaping (Workspace) -> Plugin) { + let id = ObjectIdentifier(Plugin.self) + let erasedPlugin: (Workspace) -> WorkspacePlugin = { plugin($0) } + plugins[id] = erasedPlugin + + for workspace in workspaces.values { + addPlugin(erasedPlugin, id: id, to: workspace) + } + } + + public func unregisterPlugin(_: Plugin.Type) { + let id = ObjectIdentifier(Plugin.self) + plugins[id] = nil + + for workspace in workspaces.values { + removePlugin(id: id, from: workspace) + } + } + + public func fetchFilespaceIfExisted(fileURL: URL) -> Filespace? { + for workspace in workspaces.values { + if let filespace = workspace.filespaces[fileURL] { + return filespace + } + } + return nil + } + + @WorkspaceActor + public func fetchOrCreateWorkspaceAndFilespace(fileURL: URL) async throws + -> (workspace: Workspace, filespace: Filespace) + { + let ignoreFileExtensions = ["mlmodel"] + if ignoreFileExtensions.contains(fileURL.pathExtension) { + throw Workspace.UnsupportedFileError(extensionName: fileURL.pathExtension) + } + + // If we know which project is opened. + if let currentProjectURL = try await Environment.fetchCurrentProjectRootURLFromXcode() { + if let existed = workspaces[currentProjectURL] { + let filespace = existed.createFilespaceIfNeeded(fileURL: fileURL) + return (existed, filespace) + } + + let new = createNewWorkspace(projectRootURL: currentProjectURL) + workspaces[currentProjectURL] = new + let filespace = new.createFilespaceIfNeeded(fileURL: fileURL) + return (new, filespace) + } + + // If not, we try to reuse a filespace if found. + // + // Sometimes, we can't get the project root path from Xcode window, for example, when the + // quick open window in displayed. + for workspace in workspaces.values { + if let filespace = workspace.filespaces[fileURL] { + return (workspace, filespace) + } + } + + // If we can't find an existed one, we will try to guess it. + // Most of the time we won't enter this branch, just incase. + + let workspaceURL = try await Environment.guessProjectRootURLForFile(fileURL) + + let workspace = { + if let existed = workspaces[workspaceURL] { + return existed + } + // Reuse existed workspace if possible + for (_, workspace) in workspaces { + if fileURL.path.hasPrefix(workspace.projectRootURL.path) { + return workspace + } + } + return createNewWorkspace(projectRootURL: workspaceURL) + }() + + let filespace = workspace.createFilespaceIfNeeded(fileURL: fileURL) + workspaces[workspaceURL] = workspace + workspace.refreshUpdateTime() + return (workspace, filespace) + } + + public func removeWorkspace(url: URL) { + workspaces[url] = nil + } +} + +extension WorkspacePool { + func addPlugin( + _ plugin: (Workspace) -> WorkspacePlugin, + id: ObjectIdentifier, + to workspace: Workspace + ) { + if workspace.plugins[id] != nil { return } + workspace.plugins[id] = plugin(workspace) + } + + func removePlugin(id: ObjectIdentifier, from workspace: Workspace) { + workspace.plugins[id] = nil + } + + func createNewWorkspace(projectRootURL: URL) -> Workspace { + let new = Workspace(projectRootURL: projectRootURL) + for (id, plugin) in plugins { + addPlugin(plugin, id: id, to: new) + } + return new + } +} + diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 862596e6..b7789da6 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -23,6 +23,10 @@ public final class XcodeInspector: ObservableObject { @Published public internal(set) var focusedElement: AXUIElement? @Published public internal(set) var completionPanel: AXUIElement? + public var realtimeActiveDocumentURL: URL { + latestActiveXcode?.realtimeDocumentURL ?? URL(fileURLWithPath: "/") + } + init() { let runningApplications = NSWorkspace.shared.runningApplications xcodes = runningApplications @@ -176,6 +180,17 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { @Published public private(set) var completionPanel: AXUIElement? + public var realtimeDocumentURL: URL { + guard let window = appElement.focusedWindow, + window.identifier == "Xcode.WorkspaceWindow" + else { + return URL(fileURLWithPath: "/") + } + + return WorkspaceXcodeWindowInspector.extractDocumentURL(windowElement: window) + ?? URL(fileURLWithPath: "/") + } + var _version: String? public var version: String? { if let _version { return _version } diff --git a/Version.xcconfig b/Version.xcconfig index 2d1eafad..4b0289ad 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,2 +1,2 @@ -APP_VERSION = 0.21.2 -APP_BUILD = 222 +APP_VERSION = 0.22.1 +APP_BUILD = 231 diff --git a/appcast.xml b/appcast.xml index df2e9022..42b41599 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,6 +3,30 @@ Copilot for Xcode + + 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