diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift index f7664357..8a064aef 100644 --- a/CommunicationBridge/ServiceDelegate.swift +++ b/CommunicationBridge/ServiceDelegate.swift @@ -69,6 +69,7 @@ actor EventHandler { reply(endpoint) #else if await launcher.isApplicationValid { + Logger.communicationBridge.info("Service app is still valid") reply(endpoint) } else { endpoint = nil @@ -116,7 +117,17 @@ actor ExtensionServiceLauncher { var isLaunching: Bool = false var application: NSRunningApplication? var isApplicationValid: Bool { - if let application, !application.isTerminated { return true } + guard let application else { return false } + if application.isTerminated { return false } + let identifier = application.processIdentifier + if let application = NSWorkspace.shared.runningApplications.first(where: { + $0.processIdentifier == identifier + }) { + Logger.communicationBridge.info( + "Service app found: \(application.processIdentifier) \(String(describing: application.bundleIdentifier))" + ) + return true + } return false } @@ -125,9 +136,16 @@ actor ExtensionServiceLauncher { isLaunching = true Logger.communicationBridge.info("Launching extension service app.") + NSWorkspace.shared.openApplication( at: appURL, - configuration: .init() + configuration: { + let configuration = NSWorkspace.OpenConfiguration() + configuration.createsNewApplicationInstance = false + configuration.addsToRecentItems = false + configuration.activates = false + return configuration + }() ) { app, error in if let error = error { Logger.communicationBridge.error( diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 2e36c5b3..04bebd6a 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -53,7 +53,7 @@ C882175C294187EF00A22FD3 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = C882175B294187EF00A22FD3 /* Client */; }; C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */; }; C8C8B60929AFA35F00034BEE /* CopilotForXcodeExtensionService.app in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - C8DCF00029CE11D500FDDDD7 /* ChatWithSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */; }; + C8DCF00029CE11D500FDDDD7 /* OpenChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */; }; C8DD9CB12BC673F80036641C /* CloseIdleTabsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */; }; /* End PBXBuildFile section */ @@ -236,7 +236,7 @@ C887BC832965D96000931567 /* DEVELOPMENT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPMENT.md; sourceTree = ""; }; C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Menu.swift"; sourceTree = ""; }; C8CD828229B88006008D044D /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = ""; }; - C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatWithSelection.swift; sourceTree = ""; }; + C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChat.swift; sourceTree = ""; }; C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseIdleTabsCommand.swift; sourceTree = ""; }; C8F103292A7A365000D28F4F /* launchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = launchAgent.plist; sourceTree = ""; }; /* End PBXFileReference section */ @@ -320,7 +320,7 @@ C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */, C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */, C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */, - C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */, + C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */, C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */, C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */, C81458972939EFDC00135263 /* Info.plist */, @@ -675,7 +675,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - C8DCF00029CE11D500FDDDD7 /* ChatWithSelection.swift in Sources */, + C8DCF00029CE11D500FDDDD7 /* OpenChat.swift in Sources */, C81458942939EFDC00135263 /* SourceEditorExtension.swift in Sources */, C8DD9CB12BC673F80036641C /* CloseIdleTabsCommand.swift in Sources */, C8758E7029F04BFF00D29C1C /* CustomCommand.swift in Sources */, diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/CommunicationBridge.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/CommunicationBridge.xcscheme new file mode 100644 index 00000000..578b11ea --- /dev/null +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/CommunicationBridge.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/SandboxedClientTester.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/SandboxedClientTester.xcscheme new file mode 100644 index 00000000..41fadd0b --- /dev/null +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/SandboxedClientTester.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Core/.swiftpm/xcode/xcshareddata/xcschemes/Core-Package.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme similarity index 84% rename from Core/.swiftpm/xcode/xcshareddata/xcschemes/Core-Package.xcscheme rename to Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme index b5513aeb..0deca224 100644 --- a/Core/.swiftpm/xcode/xcshareddata/xcschemes/Core-Package.xcscheme +++ b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme @@ -1,10 +1,11 @@ + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> @@ -49,9 +50,9 @@ diff --git a/Tool/.swiftpm/xcode/xcshareddata/xcschemes/Tool-Package.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme similarity index 84% rename from Tool/.swiftpm/xcode/xcshareddata/xcschemes/Tool-Package.xcscheme rename to Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme index 3bb0323b..25654d7d 100644 --- a/Tool/.swiftpm/xcode/xcshareddata/xcschemes/Tool-Package.xcscheme +++ b/Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme @@ -1,10 +1,11 @@ + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> @@ -49,9 +50,9 @@ diff --git a/Core/Package.swift b/Core/Package.swift index 1e1a1fcd..adef784a 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -93,10 +93,10 @@ let package = Package( .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.1.0"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), - .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.5.1"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", - from: "0.55.0" + from: "1.10.4" ), // quick hack to support custom UserDefaults // https://github.com/sindresorhus/KeyboardShortcuts @@ -126,6 +126,7 @@ let package = Package( "PromptToCodeService", "ServiceUpdateMigration", "ChatGPTChatTab", + "PlusFeatureFlag", .product(name: "XPCShared", package: "Tool"), .product(name: "SuggestionProvider", package: "Tool"), .product(name: "Workspace", package: "Tool"), @@ -184,6 +185,8 @@ let package = Package( .target( name: "SuggestionService", dependencies: [ + .product(name: "UserDefaultsObserver", package: "Tool"), + .product(name: "Preferences", package: "Tool"), .product(name: "SuggestionModel", package: "Tool"), .product(name: "SuggestionProvider", package: "Tool") ].pro([ diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift index 9b7f2ca8..80c4da5b 100644 --- a/Core/Sources/ChatGPTChatTab/Chat.swift +++ b/Core/Sources/ChatGPTChatTab/Chat.swift @@ -54,16 +54,18 @@ private var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } -struct Chat: ReducerProtocol { +@Reducer +struct Chat { public typealias MessageID = String + @ObservableState struct State: Equatable { var title: String = "Chat" - @BindingState var typedMessage = "" + var typedMessage = "" var history: [DisplayedChatMessage] = [] - @BindingState var isReceivingMessage = false + var isReceivingMessage = false var chatMenu = ChatMenu.State() - @BindingState var focusedField: Field? + var focusedField: Field? enum Field: String, Hashable { case textField @@ -115,7 +117,7 @@ struct Chat: ReducerProtocol { @Dependency(\.openURL) var openURL - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() Scope(state: \.chatMenu, action: /Action.chatMenu) { @@ -387,7 +389,9 @@ struct Chat: ReducerProtocol { } } -struct ChatMenu: ReducerProtocol { +@Reducer +struct ChatMenu { + @ObservableState struct State: Equatable { var systemPrompt: String = "" var extraSystemPrompt: String = "" @@ -409,7 +413,7 @@ struct ChatMenu: ReducerProtocol { let service: ChatService - var body: some ReducerProtocol { + var body: some ReducerOf { Reduce { state, action in switch action { case .appear: diff --git a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift index e6a3b2c4..768c064b 100644 --- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift +++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift @@ -8,8 +8,8 @@ struct ChatTabItemView: View { let chat: StoreOf var body: some View { - WithViewStore(chat, observe: \.title) { viewStore in - Text(viewStore.state) + WithPerceptionTracking { + Text(chat.title) } } } @@ -22,46 +22,44 @@ struct ChatContextMenu: View { @AppStorage(\.chatGPTTemperature) var defaultTemperature var body: some View { - currentSystemPrompt - .onAppear { store.send(.appear) } - currentExtraSystemPrompt - resetPrompt + WithPerceptionTracking { + currentSystemPrompt + .onAppear { store.send(.appear) } + currentExtraSystemPrompt + resetPrompt - Divider() + Divider() - chatModel - temperature - defaultScopes + chatModel + temperature + defaultScopes - Divider() + Divider() - customCommandMenu + customCommandMenu + } } @ViewBuilder var currentSystemPrompt: some View { Text("System Prompt:") - WithViewStore(store, observe: \.systemPrompt) { viewStore in - Text({ - var text = viewStore.state - if text.isEmpty { text = "N/A" } - if text.count > 30 { text = String(text.prefix(30)) + "..." } - return text - }() as String) - } + Text({ + var text = store.systemPrompt + if text.isEmpty { text = "N/A" } + if text.count > 30 { text = String(text.prefix(30)) + "..." } + return text + }() as String) } @ViewBuilder var currentExtraSystemPrompt: some View { Text("Extra Prompt:") - WithViewStore(store, observe: \.extraSystemPrompt) { viewStore in - Text({ - var text = viewStore.state - if text.isEmpty { text = "N/A" } - if text.count > 30 { text = String(text.prefix(30)) + "..." } - return text - }() as String) - } + Text({ + var text = store.extraSystemPrompt + if text.isEmpty { text = "N/A" } + if text.count > 30 { text = String(text.prefix(30)) + "..." } + return text + }() as String) } var resetPrompt: some View { @@ -73,46 +71,44 @@ struct ChatContextMenu: View { @ViewBuilder var chatModel: some View { Menu("Chat Model") { - WithViewStore(store, observe: \.chatModelIdOverride) { viewStore in - Button(action: { - viewStore.send(.chatModelIdOverrideSelected(nil)) - }) { - HStack { - if let defaultModel = chatModels - .first(where: { $0.id == defaultChatModelId }) - { - Text("Default (\(defaultModel.name))") - if viewStore.state == nil { - Image(systemName: "checkmark") - } - } else { - Text("No Model Available") + Button(action: { + store.send(.chatModelIdOverrideSelected(nil)) + }) { + HStack { + if let defaultModel = chatModels + .first(where: { $0.id == defaultChatModelId }) + { + Text("Default (\(defaultModel.name))") + if store.chatModelIdOverride == nil { + Image(systemName: "checkmark") } + } else { + Text("No Model Available") } } + } - if let id = viewStore.state, !chatModels.map(\.id).contains(id) { - Button(action: { - viewStore.send(.chatModelIdOverrideSelected(nil)) - }) { - HStack { - Text("Default (Selected Model Not Found)") - Image(systemName: "checkmark") - } + if let id = store.chatModelIdOverride, !chatModels.map(\.id).contains(id) { + Button(action: { + store.send(.chatModelIdOverrideSelected(nil)) + }) { + HStack { + Text("Default (Selected Model Not Found)") + Image(systemName: "checkmark") } } + } - Divider() + Divider() - ForEach(chatModels, id: \.id) { model in - Button(action: { - viewStore.send(.chatModelIdOverrideSelected(model.id)) - }) { - HStack { - Text(model.name) - if model.id == viewStore.state { - Image(systemName: "checkmark") - } + ForEach(chatModels, id: \.id) { model in + Button(action: { + store.send(.chatModelIdOverrideSelected(model.id)) + }) { + HStack { + Text(model.name) + if model.id == store.chatModelIdOverride { + Image(systemName: "checkmark") } } } @@ -123,31 +119,29 @@ struct ChatContextMenu: View { @ViewBuilder var temperature: some View { Menu("Temperature") { - WithViewStore(store, observe: \.temperatureOverride) { viewStore in - Button(action: { - viewStore.send(.temperatureOverrideSelected(nil)) - }) { - HStack { - Text( - "Default (\(defaultTemperature.formatted(.number.precision(.fractionLength(1)))))" - ) - if viewStore.state == nil { - Image(systemName: "checkmark") - } + Button(action: { + store.send(.temperatureOverrideSelected(nil)) + }) { + HStack { + Text( + "Default (\(defaultTemperature.formatted(.number.precision(.fractionLength(1)))))" + ) + if store.temperatureOverride == nil { + Image(systemName: "checkmark") } } + } - Divider() + Divider() - ForEach(Array(stride(from: 0.0, through: 2.0, by: 0.1)), id: \.self) { value in - Button(action: { - viewStore.send(.temperatureOverrideSelected(value)) - }) { - HStack { - Text("\(value.formatted(.number.precision(.fractionLength(1))))") - if value == viewStore.state { - Image(systemName: "checkmark") - } + ForEach(Array(stride(from: 0.0, through: 2.0, by: 0.1)), id: \.self) { value in + Button(action: { + store.send(.temperatureOverrideSelected(value)) + }) { + HStack { + Text("\(value.formatted(.number.precision(.fractionLength(1))))") + if value == store.temperatureOverride { + Image(systemName: "checkmark") } } } @@ -158,7 +152,6 @@ struct ChatContextMenu: View { @ViewBuilder var defaultScopes: some View { Menu("Default Scopes") { - WithViewStore(store, observe: \.defaultScopes) { viewStore in Button(action: { store.send(.resetDefaultScopesButtonTapped) }) { @@ -169,17 +162,16 @@ struct ChatContextMenu: View { ForEach(ChatService.Scope.allCases, id: \.rawValue) { value in Button(action: { - viewStore.send(.toggleScope(value)) + store.send(.toggleScope(value)) }) { HStack { Text("@" + value.rawValue) - if viewStore.state.contains(value) { + if store.defaultScopes.contains(value) { Image(systemName: "checkmark") } } } } - } } } diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index db14b5d3..9aade9d0 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -4,6 +4,7 @@ import ChatTab import CodableWrappers import Combine import ComposableArchitecture +import DebounceFunction import Foundation import OpenAIService import Preferences @@ -15,8 +16,9 @@ public class ChatGPTChatTab: ChatTab { public let service: ChatService let chat: StoreOf - let viewStore: ViewStoreOf private var cancellable = Set() + private var observer = NSObject() + private let updateContentDebounce = DebounceRunner(duration: 0.5) struct RestorableState: Codable { var history: [OpenAIService.ChatMessage] @@ -50,8 +52,8 @@ public class ChatGPTChatTab: ChatTab { } public func buildIcon() -> any View { - WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in - if viewStore.state { + WithPerceptionTracking { + if self.chat.isReceivingMessage { Image(systemName: "ellipsis.message") } else { Image(systemName: "message") @@ -60,7 +62,7 @@ public class ChatGPTChatTab: ChatTab { } public func buildMenu() -> any View { - ChatContextMenu(store: chat.scope(state: \.chatMenu, action: Chat.Action.chatMenu)) + ChatContextMenu(store: chat.scope(state: \.chatMenu, action: \.chatMenu)) } public func restorableState() async -> Data { @@ -89,7 +91,7 @@ public class ChatGPTChatTab: ChatTab { await tab.service.memory.mutateHistory { history in history = state.history } - tab.viewStore.send(.refresh) + tab.chat.send(.refresh) } return builder } @@ -109,46 +111,65 @@ public class ChatGPTChatTab: ChatTab { @MainActor public init(service: ChatService = .init(), store: StoreOf) { self.service = service - chat = .init(initialState: .init(), reducer: Chat(service: service)) - viewStore = .init(chat) + chat = .init(initialState: .init(), reducer: { Chat(service: service) }) super.init(store: store) } public func start() { - chatTabViewStore.send(.updateTitle("Chat")) + observer = .init() + cancellable = [] - chatTabViewStore.publisher.focusTrigger.removeDuplicates().sink { [weak self] _ in - Task { @MainActor [weak self] in - self?.viewStore.send(.focusOnTextField) - } - }.store(in: &cancellable) + chatTabStore.send(.updateTitle("Chat")) service.$systemPrompt.removeDuplicates().sink { [weak self] _ in Task { @MainActor [weak self] in - self?.chatTabViewStore.send(.tabContentUpdated) + self?.chatTabStore.send(.tabContentUpdated) } }.store(in: &cancellable) service.$extraSystemPrompt.removeDuplicates().sink { [weak self] _ in Task { @MainActor [weak self] in - self?.chatTabViewStore.send(.tabContentUpdated) + self?.chatTabStore.send(.tabContentUpdated) } }.store(in: &cancellable) - viewStore.publisher.map(\.title).removeDuplicates().sink { [weak self] title in - Task { @MainActor [weak self] in - self?.chatTabViewStore.send(.updateTitle(title)) + do { + var lastTrigger = -1 + observer.observe { [weak self] in + guard let self else { return } + let trigger = chatTabStore.focusTrigger + guard lastTrigger != trigger else { return } + lastTrigger = trigger + Task { @MainActor [weak self] in + self?.chat.send(.focusOnTextField) + } } - }.store(in: &cancellable) + } - viewStore.publisher.removeDuplicates().debounce( - for: .milliseconds(500), - scheduler: DispatchQueue.main - ).sink { [weak self] _ in - Task { @MainActor [weak self] in - self?.chatTabViewStore.send(.tabContentUpdated) + do { + var lastTitle = "" + observer.observe { [weak self] in + guard let self else { return } + let title = self.chatTabStore.state.title + guard lastTitle != title else { return } + lastTitle = title + Task { @MainActor [weak self] in + self?.chatTabStore.send(.updateTitle(title)) + } } - }.store(in: &cancellable) + } + + observer.observe { [weak self] in + guard let self else { return } + _ = chat.history + _ = chat.title + _ = chat.isReceivingMessage + Task { + await self.updateContentDebounce.debounce { @MainActor [weak self] in + self?.chatTabStore.send(.tabContentUpdated) + } + } + } } } diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index 5644d433..6b2252e4 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -18,7 +18,7 @@ public struct ChatPanel: View { Divider() ChatPanelInputArea(chat: chat) } - .background(.clear) + .background(Color(nsColor: .windowBackgroundColor)) .onAppear { chat.send(.appear) } } } @@ -54,111 +54,103 @@ struct ChatPanelMessages: View { @Environment(\.isEnabled) var isEnabled var body: some View { - ScrollViewReader { proxy in - GeometryReader { listGeo in - List { - Group { - Spacer(minLength: 12) - .id(topID) - - Instruction(chat: chat) - - ChatHistory(chat: chat) - .listItemTint(.clear) - - WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in - if viewStore.state { - Spacer(minLength: 12) - } - } - - Spacer(minLength: 12) - .id(bottomID) - .onAppear { - isBottomHidden = false - if !didScrollToBottomOnAppearOnce { - proxy.scrollTo(bottomID, anchor: .bottom) - didScrollToBottomOnAppearOnce = true + WithPerceptionTracking { + ScrollViewReader { proxy in + GeometryReader { listGeo in + List { + Group { + Spacer(minLength: 12) + .id(topID) + + Instruction(chat: chat) + + ChatHistory(chat: chat) + .listItemTint(.clear) + + ExtraSpacingInResponding(chat: chat) + + Spacer(minLength: 12) + .id(bottomID) + .onAppear { + isBottomHidden = false + if !didScrollToBottomOnAppearOnce { + proxy.scrollTo(bottomID, anchor: .bottom) + didScrollToBottomOnAppearOnce = true + } } + .onDisappear { + isBottomHidden = true + } + .background(GeometryReader { geo in + let offset = geo.frame(in: .named(scrollSpace)).minY + Color.clear.preference( + key: ScrollViewOffsetPreferenceKey.self, + value: offset + ) + }) + } + .modify { view in + if #available(macOS 13.0, *) { + view + .listRowSeparator(.hidden) + .listSectionSeparator(.hidden) + } else { + view } - .onDisappear { - isBottomHidden = true - } - .background(GeometryReader { geo in - let offset = geo.frame(in: .named(scrollSpace)).minY - Color.clear.preference( - key: ScrollViewOffsetPreferenceKey.self, - value: offset - ) - }) + } } + .listStyle(.plain) + .listRowBackground(EmptyView()) .modify { view in if #available(macOS 13.0, *) { - view - .listRowSeparator(.hidden) - .listSectionSeparator(.hidden) + view.scrollContentBackground(.hidden) } else { view } } - } - .listStyle(.plain) - .listRowBackground(EmptyView()) - .modify { view in - if #available(macOS 13.0, *) { - view.scrollContentBackground(.hidden) - } else { - view + .coordinateSpace(name: scrollSpace) + .preference( + key: ListHeightPreferenceKey.self, + value: listGeo.size.height + ) + .onPreferenceChange(ListHeightPreferenceKey.self) { value in + listHeight = value + updatePinningState() } - } - .coordinateSpace(name: scrollSpace) - .preference( - key: ListHeightPreferenceKey.self, - value: listGeo.size.height - ) - .onPreferenceChange(ListHeightPreferenceKey.self) { value in - listHeight = value - updatePinningState() - } - .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in - scrollOffset = value - updatePinningState() - } - .overlay(alignment: .bottom) { - WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in + .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in + scrollOffset = value + updatePinningState() + } + .overlay(alignment: .bottom) { StopRespondingButton(chat: chat) - .padding(.bottom, 8) - .opacity(viewStore.state ? 1 : 0) - .disabled(!viewStore.state) - .transformEffect(.init(translationX: 0, y: viewStore.state ? 0 : 20)) } - } - .overlay(alignment: .bottomTrailing) { - scrollToBottomButton(proxy: proxy) - } - .background { - PinToBottomHandler( - chat: chat, - isBottomHidden: isBottomHidden, - pinnedToBottom: $isPinnedToBottom - ) { + .overlay(alignment: .bottomTrailing) { + scrollToBottomButton(proxy: proxy) + } + .background { + PinToBottomHandler( + chat: chat, + isBottomHidden: isBottomHidden, + pinnedToBottom: $isPinnedToBottom + ) { + proxy.scrollTo(bottomID, anchor: .bottom) + } + } + .onAppear { + proxy.scrollTo(bottomID, anchor: .bottom) + } + .task { proxy.scrollTo(bottomID, anchor: .bottom) } - } - .onAppear { - proxy.scrollTo(bottomID, anchor: .bottom) - } - .task { - proxy.scrollTo(bottomID, anchor: .bottom) } } - } - .onAppear { - trackScrollWheel() - } - .onDisappear { - cancellable.forEach { $0.cancel() } - cancellable = [] + .onAppear { + trackScrollWheel() + } + .onDisappear { + cancellable.forEach { $0.cancel() } + cancellable = [] + } } } @@ -215,6 +207,18 @@ struct ChatPanelMessages: View { .buttonStyle(.plain) } + struct ExtraSpacingInResponding: View { + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + if chat.isReceivingMessage { + Spacer(minLength: 12) + } + } + } + } + struct PinToBottomHandler: View { let chat: StoreOf let isBottomHidden: Bool @@ -222,21 +226,11 @@ struct ChatPanelMessages: View { let scrollToBottom: () -> Void @State var isInitialLoad = true - - struct PinToBottomRelatedState: Equatable { - var isReceivingMessage: Bool - var lastMessage: DisplayedChatMessage? - } - + var body: some View { - WithViewStore(chat, observe: { - PinToBottomRelatedState( - isReceivingMessage: $0.isReceivingMessage, - lastMessage: $0.history.last - ) - }) { viewStore in + WithPerceptionTracking { EmptyView() - .onChange(of: viewStore.state.isReceivingMessage) { isReceiving in + .onChange(of: chat.isReceivingMessage) { isReceiving in if isReceiving { Task { pinnedToBottom = true @@ -247,7 +241,7 @@ struct ChatPanelMessages: View { } } } - .onChange(of: viewStore.state.lastMessage) { _ in + .onChange(of: chat.history.last) { _ in if pinnedToBottom || isInitialLoad { if isInitialLoad { isInitialLoad = false @@ -275,9 +269,11 @@ struct ChatHistory: View { let chat: StoreOf var body: some View { - WithViewStore(chat, observe: \.history) { viewStore in - ForEach(viewStore.state, id: \.id) { message in - ChatHistoryItem(chat: chat, message: message).id(message.id) + WithPerceptionTracking { + ForEach(chat.history, id: \.id) { message in + WithPerceptionTracking { + ChatHistoryItem(chat: chat, message: message).id(message.id) + } } } } @@ -288,11 +284,25 @@ struct ChatHistoryItem: View { let message: DisplayedChatMessage var body: some View { - let text = message.text - - switch message.role { - case .user: - UserMessage(id: message.id, text: text, chat: chat) + WithPerceptionTracking { + let text = message.text + switch message.role { + case .user: + UserMessage(id: message.id, text: text, chat: chat) + .listRowInsets(EdgeInsets( + top: 0, + leading: -8, + bottom: 0, + trailing: -8 + )) + .padding(.vertical, 4) + case .assistant: + BotMessage( + id: message.id, + text: text, + references: message.references, + chat: chat + ) .listRowInsets(EdgeInsets( top: 0, leading: -8, @@ -300,24 +310,11 @@ struct ChatHistoryItem: View { trailing: -8 )) .padding(.vertical, 4) - case .assistant: - BotMessage( - id: message.id, - text: text, - references: message.references, - chat: chat - ) - .listRowInsets(EdgeInsets( - top: 0, - leading: -8, - bottom: 0, - trailing: -8 - )) - .padding(.vertical, 4) - case .tool: - FunctionMessage(id: message.id, text: text) - case .ignored: - EmptyView() + case .tool: + FunctionMessage(id: message.id, text: text) + case .ignored: + EmptyView() + } } } } @@ -326,25 +323,36 @@ private struct StopRespondingButton: View { let chat: StoreOf var body: some View { - Button(action: { - chat.send(.stopRespondingButtonTapped) - }) { - HStack(spacing: 4) { - Image(systemName: "stop.fill") - Text("Stop Responding") - } - .padding(8) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: r, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: r, style: .continuous) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + WithPerceptionTracking { + if chat.isReceivingMessage { + Button(action: { + chat.send(.stopRespondingButtonTapped) + }) { + HStack(spacing: 4) { + Image(systemName: "stop.fill") + Text("Stop Responding") + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: r, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: r, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + .buttonStyle(.borderless) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 8) + .opacity(chat.isReceivingMessage ? 1 : 0) + .disabled(!chat.isReceivingMessage) + .transformEffect(.init( + translationX: 0, + y: chat.isReceivingMessage ? 0 : 20 + )) } } - .buttonStyle(.borderless) - .frame(maxWidth: .infinity, alignment: .center) } } @@ -355,7 +363,7 @@ struct ChatPanelInputArea: View { var body: some View { HStack { clearButton - textEditor + InputAreaTextEditor(chat: chat, focusedField: $focusedField) } .padding(8) .background(.ultraThickMaterial) @@ -384,89 +392,86 @@ struct ChatPanelInputArea: View { .buttonStyle(.plain) } - @MainActor - var textEditor: some View { - HStack(spacing: 0) { - WithViewStore( - chat, - removeDuplicates: { - $0.typedMessage == $1.typedMessage && $0.focusedField == $1.focusedField + struct InputAreaTextEditor: View { + @Perception.Bindable var chat: StoreOf + var focusedField: FocusState.Binding + + var body: some View { + WithPerceptionTracking { + HStack(spacing: 0) { + AutoresizingCustomTextEditor( + text: $chat.typedMessage, + font: .systemFont(ofSize: 14), + isEditable: true, + maxHeight: 400, + onSubmit: { chat.send(.sendButtonTapped) }, + completions: chatAutoCompletion + ) + .focused(focusedField, equals: .textField) + .bind($chat.focusedField, to: focusedField) + .padding(8) + .fixedSize(horizontal: false, vertical: true) + + Button(action: { + chat.send(.sendButtonTapped) + }) { + Image(systemName: "paperplane.fill") + .padding(8) + } + .buttonStyle(.plain) + .disabled(chat.isReceivingMessage) + .keyboardShortcut(KeyEquivalent.return, modifiers: []) } - ) { viewStore in - AutoresizingCustomTextEditor( - text: viewStore.$typedMessage, - font: .systemFont(ofSize: 14), - isEditable: true, - maxHeight: 400, - onSubmit: { viewStore.send(.sendButtonTapped) }, - completions: chatAutoCompletion - ) - .focused($focusedField, equals: .textField) - .bind(viewStore.$focusedField, to: $focusedField) - .padding(8) - .fixedSize(horizontal: false, vertical: true) - } + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .controlColor), lineWidth: 1) + } + .background { + Button(action: { + chat.send(.returnButtonTapped) + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) - WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in - Button(action: { - viewStore.send(.sendButtonTapped) - }) { - Image(systemName: "paperplane.fill") - .padding(8) + Button(action: { + focusedField.wrappedValue = .textField + }) { + EmptyView() + } + .keyboardShortcut("l", modifiers: [.command]) } - .buttonStyle(.plain) - .disabled(viewStore.state) - .keyboardShortcut(KeyEquivalent.return, modifiers: []) } } - .frame(maxWidth: .infinity) - .background { - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - .background { - Button(action: { - chat.send(.returnButtonTapped) - }) { - EmptyView() - } - .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) - Button(action: { - focusedField = .textField - }) { - EmptyView() - } - .keyboardShortcut("l", modifiers: [.command]) - } - } + func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] { + guard text.count == 1 else { return [] } + let plugins = [String]() // chat.pluginIdentifiers.map { "/\($0)" } + let availableFeatures = plugins + [ + "/exit", + "@code", + "@sense", + "@project", + "@web", + ] - func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] { - guard text.count == 1 else { return [] } - let plugins = [String]() // chat.pluginIdentifiers.map { "/\($0)" } - let availableFeatures = plugins + [ - "/exit", - "@code", - "@sense", - "@project", - "@web", - ] - - let result: [String] = availableFeatures - .filter { $0.hasPrefix(text) && $0 != text } - .compactMap { - guard let index = $0.index( - $0.startIndex, - offsetBy: range.location, - limitedBy: $0.endIndex - ) else { return nil } - return String($0[index...]) - } - return result + let result: [String] = availableFeatures + .filter { $0.hasPrefix(text) && $0 != text } + .compactMap { + guard let index = $0.index( + $0.startIndex, + offsetBy: range.location, + limitedBy: $0.endIndex + ) else { return nil } + return String($0[index...]) + } + return result + } } } @@ -553,7 +558,7 @@ struct ChatPanel_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true), - reducer: Chat(service: .init()) + reducer: { Chat(service: .init()) } )) .frame(width: 450, height: 1200) .colorScheme(.dark) @@ -563,8 +568,8 @@ struct ChatPanel_Preview: PreviewProvider { struct ChatPanel_EmptyChat_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( - initialState: .init(history: [], isReceivingMessage: false), - reducer: Chat(service: .init()) + initialState: .init(history: [DisplayedChatMessage](), isReceivingMessage: false), + reducer: { Chat(service: .init()) } )) .padding() .frame(width: 450, height: 600) @@ -576,7 +581,7 @@ struct ChatPanel_InputText_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: false), - reducer: Chat(service: .init()) + reducer: { Chat(service: .init()) } )) .padding() .frame(width: 450, height: 600) @@ -594,7 +599,7 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider { history: ChatPanel_Preview.history, isReceivingMessage: false ), - reducer: Chat(service: .init()) + reducer: { Chat(service: .init()) } ) ) .padding() @@ -607,7 +612,7 @@ struct ChatPanel_Light_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true), - reducer: Chat(service: .init()) + reducer: { Chat(service: .init()) } )) .padding() .frame(width: 450, height: 600) diff --git a/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift b/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift index f107534d..cfbde1c2 100644 --- a/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift +++ b/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift @@ -1,26 +1,29 @@ import Combine +import ComposableArchitecture import DebounceFunction import Foundation import MarkdownUI +import Perception import SharedUIComponents import SwiftUI /// Use this instead of the built in ``CodeBlockView`` to highlight code blocks asynchronously, /// so that the UI doesn't freeze when rendering large code blocks. struct AsyncCodeBlockView: View { - class Storage: ObservableObject { + @Perceptible + class Storage { static let queue = DispatchQueue( label: "chat-code-block-highlight", - qos: .userInteractive, + qos: .userInteractive, attributes: .concurrent ) - @Published var highlighted: AttributedString? - var debounceFunction: DebounceFunction? - private var highlightTask: Task? - + var highlighted: AttributedString? + @PerceptionIgnored var debounceFunction: DebounceFunction? + @PerceptionIgnored private var highlightTask: Task? + init() { - self.debounceFunction = .init(duration: 0.5, block: { [weak self] view in + debounceFunction = .init(duration: 0.5, block: { [weak self] view in self?.highlight(for: view) }) } @@ -32,7 +35,7 @@ struct AsyncCodeBlockView: View { highlight(for: view) } } - + func highlight(for view: AsyncCodeBlockView) { highlightTask?.cancel() let content = view.content @@ -42,7 +45,7 @@ struct AsyncCodeBlockView: View { highlightTask = Task { let string = await withUnsafeContinuation { continuation in Self.queue.async { - let content = highlightedCodeBlock( + let content = CodeHighlighting.highlightedCodeBlock( code: content, language: language, scenario: "chat", @@ -65,7 +68,7 @@ struct AsyncCodeBlockView: View { let font: NSFont @Environment(\.colorScheme) var colorScheme - @StateObject var storage = Storage() + @State var storage = Storage() @AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight @@ -79,33 +82,35 @@ struct AsyncCodeBlockView: View { } var body: some View { - Group { - if let highlighted = storage.highlighted { - Text(highlighted) - } else { - Text(content).font(.init(font)) + WithPerceptionTracking { + Group { + if let highlighted = storage.highlighted { + Text(highlighted) + } else { + Text(content).font(.init(font)) + } + } + .onAppear { + storage.highlight(debounce: false, for: self) + } + .onChange(of: colorScheme) { _ in + storage.highlight(debounce: false, for: self) + } + .onChange(of: syncCodeHighlightTheme) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: codeForegroundColorLight) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: codeBackgroundColorLight) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: codeForegroundColorDark) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: codeBackgroundColorDark) { _ in + storage.highlight(debounce: true, for: self) } - } - .onAppear { - storage.highlight(debounce: false, for: self) - } - .onChange(of: colorScheme) { _ in - storage.highlight(debounce: false, for: self) - } - .onChange(of: syncCodeHighlightTheme) { _ in - storage.highlight(debounce: true, for: self) - } - .onChange(of: codeForegroundColorLight) { _ in - storage.highlight(debounce: true, for: self) - } - .onChange(of: codeBackgroundColorLight) { _ in - storage.highlight(debounce: true, for: self) - } - .onChange(of: codeForegroundColorDark) { _ in - storage.highlight(debounce: true, for: self) - } - .onChange(of: codeBackgroundColorDark) { _ in - storage.highlight(debounce: true, for: self) } } } diff --git a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift index 5d202678..1683fd88 100644 --- a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift +++ b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift @@ -89,41 +89,45 @@ struct ReferenceList: View { let chat: StoreOf var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 8) { - ForEach(0.. var body: some View { - Group { - Markdown( + WithPerceptionTracking { + Group { + Markdown( """ You can use plugins to perform various tasks. - + | Plugin Name | Description | | --- | --- | | `/run` | Runs a command under the project root | @@ -19,16 +20,16 @@ struct Instruction: View { | `/search` | Searches on Bing and summarizes the results | | `/shortcut(name)` | Runs a shortcut from the Shortcuts.app, with the previous message as input | | `/shortcutInput(name)` | Runs a shortcut and uses its result as a new message | - + To use plugins, you can prefix a message with `/pluginName`. """ - ) - .modifier(InstructionModifier()) - - Markdown( + ) + .modifier(InstructionModifier()) + + Markdown( """ You can use scopes to give the bot extra abilities. - + | Scope Name | Abilities | | --- | --- | | `@file` | Read the metadata of the editing file | @@ -36,29 +37,29 @@ struct Instruction: View { | `@sense`| Experimental. Read the relevant code of the focused editor | | `@project` | Experimental. Access content of the project | | `@web` (beta) | Search on Bing or query from a web page | - + To use scopes, you can prefix a message with `@code`. - + You can use shorthand to represent a scope, such as `@c`, and enable multiple scopes with `@c+web`. """ - ) - .modifier(InstructionModifier()) - - WithViewStore(chat, observe: \.chatMenu.defaultScopes) { viewStore in + ) + .modifier(InstructionModifier()) + + let scopes = chat.chatMenu.defaultScopes Markdown( - """ - Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. - - \({ - if viewStore.state.isEmpty { - return "No scope is enabled by default" - } else { - let scopes = viewStore.state.map(\.rawValue).sorted() - .joined(separator: ", ") - return "Default scopes: `\(scopes)`" - } - }()) - """ + """ + Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. + + \({ + if scopes.isEmpty { + return "No scope is enabled by default" + } else { + let scopes = scopes.map(\.rawValue).sorted() + .joined(separator: ", ") + return "Default scopes: `\(scopes)`" + } + }()) + """ ) .modifier(InstructionModifier()) } diff --git a/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift b/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift index f27e3ed4..d8c2af86 100644 --- a/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift +++ b/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift @@ -66,11 +66,11 @@ struct UserMessage: View { ``` """#, chat: .init( - initialState: .init(history: [], isReceivingMessage: false), - reducer: Chat(service: .init()) + initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false), + reducer: { Chat(service: .init()) } ) ) .padding() - .fixedSize(horizontal: /*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/, vertical: /*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/) + .fixedSize(horizontal: true, vertical: true) } diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift index 48bd8632..8c06d2dc 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift @@ -3,51 +3,53 @@ import SharedUIComponents import SwiftUI struct APIKeyManagementView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - VStack(spacing: 0) { - HStack { - Button(action: { - store.send(.closeButtonClicked) - }) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - .padding() - } - .buttonStyle(.plain) - Text("API Keys") - Spacer() - Button(action: { - store.send(.addButtonClicked) - }) { - Image(systemName: "plus.circle.fill") - .foregroundStyle(.secondary) - .padding() + WithPerceptionTracking { + VStack(spacing: 0) { + HStack { + Button(action: { + store.send(.closeButtonClicked) + }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + Text("API Keys") + Spacer() + Button(action: { + store.send(.addButtonClicked) + }) { + Image(systemName: "plus.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) } - .buttonStyle(.plain) - } - .background(Color(nsColor: .separatorColor)) + .background(Color(nsColor: .separatorColor)) - List { - WithViewStore(store, observe: { $0.availableAPIKeyNames }) { viewStore in - ForEach(viewStore.state, id: \.self) { name in - HStack { - Text(name) - .contextMenu { - Button("Remove") { - viewStore.send(.deleteButtonClicked(name: name)) + List { + ForEach(store.availableAPIKeyNames, id: \.self) { name in + WithPerceptionTracking { + HStack { + Text(name) + .contextMenu { + Button("Remove") { + store.send(.deleteButtonClicked(name: name)) + } } - } - Spacer() + Spacer() - Button(action: { - viewStore.send(.deleteButtonClicked(name: name)) - }) { - Image(systemName: "trash.fill") - .foregroundStyle(.secondary) + Button(action: { + store.send(.deleteButtonClicked(name: name)) + }) { + Image(systemName: "trash.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } } .modify { view in @@ -58,11 +60,9 @@ struct APIKeyManagementView: View { } } } - } - .removeBackground() - .overlay { - WithViewStore(store, observe: { $0.availableAPIKeyNames }) { viewStore in - if viewStore.state.isEmpty { + .removeBackground() + .overlay { + if store.availableAPIKeyNames.isEmpty { Text(""" Empty Add a new key by clicking the add button @@ -72,52 +72,51 @@ struct APIKeyManagementView: View { } } } - } - .focusable(false) - .frame(width: 300, height: 400) - .background(.thickMaterial) - .onAppear { - store.send(.appear) - } - .sheet(store: store.scope( - state: \.$apiKeySubmission, - action: APIKeyManagement.Action.apiKeySubmission - )) { store in - APIKeySubmissionView(store: store) - .frame(minWidth: 400) + .focusable(false) + .frame(width: 300, height: 400) + .background(.thickMaterial) + .onAppear { + store.send(.appear) + } + .sheet(item: $store.scope( + state: \.apiKeySubmission, + action: \.apiKeySubmission + )) { store in + APIKeySubmissionView(store: store) + .frame(minWidth: 400) + } } } } struct APIKeySubmissionView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - ScrollView { - VStack(spacing: 0) { - Form { - WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in - TextField("Name", text: viewStore.$name) - } - WithViewStore(store, removeDuplicates: { $0.key == $1.key }) { viewStore in - SecureField("Key", text: viewStore.$key) + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + TextField("Name", text: $store.name) + SecureField("Key", text: $store.key) } - }.padding() + .padding() - Divider() + Divider() - HStack { - Spacer() + HStack { + Spacer() - Button("Cancel") { store.send(.cancelButtonClicked) } - .keyboardShortcut(.cancelAction) + Button("Cancel") { store.send(.cancelButtonClicked) } + .keyboardShortcut(.cancelAction) - Button("Save", action: { store.send(.saveButtonClicked) }) - .keyboardShortcut(.defaultAction) - }.padding() + Button("Save", action: { store.send(.saveButtonClicked) }) + .keyboardShortcut(.defaultAction) + }.padding() + } } + .textFieldStyle(.roundedBorder) } - .textFieldStyle(.roundedBorder) } } @@ -128,7 +127,7 @@ class APIKeyManagementView_Preview: PreviewProvider { initialState: .init( availableAPIKeyNames: ["test1", "test2"] ), - reducer: APIKeyManagement() + reducer: { APIKeyManagement() } ) ) } @@ -139,7 +138,7 @@ class APIKeySubmissionView_Preview: PreviewProvider { APIKeySubmissionView( store: .init( initialState: .init(), - reducer: APIKeySubmission() + reducer: { APIKeySubmission() } ) ) } diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift index 3ff3188e..2756ce1e 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift @@ -1,10 +1,12 @@ import ComposableArchitecture import Foundation -struct APIKeyManagement: ReducerProtocol { +@Reducer +struct APIKeyManagement { + @ObservableState struct State: Equatable { var availableAPIKeyNames: [String] = [] - @PresentationState var apiKeySubmission: APIKeySubmission.State? + @Presents var apiKeySubmission: APIKeySubmission.State? } enum Action: Equatable { @@ -20,7 +22,7 @@ struct APIKeyManagement: ReducerProtocol { @Dependency(\.toast) var toast @Dependency(\.apiKeyKeychain) var keychain - var body: some ReducerProtocol { + var body: some ReducerOf { Reduce { state, action in switch action { case .appear: @@ -72,7 +74,7 @@ struct APIKeyManagement: ReducerProtocol { return .none } } - .ifLet(\.$apiKeySubmission, action: /Action.apiKeySubmission) { + .ifLet(\.$apiKeySubmission, action: \.apiKeySubmission) { APIKeySubmission() } } diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift index a18e0a4c..57e853d4 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift @@ -2,26 +2,27 @@ import ComposableArchitecture import SwiftUI struct APIKeyPicker: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - WithViewStore(store) { viewStore in + WithPerceptionTracking { HStack { Picker( - selection: viewStore.$apiKeyName, + selection: $store.apiKeyName, content: { Text("No API Key").tag("") - if viewStore.state.availableAPIKeyNames.isEmpty { + if store.availableAPIKeyNames.isEmpty { Text("No API key found, please add a new one →") } - - if !viewStore.state.availableAPIKeyNames.contains(viewStore.state.apiKeyName), - !viewStore.state.apiKeyName.isEmpty { - Text("Key not found: \(viewStore.state.apiKeyName)") - .tag(viewStore.state.apiKeyName) + + if !store.availableAPIKeyNames.contains(store.apiKeyName), + !store.apiKeyName.isEmpty + { + Text("Key not found: \(store.apiKeyName)") + .tag(store.apiKeyName) } - - ForEach(viewStore.state.availableAPIKeyNames, id: \.self) { name in + + ForEach(store.availableAPIKeyNames, id: \.self) { name in Text(name).tag(name) } @@ -32,15 +33,17 @@ struct APIKeyPicker: View { Button(action: { store.send(.manageAPIKeysButtonClicked) }) { Text(Image(systemName: "key")) } - }.sheet(isPresented: viewStore.$isAPIKeyManagementPresented) { - APIKeyManagementView(store: store.scope( - state: \.apiKeyManagement, - action: APIKeySelection.Action.apiKeyManagement - )) + }.sheet(isPresented: $store.isAPIKeyManagementPresented) { + WithPerceptionTracking { + APIKeyManagementView(store: store.scope( + state: \.apiKeyManagement, + action: \.apiKeyManagement + )) + } + } + .onAppear { + store.send(.appear) } - } - .onAppear { - store.send(.appear) } } } diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift index 75e2d77c..47e8b33b 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift @@ -2,14 +2,16 @@ import Foundation import SwiftUI import ComposableArchitecture -struct APIKeySelection: ReducerProtocol { +@Reducer +struct APIKeySelection { + @ObservableState struct State: Equatable { - @BindingState var apiKeyName: String = "" + var apiKeyName: String = "" var availableAPIKeyNames: [String] { apiKeyManagement.availableAPIKeyNames } var apiKeyManagement: APIKeyManagement.State = .init() - @BindingState var isAPIKeyManagementPresented: Bool = false + var isAPIKeyManagementPresented: Bool = false } enum Action: Equatable, BindableAction { @@ -23,10 +25,10 @@ struct APIKeySelection: ReducerProtocol { @Dependency(\.toast) var toast @Dependency(\.apiKeyKeychain) var keychain - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() - Scope(state: \.apiKeyManagement, action: /Action.apiKeyManagement) { + Scope(state: \.apiKeyManagement, action: \.apiKeyManagement) { APIKeyManagement() } diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift index 64f16b7d..8fe390ee 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift @@ -1,10 +1,12 @@ import ComposableArchitecture import Foundation -struct APIKeySubmission: ReducerProtocol { +@Reducer +struct APIKeySubmission { + @ObservableState struct State: Equatable { - @BindingState var name: String = "" - @BindingState var key: String = "" + var name: String = "" + var key: String = "" } enum Action: Equatable, BindableAction { @@ -22,7 +24,7 @@ struct APIKeySubmission: ReducerProtocol { case keyIsEmpty } - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() Reduce { state, action in diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index 62eec368..7450105e 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -7,16 +7,18 @@ import Preferences import SwiftUI import Toast -struct ChatModelEdit: ReducerProtocol { +@Reducer +struct ChatModelEdit { + @ObservableState struct State: Equatable, Identifiable { var id: String - @BindingState var name: String - @BindingState var format: ChatModel.Format - @BindingState var maxTokens: Int = 4000 - @BindingState var supportsFunctionCalling: Bool = true - @BindingState var modelName: String = "" - @BindingState var ollamaKeepAlive: String = "" - @BindingState var apiVersion: String = "" + var name: String + var format: ChatModel.Format + var maxTokens: Int = 4000 + var supportsFunctionCalling: Bool = true + var modelName: String = "" + var ollamaKeepAlive: String = "" + var apiVersion: String = "" var apiKeyName: String { apiKeySelection.apiKeyName } var baseURL: String { baseURLSelection.baseURL } var isFullURL: Bool { baseURLSelection.isFullURL } @@ -26,6 +28,7 @@ struct ChatModelEdit: ReducerProtocol { var suggestedMaxTokens: Int? var apiKeySelection: APIKeySelection.State = .init() var baseURLSelection: BaseURLSelection.State = .init() + var enforceMessageOrder: Bool = false } enum Action: Equatable, BindableAction { @@ -51,14 +54,14 @@ struct ChatModelEdit: ReducerProtocol { @Dependency(\.apiKeyKeychain) var keychain - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() - Scope(state: \.apiKeySelection, action: /Action.apiKeySelection) { + Scope(state: \.apiKeySelection, action: \.apiKeySelection) { APIKeySelection() } - Scope(state: \.baseURLSelection, action: /Action.baseURLSelection) { + Scope(state: \.baseURLSelection, action: \.baseURLSelection) { BaseURLSelection() } @@ -156,13 +159,13 @@ struct ChatModelEdit: ReducerProtocol { case .baseURLSelection: return .none - case .binding(\.$format): + case .binding(\.format): return .run { send in await send(.refreshAvailableModelNames) await send(.checkSuggestedMaxTokens) } - case .binding(\.$modelName): + case .binding(\.modelName): return .run { send in await send(.checkSuggestedMaxTokens) } @@ -174,26 +177,6 @@ struct ChatModelEdit: ReducerProtocol { } } -extension ChatModelEdit.State { - init(model: ChatModel) { - self.init( - id: model.id, - name: model.name, - format: model.format, - maxTokens: model.info.maxTokens, - supportsFunctionCalling: model.info.supportsFunctionCalling, - modelName: model.info.modelName, - ollamaKeepAlive: model.info.ollamaInfo.keepAlive, - apiVersion: model.info.googleGenerativeAIInfo.apiVersion, - apiKeySelection: .init( - apiKeyName: model.info.apiKeyName, - apiKeyManagement: .init(availableAPIKeyNames: [model.info.apiKeyName]) - ), - baseURLSelection: .init(baseURL: model.info.baseURL, isFullURL: model.info.isFullURL) - ) - } -} - extension ChatModel { init(state: ChatModelEdit.State) { self.init( @@ -215,9 +198,29 @@ extension ChatModel { }(), modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines), ollamaInfo: .init(keepAlive: state.ollamaKeepAlive), - googleGenerativeAIInfo: .init(apiVersion: state.apiVersion) + googleGenerativeAIInfo: .init(apiVersion: state.apiVersion), + openAICompatibleInfo: .init(enforceMessageOrder: state.enforceMessageOrder) ) ) } + + func toState() -> ChatModelEdit.State { + .init( + id: id, + name: name, + format: format, + maxTokens: info.maxTokens, + supportsFunctionCalling: info.supportsFunctionCalling, + modelName: info.modelName, + ollamaKeepAlive: info.ollamaInfo.keepAlive, + apiVersion: info.googleGenerativeAIInfo.apiVersion, + apiKeySelection: .init( + apiKeyName: info.apiKeyName, + apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName]) + ), + baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL), + enforceMessageOrder: info.openAICompatibleInfo.enforceMessageOrder + ) + } } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index 2fe21715..1eee9725 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -6,445 +6,429 @@ import SwiftUI @MainActor struct ChatModelEditView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - ScrollView { - VStack(spacing: 0) { - Form { - nameTextField - formatPicker - - WithViewStore(store, observe: { $0.format }) { viewStore in - switch viewStore.state { + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + NameTextField(store: store) + FormatPicker(store: store) + + switch store.format { case .openAI: - openAI + OpenAIForm(store: store) case .azureOpenAI: - azureOpenAI + AzureOpenAIForm(store: store) case .openAICompatible: - openAICompatible + OpenAICompatibleForm(store: store) case .googleAI: - googleAI + GoogleAIForm(store: store) case .ollama: - ollama + OllamaForm(store: store) case .claude: - claude + ClaudeForm(store: store) } } - } - .padding() + .padding() - Divider() + Divider() - HStack { - WithViewStore(store, observe: { $0.isTesting }) { viewStore in + HStack { HStack(spacing: 8) { Button("Test") { store.send(.testButtonClicked) } - .disabled(viewStore.state) + .disabled(store.isTesting) - if viewStore.state { + if store.isTesting { ProgressView() .controlSize(.small) } } - } - Spacer() + Spacer() - Button("Cancel") { - store.send(.cancelButtonClicked) - } - .keyboardShortcut(.cancelAction) + Button("Cancel") { + store.send(.cancelButtonClicked) + } + .keyboardShortcut(.cancelAction) - Button(action: { store.send(.saveButtonClicked) }) { - Text("Save") + Button(action: { store.send(.saveButtonClicked) }) { + Text("Save") + } + .keyboardShortcut(.defaultAction) } - .keyboardShortcut(.defaultAction) + .padding() } - .padding() } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + .fixedSize(horizontal: false, vertical: true) + .handleToast(namespace: "ChatModelEdit") } - .textFieldStyle(.roundedBorder) - .onAppear { - store.send(.appear) - } - .fixedSize(horizontal: false, vertical: true) - .handleToast(namespace: "ChatModelEdit") } - var nameTextField: some View { - WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in - TextField("Name", text: viewStore.$name) + struct NameTextField: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + TextField("Name", text: $store.name) + } } } - var formatPicker: some View { - WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in - Picker( - selection: viewStore.$format, - content: { - ForEach( - ChatModel.Format.allCases, - id: \.rawValue - ) { format in - switch format { - case .openAI: - Text("OpenAI").tag(format) - case .azureOpenAI: - Text("Azure OpenAI").tag(format) - case .openAICompatible: - Text("OpenAI Compatible").tag(format) - case .googleAI: - Text("Google Generative AI").tag(format) - case .ollama: - Text("Ollama").tag(format) - case .claude: - Text("Claude").tag(format) + struct FormatPicker: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + Picker( + selection: $store.format, + content: { + ForEach( + ChatModel.Format.allCases, + id: \.rawValue + ) { format in + switch format { + case .openAI: + Text("OpenAI").tag(format) + case .azureOpenAI: + Text("Azure OpenAI").tag(format) + case .openAICompatible: + Text("OpenAI Compatible").tag(format) + case .googleAI: + Text("Google Generative AI").tag(format) + case .ollama: + Text("Ollama").tag(format) + case .claude: + Text("Claude").tag(format) + } } - } - }, - label: { Text("Format") } - ) - .pickerStyle(.segmented) + }, + label: { Text("Format") } + ) + .pickerStyle(.segmented) + } } } - func baseURLTextField( - title: String = "Base URL", - prompt: Text?, - @ViewBuilder trailingContent: @escaping () -> V - ) -> some View { - BaseURLPicker( - title: title, - prompt: prompt, - store: store.scope( - state: \.baseURLSelection, - action: ChatModelEdit.Action.baseURLSelection - ), - trailingContent: trailingContent - ) + struct BaseURLTextField: View { + let store: StoreOf + var title: String = "Base URL" + let prompt: Text? + @ViewBuilder var trailingContent: () -> V + + var body: some View { + WithPerceptionTracking { + BaseURLPicker( + title: title, + prompt: prompt, + store: store.scope( + state: \.baseURLSelection, + action: \.baseURLSelection + ), + trailingContent: trailingContent + ) + } + } } - func baseURLTextField( - title: String = "Base URL", - prompt: Text? - ) -> some View { - baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() }) - } + struct SupportsFunctionCallingToggle: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + Toggle( + "Supports Function Calling", + isOn: $store.supportsFunctionCalling + ) - var supportsFunctionCallingToggle: some View { - WithViewStore( - store, - removeDuplicates: { $0.supportsFunctionCalling == $1.supportsFunctionCalling } - ) { viewStore in - Toggle( - "Supports Function Calling", - isOn: viewStore.$supportsFunctionCalling - ) - - Text( - "Function calling is required by some features, if this model doesn't support function calling, you should turn it off to avoid undefined behaviors." - ) - .foregroundColor(.secondary) - .font(.callout) - .dynamicHeightTextInFormWorkaround() + Text( + "Function calling is required by some features, if this model doesn't support function calling, you should turn it off to avoid undefined behaviors." + ) + .foregroundColor(.secondary) + .font(.callout) + .dynamicHeightTextInFormWorkaround() + } } } - struct MaxTokensTextField: Equatable { - @BindingViewState var maxTokens: Int - var suggestedMaxTokens: Int? - } + struct MaxTokensTextField: View { + @Perception.Bindable var store: StoreOf - var maxTokensTextField: some View { - WithViewStore( - store, - observe: { - MaxTokensTextField( - maxTokens: $0.$maxTokens, - suggestedMaxTokens: $0.suggestedMaxTokens - ) - } - ) { viewStore in - HStack { - let textFieldBinding = Binding( - get: { String(viewStore.state.maxTokens) }, - set: { - if let selectionMaxToken = Int($0) { - viewStore.$maxTokens.wrappedValue = selectionMaxToken - } else { - viewStore.$maxTokens.wrappedValue = 0 + var body: some View { + WithPerceptionTracking { + HStack { + let textFieldBinding = Binding( + get: { String(store.maxTokens) }, + set: { + if let selectionMaxToken = Int($0) { + $store.maxTokens.wrappedValue = selectionMaxToken + } else { + $store.maxTokens.wrappedValue = 0 + } } - } - ) + ) - TextField(text: textFieldBinding) { - Text("Context Window") - .multilineTextAlignment(.trailing) - } - .overlay(alignment: .trailing) { - Stepper( - value: viewStore.$maxTokens, - in: 0...Int.max, - step: 100 - ) { - EmptyView() - } - } - .foregroundColor({ - guard let max = viewStore.state.suggestedMaxTokens else { - return .primary + TextField(text: textFieldBinding) { + Text("Context Window") + .multilineTextAlignment(.trailing) } - if viewStore.state.maxTokens > max { - return .red + .overlay(alignment: .trailing) { + Stepper( + value: $store.maxTokens, + in: 0...Int.max, + step: 100 + ) { + EmptyView() + } } - return .primary - }() as Color) + .foregroundColor({ + guard let max = store.suggestedMaxTokens else { + return .primary + } + if store.maxTokens > max { + return .red + } + return .primary + }() as Color) - if let max = viewStore.state.suggestedMaxTokens { - Text("Max: \(max)") + if let max = store.suggestedMaxTokens { + Text("Max: \(max)") + } } } } } - struct APIKeyState: Equatable { - @BindingViewState var apiKeyName: String - var availableAPIKeys: [String] - } - - @ViewBuilder - var apiKeyNamePicker: some View { - APIKeyPicker(store: store.scope( - state: \.apiKeySelection, - action: ChatModelEdit.Action.apiKeySelection - )) + struct ApiKeyNamePicker: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + } + } } - @ViewBuilder - var openAI: some View { - baseURLTextField(prompt: Text("https://api.openai.com")) { - Text("/v1/chat/completions") - } - apiKeyNamePicker - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - .overlay(alignment: .trailing) { - Picker( - "", - selection: viewStore.$modelName, - content: { - if ChatGPTModel(rawValue: viewStore.state.modelName) == nil { - Text("Custom Model").tag(viewStore.state.modelName) - } - ForEach(ChatGPTModel.allCases, id: \.self) { model in - Text(model.rawValue).tag(model.rawValue) + struct OpenAIForm: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("https://api.openai.com")) { + Text("/v1/chat/completions") + } + ApiKeyNamePicker(store: store) + + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if ChatGPTModel(rawValue: store.modelName) == nil { + Text("Custom Model").tag(store.modelName) + } + ForEach(ChatGPTModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } } - } + ) + .frame(width: 20) + } + + MaxTokensTextField(store: store) + SupportsFunctionCallingToggle(store: store) + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)" + ) + + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API." ) - .frame(width: 20) } + .padding(.vertical) + } } + } - maxTokensTextField - supportsFunctionCallingToggle + struct AzureOpenAIForm: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("https://xxxx.openai.azure.com")) { + EmptyView() + } + ApiKeyNamePicker(store: store) - VStack(alignment: .leading, spacing: 8) { - Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( - " To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)" - ) + TextField("Deployment Name", text: $store.modelName) - Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( - " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API." - ) + MaxTokensTextField(store: store) + SupportsFunctionCallingToggle(store: store) + } } - .padding(.vertical) } - @ViewBuilder - var azureOpenAI: some View { - baseURLTextField(prompt: Text("https://xxxx.openai.azure.com")) - apiKeyNamePicker - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Deployment Name", text: viewStore.$modelName) - } + struct OpenAICompatibleForm: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + Picker( + selection: $store.baseURLSelection.isFullURL, + content: { + Text("Base URL").tag(false) + Text("Full URL").tag(true) + }, + label: { Text("URL") } + ) + .pickerStyle(.segmented) + + BaseURLTextField( + store: store, + title: "", + prompt: store.isFullURL + ? Text("https://api.openai.com/v1/chat/completions") + : Text("https://api.openai.com") + ) { + if !store.isFullURL { + Text("/v1/chat/completions") + } + } - maxTokensTextField - supportsFunctionCallingToggle - } + ApiKeyNamePicker(store: store) - @ViewBuilder - var openAICompatible: some View { - WithViewStore(store.scope( - state: \.baseURLSelection, - action: ChatModelEdit.Action.baseURLSelection - ), removeDuplicates: { $0.isFullURL != $1.isFullURL }) { viewStore in - Picker( - selection: viewStore.$isFullURL, - content: { - Text("Base URL").tag(false) - Text("Full URL").tag(true) - }, - label: { Text("URL") } - ) - .pickerStyle(.segmented) - } + TextField("Model Name", text: $store.modelName) - WithViewStore(store, observe: \.isFullURL) { viewStore in - baseURLTextField( - title: "", - prompt: viewStore.state - ? Text("https://api.openai.com/v1/chat/completions") - : Text("https://api.openai.com") - ) { - if !viewStore.state { - Text("/v1/chat/completions") + MaxTokensTextField(store: store) + SupportsFunctionCallingToggle(store: store) + + Toggle(isOn: $store.enforceMessageOrder) { + Text("Enforce message order to be user/assistant alternated") } } } - - apiKeyNamePicker - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - } - - maxTokensTextField - supportsFunctionCallingToggle } - @ViewBuilder - var googleAI: some View { - baseURLTextField(prompt: Text("https://generativelanguage.googleapis.com")) { - Text("/v1") - } + struct GoogleAIForm: View { + @Perception.Bindable var store: StoreOf - apiKeyNamePicker - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - .overlay(alignment: .trailing) { - Picker( - "", - selection: viewStore.$modelName, - content: { - if GoogleGenerativeAIModel(rawValue: viewStore.state.modelName) == nil { - Text("Custom Model").tag(viewStore.state.modelName) - } - ForEach(GoogleGenerativeAIModel.allCases, id: \.self) { model in - Text(model.rawValue).tag(model.rawValue) - } - } - ) - .frame(width: 20) + var body: some View { + WithPerceptionTracking { + BaseURLTextField( + store: store, + prompt: Text("https://generativelanguage.googleapis.com") + ) { + Text("/v1") } - } - maxTokensTextField + ApiKeyNamePicker(store: store) + + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if GoogleGenerativeAIModel(rawValue: store.modelName) == nil { + Text("Custom Model").tag(store.modelName) + } + ForEach(GoogleGenerativeAIModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } - WithViewStore(store, removeDuplicates: { $0.apiVersion == $1.apiVersion }) { viewStore in - TextField("API Version", text: viewStore.$apiVersion, prompt: Text("v1")) + MaxTokensTextField(store: store) + + TextField("API Version", text: $store.apiVersion, prompt: Text("v1")) + } } } - @ViewBuilder - var ollama: some View { - baseURLTextField(prompt: Text("http://127.0.0.1:11434")) { - Text("/api/chat") - } + struct OllamaForm: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) { + Text("/api/chat") + } - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - } + TextField("Model Name", text: $store.modelName) - maxTokensTextField + MaxTokensTextField(store: store) - WithViewStore( - store, - removeDuplicates: { $0.ollamaKeepAlive == $1.ollamaKeepAlive } - ) { viewStore in - TextField(text: viewStore.$ollamaKeepAlive, prompt: Text("Default Value")) { - Text("Keep Alive") - } - } + TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) { + Text("Keep Alive") + } - VStack(alignment: .leading, spacing: 8) { - Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( - " For more details, please visit [https://ollama.com](https://ollama.com)." - ) + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " For more details, please visit [https://ollama.com](https://ollama.com)." + ) + } + .padding(.vertical) + } } - .padding(.vertical) } - @ViewBuilder - var claude: some View { - baseURLTextField(prompt: Text("https://api.anthropic.com")) { - Text("/v1/messages") - } - - apiKeyNamePicker - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - .overlay(alignment: .trailing) { - Picker( - "", - selection: viewStore.$modelName, - content: { - if ClaudeChatCompletionsService - .KnownModel(rawValue: viewStore.state.modelName) == nil - { - Text("Custom Model").tag(viewStore.state.modelName) - } - ForEach( - ClaudeChatCompletionsService.KnownModel.allCases, - id: \.self - ) { model in - Text(model.rawValue).tag(model.rawValue) + struct ClaudeForm: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("https://api.anthropic.com")) { + Text("/v1/messages") + } + + ApiKeyNamePicker(store: store) + + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if ClaudeChatCompletionsService + .KnownModel(rawValue: store.modelName) == nil + { + Text("Custom Model").tag(store.modelName) + } + ForEach( + ClaudeChatCompletionsService.KnownModel.allCases, + id: \.self + ) { model in + Text(model.rawValue).tag(model.rawValue) + } } - } + ) + .frame(width: 20) + } + + MaxTokensTextField(store: store) + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " For more details, please visit [https://anthropic.com](https://anthropic.com)." ) - .frame(width: 20) } + .padding(.vertical) + } } - - maxTokensTextField - - VStack(alignment: .leading, spacing: 8) { - Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( - " For more details, please visit [https://anthropic.com](https://anthropic.com)." - ) - } - .padding(.vertical) } } #Preview("OpenAI") { ChatModelEditView( store: .init( - initialState: .init(model: ChatModel( + initialState: ChatModel( id: "3", name: "Test Model 3", format: .openAI, @@ -455,8 +439,8 @@ struct ChatModelEditView: View { supportsFunctionCalling: false, modelName: "gpt-3.5-turbo" ) - )), - reducer: ChatModelEdit() + ).toState(), + reducer: { ChatModelEdit() } ) ) } @@ -464,7 +448,7 @@ struct ChatModelEditView: View { #Preview("OpenAI Compatible") { ChatModelEditView( store: .init( - initialState: .init(model: ChatModel( + initialState: ChatModel( id: "3", name: "Test Model 3", format: .openAICompatible, @@ -476,8 +460,8 @@ struct ChatModelEditView: View { supportsFunctionCalling: false, modelName: "gpt-3.5-turbo" ) - )), - reducer: ChatModelEdit() + ).toState(), + reducer: { ChatModelEdit() } ) ) } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift index 8fbe3a52..4dc46630 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift @@ -37,13 +37,15 @@ extension ChatModel: ManageableAIModel { } } +@Reducer struct ChatModelManagement: AIModelManagement { typealias Model = ChatModel + @ObservableState struct State: Equatable, AIModelManagementState { typealias Model = ChatModel var models: IdentifiedArrayOf = [] - @PresentationState var editingModel: ChatModelEdit.State? + @Presents var editingModel: ChatModelEdit.State? var selectedModelId: String? { editingModel?.id } } @@ -61,7 +63,7 @@ struct ChatModelManagement: AIModelManagement { @Dependency(\.toast) var toast @Dependency(\.userDefaults) var userDefaults - var body: some ReducerProtocol { + var body: some ReducerOf { Reduce { state, action in switch action { case .appear: @@ -89,7 +91,7 @@ struct ChatModelManagement: AIModelManagement { case let .selectModel(id): guard let model = state.models[id: id] else { return .none } - state.editingModel = .init(model: model) + state.editingModel = model.toState() return .none case let .duplicateModel(id): @@ -134,7 +136,7 @@ struct ChatModelManagement: AIModelManagement { case .chatModelItem: return .none } - }.ifLet(\.$editingModel, action: /Action.chatModelItem) { + }.ifLet(\.$editingModel, action: \.chatModelItem) { ChatModelEdit() } } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift index 6101de58..e81b4a97 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift @@ -3,17 +3,19 @@ import ComposableArchitecture import SwiftUI struct ChatModelManagementView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - AIModelManagementView(store: store) - .sheet(store: store.scope( - state: \.$editingModel, - action: ChatModelManagement.Action.chatModelItem - )) { store in - ChatModelEditView(store: store) - .frame(width: 800) - } + WithPerceptionTracking { + AIModelManagementView(store: store) + .sheet(item: $store.scope( + state: \.editingModel, + action: \.chatModelItem + )) { store in + ChatModelEditView(store: store) + .frame(width: 800) + } + } } } @@ -62,23 +64,22 @@ class ChatModelManagementView_Previews: PreviewProvider { ) ), ]), - editingModel: .init( - model: ChatModel( - id: "3", - name: "Test Model 3", - format: .openAICompatible, - info: .init( - apiKeyName: "key", - baseURL: "apple.com", - maxTokens: 3000, - supportsFunctionCalling: false, - modelName: "gpt-3.5-turbo" - ) + editingModel: ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" ) - ) + ).toState() ), - reducer: ChatModelManagement() + reducer: { ChatModelManagement() } ) ) } } + diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift index 45ae25fd..5506ba4f 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift @@ -1,20 +1,22 @@ import AIModel -import Toast import ComposableArchitecture import Dependencies import Keychain import OpenAIService import Preferences import SwiftUI +import Toast -struct EmbeddingModelEdit: ReducerProtocol { +@Reducer +struct EmbeddingModelEdit { + @ObservableState struct State: Equatable, Identifiable { var id: String - @BindingState var name: String - @BindingState var format: EmbeddingModel.Format - @BindingState var maxTokens: Int = 8191 - @BindingState var modelName: String = "" - @BindingState var ollamaKeepAlive: String = "" + var name: String + var format: EmbeddingModel.Format + var maxTokens: Int = 8191 + var modelName: String = "" + var ollamaKeepAlive: String = "" var apiKeyName: String { apiKeySelection.apiKeyName } var baseURL: String { baseURLSelection.baseURL } var isFullURL: Bool { baseURLSelection.isFullURL } @@ -46,16 +48,17 @@ struct EmbeddingModelEdit: ReducerProtocol { toast($0, $1, "EmbeddingModelEdit") } } + @Dependency(\.apiKeyKeychain) var keychain - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() - Scope(state: \.apiKeySelection, action: /Action.apiKeySelection) { + Scope(state: \.apiKeySelection, action: \.apiKeySelection) { APIKeySelection() } - Scope(state: \.baseURLSelection, action: /Action.baseURLSelection) { + Scope(state: \.baseURLSelection, action: \.baseURLSelection) { BaseURLSelection() } @@ -135,13 +138,13 @@ struct EmbeddingModelEdit: ReducerProtocol { case .baseURLSelection: return .none - case .binding(\.$format): + case .binding(\.format): return .run { send in await send(.refreshAvailableModelNames) await send(.checkSuggestedMaxTokens) } - case .binding(\.$modelName): + case .binding(\.modelName): return .run { send in await send(.checkSuggestedMaxTokens) } @@ -153,24 +156,6 @@ struct EmbeddingModelEdit: ReducerProtocol { } } -extension EmbeddingModelEdit.State { - init(model: EmbeddingModel) { - self.init( - id: model.id, - name: model.name, - format: model.format, - maxTokens: model.info.maxTokens, - modelName: model.info.modelName, - ollamaKeepAlive: model.info.ollamaInfo.keepAlive, - apiKeySelection: .init( - apiKeyName: model.info.apiKeyName, - apiKeyManagement: .init(availableAPIKeyNames: [model.info.apiKeyName]) - ), - baseURLSelection: .init(baseURL: model.info.baseURL, isFullURL: model.info.isFullURL) - ) - } -} - extension EmbeddingModel { init(state: EmbeddingModelEdit.State) { self.init( @@ -187,5 +172,24 @@ extension EmbeddingModel { ) ) } + + func toState() -> EmbeddingModelEdit.State { + .init( + id: id, + name: name, + format: format, + maxTokens: info.maxTokens, + modelName: info.modelName, + ollamaKeepAlive: info.ollamaInfo.keepAlive, + apiKeySelection: .init( + apiKeyName: info.apiKeyName, + apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName]) + ), + baseURLSelection: .init( + baseURL: info.baseURL, + isFullURL: info.isFullURL + ) + ) + } } diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift index ca7037e2..76f8a27d 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -5,326 +5,308 @@ import SwiftUI @MainActor struct EmbeddingModelEditView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - ScrollView { - VStack(spacing: 0) { - Form { - nameTextField - formatPicker - - WithViewStore(store, observe: { $0.format }) { viewStore in - switch viewStore.state { + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + NameTextField(store: store) + FormatPicker(store: store) + + switch store.format { case .openAI: - openAI + OpenAIForm(store: store) case .azureOpenAI: - azureOpenAI + AzureOpenAIForm(store: store) case .openAICompatible: - openAICompatible + OpenAICompatibleForm(store: store) case .ollama: - ollama + OllamaForm(store: store) } } - } - .padding() + .padding() - Divider() + Divider() - HStack { - WithViewStore(store, observe: { $0.isTesting }) { viewStore in + HStack { HStack(spacing: 8) { Button("Test") { store.send(.testButtonClicked) } - .disabled(viewStore.state) + .disabled(store.isTesting) - if viewStore.state { + if store.isTesting { ProgressView() .controlSize(.small) } } - } - Spacer() + Spacer() - Button("Cancel") { - store.send(.cancelButtonClicked) - } - .keyboardShortcut(.cancelAction) + Button("Cancel") { + store.send(.cancelButtonClicked) + } + .keyboardShortcut(.cancelAction) - Button(action: { store.send(.saveButtonClicked) }) { - Text("Save") + Button(action: { store.send(.saveButtonClicked) }) { + Text("Save") + } + .keyboardShortcut(.defaultAction) } - .keyboardShortcut(.defaultAction) + .padding() } - .padding() } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + .fixedSize(horizontal: false, vertical: true) + .handleToast(namespace: "EmbeddingModelEdit") } - .textFieldStyle(.roundedBorder) - .onAppear { - store.send(.appear) - } - .fixedSize(horizontal: false, vertical: true) - .handleToast(namespace: "EmbeddingModelEdit") } - var nameTextField: some View { - WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in - TextField("Name", text: viewStore.$name) + struct NameTextField: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + TextField("Name", text: $store.name) + } } } - var formatPicker: some View { - WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in - Picker( - selection: viewStore.$format, - content: { - ForEach( - EmbeddingModel.Format.allCases, - id: \.rawValue - ) { format in - switch format { - case .openAI: - Text("OpenAI").tag(format) - case .azureOpenAI: - Text("Azure OpenAI").tag(format) - case .openAICompatible: - Text("OpenAI Compatible").tag(format) - case .ollama: - Text("Ollama").tag(format) + struct FormatPicker: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + Picker( + selection: $store.format, + content: { + ForEach( + EmbeddingModel.Format.allCases, + id: \.rawValue + ) { format in + switch format { + case .openAI: + Text("OpenAI").tag(format) + case .azureOpenAI: + Text("Azure OpenAI").tag(format) + case .openAICompatible: + Text("OpenAI Compatible").tag(format) + case .ollama: + Text("Ollama").tag(format) + } } - } - }, - label: { Text("Format") } - ) - .pickerStyle(.segmented) + }, + label: { Text("Format") } + ) + .pickerStyle(.segmented) + } } } - func baseURLTextField( - title: String = "Base URL", - prompt: Text?, - @ViewBuilder trailingContent: @escaping () -> V - ) -> some View { - BaseURLPicker( - title: title, - prompt: prompt, - store: store.scope( - state: \.baseURLSelection, - action: EmbeddingModelEdit.Action.baseURLSelection - ), - trailingContent: trailingContent - ) - } - - func baseURLTextField( - title: String = "Base URL", - prompt: Text? - ) -> some View { - baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() }) + struct BaseURLTextField: View { + let store: StoreOf + var title: String = "Base URL" + let prompt: Text? + @ViewBuilder var trailingContent: () -> V + + var body: some View { + WithPerceptionTracking { + BaseURLPicker( + title: title, + prompt: prompt, + store: store.scope( + state: \.baseURLSelection, + action: \.baseURLSelection + ), + trailingContent: trailingContent + ) + } + } } - struct MaxTokensTextField: Equatable { - @BindingViewState var maxTokens: Int - var suggestedMaxTokens: Int? - } + struct MaxTokensTextField: View { + @Perception.Bindable var store: StoreOf - var maxTokensTextField: some View { - WithViewStore( - store, - observe: { - MaxTokensTextField( - maxTokens: $0.$maxTokens, - suggestedMaxTokens: $0.suggestedMaxTokens - ) - } - ) { viewStore in - HStack { - let textFieldBinding = Binding( - get: { String(viewStore.state.maxTokens) }, - set: { - if let selectionMaxToken = Int($0) { - viewStore.$maxTokens.wrappedValue = selectionMaxToken - } else { - viewStore.$maxTokens.wrappedValue = 0 + var body: some View { + WithPerceptionTracking { + HStack { + let textFieldBinding = Binding( + get: { String(store.maxTokens) }, + set: { + if let selectionMaxToken = Int($0) { + $store.maxTokens.wrappedValue = selectionMaxToken + } else { + $store.maxTokens.wrappedValue = 0 + } } - } - ) + ) - TextField(text: textFieldBinding) { - Text("Max Input Tokens") - .multilineTextAlignment(.trailing) - } - .overlay(alignment: .trailing) { - Stepper( - value: viewStore.$maxTokens, - in: 0...Int.max, - step: 100 - ) { - EmptyView() + TextField(text: textFieldBinding) { + Text("Max Input Tokens") + .multilineTextAlignment(.trailing) } - } - .foregroundColor({ - guard let max = viewStore.state.suggestedMaxTokens else { - return .primary - } - if viewStore.state.maxTokens > max { - return .red + .overlay(alignment: .trailing) { + Stepper( + value: $store.maxTokens, + in: 0...Int.max, + step: 100 + ) { + EmptyView() + } } - return .primary - }() as Color) + .foregroundColor({ + guard let max = store.suggestedMaxTokens else { + return .primary + } + if store.maxTokens > max { + return .red + } + return .primary + }() as Color) - if let max = viewStore.state.suggestedMaxTokens { - Text("Max: \(max)") + if let max = store.suggestedMaxTokens { + Text("Max: \(max)") + } } } } } - struct APIKeyState: Equatable { - @BindingViewState var apiKeyName: String - var availableAPIKeys: [String] + struct ApiKeyNamePicker: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + } + } } - @ViewBuilder - var apiKeyNamePicker: some View { - APIKeyPicker(store: store.scope( - state: \.apiKeySelection, - action: EmbeddingModelEdit.Action.apiKeySelection - )) - } + struct OpenAIForm: View { + @Perception.Bindable var store: StoreOf - @ViewBuilder - var openAI: some View { - baseURLTextField(prompt: Text("https://api.openai.com")) { - Text("/v1/embeddings") - } - apiKeyNamePicker - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - .overlay(alignment: .trailing) { - Picker( - "", - selection: viewStore.$modelName, - content: { - if OpenAIEmbeddingModel(rawValue: viewStore.state.modelName) == nil { - Text("Custom Model").tag(viewStore.state.modelName) - } - ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in - Text(model.rawValue).tag(model.rawValue) + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("https://api.openai.com")) { + Text("/v1/embeddings") + } + ApiKeyNamePicker(store: store) + + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if OpenAIEmbeddingModel(rawValue: store.modelName) == nil { + Text("Custom Model").tag(store.modelName) + } + ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } } - } + ) + .frame(width: 20) + } + + MaxTokensTextField(store: store) + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)" + ) + + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API." ) - .frame(width: 20) } + .padding(.vertical) + } } + } - maxTokensTextField - - VStack(alignment: .leading, spacing: 8) { - Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( - " To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)" - ) + struct AzureOpenAIForm: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("https://xxxx.openai.azure.com")) { + EmptyView() + } + ApiKeyNamePicker(store: store) - Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( - " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API." - ) + TextField("Deployment Name", text: $store.modelName) + + MaxTokensTextField(store: store) + } } - .padding(.vertical) } - @ViewBuilder - var azureOpenAI: some View { - baseURLTextField(prompt: Text("https://xxxx.openai.azure.com")) - apiKeyNamePicker - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Deployment Name", text: viewStore.$modelName) - } + struct OpenAICompatibleForm: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + Picker( + selection: $store.baseURLSelection.isFullURL, + content: { + Text("Base URL").tag(false) + Text("Full URL").tag(true) + }, + label: { Text("URL") } + ) + .pickerStyle(.segmented) + + BaseURLTextField( + store: store, + title: "", + prompt: store.isFullURL + ? Text("https://api.openai.com/v1/embeddings") + : Text("https://api.openai.com") + ) { + if !store.isFullURL { + Text("/v1/embeddings") + } + } - maxTokensTextField - } + ApiKeyNamePicker(store: store) - @ViewBuilder - var openAICompatible: some View { - WithViewStore(store.scope( - state: \.baseURLSelection, - action: EmbeddingModelEdit.Action.baseURLSelection - ), removeDuplicates: { $0.isFullURL != $1.isFullURL }) { viewStore in - Picker( - selection: viewStore.$isFullURL, - content: { - Text("Base URL").tag(false) - Text("Full URL").tag(true) - }, - label: { Text("URL") } - ) - .pickerStyle(.segmented) - } + TextField("Model Name", text: $store.modelName) - WithViewStore(store, observe: \.isFullURL) { viewStore in - baseURLTextField( - title: "", - prompt: viewStore.state - ? Text("https://api.openai.com/v1/embeddings") - : Text("https://api.openai.com") - ) { - if !viewStore.state { - Text("/v1/embeddings") - } + MaxTokensTextField(store: store) } } + } - apiKeyNamePicker - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - } + struct OllamaForm: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) { + Text("/api/embeddings") + } + TextField("Model Name", text: $store.modelName) - maxTokensTextField - } - - @ViewBuilder - var ollama: some View { - baseURLTextField(prompt: Text("http://127.0.0.1:11434")) { - Text("/api/embeddings") - } + MaxTokensTextField(store: store) - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - } + WithPerceptionTracking { + TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) { + Text("Keep Alive") + } + } - maxTokensTextField - - WithViewStore( - store, - removeDuplicates: { $0.ollamaKeepAlive == $1.ollamaKeepAlive } - ) { viewStore in - TextField(text: viewStore.$ollamaKeepAlive, prompt: Text("Default Value")) { - Text("Keep Alive") + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " For more details, please visit [https://ollama.com](https://ollama.com)." + ) + } + .padding(.vertical) } } - - VStack(alignment: .leading, spacing: 8) { - Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( - " For more details, please visit [https://ollama.com](https://ollama.com)." - ) - } - .padding(.vertical) } } @@ -332,7 +314,7 @@ class EmbeddingModelManagementView_Editing_Previews: PreviewProvider { static var previews: some View { EmbeddingModelEditView( store: .init( - initialState: .init(model: EmbeddingModel( + initialState: EmbeddingModel( id: "3", name: "Test Model 3", format: .openAICompatible, @@ -342,8 +324,8 @@ class EmbeddingModelManagementView_Editing_Previews: PreviewProvider { maxTokens: 3000, modelName: "gpt-3.5-turbo" ) - )), - reducer: EmbeddingModelEdit() + ).toState(), + reducer: { EmbeddingModelEdit() } ) ) } diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift index 71b0d4a5..294ca401 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift @@ -29,13 +29,15 @@ extension EmbeddingModel: ManageableAIModel { } } +@Reducer struct EmbeddingModelManagement: AIModelManagement { typealias Model = EmbeddingModel + @ObservableState struct State: Equatable, AIModelManagementState { typealias Model = EmbeddingModel var models: IdentifiedArrayOf = [] - @PresentationState var editingModel: EmbeddingModelEdit.State? + @Presents var editingModel: EmbeddingModelEdit.State? var selectedModelId: Model.ID? { editingModel?.id } } @@ -53,7 +55,7 @@ struct EmbeddingModelManagement: AIModelManagement { @Dependency(\.toast) var toast @Dependency(\.userDefaults) var userDefaults - var body: some ReducerProtocol { + var body: some ReducerOf { Reduce { state, action in switch action { case .appear: @@ -81,7 +83,7 @@ struct EmbeddingModelManagement: AIModelManagement { case let .selectModel(id): guard let model = state.models[id: id] else { return .none } - state.editingModel = .init(model: model) + state.editingModel = model.toState() return .none case let .duplicateModel(id): @@ -126,7 +128,7 @@ struct EmbeddingModelManagement: AIModelManagement { case .embeddingModelItem: return .none } - }.ifLet(\.$editingModel, action: /Action.embeddingModelItem) { + }.ifLet(\.$editingModel, action: \.embeddingModelItem) { EmbeddingModelEdit() } } diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift index a3bfa16c..e251af10 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift @@ -3,17 +3,19 @@ import ComposableArchitecture import SwiftUI struct EmbeddingModelManagementView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - AIModelManagementView(store: store) - .sheet(store: store.scope( - state: \.$editingModel, - action: EmbeddingModelManagement.Action.embeddingModelItem - )) { store in - EmbeddingModelEditView(store: store) - .frame(width: 800) - } + WithPerceptionTracking { + AIModelManagementView(store: store) + .sheet(item: $store.scope( + state: \.editingModel, + action: \.embeddingModelItem + )) { store in + EmbeddingModelEditView(store: store) + .frame(width: 800) + } + } } } @@ -59,22 +61,21 @@ class EmbeddingModelManagementView_Previews: PreviewProvider { ) ), ]), - editingModel: .init( - model: EmbeddingModel( - id: "3", - name: "Test Model 3", - format: .openAICompatible, - info: .init( - apiKeyName: "key", - baseURL: "apple.com", - maxTokens: 3000, - modelName: "gpt-3.5-turbo" - ) + editingModel: EmbeddingModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" ) - ) + ).toState() ), - reducer: EmbeddingModelManagement() + reducer: { EmbeddingModelManagement() } ) ) } } + diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift index 1d3f6975..94affa6d 100644 --- a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift @@ -22,6 +22,8 @@ struct GitHubCopilotView: View { @AppStorage(\.gitHubCopilotEnterpriseURI) var gitHubCopilotEnterpriseURI @AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear) var disableGitHubCopilotSettingsAutoRefreshOnAppear + @AppStorage(\.gitHubCopilotLoadKeyChainCertificates) + var gitHubCopilotLoadKeyChainCertificates init() {} } @@ -196,6 +198,10 @@ struct GitHubCopilotView: View { .foregroundColor(.secondary) .font(.callout) .dynamicHeightTextInFormWorkaround() + + Toggle(isOn: $settings.gitHubCopilotLoadKeyChainCertificates) { + Text("Load certificates in keychain") + } } } diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift index 621ed75d..2c1fd2d7 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift @@ -20,9 +20,9 @@ protocol AIModelManagementState: Equatable { var selectedModelId: Model.ID? { get } } -protocol AIModelManagement: ReducerProtocol where +protocol AIModelManagement: Reducer where Action: AIModelManagementAction, - State: AIModelManagementState, + State: AIModelManagementState & ObservableState, Action.Model == Self.Model, State.Model == Self.Model { @@ -39,69 +39,71 @@ protocol ManageableAIModel: Identifiable { struct AIModelManagementView: View where Management.Model == Model { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - VStack(spacing: 0) { - HStack { - Spacer() - if isFeatureAvailable(\.unlimitedChatAndEmbeddingModels) { - Button("Add Model") { - store.send(.createModel) - } - } else { - WithViewStore(store, observe: { $0.models.count }) { viewStore in - Text("\(viewStore.state) / 2") + WithPerceptionTracking { + VStack(spacing: 0) { + HStack { + Spacer() + if isFeatureAvailable(\.unlimitedChatAndEmbeddingModels) { + Button("Add Model") { + store.send(.createModel) + } + } else { + Text("\(store.models.count) / 2") .foregroundColor(.secondary) - let disabled = viewStore.state >= 2 + let disabled = store.models.count >= 2 Button(disabled ? "Add More Model (Plus)" : "Add Model") { store.send(.createModel) }.disabled(disabled) } - } - }.padding(4) + }.padding(4) - Divider() + Divider() - ModelList(store: store) - } - .onAppear { - store.send(.appear) + ModelList(store: store) + } + .onAppear { + store.send(.appear) + } } } struct ModelList: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - WithViewStore(store) { viewStore in + WithPerceptionTracking { List { - ForEach(viewStore.state.models) { model in - let isSelected = viewStore.state.selectedModelId == model.id - HStack(spacing: 4) { - Image(systemName: "line.3.horizontal") + ForEach(store.models) { model in + WithPerceptionTracking { + let isSelected = store.selectedModelId == model.id + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal") - Button(action: { - viewStore.send(.selectModel(id: model.id)) - }) { - Cell(model: model, isSelected: isSelected) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .contextMenu { - Button("Duplicate") { - store.send(.duplicateModel(id: model.id)) + Button(action: { + store.send(.selectModel(id: model.id)) + }) { + Cell(model: model, isSelected: isSelected) + .contentShape(Rectangle()) } - Button("Remove") { - store.send(.removeModel(id: model.id)) + .buttonStyle(.plain) + .contextMenu { + Button("Duplicate") { + store.send(.duplicateModel(id: model.id)) + } + Button("Remove") { + store.send(.removeModel(id: model.id)) + } } } } } .onMove(perform: { indices, newOffset in - viewStore.send(.moveModel(from: indices, to: newOffset)) + store.send(.moveModel(from: indices, to: newOffset)) }) .modify { view in if #available(macOS 13.0, *) { @@ -115,7 +117,7 @@ struct AIModelManagementView( store: .init( - initialState: .init(models: []), - reducer: ChatModelManagement() + initialState: .init(models: [] as IdentifiedArrayOf), + reducer: { ChatModelManagement() } ) ) } diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift index 066983e7..9456946e 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift @@ -4,40 +4,40 @@ import SwiftUI struct BaseURLPicker: View { let title: String let prompt: Text? - let store: StoreOf + @Perception.Bindable var store: StoreOf @ViewBuilder let trailingContent: () -> TrailingContent - + var body: some View { - WithViewStore(store) { viewStore in + WithPerceptionTracking { HStack { - TextField(title, text: viewStore.$baseURL, prompt: prompt) + TextField(title, text: $store.baseURL, prompt: prompt) .overlay(alignment: .trailing) { Picker( "", - selection: viewStore.$baseURL, + selection: $store.baseURL, content: { - if !viewStore.state.availableBaseURLs - .contains(viewStore.state.baseURL), - !viewStore.state.baseURL.isEmpty + if !store.availableBaseURLs + .contains(store.baseURL), + !store.baseURL.isEmpty { - Text("Custom Value").tag(viewStore.state.baseURL) + Text("Custom Value").tag(store.baseURL) } - + Text("Empty (Default Value)").tag("") - - ForEach(viewStore.state.availableBaseURLs, id: \.self) { baseURL in + + ForEach(store.availableBaseURLs, id: \.self) { baseURL in Text(baseURL).tag(baseURL) } } ) .frame(width: 20) } - + trailingContent() .foregroundStyle(.secondary) } .onAppear { - viewStore.send(.appear) + store.send(.appear) } } } @@ -57,3 +57,4 @@ extension BaseURLPicker where TrailingContent == EmptyView { ) } } + diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift index daff8e21..502d79a7 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift @@ -3,10 +3,12 @@ import Foundation import Preferences import SwiftUI -struct BaseURLSelection: ReducerProtocol { +@Reducer +struct BaseURLSelection { + @ObservableState struct State: Equatable { - @BindingState var baseURL: String = "" - @BindingState var isFullURL: Bool = false + var baseURL: String = "" + var isFullURL: Bool = false var availableBaseURLs: [String] = [] } @@ -19,7 +21,7 @@ struct BaseURLSelection: ReducerProtocol { @Dependency(\.toast) var toast @Dependency(\.userDefaults) var userDefaults - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() Reduce { state, action in diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift index 212b8313..884c58f0 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift @@ -5,7 +5,9 @@ import Preferences import SwiftUI import Toast -struct CustomCommandFeature: ReducerProtocol { +@Reducer +struct CustomCommandFeature { + @ObservableState struct State: Equatable { var editCustomCommand: EditCustomCommand.State? } @@ -24,7 +26,7 @@ struct CustomCommandFeature: ReducerProtocol { @Dependency(\.toast) var toast - var body: some ReducerProtocol { + var body: some ReducerOf { Reduce { state, action in switch action { case .createNewCommand: @@ -122,7 +124,7 @@ struct CustomCommandFeature: ReducerProtocol { } } } - }.ifLet(\.editCustomCommand, action: /Action.editCustomCommand) { + }.ifLet(\.editCustomCommand, action: \.editCustomCommand) { EditCustomCommand(settings: settings) } } diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift index 22594715..13f37404 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -21,9 +21,7 @@ extension List { let customCommandStore = StoreOf( initialState: .init(), - reducer: CustomCommandFeature( - settings: .init() - ) + reducer: { CustomCommandFeature(settings: .init()) } ) struct CustomCommandView: View { @@ -43,63 +41,70 @@ struct CustomCommandView: View { var body: some View { HStack(spacing: 0) { - leftPane + LeftPanel(store: store, settings: settings) Divider() - rightPane + RightPanel(store: store) } } - @ViewBuilder - var leftPane: some View { - List { - ForEach(settings.customCommands, id: \.commandId) { command in - CommandButton(store: store, command: command) - } - .onMove(perform: { indices, newOffset in - settings.customCommands.move(fromOffsets: indices, toOffset: newOffset) - }) - .modify { view in - if #available(macOS 13.0, *) { - view.listRowSeparator(.hidden).listSectionSeparator(.hidden) - } else { - view + struct LeftPanel: View { + let store: StoreOf + @ObservedObject var settings: Settings + @Environment(\.toast) var toast + + var body: some View { + WithPerceptionTracking { + List { + ForEach(settings.customCommands, id: \.commandId) { command in + CommandButton(store: store, command: command) + } + .onMove(perform: { indices, newOffset in + settings.customCommands.move(fromOffsets: indices, toOffset: newOffset) + }) + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view + } + } } - } - } - .removeBackground() - .padding(.vertical, 4) - .listStyle(.plain) - .frame(width: 200) - .background(Color.primary.opacity(0.05)) - .overlay { - if settings.customCommands.isEmpty { - Text(""" - Empty - Add command with "+" button - """) - .multilineTextAlignment(.center) - } - } - .safeAreaInset(edge: .bottom) { - Button(action: { - store.send(.createNewCommand) - }) { - if isFeatureAvailable(\.unlimitedCustomCommands) { - Text(Image(systemName: "plus.circle.fill")) + Text(" New Command") - } else { - Text(Image(systemName: "plus.circle.fill")) + - Text(" New Command (\(settings.customCommands.count)/10)") + .removeBackground() + .padding(.vertical, 4) + .listStyle(.plain) + .frame(width: 200) + .background(Color.primary.opacity(0.05)) + .overlay { + if settings.customCommands.isEmpty { + Text(""" + Empty + Add command with "+" button + """) + .multilineTextAlignment(.center) + } } - } - .buttonStyle(.plain) - .padding() - .contextMenu { - Button("Import") { - store.send(.importCommandClicked) + .safeAreaInset(edge: .bottom) { + Button(action: { + store.send(.createNewCommand) + }) { + if isFeatureAvailable(\.unlimitedCustomCommands) { + Text(Image(systemName: "plus.circle.fill")) + Text(" New Command") + } else { + Text(Image(systemName: "plus.circle.fill")) + + Text(" New Command (\(settings.customCommands.count)/10)") + } + } + .buttonStyle(.plain) + .padding() + .contextMenu { + Button("Import") { + store.send(.importCommandClicked) + } + } } + .onDrop(of: [.json], delegate: FileDropDelegate(store: store, toast: toast)) } } - .onDrop(of: [.json], delegate: FileDropDelegate(store: store, toast: toast)) } struct FileDropDelegate: DropDelegate { @@ -108,15 +113,16 @@ struct CustomCommandView: View { func performDrop(info: DropInfo) -> Bool { let jsonFiles = info.itemProviders(for: [.json]) for file in jsonFiles { - file.loadInPlaceFileRepresentation(forTypeIdentifier: "public.json") { url, _, error in - Task { @MainActor in - if let url { - store.send(.importCommand(at: url)) - } else if let error { - toast(error.localizedDescription, .error) + file + .loadInPlaceFileRepresentation(forTypeIdentifier: "public.json") { url, _, error in + Task { @MainActor in + if let url { + store.send(.importCommand(at: url)) + } else if let error { + toast(error.localizedDescription, .error) + } } } - } } return !jsonFiles.isEmpty @@ -124,92 +130,96 @@ struct CustomCommandView: View { } struct CommandButton: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf let command: CustomCommand var body: some View { - HStack(spacing: 4) { - Image(systemName: "line.3.horizontal") + WithPerceptionTracking { + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal") - VStack(alignment: .leading) { - Text(command.name) - .foregroundStyle(.primary) + VStack(alignment: .leading) { + Text(command.name) + .foregroundStyle(.primary) - Group { - switch command.feature { - case .chatWithSelection: - Text("Send Message") - case .customChat: - Text("Custom Chat") - case .promptToCode: - Text("Prompt to Code") - case .singleRoundDialog: - Text("Single Round Dialog") + Group { + switch command.feature { + case .chatWithSelection: + Text("Send Message") + case .customChat: + Text("Custom Chat") + case .promptToCode: + Text("Prompt to Code") + case .singleRoundDialog: + Text("Single Round Dialog") + } } + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.editCommand(command)) } - .font(.caption) - .foregroundStyle(.tertiary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { - store.send(.editCommand(command)) } - } - .padding(4) - .background { - WithViewStore(store, observe: { $0.editCustomCommand?.commandId }) { viewStore in + .padding(4) + .background { RoundedRectangle(cornerRadius: 4) .fill( - viewStore.state == command.id + store.editCustomCommand?.commandId == command.id ? Color.primary.opacity(0.05) : Color.clear ) } - } - .contextMenu { - Button("Remove") { - store.send(.deleteCommand(command)) - } + .contextMenu { + Button("Remove") { + store.send(.deleteCommand(command)) + } - Button("Export") { - store.send(.exportCommand(command)) + Button("Export") { + store.send(.exportCommand(command)) + } } } } } - @ViewBuilder - var rightPane: some View { - IfLetStore(store.scope( - state: \.editCustomCommand, - action: CustomCommandFeature.Action.editCustomCommand - )) { store in - EditCustomCommandView(store: store) - } else: { - VStack { - SubSection(title: Text("Send Message")) { - Text( - "This command sends a message to the active chat tab. You can provide additional context through the \"Extra System Prompt\" as well." - ) - } - SubSection(title: Text("Prompt to Code")) { - Text( - "This command opens the prompt-to-code panel and executes the provided requirements on the selected code. You can provide additional context through the \"Extra Context\" as well." - ) - } - SubSection(title: Text("Custom Chat")) { - Text( - "This command will overwrite the system prompt to let the bot behave differently." - ) - } - SubSection(title: Text("Single Round Dialog")) { - Text( - "This command allows you to send a message to a temporary chat without opening the chat panel. It is particularly useful for one-time commands, such as running a terminal command with `/run`. For example, you can set the prompt to `/run open .` to open the project in Finder." - ) + struct RightPanel: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + if let store = store.scope( + state: \.editCustomCommand, + action: \.editCustomCommand + ) { + EditCustomCommandView(store: store) + } else { + VStack { + SubSection(title: Text("Send Message")) { + Text( + "This command sends a message to the active chat tab. You can provide additional context through the \"Extra System Prompt\" as well." + ) + } + SubSection(title: Text("Prompt to Code")) { + Text( + "This command opens the prompt-to-code panel and executes the provided requirements on the selected code. You can provide additional context through the \"Extra Context\" as well." + ) + } + SubSection(title: Text("Custom Chat")) { + Text( + "This command will overwrite the system prompt to let the bot behave differently." + ) + } + SubSection(title: Text("Single Round Dialog")) { + Text( + "This command allows you to send a message to a temporary chat without opening the chat panel. It is particularly useful for one-time commands, such as running a terminal command with `/run`. For example, you can set the prompt to `/run open .` to open the project in Finder." + ) + } + } + .padding() } } - .padding() } } } @@ -292,7 +302,7 @@ struct CustomCommandView_Preview: PreviewProvider { ) ))) ), - reducer: CustomCommandFeature(settings: settings) + reducer: { CustomCommandFeature(settings: settings) } ), settings: settings ) @@ -328,7 +338,7 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider { initialState: .init( editCustomCommand: nil ), - reducer: CustomCommandFeature(settings: settings) + reducer: { CustomCommandFeature(settings: settings) } ), settings: settings ) diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift index 03d8ddf9..f914d068 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift @@ -3,7 +3,8 @@ import Foundation import Preferences import SwiftUI -struct EditCustomCommand: ReducerProtocol { +@Reducer +struct EditCustomCommand { enum CommandType: Int, CaseIterable, Equatable { case sendMessage case promptToCode @@ -11,9 +12,10 @@ struct EditCustomCommand: ReducerProtocol { case singleRoundDialog } + @ObservableState struct State: Equatable { - @BindingState var name: String = "" - @BindingState var commandType: CommandType = .sendMessage + var name: String = "" + var commandType: CommandType = .sendMessage var isNewCommand: Bool = false let commandId: String @@ -87,20 +89,20 @@ struct EditCustomCommand: ReducerProtocol { @Dependency(\.toast) var toast - var body: some ReducerProtocol { - Scope(state: \.sendMessage, action: /Action.sendMessage) { + var body: some ReducerOf { + Scope(state: \.sendMessage, action: \.sendMessage) { EditSendMessageCommand() } - Scope(state: \.promptToCode, action: /Action.promptToCode) { + Scope(state: \.promptToCode, action: \.promptToCode) { EditPromptToCodeCommand() } - Scope(state: \.customChat, action: /Action.customChat) { + Scope(state: \.customChat, action: \.customChat) { EditCustomChatCommand() } - Scope(state: \.singleRoundDialog, action: /Action.singleRoundDialog) { + Scope(state: \.singleRoundDialog, action: \.singleRoundDialog) { EditSingleRoundDialogCommand() } @@ -187,18 +189,20 @@ struct EditCustomCommand: ReducerProtocol { } } -struct EditSendMessageCommand: ReducerProtocol { +@Reducer +struct EditSendMessageCommand { + @ObservableState struct State: Equatable { - @BindingState var extraSystemPrompt: String = "" - @BindingState var useExtraSystemPrompt: Bool = false - @BindingState var prompt: String = "" + var extraSystemPrompt: String = "" + var useExtraSystemPrompt: Bool = false + var prompt: String = "" } enum Action: BindableAction, Equatable { case binding(BindingAction) } - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() Reduce { _, action in @@ -210,51 +214,57 @@ struct EditSendMessageCommand: ReducerProtocol { } } -struct EditPromptToCodeCommand: ReducerProtocol { +@Reducer +struct EditPromptToCodeCommand { + @ObservableState struct State: Equatable { - @BindingState var extraSystemPrompt: String = "" - @BindingState var prompt: String = "" - @BindingState var continuousMode: Bool = false - @BindingState var generateDescription: Bool = false + var extraSystemPrompt: String = "" + var prompt: String = "" + var continuousMode: Bool = false + var generateDescription: Bool = false } enum Action: BindableAction, Equatable { case binding(BindingAction) } - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() } } -struct EditCustomChatCommand: ReducerProtocol { +@Reducer +struct EditCustomChatCommand { + @ObservableState struct State: Equatable { - @BindingState var systemPrompt: String = "" - @BindingState var prompt: String = "" + var systemPrompt: String = "" + var prompt: String = "" } enum Action: BindableAction, Equatable { case binding(BindingAction) } - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() } } -struct EditSingleRoundDialogCommand: ReducerProtocol { +@Reducer +struct EditSingleRoundDialogCommand { + @ObservableState struct State: Equatable { - @BindingState var systemPrompt: String = "" - @BindingState var overwriteSystemPrompt: Bool = false - @BindingState var prompt: String = "" - @BindingState var receiveReplyInNotification: Bool = false + var systemPrompt: String = "" + var overwriteSystemPrompt: Bool = false + var prompt: String = "" + var receiveReplyInNotification: Bool = false } enum Action: BindableAction, Equatable { case binding(BindingAction) } - var body: some ReducerProtocol { + var body: some ReducerOf { BindingReducer() } } diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift index 74b61585..c2a0f5c0 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift @@ -6,7 +6,7 @@ import SwiftUI @MainActor struct EditCustomCommandView: View { @Environment(\.toast) var toast - let store: StoreOf + @Perception.Bindable var store: StoreOf init(store: StoreOf) { self.store = store @@ -24,10 +24,10 @@ struct EditCustomCommandView: View { } @ViewBuilder var sharedForm: some View { - WithViewStore(store, observe: { $0 }) { viewStore in - TextField("Name", text: viewStore.$name) + WithPerceptionTracking { + TextField("Name", text: $store.name) - Picker("Command Type", selection: viewStore.$commandType) { + Picker("Command Type", selection: $store.commandType) { ForEach( EditCustomCommand.CommandType.allCases, id: \.rawValue @@ -50,37 +50,34 @@ struct EditCustomCommandView: View { } @ViewBuilder var featureSpecificForm: some View { - WithViewStore( - store, - observe: { $0.commandType } - ) { viewStore in - switch viewStore.state { + WithPerceptionTracking { + switch store.commandType { case .sendMessage: EditSendMessageCommandView( store: store.scope( state: \.sendMessage, - action: EditCustomCommand.Action.sendMessage + action: \.sendMessage ) ) case .promptToCode: EditPromptToCodeCommandView( store: store.scope( state: \.promptToCode, - action: EditCustomCommand.Action.promptToCode + action: \.promptToCode ) ) case .customChat: EditCustomChatCommandView( store: store.scope( state: \.customChat, - action: EditCustomCommand.Action.customChat + action: \.customChat ) ) case .singleRoundDialog: EditSingleRoundDialogCommandView( store: store.scope( state: \.singleRoundDialog, - action: EditCustomCommand.Action.singleRoundDialog + action: \.singleRoundDialog ) ) } @@ -88,23 +85,23 @@ struct EditCustomCommandView: View { } @ViewBuilder var bottomBar: some View { - VStack { - Divider() - - VStack(alignment: .trailing) { - Text( - "After renaming or adding a custom command, please restart Xcode to refresh the menu." - ) - .foregroundStyle(.secondary) - - HStack { - Spacer() - Button("Close") { - store.send(.close) - } - - WithViewStore(store, observe: { $0.isNewCommand }) { viewStore in - if viewStore.state { + WithPerceptionTracking { + VStack { + Divider() + + VStack(alignment: .trailing) { + Text( + "After renaming or adding a custom command, please restart Xcode to refresh the menu." + ) + .foregroundStyle(.secondary) + + HStack { + Spacer() + Button("Close") { + store.send(.close) + } + + if store.isNewCommand { Button("Add") { store.send(.saveCommand) } @@ -115,28 +112,28 @@ struct EditCustomCommandView: View { } } } + .padding(.horizontal) } - .padding(.horizontal) + .padding(.bottom) + .background(.regularMaterial) } - .padding(.bottom) - .background(.regularMaterial) } } struct EditSendMessageCommandView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in + WithPerceptionTracking { VStack(alignment: .leading, spacing: 4) { - Toggle("Extra System Prompt", isOn: viewStore.$useExtraSystemPrompt) - EditableText(text: viewStore.$extraSystemPrompt) + Toggle("Extra System Prompt", isOn: $store.useExtraSystemPrompt) + EditableText(text: $store.extraSystemPrompt) } .padding(.vertical, 4) VStack(alignment: .leading, spacing: 4) { Text("Prompt") - EditableText(text: viewStore.$prompt) + EditableText(text: $store.prompt) } .padding(.vertical, 4) } @@ -144,22 +141,22 @@ struct EditSendMessageCommandView: View { } struct EditPromptToCodeCommandView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in - Toggle("Continuous Mode", isOn: viewStore.$continuousMode) - Toggle("Generate Description", isOn: viewStore.$generateDescription) + WithPerceptionTracking { + Toggle("Continuous Mode", isOn: $store.continuousMode) + Toggle("Generate Description", isOn: $store.generateDescription) VStack(alignment: .leading, spacing: 4) { Text("Extra Context") - EditableText(text: viewStore.$extraSystemPrompt) + EditableText(text: $store.extraSystemPrompt) } .padding(.vertical, 4) VStack(alignment: .leading, spacing: 4) { Text("Prompt") - EditableText(text: viewStore.$prompt) + EditableText(text: $store.prompt) } .padding(.vertical, 4) } @@ -167,19 +164,19 @@ struct EditPromptToCodeCommandView: View { } struct EditCustomChatCommandView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in + WithPerceptionTracking { VStack(alignment: .leading, spacing: 4) { Text("System Prompt") - EditableText(text: viewStore.$systemPrompt) + EditableText(text: $store.systemPrompt) } .padding(.vertical, 4) VStack(alignment: .leading, spacing: 4) { Text("Prompt") - EditableText(text: viewStore.$prompt) + EditableText(text: $store.prompt) } .padding(.vertical, 4) } @@ -187,17 +184,17 @@ struct EditCustomChatCommandView: View { } struct EditSingleRoundDialogCommandView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in + WithPerceptionTracking { VStack(alignment: .leading, spacing: 4) { Text("System Prompt") - EditableText(text: viewStore.$systemPrompt) + EditableText(text: $store.systemPrompt) } .padding(.vertical, 4) - Picker(selection: viewStore.$overwriteSystemPrompt) { + Picker(selection: $store.overwriteSystemPrompt) { Text("Append to Default System Prompt").tag(false) Text("Overwrite Default System Prompt").tag(true) } label: { @@ -207,11 +204,11 @@ struct EditSingleRoundDialogCommandView: View { VStack(alignment: .leading, spacing: 4) { Text("Prompt") - EditableText(text: viewStore.$prompt) + EditableText(text: $store.prompt) } .padding(.vertical, 4) - Toggle("Receive Reply in Notification", isOn: viewStore.$receiveReplyInNotification) + Toggle("Receive Reply in Notification", isOn: $store.receiveReplyInNotification) Text( "You will be prompted to grant the app permission to send notifications for the first time." ) @@ -221,8 +218,6 @@ struct EditSingleRoundDialogCommandView: View { } } - - // MARK: - Preview struct EditCustomCommandView_Preview: PreviewProvider { @@ -239,12 +234,14 @@ struct EditCustomCommandView_Preview: PreviewProvider { generateDescription: true ) )), - reducer: EditCustomCommand( - settings: .init(customCommands: .init( - wrappedValue: [], - "CustomCommandView_Preview" - )) - ) + reducer: { + EditCustomCommand( + settings: .init(customCommands: .init( + wrappedValue: [], + "CustomCommandView_Preview" + )) + ) + } ) ) .frame(width: 800) @@ -255,7 +252,7 @@ struct EditSingleRoundDialogCommandView_Preview: PreviewProvider { static var previews: some View { EditSingleRoundDialogCommandView(store: .init( initialState: .init(), - reducer: EditSingleRoundDialogCommand() + reducer: { EditSingleRoundDialogCommand() } )) .frame(width: 800, height: 600) } diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift index 275f6fd7..36b27f74 100644 --- a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift @@ -28,6 +28,9 @@ struct ChatSettingsGeneralSectionView: View { @AppStorage( \.disableFloatOnTopWhenTheChatPanelIsDetached ) var disableFloatOnTopWhenTheChatPanelIsDetached + @AppStorage(\.openChatMode) var openChatMode + @AppStorage(\.openChatInBrowserURL) var openChatInBrowserURL + @AppStorage(\.openChatInBrowserInInAppBrowser) var openChatInBrowserInInAppBrowser init() {} } @@ -39,6 +42,8 @@ struct ChatSettingsGeneralSectionView: View { var body: some View { VStack { + openChatSettingsForm + SettingsDivider("Conversation") chatSettingsForm SettingsDivider("UI") uiForm @@ -47,6 +52,45 @@ struct ChatSettingsGeneralSectionView: View { } } + @ViewBuilder + var openChatSettingsForm: some View { + Form { + Picker( + "Open Chat Mode", + selection: $settings.openChatMode + ) { + ForEach(OpenChatMode.allCases, id: \.rawValue) { mode in + switch mode { + case .chatPanel: + Text("Open chat panel").tag(mode) + case .browser: + Text("Open web page in browser").tag(mode) + } + } + } + + if settings.openChatMode == .browser { + TextField( + "Chat web page URL", + text: $settings.openChatInBrowserURL, + prompt: Text("https://") + ) + .textFieldStyle(.roundedBorder) + .disableAutocorrection(true) + .autocorrectionDisabled(true) + + #if canImport(ProHostApp) + WithFeatureEnabled(\.browserTab) { + Toggle( + "Open web page in chat panel", + isOn: $settings.openChatInBrowserInInAppBrowser + ) + } + #endif + } + } + } + @ViewBuilder var chatSettingsForm: some View { Form { diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift index e011751b..60bb661b 100644 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift @@ -29,16 +29,8 @@ struct SuggestionFeatureDisabledLanguageListView: View { .padding() } .buttonStyle(.plain) - Text("Enabled Projects") + Text("Disabled Languages") Spacer() - Button(action: { - isAddingNewProject = true - }) { - Image(systemName: "plus.circle.fill") - .foregroundStyle(.secondary) - .padding() - } - .buttonStyle(.plain) } .background(Color(nsColor: .separatorColor)) @@ -85,6 +77,7 @@ struct SuggestionFeatureDisabledLanguageListView: View { Disable the language of a file by right clicking the circular widget. """) .multilineTextAlignment(.center) + .padding() } } } diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index cb5165da..b50cd876 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -5,7 +5,9 @@ import LaunchAgentManager import SwiftUI import XPCShared -struct General: ReducerProtocol { +@Reducer +struct General { + @ObservableState struct State: Equatable { var xpcServiceVersion: String? var isAccessibilityPermissionGranted: Bool? @@ -22,8 +24,10 @@ struct General: ReducerProtocol { } @Dependency(\.toast) var toast + + struct ReloadStatusCancellableId: Hashable {} - var body: some ReducerProtocol { + var body: some ReducerOf { Reduce { state, action in switch action { case .appear: @@ -89,7 +93,7 @@ struct General: ReducerProtocol { toast(error.localizedDescription, .error) await send(.failedReloading) } - } + }.cancellable(id: ReloadStatusCancellableId(), cancelInFlight: true) case let .finishReloading(version, granted): state.xpcServiceVersion = version diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index f25a7fb8..ba57a242 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -46,7 +46,7 @@ struct AppInfoView: View { .foregroundColor(.secondary) Spacer() - + Button(action: { store.send(.openExtensionManager) }) { @@ -92,58 +92,55 @@ struct AppInfoView: View { } struct ExtensionServiceView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - VStack(alignment: .leading) { - WithViewStore(store, observe: { $0.xpcServiceVersion }) { viewStore in - Text("Extension Service Version: \(viewStore.state ?? "Loading..")") - } + WithPerceptionTracking { + VStack(alignment: .leading) { + Text("Extension Service Version: \(store.xpcServiceVersion ?? "Loading..")") - WithViewStore(store, observe: { $0.isAccessibilityPermissionGranted }) { viewStore in let grantedStatus: String = { - guard let granted = viewStore.state else { return "Loading.." } + guard let granted = store.isAccessibilityPermissionGranted + else { return "Loading.." } return granted ? "Granted" : "Not Granted" }() Text("Accessibility Permission: \(grantedStatus)") - } - HStack { - WithViewStore(store, observe: { $0.isReloading }) { viewStore in - Button(action: { viewStore.send(.reloadStatus) }) { + HStack { + Button(action: { store.send(.reloadStatus) }) { Text("Refresh") - }.disabled(viewStore.state) - } - - Button(action: { - Task { - let workspace = NSWorkspace.shared - let url = Bundle.main.bundleURL - .appendingPathComponent("Contents") - .appendingPathComponent("Applications") - .appendingPathComponent("CopilotForXcodeExtensionService.app") - workspace.activateFileViewerSelecting([url]) + }.disabled(store.isReloading) + + Button(action: { + Task { + let workspace = NSWorkspace.shared + let url = Bundle.main.bundleURL + .appendingPathComponent("Contents") + .appendingPathComponent("Applications") + .appendingPathComponent("CopilotForXcodeExtensionService.app") + workspace.activateFileViewerSelecting([url]) + } + }) { + Text("Reveal Extension Service in Finder") } - }) { - Text("Reveal Extension Service in Finder") - } - Button(action: { - let url = URL( - string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" - )! - NSWorkspace.shared.open(url) - }) { - Text("Accessibility Settings") - } + Button(action: { + let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + )! + NSWorkspace.shared.open(url) + }) { + Text("Accessibility Settings") + } - Button(action: { - let url = URL( - string: "x-apple.systempreferences:com.apple.ExtensionsPreferences" - )! - NSWorkspace.shared.open(url) - }) { - Text("Extensions Settings") + Button(action: { + let url = URL( + string: "x-apple.systempreferences:com.apple.ExtensionsPreferences" + )! + NSWorkspace.shared.open(url) + }) { + Text("Extensions Settings") + } } } } @@ -245,7 +242,7 @@ struct GeneralSettingsView: View { @StateObject var settings = Settings() @Environment(\.updateChecker) var updateChecker @State var automaticallyCheckForUpdate: Bool? - + var body: some View { Form { Toggle(isOn: $settings.quitXPCServiceOnXcodeAndAppQuit) { @@ -261,7 +258,7 @@ struct GeneralSettingsView: View { )) { Text("Automatically Check for Update") } - + Toggle(isOn: $settings.installBetaBuilds) { Text("Install beta builds") } @@ -394,7 +391,7 @@ struct LargeIconPicker< struct GeneralView_Previews: PreviewProvider { static var previews: some View { - GeneralView(store: .init(initialState: .init(), reducer: General())) + GeneralView(store: .init(initialState: .init(), reducer: { General() })) .frame(height: 800) } } diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index e5379319..69ec3120 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -11,7 +11,9 @@ extension KeyboardShortcuts.Name { static let showHideWidget = Self("ShowHideWidget") } -struct HostApp: ReducerProtocol { +@Reducer +struct HostApp { + @ObservableState struct State: Equatable { var general = General.State() var chatModelManagement = ChatModelManagement.State() @@ -32,7 +34,7 @@ struct HostApp: ReducerProtocol { KeyboardShortcuts.userDefaults = .shared } - var body: some ReducerProtocol { + var body: some ReducerOf { Scope(state: \.general, action: /Action.general) { General() } diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift index e5b74332..07f8af77 100644 --- a/Core/Sources/HostApp/ServiceView.swift +++ b/Core/Sources/HostApp/ServiceView.swift @@ -1,73 +1,78 @@ -import SwiftUI import ComposableArchitecture +import SwiftUI struct ServiceView: View { let store: StoreOf @State var tag = 0 - + var body: some View { - SidebarTabView(tag: $tag) { - ScrollView { - GitHubCopilotView().padding() - }.sidebarItem( - tag: 0, - title: "GitHub Copilot", - subtitle: "Suggestion", - image: "globe" - ) - - ScrollView { - CodeiumView().padding() - }.sidebarItem( - tag: 1, - title: "Codeium", - subtitle: "Suggestion", - image: "globe" - ) - - ChatModelManagementView(store: store.scope( - state: \.chatModelManagement, - action: HostApp.Action.chatModelManagement - )).sidebarItem( - tag: 2, - title: "Chat Models", - subtitle: "Chat, Prompt to Code", - image: "globe" - ) - - EmbeddingModelManagementView(store: store.scope( - state: \.embeddingModelManagement, - action: HostApp.Action.embeddingModelManagement - )).sidebarItem( - tag: 3, - title: "Embedding Models", - subtitle: "Chat, Prompt to Code", - image: "globe" - ) - - ScrollView { - BingSearchView().padding() - }.sidebarItem( - tag: 4, - title: "Bing Search", - subtitle: "Search Chat Plugin", - image: "globe" - ) - - ScrollView { - OtherSuggestionServicesView().padding() - }.sidebarItem( - tag: 5, - title: "Other Suggestion Services", - subtitle: "Suggestion", - image: "globe" - ) + WithPerceptionTracking { + SidebarTabView(tag: $tag) { + WithPerceptionTracking { + ScrollView { + GitHubCopilotView().padding() + }.sidebarItem( + tag: 0, + title: "GitHub Copilot", + subtitle: "Suggestion", + image: "globe" + ) + + ScrollView { + CodeiumView().padding() + }.sidebarItem( + tag: 1, + title: "Codeium", + subtitle: "Suggestion", + image: "globe" + ) + + ChatModelManagementView(store: store.scope( + state: \.chatModelManagement, + action: \.chatModelManagement + )).sidebarItem( + tag: 2, + title: "Chat Models", + subtitle: "Chat, Prompt to Code", + image: "globe" + ) + + EmbeddingModelManagementView(store: store.scope( + state: \.embeddingModelManagement, + action: \.embeddingModelManagement + )).sidebarItem( + tag: 3, + title: "Embedding Models", + subtitle: "Chat, Prompt to Code", + image: "globe" + ) + + ScrollView { + BingSearchView().padding() + }.sidebarItem( + tag: 4, + title: "Bing Search", + subtitle: "Search Chat Plugin", + image: "globe" + ) + + ScrollView { + OtherSuggestionServicesView().padding() + }.sidebarItem( + tag: 5, + title: "Other Suggestion Services", + subtitle: "Suggestion", + image: "globe" + ) + } + } } } } struct AccountView_Previews: PreviewProvider { static var previews: some View { - ServiceView(store: .init(initialState: .init(), reducer: HostApp())) + ServiceView(store: .init(initialState: .init(), reducer: { HostApp() })) } } + diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index ac4bbb40..752158ea 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -11,7 +11,7 @@ import ProHostApp #endif @MainActor -let hostAppStore: StoreOf = .init(initialState: .init(), reducer: HostApp()) +let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() }) public struct TabContainer: View { let store: StoreOf @@ -30,62 +30,64 @@ public struct TabContainer: View { } public var body: some View { - VStack(spacing: 0) { - TabBar(tag: $tag, tabBarItems: tabBarItems) - .padding(.bottom, 8) - - Divider() - - ZStack(alignment: .center) { - GeneralView(store: store.scope(state: \.general, action: HostApp.Action.general)) - .tabBarItem( - tag: 0, - title: "General", - image: "app.gift" + WithPerceptionTracking { + VStack(spacing: 0) { + TabBar(tag: $tag, tabBarItems: tabBarItems) + .padding(.bottom, 8) + + Divider() + + ZStack(alignment: .center) { + GeneralView(store: store.scope(state: \.general, action: \.general)) + .tabBarItem( + tag: 0, + title: "General", + image: "app.gift" + ) + ServiceView(store: store).tabBarItem( + tag: 1, + title: "Service", + image: "globe" ) - ServiceView(store: store).tabBarItem( - tag: 1, - title: "Service", - image: "globe" - ) - FeatureSettingsView().tabBarItem( - tag: 2, - title: "Feature", - image: "star.square" - ) - CustomCommandView(store: customCommandStore).tabBarItem( - tag: 3, - title: "Custom Command", - image: "command.square" - ) - #if canImport(ProHostApp) - PlusView(onLicenseKeyChanged: { - store.send(.informExtensionServiceAboutLicenseKeyChange) - }).tabBarItem( - tag: 5, - title: "Plus", - image: "plus.diamond" - ) - #endif - DebugSettingsView().tabBarItem( - tag: 4, - title: "Advanced", - image: "gearshape.2" - ) + FeatureSettingsView().tabBarItem( + tag: 2, + title: "Feature", + image: "star.square" + ) + CustomCommandView(store: customCommandStore).tabBarItem( + tag: 3, + title: "Custom Command", + image: "command.square" + ) + #if canImport(ProHostApp) + PlusView(onLicenseKeyChanged: { + store.send(.informExtensionServiceAboutLicenseKeyChange) + }).tabBarItem( + tag: 5, + title: "Plus", + image: "plus.diamond" + ) + #endif + DebugSettingsView().tabBarItem( + tag: 4, + title: "Advanced", + image: "gearshape.2" + ) + } + .environment(\.tabBarTabTag, tag) + .frame(minHeight: 400) + } + .focusable(false) + .padding(.top, 8) + .background(.ultraThinMaterial.opacity(0.01)) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.4)) + .handleToast() + .onPreferenceChange(TabBarItemPreferenceKey.self) { items in + tabBarItems = items + } + .onAppear { + store.send(.appear) } - .environment(\.tabBarTabTag, tag) - .frame(minHeight: 400) - } - .focusable(false) - .padding(.top, 8) - .background(.ultraThinMaterial.opacity(0.01)) - .background(Color(nsColor: .controlBackgroundColor).opacity(0.4)) - .handleToast() - .onPreferenceChange(TabBarItemPreferenceKey.self) { items in - tabBarItems = items - } - .onAppear { - store.send(.appear) } } } @@ -235,7 +237,7 @@ struct TabContainer_Previews: PreviewProvider { struct TabContainer_Toasts_Previews: PreviewProvider { static var previews: some View { TabContainer( - store: .init(initialState: .init(), reducer: HostApp()), + store: .init(initialState: .init(), reducer: { HostApp() }), toastController: .init(messages: [ .init(id: UUID(), type: .info, content: Text("info")), .init(id: UUID(), type: .error, content: Text("error")), diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 4f17fc81..58bbb1b5 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -17,7 +17,9 @@ import ProChatTabs import ChatTabPersistent #endif -struct GUI: ReducerProtocol { +@Reducer +struct GUI { + @ObservableState struct State: Equatable { var suggestionWidgetState = WidgetFeature.State() @@ -53,7 +55,8 @@ struct GUI: ReducerProtocol { enum Action { case start case openChatPanel(forceDetach: Bool) - case createChatGPTChatTabIfNeeded + case createAndSwitchToChatGPTChatTabIfNeeded + case createAndSwitchToBrowserTabIfNeeded(url: URL) case sendCustomCommandToActiveChat(CustomCommand) case toggleWidgetsHotkeyPressed @@ -75,15 +78,15 @@ struct GUI: ReducerProtocol { case updateChatTabOrder } - var body: some ReducerProtocol { + var body: some ReducerOf { CombineReducers { - Scope(state: \.suggestionWidgetState, action: /Action.suggestionWidget) { + Scope(state: \.suggestionWidgetState, action: \.suggestionWidget) { WidgetFeature() } Scope( state: \.chatTabGroup, - action: /Action.suggestionWidget .. /WidgetFeature.Action.chatPanel + action: \.suggestionWidget.chatPanel ) { Reduce { _, action in switch action { @@ -115,7 +118,7 @@ struct GUI: ReducerProtocol { } #if canImport(ChatTabPersistent) - Scope(state: \.persistentState, action: /Action.persistent) { + Scope(state: \.persistentState, action: \.persistent) { ChatTabPersistent() } #endif @@ -143,11 +146,22 @@ struct GUI: ReducerProtocol { activateThisApp() } - case .createChatGPTChatTabIfNeeded: - if state.chatTabGroup.tabInfo.contains(where: { + case .createAndSwitchToChatGPTChatTabIfNeeded: + if let selectedTabInfo = state.chatTabGroup.selectedTabInfo, + chatTabPool.getTab(of: selectedTabInfo.id) is ChatGPTChatTab + { + // Already in ChatGPT tab + return .none + } + + if let firstChatGPTTabInfo = state.chatTabGroup.tabInfo.first(where: { chatTabPool.getTab(of: $0.id) is ChatGPTChatTab }) { - return .none + return .run { send in + await send(.suggestionWidget(.chatPanel(.tabClicked( + id: firstChatGPTTabInfo.id + )))) + } } return .run { send in if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil) { @@ -157,6 +171,53 @@ struct GUI: ReducerProtocol { } } + case let .createAndSwitchToBrowserTabIfNeeded(url): + #if canImport(BrowserChatTab) + func match(_ tabURL: URL?) -> Bool { + guard let tabURL else { return false } + return tabURL == url + || tabURL.absoluteString.hasPrefix(url.absoluteString) + } + + if let selectedTabInfo = state.chatTabGroup.selectedTabInfo, + let tab = chatTabPool.getTab(of: selectedTabInfo.id) as? BrowserChatTab, + match(tab.url) + { + // Already in the target Browser tab + return .none + } + + if let firstChatGPTTabInfo = state.chatTabGroup.tabInfo.first(where: { + guard let tab = chatTabPool.getTab(of: $0.id) as? BrowserChatTab, + match(tab.url) + else { return false } + return true + }) { + return .run { send in + await send(.suggestionWidget(.chatPanel(.tabClicked( + id: firstChatGPTTabInfo.id + )))) + } + } + + return .run { send in + if let (_, chatTabInfo) = await chatTabPool.createTab( + for: .init(BrowserChatTab.urlChatBuilder( + url: url, + externalDependency: ChatTabFactory + .externalDependenciesForBrowserChatTab() + )) + ) { + await send( + .suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo))) + ) + } + } + + #else + return .none + #endif + case let .sendCustomCommandToActiveChat(command): @Sendable func stopAndHandleCommand(_ tab: ChatGPTChatTab) async { if tab.service.isReceivingMessage { @@ -251,10 +312,9 @@ struct GUI: ReducerProtocol { @MainActor public final class GraphicalUserInterfaceController { - private let store: StoreOf + let store: StoreOf let widgetController: SuggestionWidgetController let widgetDataSource: WidgetDataSource - let viewStore: ViewStoreOf let chatTabPool: ChatTabPool class WeakStoreHolder { @@ -289,18 +349,17 @@ public final class GraphicalUserInterfaceController { } let store = StoreOf( initialState: .init(), - reducer: GUI(), - prepareDependencies: setupDependency + reducer: { GUI() }, + withDependencies: setupDependency ) self.store = store self.chatTabPool = chatTabPool - viewStore = ViewStore(store) widgetDataSource = .init() widgetController = SuggestionWidgetController( store: store.scope( state: \.suggestionWidgetState, - action: GUI.Action.suggestionWidget + action: \.suggestionWidget ), chatTabPool: chatTabPool, dependency: suggestionDependency @@ -309,8 +368,7 @@ public final class GraphicalUserInterfaceController { chatTabPool.createStore = { id in store.scope( state: { state in - state.chatTabGroup.tabInfo[id: id] - ?? .init(id: id, title: "") + state.chatTabGroup.tabInfo[id: id] ?? .init(id: id, title: "") }, action: { childAction in .suggestionWidget(.chatPanel(.chatTab(id: id, action: childAction))) @@ -321,8 +379,8 @@ public final class GraphicalUserInterfaceController { suggestionDependency.suggestionWidgetDataSource = widgetDataSource suggestionDependency.onOpenChatClicked = { [weak self] in Task { [weak self] in - await self?.viewStore.send(.createChatGPTChatTabIfNeeded).finish() - self?.viewStore.send(.openChatPanel(forceDetach: false)) + await self?.store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish() + self?.store.send(.openChatPanel(forceDetach: false)) } } suggestionDependency.onCustomCommandClicked = { command in @@ -338,10 +396,7 @@ public final class GraphicalUserInterfaceController { } public func openGlobalChat() { - Task { - await self.viewStore.send(.createChatGPTChatTabIfNeeded).finish() - viewStore.send(.openChatPanel(forceDetach: true)) - } + PseudoCommandHandler().openChat(forceDetach: true) } } diff --git a/Core/Sources/Service/GlobalShortcutManager.swift b/Core/Sources/Service/GlobalShortcutManager.swift index 3ed6a69c..9620f25a 100644 --- a/Core/Sources/Service/GlobalShortcutManager.swift +++ b/Core/Sources/Service/GlobalShortcutManager.swift @@ -25,12 +25,12 @@ final class GlobalShortcutManager { let isXCodeActive = XcodeInspector.shared.activeXcode != nil if !isXCodeActive, - !guiController.viewStore.state.suggestionWidgetState.chatPanelState.isPanelDisplayed, + !guiController.store.state.suggestionWidgetState.chatPanelState.isPanelDisplayed, UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) { - guiController.viewStore.send(.openChatPanel(forceDetach: true)) + guiController.store.send(.openChatPanel(forceDetach: true)) } else { - guiController.viewStore.send(.toggleWidgetsHotkeyPressed) + guiController.store.send(.toggleWidgetsHotkeyPressed) } } diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 27965c7a..06d6f522 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -124,7 +124,7 @@ public actor RealtimeSuggestionController { filespace.codeMetadata.uti = "" do { try await XcodeInspector.shared.safe.latestActiveXcode? - .triggerCopilotCommand(name: "Real-time Suggestions") + .triggerCopilotCommand(name: "Prepare for Real-time Suggestions") } catch { if filespace.codeMetadata.uti?.isEmpty ?? true { filespace.codeMetadata.uti = nil diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 7e96e113..0475baf9 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -1,6 +1,7 @@ import ActiveApplicationMonitor import AppKit import AXExtension +import BuiltinExtension import Foundation import Logger import Workspace @@ -32,7 +33,7 @@ public final class ScheduledCleaner { @ServiceActor func cleanUp() async { guard let service else { return } - + let workspaceInfos = XcodeInspector.shared.xcodes.reduce( into: [ XcodeAppInstanceInspector.WorkspaceIdentifier: @@ -52,7 +53,7 @@ public final class ScheduledCleaner { if workspace.isExpired, workspaceInfos[.url(url)] == nil { Logger.service.info("Remove idle workspace") _ = await Task { @MainActor in - service.guiController.viewStore.send( + service.guiController.store.send( .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: Array( workspace.filespaces.keys ))) @@ -72,7 +73,7 @@ public final class ScheduledCleaner { ) { Logger.service.info("Remove idle filespace") _ = await Task { @MainActor in - service.guiController.viewStore.send( + service.guiController.store.send( .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: [url])) ) }.result @@ -82,7 +83,7 @@ public final class ScheduledCleaner { await workspace.cleanUp(availableTabs: tabs) } } - + #if canImport(ProService) await service.proService.cleanUp(workspaceInfos: workspaceInfos) #endif @@ -90,10 +91,7 @@ public final class ScheduledCleaner { @ServiceActor public func closeAllChildProcesses() async { - guard let service else { return } - for (_, workspace) in service.workspacePool.workspaces { - await workspace.terminateSuggestionService() - } + BuiltinExtensionManager.shared.terminate() } } diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 9c977054..fa62b47e 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -1,6 +1,9 @@ +import BuiltinExtension +import CodeiumService import Combine import Dependencies import Foundation +import GitHubCopilotService import SuggestionService import Toast import Workspace @@ -39,11 +42,22 @@ public final class Service { private init() { @Dependency(\.workspacePool) var workspacePool + BuiltinExtensionManager.shared.setupExtensions([ + GitHubCopilotExtension(workspacePool: workspacePool), + CodeiumExtension(workspacePool: workspacePool), + ]) scheduledCleaner = .init() workspacePool.registerPlugin { - SuggestionServiceWorkspacePlugin(workspace: $0) { projectRootURL, onLaunched in - SuggestionService(projectRootURL: projectRootURL, onServiceLaunched: onLaunched) - } + SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } + } + workspacePool.registerPlugin { + GitHubCopilotWorkspacePlugin(workspace: $0) + } + workspacePool.registerPlugin { + CodeiumWorkspacePlugin(workspace: $0) + } + workspacePool.registerPlugin { + BuiltinExtensionWorkspacePlugin(workspace: $0) } self.workspacePool = workspacePool globalShortcutManager = .init(guiController: guiController) @@ -86,7 +100,7 @@ public final class Service { }.store(in: &cancellable) } } - + @MainActor public func prepareForExit() async { #if canImport(ProService) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index ebbbebb1..66f6b2eb 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -1,5 +1,7 @@ import ActiveApplicationMonitor import AppKit +import Dependencies +import PlusFeatureFlag import Preferences import SuggestionInjector import SuggestionModel @@ -210,7 +212,13 @@ struct PseudoCommandHandler { guard let focusElement = application.focusedElement, focusElement.description == "Source Editor" else { return } - guard let (content, lines, _, cursorPosition, cursorOffset) = await getFileContent(sourceEditor: nil) + guard let ( + content, + lines, + _, + cursorPosition, + cursorOffset + ) = await getFileContent(sourceEditor: nil) else { PresentInWindowSuggestionPresenter() .presentErrorMessage("Unable to get file content.") @@ -266,7 +274,13 @@ struct PseudoCommandHandler { guard let focusElement = application.focusedElement, focusElement.description == "Source Editor" else { return } - guard let (content, lines, _, cursorPosition, cursorOffset) = await getFileContent(sourceEditor: nil) + guard let ( + content, + lines, + _, + cursorPosition, + cursorOffset + ) = await getFileContent(sourceEditor: nil) else { PresentInWindowSuggestionPresenter() .presentErrorMessage("Unable to get file content.") @@ -301,6 +315,46 @@ struct PseudoCommandHandler { await filespace.reset() PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL) } + + func openChat(forceDetach: Bool) { + switch UserDefaults.shared.value(for: \.openChatMode) { + case .chatPanel: + let store = Service.shared.guiController.store + Task { @MainActor in + await store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish() + store.send(.openChatPanel(forceDetach: forceDetach)) + } + case .browser: + let urlString = UserDefaults.shared.value(for: \.openChatInBrowserURL) + let openInApp = { + if !UserDefaults.shared.value(for: \.openChatInBrowserInInAppBrowser) { + return false + } + return isFeatureAvailable(\.browserTab) + }() + guard let url = URL(string: urlString) else { + let alert = NSAlert() + alert.messageText = "Invalid URL" + alert.informativeText = "The URL provided is not valid." + alert.alertStyle = .warning + alert.runModal() + return + } + + if openInApp { + let store = Service.shared.guiController.store + Task { @MainActor in + await store.send(.createAndSwitchToBrowserTabIfNeeded(url: url)).finish() + store.send(.openChatPanel(forceDetach: forceDetach)) + } + } else { + Task { + @Dependency(\.openURL) var openURL + await openURL(url) + } + } + } + } } extension PseudoCommandHandler { diff --git a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift index 35eec4fb..4c07b5bb 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift @@ -19,8 +19,6 @@ protocol SuggestionCommandHandler { @ServiceActor func generateRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor - func chatWithSelection(editor: EditorContent) async throws -> UpdatedContent? - @ServiceActor func promptToCode(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor func customCommand(id: String, editor: EditorContent) async throws -> UpdatedContent? diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index a8d10385..d493dded 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -178,9 +178,9 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { var cursorPosition = editor.cursorPosition var extraInfo = SuggestionInjector.ExtraInfo() - let viewStore = Service.shared.guiController.viewStore + let store = Service.shared.guiController.store - if let promptToCode = viewStore.state.promptToCodeGroup.activePromptToCode { + if let promptToCode = store.state.promptToCodeGroup.activePromptToCode { if promptToCode.isAttachedToSelectionRange, promptToCode.documentURL != fileURL { return nil } @@ -214,13 +214,13 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { ) _ = await Task { @MainActor [cursorPosition] in - viewStore.send( + store.send( .promptToCodeGroup(.updatePromptToCodeRange( id: promptToCode.id, range: .init(start: range.start, end: cursorPosition) )) ) - viewStore.send( + store.send( .promptToCodeGroup(.discardAcceptedPromptToCodeIfNotContinuous( id: promptToCode.id )) @@ -262,15 +262,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return try await presentSuggestions(editor: editor) } - func chatWithSelection(editor: EditorContent) async throws -> UpdatedContent? { - Task { @MainActor in - let viewStore = Service.shared.guiController.viewStore - viewStore.send(.createChatGPTChatTabIfNeeded) - viewStore.send(.openChatPanel(forceDetach: false)) - } - return nil - } - func promptToCode(editor: EditorContent) async throws -> UpdatedContent? { Task { do { @@ -314,7 +305,7 @@ extension WindowBaseCommandHandler { switch command.feature { case .chatWithSelection, .customChat: Task { @MainActor in - Service.shared.guiController.viewStore + Service.shared.guiController.store .send(.sendCustomCommandToActiveChat(command)) } case let .promptToCode(extraSystemPrompt, prompt, continuousMode, generateDescription): @@ -397,7 +388,7 @@ extension WindowBaseCommandHandler { ) }() as (String, CursorRange) - let viewStore = Service.shared.guiController.viewStore + let store = Service.shared.guiController.store let customCommandTemplateProcessor = CustomCommandTemplateProcessor() @@ -415,7 +406,7 @@ extension WindowBaseCommandHandler { _ = await Task { @MainActor in // if there is already a prompt to code presenting, we should not present another one - viewStore.send(.promptToCodeGroup(.activateOrCreatePromptToCode(.init( + store.send(.promptToCodeGroup(.activateOrCreatePromptToCode(.init( code: code, selectionRange: selection, language: codeLanguage, diff --git a/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift index 6841a24c..d154aade 100644 --- a/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift +++ b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift @@ -1,6 +1,6 @@ import Foundation -import Workspace import SuggestionProvider +import Workspace import WorkspaceSuggestionService extension Workspace { @@ -8,9 +8,6 @@ extension Workspace { func cleanUp(availableTabs: Set) { for (fileURL, _) in filespaces { if isFilespaceExpired(fileURL: fileURL, availableTabs: availableTabs) { - Task { - try await suggestionService?.notifyCloseTextDocument(fileURL: fileURL) - } openedFileRecoverableStorage.closeFile(fileURL: fileURL) closeFilespace(fileURL: fileURL) } @@ -26,10 +23,10 @@ extension Workspace { func cancelInFlightRealtimeSuggestionRequests() async { guard let suggestionService else { return } - await suggestionService.cancelRequest() - } - - func terminateSuggestionService() async { - await suggestionPlugin?.terminateSuggestionService() + await suggestionService.cancelRequest(workspaceInfo: .init( + workspaceURL: workspaceURL, + projectURL: projectRootURL + )) } } + diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 3c2cf47b..1cffcc7a 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -140,13 +140,13 @@ public class XPCService: NSObject, XPCServiceProtocol { } } - public func chatWithSelection( + public func openChat( editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void ) { - replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in - try await handler.chatWithSelection(editor: editor) - } + let handler = PseudoCommandHandler() + handler.openChat(forceDetach: false) + reply(nil, nil) } public func promptToCode( @@ -177,10 +177,16 @@ public class XPCService: NSObject, XPCServiceProtocol { } Task { @ServiceActor in await Service.shared.realtimeSuggestionController.cancelInFlightTasks() - UserDefaults.shared.set( - !UserDefaults.shared.value(for: \.realtimeSuggestionToggle), - for: \.realtimeSuggestionToggle - ) + let on = !UserDefaults.shared.value(for: \.realtimeSuggestionToggle) + UserDefaults.shared.set(on, for: \.realtimeSuggestionToggle) + Task { @MainActor in + Service.shared.guiController.store + .send(.suggestionWidget(.toastPanel(.toast(.toast( + "Real-time suggestion is turned \(on ? "on" : "off")", + .info, + nil + ))))) + } reply(nil) } } diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index d20b3f1b..5fdd6ccc 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -1,8 +1,13 @@ +import BuiltinExtension +import CodeiumService +import struct CopilotForXcodeKit.WorkspaceInfo import Foundation +import GitHubCopilotService import Preferences import SuggestionModel import SuggestionProvider import UserDefaultsObserver +import Workspace #if canImport(ProExtension) import ProExtension @@ -11,119 +16,87 @@ import ProExtension public protocol SuggestionServiceType: SuggestionServiceProvider {} public actor SuggestionService: SuggestionServiceType { - public var configuration: SuggestionServiceConfiguration { + public var configuration: SuggestionProvider.SuggestionServiceConfiguration { get async { await suggestionProvider.configuration } } - var middlewares: [SuggestionServiceMiddleware] { - SuggestionServiceMiddlewareContainer.middlewares - } - - let projectRootURL: URL - let onServiceLaunched: (SuggestionServiceProvider) -> Void - let providerChangeObserver = UserDefaultsObserver( - object: UserDefaults.shared, - forKeyPaths: [UserDefaultPreferenceKeys().suggestionFeatureProvider.key], - context: nil - ) + let middlewares: [SuggestionServiceMiddleware] - lazy var suggestionProvider: SuggestionServiceProvider = buildService() - - var serviceType: SuggestionFeatureProvider { - UserDefaults.shared.value(for: \.suggestionFeatureProvider) - } + let suggestionProvider: SuggestionServiceProvider public init( - projectRootURL: URL, - onServiceLaunched: @escaping (SuggestionServiceProvider) -> Void + provider: any SuggestionServiceProvider, + middlewares: [SuggestionServiceMiddleware] = SuggestionServiceMiddlewareContainer + .middlewares ) { - self.projectRootURL = projectRootURL - self.onServiceLaunched = onServiceLaunched - - providerChangeObserver.onChange = { [weak self] in - Task { [weak self] in - guard let self else { return } - await rebuildService() - } - } + suggestionProvider = provider + self.middlewares = middlewares } - func buildService() -> SuggestionServiceProvider { + public static func service( + for serviceType: SuggestionFeatureProvider = UserDefaults.shared + .value(for: \.suggestionFeatureProvider) + ) -> SuggestionService { #if canImport(ProExtension) if let provider = ProExtension.suggestionProviderFactory(serviceType) { - return provider + return SuggestionService(provider: provider) } #endif switch serviceType { case .builtIn(.codeium): - return CodeiumSuggestionProvider( - projectRootURL: projectRootURL, - onServiceLaunched: onServiceLaunched + let provider = BuiltinExtensionSuggestionServiceProvider( + extension: CodeiumExtension.self ) + return SuggestionService(provider: provider) case .builtIn(.gitHubCopilot), .extension: - return GitHubCopilotSuggestionProvider( - projectRootURL: projectRootURL, - onServiceLaunched: onServiceLaunched + let provider = BuiltinExtensionSuggestionServiceProvider( + extension: GitHubCopilotExtension.self ) + return SuggestionService(provider: provider) } } - - func rebuildService() { - suggestionProvider = buildService() - } } public extension SuggestionService { func getSuggestions( - _ request: SuggestionRequest + _ request: SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo ) async throws -> [SuggestionModel.CodeSuggestion] { - var getSuggestion = suggestionProvider.getSuggestions + var getSuggestion = suggestionProvider.getSuggestions(_:workspaceInfo:) let configuration = await configuration for middleware in middlewares.reversed() { - getSuggestion = { [getSuggestion] request in + getSuggestion = { [getSuggestion] request, workspaceInfo in try await middleware.getSuggestion( request, configuration: configuration, - next: getSuggestion + next: { [getSuggestion] request in + try await getSuggestion(request, workspaceInfo) + } ) } } - return try await getSuggestion(request) - } - - func notifyAccepted(_ suggestion: SuggestionModel.CodeSuggestion) async { - await suggestionProvider.notifyAccepted(suggestion) - } - - func notifyRejected(_ suggestions: [SuggestionModel.CodeSuggestion]) async { - await suggestionProvider.notifyRejected(suggestions) - } - - func notifyOpenTextDocument(fileURL: URL, content: String) async throws { - try await suggestionProvider.notifyOpenTextDocument(fileURL: fileURL, content: content) - } - - func notifyChangeTextDocument(fileURL: URL, content: String) async throws { - try await suggestionProvider.notifyChangeTextDocument(fileURL: fileURL, content: content) - } - - func notifyCloseTextDocument(fileURL: URL) async throws { - try await suggestionProvider.notifyCloseTextDocument(fileURL: fileURL) + return try await getSuggestion(request, workspaceInfo) } - func notifySaveTextDocument(fileURL: URL) async throws { - try await suggestionProvider.notifySaveTextDocument(fileURL: fileURL) + func notifyAccepted( + _ suggestion: SuggestionModel.CodeSuggestion, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + await suggestionProvider.notifyAccepted(suggestion, workspaceInfo: workspaceInfo) } - func cancelRequest() async { - await suggestionProvider.cancelRequest() + func notifyRejected( + _ suggestions: [SuggestionModel.CodeSuggestion], + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + await suggestionProvider.notifyRejected(suggestions, workspaceInfo: workspaceInfo) } - func terminate() async { - await suggestionProvider.terminate() + func cancelRequest(workspaceInfo: CopilotForXcodeKit.WorkspaceInfo) async { + await suggestionProvider.cancelRequest(workspaceInfo: workspaceInfo) } } diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index dd246840..27450898 100644 --- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -1,6 +1,5 @@ import AppKit import ChatTab -import Combine import ComposableArchitecture import Foundation import SwiftUI @@ -9,7 +8,7 @@ final class ChatPanelWindow: NSWindow { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } - private var cancellable: Set = [] + private let storeObserver = NSObject() var minimizeWindow: () -> Void = {} @@ -60,18 +59,17 @@ final class ChatPanelWindow: NSWindow { setIsVisible(true) isPanelDisplayed = false - let viewStore = ViewStore(store) - viewStore.publisher - .map(\.isDetached) - .receive(on: DispatchQueue.main) - .sink { [weak self] isDetached in - guard let self else { return } + storeObserver.observe { [weak self] in + guard let self else { return } + let isDetached = store.isDetached + Task { @MainActor in if UserDefaults.shared.value(for: \.disableFloatOnTopWhenTheChatPanelIsDetached) { self.setFloatOnTop(!isDetached) } else { self.setFloatOnTop(true) } - }.store(in: &cancellable) + } + } } func setFloatOnTop(_ isFloatOnTop: Bool) { diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 35d19b0e..f73f3070 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -11,23 +11,9 @@ struct ChatWindowView: View { let store: StoreOf let toggleVisibility: (Bool) -> Void - struct OverallState: Equatable { - var isPanelDisplayed: Bool - var colorScheme: ColorScheme - var selectedTabId: String? - } - var body: some View { - WithViewStore( - store, - observe: { - OverallState( - isPanelDisplayed: $0.isPanelDisplayed, - colorScheme: $0.colorScheme, - selectedTabId: $0.chatTabGroup.selectedTabId - ) - } - ) { viewStore in + WithPerceptionTracking { + let _ = store.chatTabGroup.selectedTabId // force re-evaluation VStack(spacing: 0) { Rectangle().fill(.regularMaterial).frame(height: 28) @@ -43,10 +29,10 @@ struct ChatWindowView: View { } .xcodeStyleFrame(cornerRadius: 10) .ignoresSafeArea(edges: .top) - .onChange(of: viewStore.state.isPanelDisplayed) { isDisplayed in + .onChange(of: store.isPanelDisplayed) { isDisplayed in toggleVisibility(isDisplayed) } - .preferredColorScheme(viewStore.state.colorScheme) + .preferredColorScheme(store.colorScheme) } } } @@ -56,33 +42,33 @@ struct ChatTitleBar: View { @State var isHovering = false var body: some View { - HStack(spacing: 6) { - Button(action: { - store.send(.closeActiveTabClicked) - }) { - EmptyView() - } - .opacity(0) - .keyboardShortcut("w", modifiers: [.command]) + WithPerceptionTracking { + HStack(spacing: 6) { + Button(action: { + store.send(.closeActiveTabClicked) + }) { + EmptyView() + } + .opacity(0) + .keyboardShortcut("w", modifiers: [.command]) - Button( - action: { - store.send(.hideButtonClicked) + Button( + action: { + store.send(.hideButtonClicked) + } + ) { + Image(systemName: "minus") + .foregroundStyle(.black.opacity(0.5)) + .font(Font.system(size: 8).weight(.heavy)) } - ) { - Image(systemName: "minus") - .foregroundStyle(.black.opacity(0.5)) - .font(Font.system(size: 8).weight(.heavy)) - } - .opacity(0) - .keyboardShortcut("m", modifiers: [.command]) + .opacity(0) + .keyboardShortcut("m", modifiers: [.command]) - Spacer() + Spacer() - WithViewStore(store, observe: { $0.isDetached }) { viewStore in TrafficLightButton( isHovering: isHovering, - isActive: viewStore.state, + isActive: store.isDetached, color: Color(nsColor: .systemCyan), action: { store.send(.toggleChatPanelDetachedButtonClicked) @@ -94,12 +80,12 @@ struct ChatTitleBar: View { .transformEffect(.init(translationX: 0, y: 0.5)) } } + .buttonStyle(.plain) + .padding(.trailing, 8) + .onHover(perform: { hovering in + isHovering = hovering + }) } - .buttonStyle(.plain) - .padding(.trailing, 8) - .onHover(perform: { hovering in - isHovering = hovering - }) } struct TrafficLightButton: View { @@ -157,30 +143,44 @@ struct ChatTabBar: View { var selectedTabId: String } - @Environment(\.chatTabPool) var chatTabPool - @State var draggingTabId: String? - var body: some View { - WithViewStore( - store, - observe: { TabBarState( - tabInfo: $0.chatTabGroup.tabInfo, - selectedTabId: $0.chatTabGroup.selectedTabId - ?? $0.chatTabGroup.tabInfo.first?.id ?? "" - ) } - ) { viewStore in - HStack(spacing: 0) { + HStack(spacing: 0) { + Divider() + Tabs(store: store) + CreateButton(store: store) + } + .background { + Button(action: { store.send(.switchToNextTab) }) { EmptyView() } + .opacity(0) + .keyboardShortcut("]", modifiers: [.command, .shift]) + Button(action: { store.send(.switchToPreviousTab) }) { EmptyView() } + .opacity(0) + .keyboardShortcut("[", modifiers: [.command, .shift]) + } + } + + struct Tabs: View { + let store: StoreOf + @State var draggingTabId: String? + @Environment(\.chatTabPool) var chatTabPool + + var body: some View { + WithPerceptionTracking { + let tabInfo = store.chatTabGroup.tabInfo + let selectedTabId = store.chatTabGroup.selectedTabId + ?? store.chatTabGroup.tabInfo.first?.id + ?? "" ScrollViewReader { proxy in ScrollView(.horizontal) { HStack(spacing: 0) { - ForEach(viewStore.state.tabInfo, id: \.id) { info in + ForEach(tabInfo, id: \.id) { info in if let tab = chatTabPool.getTab(of: info.id) { ChatTabBarButton( store: store, info: info, content: { tab.tabItem }, icon: { tab.icon }, - isSelected: info.id == viewStore.state.selectedTabId + isSelected: info.id == selectedTabId ) .contextMenu { tab.menu @@ -194,7 +194,7 @@ struct ChatTabBar: View { of: [.text], delegate: ChatTabBarDropDelegate( store: store, - tabs: viewStore.state.tabInfo, + tabs: tabInfo, itemId: info.id, draggingTabId: $draggingTabId ) @@ -207,72 +207,61 @@ struct ChatTabBar: View { } } .hideScrollIndicator() - .onChange(of: viewStore.selectedTabId) { id in + .onChange(of: selectedTabId) { id in withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo(id) } } } - - Divider() - - createButton } } - .background { - Button(action: { store.send(.switchToNextTab) }) { EmptyView() } - .opacity(0) - .keyboardShortcut("]", modifiers: [.command, .shift]) - Button(action: { store.send(.switchToPreviousTab) }) { EmptyView() } - .opacity(0) - .keyboardShortcut("[", modifiers: [.command, .shift]) - } } - @ViewBuilder - var createButton: some View { - Menu { - WithViewStore(store, observe: { $0.chatTabGroup.tabCollection }) { viewStore in - ForEach(0.. + + var body: some View { + WithPerceptionTracking { + let collection = store.chatTabGroup.tabCollection + Menu { + ForEach(0..: View { struct ChatTabContainer: View { let store: StoreOf - - struct TabContainerState: Equatable { - var tabInfo: IdentifiedArray - var selectedTabId: String? - } - @Environment(\.chatTabPool) var chatTabPool var body: some View { - WithViewStore( - store, - observe: { - TabContainerState( - tabInfo: $0.chatTabGroup.tabInfo, - selectedTabId: $0.chatTabGroup.selectedTabId - ?? $0.chatTabGroup.tabInfo.first?.id ?? "" - ) - } - ) { viewStore in + WithPerceptionTracking { + let tabInfo = store.chatTabGroup.tabInfo + let selectedTabId = store.chatTabGroup.selectedTabId + ?? store.chatTabGroup.tabInfo.first?.id + ?? "" + ZStack { - if viewStore.state.tabInfo.isEmpty { + if tabInfo.isEmpty { Text("Empty") } else { - ForEach(viewStore.state.tabInfo) { tabInfo in + ForEach(tabInfo) { tabInfo in if let tab = chatTabPool.getTab(of: tabInfo.id) { - let isActive = tab.id == viewStore.state.selectedTabId + let isActive = tab.id == selectedTabId tab.body .opacity(isActive ? 1 : 0) .disabled(!isActive) @@ -428,12 +407,12 @@ struct ChatWindowView_Previews: PreviewProvider { .init(id: "5", title: "Empty-5"), .init(id: "6", title: "Empty-6"), .init(id: "7", title: "Empty-7"), - ], + ] as IdentifiedArray, selectedTabId: "2" ), isPanelDisplayed: true ), - reducer: ChatPanelFeature() + reducer: { ChatPanelFeature() } ) } diff --git a/Core/Sources/SuggestionWidget/CursorPositionTracker.swift b/Core/Sources/SuggestionWidget/CursorPositionTracker.swift new file mode 100644 index 00000000..ef5d1155 --- /dev/null +++ b/Core/Sources/SuggestionWidget/CursorPositionTracker.swift @@ -0,0 +1,53 @@ +import Combine +import Foundation +import Perception +import SuggestionModel +import XcodeInspector + +@Perceptible +final class CursorPositionTracker { + @MainActor + var cursorPosition: CursorPosition = .zero + + @PerceptionIgnored var editorObservationTask: Set = [] + @PerceptionIgnored var eventObservationTask: Task? + + init() { + observeAppChange() + } + + deinit { + eventObservationTask?.cancel() + } + + private func observeAppChange() { + editorObservationTask = [] + Task { + await XcodeInspector.shared.safe.$focusedEditor.sink { [weak self] editor in + guard let editor, let self else { return } + Task { @MainActor in + self.observeAXNotifications(editor) + } + }.store(in: &editorObservationTask) + } + } + + private func observeAXNotifications(_ editor: SourceEditor) { + eventObservationTask?.cancel() + let content = editor.getLatestEvaluatedContent() + Task { @MainActor in + self.cursorPosition = content.cursorPosition + } + eventObservationTask = Task { [weak self] in + for await event in await editor.axNotifications.notifications() { + guard let self else { return } + guard event.kind == .evaluatedContentChanged else { continue } + let content = editor.getLatestEvaluatedContent() + Task { @MainActor in + self.cursorPosition = content.cursorPosition + } + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index 0d4ef340..a97ba373 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -22,7 +22,8 @@ public struct ChatTabKind: Equatable { } } -public struct ChatPanelFeature: ReducerProtocol { +@Reducer +public struct ChatPanelFeature { public struct ChatTabGroup: Equatable { public var tabInfo: IdentifiedArray public var tabCollection: [ChatTabBuilderCollection] @@ -44,6 +45,7 @@ public struct ChatPanelFeature: ReducerProtocol { } } + @ObservableState public struct State: Equatable { public var chatTabGroup = ChatTabGroup() var colorScheme: ColorScheme = .light @@ -90,7 +92,7 @@ public struct ChatPanelFeature: ReducerProtocol { window?.toggleFullScreen(nil) } - public var body: some ReducerProtocol { + public var body: some ReducerOf { Reduce { state, action in switch action { case .hideButtonClicked: diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift index 8c09a769..40e95e62 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift @@ -4,11 +4,13 @@ import Preferences import SuggestionModel import SwiftUI -public struct CircularWidgetFeature: ReducerProtocol { +@Reducer +public struct CircularWidgetFeature { public struct IsProcessingCounter: Equatable { var expirationDate: TimeInterval } + @ObservableState public struct State: Equatable { var isProcessingCounters = [IsProcessingCounter]() var isProcessing: Bool @@ -31,48 +33,50 @@ public struct CircularWidgetFeature: ReducerProtocol { struct CancelAutoEndIsProcessKey: Hashable {} @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency - - public func reduce(into state: inout State, action: Action) -> EffectTask { - switch action { - case .detachChatPanelToggleClicked: - return .none // handled elsewhere - - case .openChatButtonClicked: - return .run { _ in - suggestionWidgetControllerDependency.onOpenChatClicked() + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .detachChatPanelToggleClicked: + return .none // handled elsewhere + + case .openChatButtonClicked: + return .run { _ in + suggestionWidgetControllerDependency.onOpenChatClicked() + } + + case let .runCustomCommandButtonClicked(command): + return .run { _ in + suggestionWidgetControllerDependency.onCustomCommandClicked(command) + } + + case .widgetClicked: + return .none // handled elsewhere + + case .markIsProcessing: + let deadline = Date().timeIntervalSince1970 + 20 + state.isProcessingCounters.append(IsProcessingCounter(expirationDate: deadline)) + state.isProcessing = true + return .run { send in + try await Task.sleep(nanoseconds: 20 * 1_000_000_000) + try Task.checkCancellation() + await send(._forceEndIsProcessing) + }.cancellable(id: CancelAutoEndIsProcessKey(), cancelInFlight: true) + + case .endIsProcessing: + if !state.isProcessingCounters.isEmpty { + state.isProcessingCounters.removeFirst() + } + state.isProcessingCounters + .removeAll(where: { $0.expirationDate < Date().timeIntervalSince1970 }) + state.isProcessing = !state.isProcessingCounters.isEmpty + return .none + + case ._forceEndIsProcessing: + state.isProcessingCounters.removeAll() + state.isProcessing = false + return .none } - - case let .runCustomCommandButtonClicked(command): - return .run { _ in - suggestionWidgetControllerDependency.onCustomCommandClicked(command) - } - - case .widgetClicked: - return .none // handled elsewhere - - case .markIsProcessing: - let deadline = Date().timeIntervalSince1970 + 20 - state.isProcessingCounters.append(IsProcessingCounter(expirationDate: deadline)) - state.isProcessing = true - return .run { send in - try await Task.sleep(nanoseconds: 20 * 1_000_000_000) - try Task.checkCancellation() - await send(._forceEndIsProcessing) - }.cancellable(id: CancelAutoEndIsProcessKey(), cancelInFlight: true) - - case .endIsProcessing: - if !state.isProcessingCounters.isEmpty { - state.isProcessingCounters.removeFirst() - } - state.isProcessingCounters - .removeAll(where: { $0.expirationDate < Date().timeIntervalSince1970 }) - state.isProcessing = !state.isProcessingCounters.isEmpty - return .none - - case ._forceEndIsProcessing: - state.isProcessingCounters.removeAll() - state.isProcessing = false - return .none } } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift index 954c5743..0467da4b 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -2,7 +2,9 @@ import AppKit import ComposableArchitecture import Foundation -public struct PanelFeature: ReducerProtocol { +@Reducer +public struct PanelFeature { + @ObservableState public struct State: Equatable { public var content: SharedPanelFeature.Content { get { sharedPanelState.content } @@ -40,12 +42,12 @@ public struct PanelFeature: ReducerProtocol { @Dependency(\.activateThisApp) var activateThisApp var windows: WidgetWindows? { suggestionWidgetControllerDependency.windowsController?.windows } - public var body: some ReducerProtocol { - Scope(state: \.suggestionPanelState, action: /Action.suggestionPanel) { + public var body: some ReducerOf { + Scope(state: \.suggestionPanelState, action: \.suggestionPanel) { SuggestionPanelFeature() } - Scope(state: \.sharedPanelState, action: /Action.sharedPanel) { + Scope(state: \.sharedPanelState, action: \.sharedPanel) { SharedPanelFeature() } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift index da791871..02c3b797 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift @@ -23,7 +23,9 @@ public extension DependencyValues { } } -public struct PromptToCode: ReducerProtocol { +@Reducer +public struct PromptToCode { + @ObservableState public struct State: Equatable, Identifiable { public indirect enum HistoryNode: Equatable { case empty @@ -66,10 +68,10 @@ public struct PromptToCode: ReducerProtocol { public var extraSystemPrompt: String? public var generateDescriptionRequirement: Bool? public var commandName: String? - @BindingState public var prompt: String - @BindingState public var isContinuous: Bool - @BindingState public var isAttachedToSelectionRange: Bool - @BindingState public var focusedField: FocusField? = .textField + public var prompt: String + public var isContinuous: Bool + public var isAttachedToSelectionRange: Bool + public var focusedField: FocusField? = .textField public var filename: String { documentURL.lastPathComponent } public var canRevert: Bool { history != .empty } @@ -145,7 +147,7 @@ public struct PromptToCode: ReducerProtocol { case modifyCode(State.ID) } - public var body: some ReducerProtocol { + public var body: some ReducerOf { BindingReducer() Reduce { state, action in diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift index ec43c49c..ad644677 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -4,7 +4,9 @@ import PromptToCodeService import SuggestionModel import XcodeInspector -public struct PromptToCodeGroup: ReducerProtocol { +@Reducer +public struct PromptToCodeGroup { + @ObservableState public struct State: Equatable { public var promptToCodes: IdentifiedArrayOf = [] public var activeDocumentURL: PromptToCode.State.ID? = XcodeInspector.shared @@ -89,7 +91,7 @@ public struct PromptToCodeGroup: ReducerProtocol { @Dependency(\.promptToCodeServiceFactory) var promptToCodeServiceFactory @Dependency(\.activatePreviousActiveXcode) var activatePreviousActiveXcode - public var body: some ReducerProtocol { + public var body: some ReducerOf { Reduce { state, action in switch action { case let .activateOrCreatePromptToCode(s): @@ -156,7 +158,7 @@ public struct PromptToCodeGroup: ReducerProtocol { return .none } } - .ifLet(\.activePromptToCode, action: /Action.activePromptToCode) { + .ifLet(\.activePromptToCode, action: \.activePromptToCode) { PromptToCode() .dependency(\.promptToCodeService, promptToCodeServiceFactory()) } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift index 27602dac..232f29f4 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift @@ -2,7 +2,8 @@ import ComposableArchitecture import Preferences import SwiftUI -public struct SharedPanelFeature: ReducerProtocol { +@Reducer +public struct SharedPanelFeature { public struct Content: Equatable { public var promptToCodeGroup = PromptToCodeGroup.State() var suggestion: CodeSuggestionProvider? @@ -10,6 +11,7 @@ public struct SharedPanelFeature: ReducerProtocol { var error: String? } + @ObservableState public struct State: Equatable { var content: Content = .init() var colorScheme: ColorScheme = .light @@ -36,8 +38,8 @@ public struct SharedPanelFeature: ReducerProtocol { case promptToCodeGroup(PromptToCodeGroup.Action) } - public var body: some ReducerProtocol { - Scope(state: \.content.promptToCodeGroup, action: /Action.promptToCodeGroup) { + public var body: some ReducerOf { + Scope(state: \.content.promptToCodeGroup, action: \.promptToCodeGroup) { PromptToCodeGroup() } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift index db6061e8..00805391 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift @@ -2,7 +2,9 @@ import ComposableArchitecture import Foundation import SwiftUI -public struct SuggestionPanelFeature: ReducerProtocol { +@Reducer +public struct SuggestionPanelFeature { + @ObservableState public struct State: Equatable { var content: CodeSuggestionProvider? var colorScheme: ColorScheme = .light @@ -21,7 +23,7 @@ public struct SuggestionPanelFeature: ReducerProtocol { case noAction } - public var body: some ReducerProtocol { + public var body: some ReducerOf { Reduce { _, _ in .none } } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift index 332caf9d..14ac9d4b 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift @@ -3,7 +3,9 @@ import Preferences import SwiftUI import Toast -public struct ToastPanel: ReducerProtocol { +@Reducer +public struct ToastPanel { + @ObservableState public struct State: Equatable { var toast: Toast.State = .init() var colorScheme: ColorScheme = .light @@ -15,8 +17,8 @@ public struct ToastPanel: ReducerProtocol { case toast(Toast.Action) } - public var body: some ReducerProtocol { - Scope(state: \.toast, action: /Action.toast) { + public var body: some ReducerOf { + Scope(state: \.toast, action: \.toast) { Toast() } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index cc0509a6..6e8ee37f 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -9,7 +9,8 @@ import SwiftUI import Toast import XcodeInspector -public struct WidgetFeature: ReducerProtocol { +@Reducer +public struct WidgetFeature { public struct WindowState: Equatable { var alphaValue: Double = 0 var frame: CGRect = .zero @@ -20,6 +21,7 @@ public struct WidgetFeature: ReducerProtocol { case chatPanel } + @ObservableState public struct State: Equatable { var focusingDocumentURL: URL? public var colorScheme: ColorScheme = .light @@ -42,7 +44,7 @@ public struct WidgetFeature: ReducerProtocol { } public var circularWidgetState = CircularWidgetState() - var _circularWidgetState: CircularWidgetFeature.State { + var _internalCircularWidgetState: CircularWidgetFeature.State { get { .init( isProcessingCounters: circularWidgetState.isProcessingCounters, @@ -126,12 +128,12 @@ public struct WidgetFeature: ReducerProtocol { public init() {} - public var body: some ReducerProtocol { - Scope(state: \.toastPanel, action: /Action.toastPanel) { + public var body: some ReducerOf { + Scope(state: \.toastPanel, action: \.toastPanel) { ToastPanel() } - Scope(state: \._circularWidgetState, action: /Action.circularWidget) { + Scope(state: \._internalCircularWidgetState, action: \.circularWidget) { CircularWidgetFeature() } @@ -143,7 +145,7 @@ public struct WidgetFeature: ReducerProtocol { } case .circularWidget(.widgetClicked): - let wasDisplayingContent = state._circularWidgetState.isDisplayingContent + let wasDisplayingContent = state._internalCircularWidgetState.isDisplayingContent if wasDisplayingContent { state.panelState.sharedPanelState.isPanelDisplayed = false state.panelState.suggestionPanelState.isPanelDisplayed = false @@ -154,7 +156,7 @@ public struct WidgetFeature: ReducerProtocol { state.chatPanelState.isPanelDisplayed = true } - let isDisplayingContent = state._circularWidgetState.isDisplayingContent + let isDisplayingContent = state._internalCircularWidgetState.isDisplayingContent let hasChat = state.chatPanelState.chatTabGroup.selectedTabInfo != nil let hasPromptToCode = state.panelState.sharedPanelState.content .promptToCodeGroup.activePromptToCode != nil @@ -180,11 +182,11 @@ public struct WidgetFeature: ReducerProtocol { } } - Scope(state: \.panelState, action: /Action.panel) { + Scope(state: \.panelState, action: \.panel) { PanelFeature() } - Scope(state: \.chatPanelState, action: /Action.chatPanel) { + Scope(state: \.chatPanelState, action: \.chatPanel) { ChatPanelFeature() } diff --git a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift index afede0a2..dd50233f 100644 --- a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift +++ b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift @@ -1,29 +1,34 @@ +import Combine import Foundation +import Perception +import SharedUIComponents import SwiftUI +import XcodeInspector -public final class CodeSuggestionProvider: ObservableObject, Equatable { +@Perceptible +public final class CodeSuggestionProvider: Equatable { public static func == (lhs: CodeSuggestionProvider, rhs: CodeSuggestionProvider) -> Bool { lhs.code == rhs.code && lhs.language == rhs.language } - @Published public var code: String = "" - @Published public var language: String = "" - @Published public var startLineIndex: Int = 0 - @Published public var suggestionCount: Int = 0 - @Published public var currentSuggestionIndex: Int = 0 - @Published public var commonPrecedingSpaceCount = 0 - @Published public var extraInformation: String = "" + public var code: String = "" + public var language: String = "" + public var startLineIndex: Int = 0 + public var suggestionCount: Int = 0 + public var currentSuggestionIndex: Int = 0 + public var extraInformation: String = "" - public var onSelectPreviousSuggestionTapped: () -> Void - public var onSelectNextSuggestionTapped: () -> Void - public var onRejectSuggestionTapped: () -> Void - public var onAcceptSuggestionTapped: () -> Void - public var onDismissSuggestionTapped: () -> Void + @PerceptionIgnored public var onSelectPreviousSuggestionTapped: () -> Void + @PerceptionIgnored public var onSelectNextSuggestionTapped: () -> Void + @PerceptionIgnored public var onRejectSuggestionTapped: () -> Void + @PerceptionIgnored public var onAcceptSuggestionTapped: () -> Void + @PerceptionIgnored public var onDismissSuggestionTapped: () -> Void public init( code: String = "", language: String = "", startLineIndex: Int = 0, + startCharacerIndex: Int = 0, suggestionCount: Int = 0, currentSuggestionIndex: Int = 0, onSelectPreviousSuggestionTapped: @escaping () -> Void = {}, @@ -49,5 +54,7 @@ public final class CodeSuggestionProvider: ObservableObject, Equatable { func rejectSuggestion() { onRejectSuggestionTapped() } func acceptSuggestion() { onAcceptSuggestionTapped() } func dismissSuggestion() { onDismissSuggestionTapped() } + + } diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift index d9f42dde..697a0663 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -20,7 +20,6 @@ extension View { struct SharedPanelView: View { var store: StoreOf - @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode struct OverallState: Equatable { var isPanelDisplayed: Bool @@ -30,73 +29,87 @@ struct SharedPanelView: View { } var body: some View { - WithViewStore( - store, - observe: { OverallState( - isPanelDisplayed: $0.isPanelDisplayed, - opacity: $0.opacity, - colorScheme: $0.colorScheme, - alignTopToAnchor: $0.alignTopToAnchor - ) } - ) { viewStore in + WithPerceptionTracking { VStack(spacing: 0) { - if !viewStore.state.alignTopToAnchor { + if !store.alignTopToAnchor { Spacer() .frame(minHeight: 0, maxHeight: .infinity) .allowsHitTesting(false) } - WithViewStore(store, observe: { $0.content }) { viewStore in - ZStack(alignment: .topLeading) { - if let error = viewStore.state.error { - ErrorPanel(description: error) { - viewStore.send( - .errorMessageCloseButtonTapped, - animation: .easeInOut(duration: 0.2) - ) - } - } else if let _ = viewStore.state.promptToCode { - IfLetStore(store.scope( - state: { $0.content.promptToCodeGroup.activePromptToCode }, - action: { - SharedPanelFeature.Action - .promptToCodeGroup(.activePromptToCode($0)) - } - )) { - PromptToCodePanel(store: $0) - } - - } else if let suggestion = viewStore.state.suggestion { - switch suggestionPresentationMode { - case .nearbyTextCursor: - EmptyView() - case .floatingWidget: - CodeBlockSuggestionPanel(suggestion: suggestion) - } - } - } + DynamicContent(store: store) + .frame(maxWidth: .infinity, maxHeight: Style.panelHeight) .fixedSize(horizontal: false, vertical: true) - } - .allowsHitTesting(viewStore.isPanelDisplayed) - .frame(maxWidth: .infinity) + .allowsHitTesting(store.isPanelDisplayed) + .frame(maxWidth: .infinity) - if viewStore.alignTopToAnchor { + if store.alignTopToAnchor { Spacer() .frame(minHeight: 0, maxHeight: .infinity) .allowsHitTesting(false) } } - .preferredColorScheme(viewStore.colorScheme) - .opacity(viewStore.opacity) + .preferredColorScheme(store.colorScheme) + .opacity(store.opacity) .animation( featureFlag: \.animationBCrashSuggestion, .easeInOut(duration: 0.2), - value: viewStore.isPanelDisplayed + value: store.isPanelDisplayed ) .frame(maxWidth: Style.panelWidth, maxHeight: Style.panelHeight) } } + + struct DynamicContent: View { + let store: StoreOf + + @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode + + var body: some View { + WithPerceptionTracking { + ZStack(alignment: .topLeading) { + if let errorMessage = store.content.error { + error(errorMessage) + } else if let _ = store.content.promptToCode { + promptToCode() + } else if let suggestionProvider = store.content.suggestion { + suggestion(suggestionProvider) + } + } + } + } + + @ViewBuilder + func error(_ error: String) -> some View { + ErrorPanel(description: error) { + store.send( + .errorMessageCloseButtonTapped, + animation: .easeInOut(duration: 0.2) + ) + } + } + + @ViewBuilder + func promptToCode() -> some View { + if let store = store.scope( + state: \.content.promptToCodeGroup.activePromptToCode, + action: \.promptToCodeGroup.activePromptToCode + ) { + PromptToCodePanel(store: store) + } + } + + @ViewBuilder + func suggestion(_ suggestion: CodeSuggestionProvider) -> some View { + switch suggestionPresentationMode { + case .nearbyTextCursor: + EmptyView() + case .floatingWidget: + CodeBlockSuggestionPanel(suggestion: suggestion) + } + } + } } struct CommandButtonStyle: ButtonStyle { @@ -130,7 +143,7 @@ struct SharedPanelView_Error_Preview: PreviewProvider { colorScheme: .light, isPanelDisplayed: true ), - reducer: SharedPanelFeature() + reducer: { SharedPanelFeature() } )) .frame(width: 450, height: 200) } @@ -156,7 +169,7 @@ struct SharedPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { colorScheme: .dark, isPanelDisplayed: true ), - reducer: SharedPanelFeature() + reducer: { SharedPanelFeature() } )) .frame(width: 450, height: 200) .background { diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index 1b7e715f..3e2c64be 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -1,8 +1,13 @@ +import Combine +import Perception import SharedUIComponents +import SuggestionModel import SwiftUI +import XcodeInspector struct CodeBlockSuggestionPanel: View { - @ObservedObject var suggestion: CodeSuggestionProvider + let suggestion: CodeSuggestionProvider + @Environment(CursorPositionTracker.self) var cursorPositionTracker @Environment(\.colorScheme) var colorScheme @AppStorage(\.suggestionCodeFont) var codeFont @AppStorage(\.suggestionDisplayCompactMode) var suggestionDisplayCompactMode @@ -15,141 +20,154 @@ struct CodeBlockSuggestionPanel: View { @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark struct ToolBar: View { - @ObservedObject var suggestion: CodeSuggestionProvider + let suggestion: CodeSuggestionProvider var body: some View { - HStack { - Button(action: { - suggestion.selectPreviousSuggestion() - }) { - Image(systemName: "chevron.left") - }.buttonStyle(.plain) - - Text( - "\(suggestion.currentSuggestionIndex + 1) / \(suggestion.suggestionCount)" - ) - .monospacedDigit() - - Button(action: { - suggestion.selectNextSuggestion() - }) { - Image(systemName: "chevron.right") - }.buttonStyle(.plain) - - Spacer() - - Button(action: { - suggestion.dismissSuggestion() - }) { - Text("Dismiss").foregroundStyle(.tertiary).padding(.trailing, 4) - }.buttonStyle(.plain) - - Button(action: { - suggestion.rejectSuggestion() - }) { - Text("Reject") - }.buttonStyle(CommandButtonStyle(color: .gray)) - - Button(action: { - suggestion.acceptSuggestion() - }) { - Text("Accept") - }.buttonStyle(CommandButtonStyle(color: .accentColor)) + WithPerceptionTracking { + HStack { + Button(action: { + suggestion.selectPreviousSuggestion() + }) { + Image(systemName: "chevron.left") + }.buttonStyle(.plain) + + Text( + "\(suggestion.currentSuggestionIndex + 1) / \(suggestion.suggestionCount)" + ) + .monospacedDigit() + + Button(action: { + suggestion.selectNextSuggestion() + }) { + Image(systemName: "chevron.right") + }.buttonStyle(.plain) + + Spacer() + + Button(action: { + suggestion.dismissSuggestion() + }) { + Text("Dismiss").foregroundStyle(.tertiary).padding(.trailing, 4) + }.buttonStyle(.plain) + + Button(action: { + suggestion.rejectSuggestion() + }) { + Text("Reject") + }.buttonStyle(CommandButtonStyle(color: .gray)) + + Button(action: { + suggestion.acceptSuggestion() + }) { + Text("Accept") + }.buttonStyle(CommandButtonStyle(color: .accentColor)) + } + .padding() + .foregroundColor(.secondary) + .background(.regularMaterial) } - .padding() - .foregroundColor(.secondary) - .background(.regularMaterial) } } struct CompactToolBar: View { - @ObservedObject var suggestion: CodeSuggestionProvider + let suggestion: CodeSuggestionProvider var body: some View { - HStack { - Button(action: { - suggestion.selectPreviousSuggestion() - }) { - Image(systemName: "chevron.left") - }.buttonStyle(.plain) - - Text( - "\(suggestion.currentSuggestionIndex + 1) / \(suggestion.suggestionCount)" - ) - .monospacedDigit() - - Button(action: { - suggestion.selectNextSuggestion() - }) { - Image(systemName: "chevron.right") - }.buttonStyle(.plain) - - Spacer() - - Button(action: { - suggestion.dismissSuggestion() - }) { - Image(systemName: "xmark") - }.buttonStyle(.plain) + WithPerceptionTracking { + HStack { + Button(action: { + suggestion.selectPreviousSuggestion() + }) { + Image(systemName: "chevron.left") + }.buttonStyle(.plain) + + Text( + "\(suggestion.currentSuggestionIndex + 1) / \(suggestion.suggestionCount)" + ) + .monospacedDigit() + + Button(action: { + suggestion.selectNextSuggestion() + }) { + Image(systemName: "chevron.right") + }.buttonStyle(.plain) + + Spacer() + + Button(action: { + suggestion.dismissSuggestion() + }) { + Image(systemName: "xmark") + }.buttonStyle(.plain) + } + .padding(4) + .font(.caption) + .foregroundColor(.secondary) + .background(.regularMaterial) } - .padding(4) - .font(.caption) - .foregroundColor(.secondary) - .background(.regularMaterial) } } var body: some View { - VStack(spacing: 0) { - CustomScrollView { - CodeBlock( - code: suggestion.code, - language: suggestion.language, - startLineIndex: suggestion.startLineIndex, - scenario: "suggestion", - colorScheme: colorScheme, - font: codeFont.value.nsFont, - droppingLeadingSpaces: hideCommonPrecedingSpaces, - proposedForegroundColor: { - if syncHighlightTheme { - if colorScheme == .light, - let color = codeForegroundColorLight.value?.swiftUIColor - { - return color - } else if let color = codeForegroundColorDark.value?.swiftUIColor { - return color + WithPerceptionTracking { + VStack(spacing: 0) { + CustomScrollView { + WithPerceptionTracking { + AsyncCodeBlock( + code: suggestion.code, + language: suggestion.language, + startLineIndex: suggestion.startLineIndex, + scenario: "suggestion", + font: codeFont.value.nsFont, + droppingLeadingSpaces: hideCommonPrecedingSpaces, + proposedForegroundColor: { + if syncHighlightTheme { + if colorScheme == .light, + let color = codeForegroundColorLight.value?.swiftUIColor + { + return color + } else if let color = codeForegroundColorDark.value? + .swiftUIColor + { + return color + } + } + return nil + }(), + dimmedCharacterCount: suggestion.startLineIndex + == cursorPositionTracker.cursorPosition.line + ? cursorPositionTracker.cursorPosition.character + : 0 + ) + .frame(maxWidth: .infinity) + .background({ () -> Color in + if syncHighlightTheme { + if colorScheme == .light, + let color = codeBackgroundColorLight.value?.swiftUIColor + { + return color + } else if let color = codeBackgroundColorDark.value?.swiftUIColor { + return color + } } - } - return nil - }() - ) - .frame(maxWidth: .infinity) - .background({ () -> Color in - if syncHighlightTheme { - if colorScheme == .light, - let color = codeBackgroundColorLight.value?.swiftUIColor - { - return color - } else if let color = codeBackgroundColorDark.value?.swiftUIColor { - return color - } + return Color.contentBackground + }()) } - return Color.contentBackground - }()) - } + } - if suggestionDisplayCompactMode { - CompactToolBar(suggestion: suggestion) - } else { - ToolBar(suggestion: suggestion) + if suggestionDisplayCompactMode { + CompactToolBar(suggestion: suggestion) + } else { + ToolBar(suggestion: suggestion) + } } + .xcodeStyleFrame(cornerRadius: { + switch suggestionPresentationMode { + case .nearbyTextCursor: 6 + case .floatingWidget: nil + } + }()) } - .xcodeStyleFrame(cornerRadius: { - switch suggestionPresentationMode { - case .nearbyTextCursor: 6 - case .floatingWidget: nil - } - }()) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 49a8391d..275ea26f 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -8,21 +8,23 @@ struct PromptToCodePanel: View { let store: StoreOf var body: some View { - VStack(spacing: 0) { - TopBar(store: store) - - Content(store: store) - .overlay(alignment: .bottom) { - ActionBar(store: store) - .padding(.bottom, 8) - } + WithPerceptionTracking { + VStack(spacing: 0) { + TopBar(store: store) + + Content(store: store) + .overlay(alignment: .bottom) { + ActionBar(store: store) + .padding(.bottom, 8) + } - Divider() + Divider() - Toolbar(store: store) + Toolbar(store: store) + } + .background(.ultraThickMaterial) + .xcodeStyleFrame() } - .background(.ultraThickMaterial) - .xcodeStyleFrame() } } @@ -30,28 +32,25 @@ extension PromptToCodePanel { struct TopBar: View { let store: StoreOf - struct AttachButtonState: Equatable { - var attachedToFilename: String - var isAttachedToSelectionRange: Bool - var selectionRange: CursorRange? - } - var body: some View { HStack { - Button(action: { - withAnimation(.linear(duration: 0.1)) { - store.send(.selectionRangeToggleTapped) - } - }) { - WithViewStore( - store, - observe: { AttachButtonState( - attachedToFilename: $0.filename, - isAttachedToSelectionRange: $0.isAttachedToSelectionRange, - selectionRange: $0.selectionRange - ) } - ) { viewStore in - let isAttached = viewStore.state.isAttachedToSelectionRange + SelectionRangeButton(store: store) + Spacer() + CopyCodeButton(store: store) + } + .padding(2) + } + + struct SelectionRangeButton: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + Button(action: { + store.send(.selectionRangeToggleTapped, animation: .linear(duration: 0.1)) + }) { + let attachedToFilename = store.filename + let isAttached = store.isAttachedToSelectionRange + let selectionRange = store.selectionRange let color: Color = isAttached ? .accentColor : .secondary.opacity(0.6) HStack(spacing: 4) { Image( @@ -72,10 +71,10 @@ extension PromptToCodePanel { if isAttached { HStack(spacing: 4) { - Text(viewStore.state.attachedToFilename) + Text(attachedToFilename) .lineLimit(1) .truncationMode(.middle) - if let range = viewStore.state.selectionRange { + if let range = selectionRange { Text(range.description) } }.foregroundColor(.primary) @@ -95,45 +94,44 @@ extension PromptToCodePanel { } .padding(2) } + .keyboardShortcut("j", modifiers: [.command]) + .buttonStyle(.plain) } - .keyboardShortcut("j", modifiers: [.command]) - .buttonStyle(.plain) - - Spacer() + } + } - WithViewStore(store, observe: { $0.code }) { viewStore in - if !viewStore.state.isEmpty { + struct CopyCodeButton: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + if !store.code.isEmpty { CopyButton { - viewStore.send(.copyCodeButtonTapped) + store.send(.copyCodeButtonTapped) } } } } - .padding(2) } } struct ActionBar: View { let store: StoreOf - struct ActionState: Equatable { - var isResponding: Bool - var isCodeEmpty: Bool - var isDescriptionEmpty: Bool - @BindingViewState var isContinuous: Bool - var isRespondingButCodeIsReady: Bool { - isResponding - && !isCodeEmpty - && !isDescriptionEmpty + var body: some View { + HStack { + StopRespondingButton(store: store) + ActionButtons(store: store) } } - var body: some View { - HStack { - WithViewStore(store, observe: { $0.isResponding }) { viewStore in - if viewStore.state { + struct StopRespondingButton: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + if store.isResponding { Button(action: { - viewStore.send(.stopRespondingButtonTapped) + store.send(.stopRespondingButtonTapped) }) { HStack(spacing: 4) { Image(systemName: "stop.fill") @@ -152,31 +150,38 @@ extension PromptToCodePanel { .buttonStyle(.plain) } } + } + } - WithViewStore(store, observe: { - ActionState( - isResponding: $0.isResponding, - isCodeEmpty: $0.code.isEmpty, - isDescriptionEmpty: $0.description.isEmpty, - isContinuous: $0.$isContinuous - ) - }) { viewStore in - if !viewStore.state.isResponding || viewStore.state.isRespondingButCodeIsReady { + struct ActionButtons: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + let isResponding = store.isResponding + let isCodeEmpty = store.code.isEmpty + let isDescriptionEmpty = store.description.isEmpty + var isRespondingButCodeIsReady: Bool { + isResponding + && !isCodeEmpty + && !isDescriptionEmpty + } + if !isResponding || isRespondingButCodeIsReady { HStack { - Toggle("Continuous Mode", isOn: viewStore.$isContinuous) + Toggle("Continuous Mode", isOn: $store.isContinuous) .toggleStyle(.checkbox) Button(action: { - viewStore.send(.cancelButtonTapped) + store.send(.cancelButtonTapped) }) { Text("Cancel") } .buttonStyle(CommandButtonStyle(color: .gray)) .keyboardShortcut("w", modifiers: [.command]) - if !viewStore.state.isCodeEmpty { + if !isCodeEmpty { Button(action: { - viewStore.send(.acceptButtonTapped) + store.send(.acceptButtonTapped) }) { Text("Accept(⌘ + ⏎)") } @@ -202,23 +207,12 @@ extension PromptToCodePanel { struct Content: View { let store: StoreOf @Environment(\.colorScheme) var colorScheme - @AppStorage(\.promptToCodeCodeFont) var codeFont - @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) var hideCommonPrecedingSpaces @AppStorage(\.syncPromptToCodeHighlightTheme) var syncHighlightTheme @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark - @AppStorage(\.wrapCodeInPromptToCode) var wrapCode - - struct CodeContent: Equatable { - var code: String - var language: String - var startLineIndex: Int - var firstLinePrecedingSpaceCount: Int - var isResponding: Bool - } - + var codeForegroundColor: Color? { if syncHighlightTheme { if colorScheme == .light, @@ -231,7 +225,7 @@ extension PromptToCodePanel { } return nil } - + var codeBackgroundColor: Color { if syncHighlightTheme { if colorScheme == .light, @@ -246,101 +240,139 @@ extension PromptToCodePanel { } var body: some View { - ScrollView { - VStack(spacing: 0) { - Spacer(minLength: 60) - - WithViewStore(store, observe: { $0.error }) { viewStore in - if let errorMessage = viewStore.state, !errorMessage.isEmpty { - Text(errorMessage) - .multilineTextAlignment(.leading) - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Color.red, - in: RoundedRectangle(cornerRadius: 4, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .stroke(Color.primary.opacity(0.2), lineWidth: 1) - } - .scaleEffect(x: 1, y: -1, anchor: .center) - } + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Spacer(minLength: 60) + ErrorMessage(store: store) + DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + CodeContent(store: store, codeForegroundColor: codeForegroundColor) } + } + .background(codeBackgroundColor) + .scaleEffect(x: 1, y: -1, anchor: .center) + } + } - WithViewStore(store, observe: { $0.description }) { viewStore in - if !viewStore.state.isEmpty { - Markdown(viewStore.state) - .textSelection(.enabled) - .markdownTheme(.gitHub.text { - BackgroundColor(Color.clear) - ForegroundColor(codeForegroundColor) - }) - .padding() - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) - } - } + struct ErrorMessage: View { + let store: StoreOf - WithViewStore(store, observe: { - CodeContent( - code: $0.code, - language: $0.language.rawValue, - startLineIndex: $0.selectionRange?.start.line ?? 0, - firstLinePrecedingSpaceCount: $0.selectionRange?.start - .character ?? 0, - isResponding: $0.isResponding - ) - }) { viewStore in - if viewStore.state.code.isEmpty { - Text( - viewStore.state.isResponding - ? "Thinking..." - : "Enter your requirement to generate code." + var body: some View { + WithPerceptionTracking { + if let errorMessage = store.error, !errorMessage.isEmpty { + Text(errorMessage) + .multilineTextAlignment(.leading) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Color.red, + in: RoundedRectangle(cornerRadius: 4, style: .continuous) ) - .foregroundColor(codeForegroundColor?.opacity(0.7) ?? .secondary) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.primary.opacity(0.2), lineWidth: 1) + } + .scaleEffect(x: 1, y: -1, anchor: .center) + } + } + } + } + + struct DescriptionContent: View { + let store: StoreOf + let codeForegroundColor: Color? + + var body: some View { + WithPerceptionTracking { + if !store.description.isEmpty { + Markdown(store.description) + .textSelection(.enabled) + .markdownTheme(.gitHub.text { + BackgroundColor(Color.clear) + ForegroundColor(codeForegroundColor) + }) .padding() - .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .scaleEffect(x: 1, y: -1, anchor: .center) + } + } + } + } + + struct CodeContent: View { + let store: StoreOf + let codeForegroundColor: Color? + + @AppStorage(\.wrapCodeInPromptToCode) var wrapCode + + var body: some View { + WithPerceptionTracking { + if store.code.isEmpty { + Text( + store.isResponding + ? "Thinking..." + : "Enter your requirement to generate code." + ) + .foregroundColor(codeForegroundColor?.opacity(0.7) ?? .secondary) + .padding() + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1, anchor: .center) + } else { + if wrapCode { + CodeBlockInContent( + store: store, + codeForegroundColor: codeForegroundColor + ) } else { - if wrapCode { - codeBlock(viewStore.state) - } else { - ScrollView(.horizontal) { - codeBlock(viewStore.state) - } - .modify { - if #available(macOS 13.0, *) { - $0.scrollIndicators(.hidden) - } else { - $0 - } + ScrollView(.horizontal) { + CodeBlockInContent( + store: store, + codeForegroundColor: codeForegroundColor + ) + } + .modify { + if #available(macOS 13.0, *) { + $0.scrollIndicators(.hidden) + } else { + $0 } } } } } } - .background(codeBackgroundColor) - .scaleEffect(x: 1, y: -1, anchor: .center) - } - - func codeBlock(_ state: CodeContent) -> some View { - CodeBlock( - code: state.code, - language: state.language, - startLineIndex: state.startLineIndex, - scenario: "promptToCode", - colorScheme: colorScheme, - firstLinePrecedingSpaceCount: state.firstLinePrecedingSpaceCount, - font: codeFont.value.nsFont, - droppingLeadingSpaces: hideCommonPrecedingSpaces, - proposedForegroundColor:codeForegroundColor - ) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) + + struct CodeBlockInContent: View { + let store: StoreOf + let codeForegroundColor: Color? + + @Environment(\.colorScheme) var colorScheme + @AppStorage(\.promptToCodeCodeFont) var codeFont + @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) var hideCommonPrecedingSpaces + + var body: some View { + WithPerceptionTracking { + let startLineIndex = store.selectionRange?.start.line ?? 0 + let firstLinePrecedingSpaceCount = store.selectionRange?.start + .character ?? 0 + CodeBlock( + code: store.code, + language: store.language.rawValue, + startLineIndex: startLineIndex, + scenario: "promptToCode", + colorScheme: colorScheme, + firstLinePrecedingSpaceCount: firstLinePrecedingSpaceCount, + font: codeFont.value.nsFont, + droppingLeadingSpaces: hideCommonPrecedingSpaces, + proposedForegroundColor: codeForegroundColor + ) + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1, anchor: .center) + } + } + } } } @@ -353,19 +385,13 @@ extension PromptToCodePanel { var canRevert: Bool } - struct InputFieldState: Equatable { - @BindingViewState var prompt: String - @BindingViewState var focusField: PromptToCode.State.FocusField? - var isResponding: Bool - } - var body: some View { HStack { - revertButton + RevertButton(store: store) HStack(spacing: 0) { - inputField - sendButton + InputField(store: store, focusField: $focusField) + SendButton(store: store) } .frame(maxWidth: .infinity) .background { @@ -393,68 +419,68 @@ extension PromptToCodePanel { .background(.ultraThickMaterial) } - var revertButton: some View { - WithViewStore(store, observe: { - RevertButtonState(isResponding: $0.isResponding, canRevert: $0.canRevert) - }) { viewStore in - Button(action: { - viewStore.send(.revertButtonTapped) - }) { - Group { - Image(systemName: "arrow.uturn.backward") - } - .padding(6) - .background { - Circle().fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - Circle() - .stroke(Color(nsColor: .controlColor), lineWidth: 1) + struct RevertButton: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + Button(action: { + store.send(.revertButtonTapped) + }) { + Group { + Image(systemName: "arrow.uturn.backward") + } + .padding(6) + .background { + Circle().fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + Circle() + .stroke(Color(nsColor: .controlColor), lineWidth: 1) + } } + .buttonStyle(.plain) + .disabled(store.isResponding || !store.canRevert) } - .buttonStyle(.plain) - .disabled(viewStore.state.isResponding || !viewStore.state.canRevert) } } - var inputField: some View { - WithViewStore( - store, - observe: { - InputFieldState( - prompt: $0.$prompt, - focusField: $0.$focusedField, - isResponding: $0.isResponding + struct InputField: View { + @Perception.Bindable var store: StoreOf + var focusField: FocusState.Binding + + var body: some View { + WithPerceptionTracking { + AutoresizingCustomTextEditor( + text: $store.prompt, + font: .systemFont(ofSize: 14), + isEditable: !store.isResponding, + maxHeight: 400, + onSubmit: { store.send(.modifyCodeButtonTapped) } ) + .opacity(store.isResponding ? 0.5 : 1) + .disabled(store.isResponding) + .focused(focusField, equals: PromptToCode.State.FocusField.textField) + .bind($store.focusedField, to: focusField) } - ) { viewStore in - AutoresizingCustomTextEditor( - text: viewStore.$prompt, - font: .systemFont(ofSize: 14), - isEditable: !viewStore.state.isResponding, - maxHeight: 400, - onSubmit: { viewStore.send(.modifyCodeButtonTapped) } - ) - .opacity(viewStore.state.isResponding ? 0.5 : 1) - .disabled(viewStore.state.isResponding) - .focused($focusField, equals: .textField) - .bind(viewStore.$focusField, to: $focusField) + .padding(8) + .fixedSize(horizontal: false, vertical: true) } - .padding(8) - .fixedSize(horizontal: false, vertical: true) } - var sendButton: some View { - WithViewStore(store, observe: { $0.isResponding }) { viewStore in - Button(action: { - viewStore.send(.modifyCodeButtonTapped) - }) { - Image(systemName: "paperplane.fill") - .padding(8) + struct SendButton: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + Button(action: { + store.send(.modifyCodeButtonTapped) + }) { + Image(systemName: "paperplane.fill") + .padding(8) + } + .buttonStyle(.plain) + .disabled(store.isResponding) + .keyboardShortcut(KeyEquivalent.return, modifiers: []) } - .buttonStyle(.plain) - .disabled(viewStore.state) - .keyboardShortcut(KeyEquivalent.return, modifiers: []) } } } @@ -462,38 +488,36 @@ extension PromptToCodePanel { // MARK: - Previews -struct PromptToCodePanel_Preview: PreviewProvider { - static var previews: some View { - PromptToCodePanel(store: .init(initialState: .init( - code: """ - ForEach(0.. - struct ViewState: Equatable { - let colorScheme: ColorScheme - let alignTopToAnchor: Bool - } - var body: some View { - WithViewStore(store, observe: { - ViewState( - colorScheme: $0.colorScheme, - alignTopToAnchor: $0.alignTopToAnchor - ) - }) { viewStore in + WithPerceptionTracking { VStack(spacing: 4) { - if !viewStore.alignTopToAnchor { + if !store.alignTopToAnchor { Spacer() } - - WithViewStore(store, observe: \.toast.messages) { viewStore in - ForEach(viewStore.state) { message in - message.content - .foregroundColor(.white) - .padding(8) - .frame(maxWidth: .infinity) - .background({ - switch message.type { - case .info: return Color.accentColor - case .error: return Color(nsColor: .systemRed) - case .warning: return Color(nsColor: .systemOrange) - } - }() as Color, in: RoundedRectangle(cornerRadius: 8)) - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color.black.opacity(0.3), lineWidth: 1) + + ForEach(store.toast.messages) { message in + message.content + .foregroundColor(.white) + .padding(8) + .frame(maxWidth: .infinity) + .background({ + switch message.type { + case .info: return Color.accentColor + case .error: return Color(nsColor: .systemRed) + case .warning: return Color(nsColor: .systemOrange) } - } + }() as Color, in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color.black.opacity(0.3), lineWidth: 1) + } } - - if viewStore.alignTopToAnchor { + + if store.alignTopToAnchor { Spacer() } } - .colorScheme(viewStore.colorScheme) + .colorScheme(store.colorScheme) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .allowsHitTesting(false) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift index 7d5a6485..a1b0f425 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift @@ -4,7 +4,6 @@ import SwiftUI struct SuggestionPanelView: View { let store: StoreOf - @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode struct OverallState: Equatable { var isPanelDisplayed: Bool @@ -15,59 +14,37 @@ struct SuggestionPanelView: View { } var body: some View { - WithViewStore( - store, - observe: { OverallState( - isPanelDisplayed: $0.isPanelDisplayed, - opacity: $0.opacity, - colorScheme: $0.colorScheme, - isPanelOutOfFrame: $0.isPanelOutOfFrame, - alignTopToAnchor: $0.alignTopToAnchor - ) } - ) { viewStore in + WithPerceptionTracking { VStack(spacing: 0) { - if !viewStore.alignTopToAnchor { + if !store.alignTopToAnchor { Spacer() .frame(minHeight: 0, maxHeight: .infinity) .allowsHitTesting(false) } - IfLetStore(store.scope(state: \.content, action: { $0 })) { store in - WithViewStore(store) { viewStore in - ZStack(alignment: .topLeading) { - switch suggestionPresentationMode { - case .nearbyTextCursor: - CodeBlockSuggestionPanel(suggestion: viewStore.state) - case .floatingWidget: - EmptyView() - } - } - .frame(maxWidth: .infinity, maxHeight: Style.inlineSuggestionMaxHeight) - .fixedSize(horizontal: false, vertical: true) - } - } - .allowsHitTesting( - viewStore.isPanelDisplayed && !viewStore.isPanelOutOfFrame - ) - .frame(maxWidth: .infinity) + Content(store: store) + .allowsHitTesting( + store.isPanelDisplayed && !store.isPanelOutOfFrame + ) + .frame(maxWidth: .infinity) - if viewStore.alignTopToAnchor { + if store.alignTopToAnchor { Spacer() .frame(minHeight: 0, maxHeight: .infinity) .allowsHitTesting(false) } } - .preferredColorScheme(viewStore.colorScheme) - .opacity(viewStore.opacity) + .preferredColorScheme(store.colorScheme) + .opacity(store.opacity) .animation( featureFlag: \.animationBCrashSuggestion, .easeInOut(duration: 0.2), - value: viewStore.isPanelDisplayed + value: store.isPanelDisplayed ) .animation( featureFlag: \.animationBCrashSuggestion, .easeInOut(duration: 0.2), - value: viewStore.isPanelOutOfFrame + value: store.isPanelOutOfFrame ) .frame( maxWidth: Style.inlineSuggestionMinWidth, @@ -75,5 +52,27 @@ struct SuggestionPanelView: View { ) } } + + struct Content: View { + let store: StoreOf + @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode + + var body: some View { + WithPerceptionTracking { + if let content = store.content { + ZStack(alignment: .topLeading) { + switch suggestionPresentationMode { + case .nearbyTextCursor: + CodeBlockSuggestionPanel(suggestion: content) + case .floatingWidget: + EmptyView() + } + } + .frame(maxWidth: .infinity, maxHeight: Style.inlineSuggestionMaxHeight) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 618b3682..ab15d53b 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -12,7 +12,6 @@ import XcodeInspector @MainActor public final class SuggestionWidgetController: NSObject { let store: StoreOf - let viewStore: ViewStoreOf let chatTabPool: ChatTabPool let windowsController: WidgetWindowsController private var cancellable = Set() @@ -27,7 +26,6 @@ public final class SuggestionWidgetController: NSObject { self.dependency = dependency self.store = store self.chatTabPool = chatTabPool - viewStore = .init(store, observe: { $0 }) windowsController = .init(store: store, chatTabPool: chatTabPool) super.init() diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index 2b4b3b8e..64443570 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -13,13 +13,11 @@ struct WidgetView: View { @AppStorage(\.hideCircularWidget) var hideCircularWidget var body: some View { - WithViewStore(store, observe: { $0.isProcessing }) { viewStore in + WithPerceptionTracking { Circle() .fill(isHovering ? .white.opacity(0.5) : .white.opacity(0.15)) .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - store.send(.widgetClicked) - } + store.send(.widgetClicked, animation: .easeInOut(duration: 0.2)) } .overlay { WidgetAnimatedCircle(store: store) } .onHover { yes in @@ -31,12 +29,12 @@ struct WidgetView: View { } .opacity({ if !hideCircularWidget { return 1 } - return viewStore.state ? 1 : 0 + return store.isProcessing ? 1 : 0 }()) .animation( featureFlag: \.animationCCrashSuggestion, .easeInOut(duration: 0.2), - value: viewStore.state + value: store.isProcessing ) } } @@ -52,31 +50,23 @@ struct WidgetAnimatedCircle: View { } var body: some View { - let minimumLineWidth: Double = 3 - let lineWidth = (1 - processingProgress) * - (Style.widgetWidth - minimumLineWidth / 2) + minimumLineWidth - let scale = max(processingProgress * 1, 0.0001) - ZStack { - Circle() - .stroke( - Color(nsColor: .darkGray), - style: .init(lineWidth: minimumLineWidth) - ) - .padding(minimumLineWidth / 2) - - // how do I stop the repeatForever animation without removing the view? - // I tried many solutions found on stackoverflow but non of them works. - WithViewStore( - store, - observe: { - OverlayCircleState( - isProcessing: $0.isProcessing, - isContentEmpty: $0.isContentEmpty + WithPerceptionTracking { + let minimumLineWidth: Double = 3 + let lineWidth = (1 - processingProgress) * + (Style.widgetWidth - minimumLineWidth / 2) + minimumLineWidth + let scale = max(processingProgress * 1, 0.0001) + ZStack { + Circle() + .stroke( + Color(nsColor: .darkGray), + style: .init(lineWidth: minimumLineWidth) ) - } - ) { viewStore in + .padding(minimumLineWidth / 2) + + // how do I stop the repeatForever animation without removing the view? + // I tried many solutions found on stackoverflow but non of them works. Group { - if viewStore.isProcessing { + if store.isProcessing { Circle() .stroke( Color.accentColor, @@ -85,7 +75,7 @@ struct WidgetAnimatedCircle: View { .padding(minimumLineWidth / 2) .scaleEffect(x: scale, y: scale) .opacity( - !viewStore.isContentEmpty || viewStore.isProcessing ? 1 : 0 + !store.isContentEmpty || store.isProcessing ? 1 : 0 ) .animation( featureFlag: \.animationCCrashSuggestion, @@ -102,8 +92,7 @@ struct WidgetAnimatedCircle: View { .padding(minimumLineWidth / 2) .scaleEffect(x: scale, y: scale) .opacity( - !viewStore.isContentEmpty || viewStore - .isProcessing ? 1 : 0 + !store.isContentEmpty || store.isProcessing ? 1 : 0 ) .animation( featureFlag: \.animationCCrashSuggestion, @@ -112,16 +101,16 @@ struct WidgetAnimatedCircle: View { ) } } - .onChange(of: viewStore.isProcessing) { _ in + .onChange(of: store.isProcessing) { _ in refreshRing( - isProcessing: viewStore.state.isProcessing, - isContentEmpty: viewStore.state.isContentEmpty + isProcessing: store.isProcessing, + isContentEmpty: store.isContentEmpty ) } - .onChange(of: viewStore.isContentEmpty) { _ in + .onChange(of: store.isContentEmpty) { _ in refreshRing( - isProcessing: viewStore.state.isProcessing, - isContentEmpty: viewStore.state.isContentEmpty + isProcessing: store.isProcessing, + isContentEmpty: store.isContentEmpty ) } } @@ -149,15 +138,13 @@ struct WidgetContextMenu: View { @Dependency(\.xcodeInspector) var xcodeInspector var body: some View { - Group { + WithPerceptionTracking { Group { // Commands - WithViewStore(store, observe: { $0.isChatOpen }) { viewStore in - if !viewStore.state { - Button(action: { - viewStore.send(.openChatButtonClicked) - }) { - Text("Open Chat") - } + if !store.isChatOpen { + Button(action: { + store.send(.openChatButtonClicked) + }) { + Text("Open Chat") } } @@ -175,17 +162,12 @@ struct WidgetContextMenu: View { Divider() Group { // Settings - WithViewStore( - store, - observe: { $0.isChatPanelDetached } - ) { viewStore in - Button(action: { - viewStore.send(.detachChatPanelToggleClicked) - }) { - Text("Detach Chat Panel") - if viewStore.state { - Image(systemName: "checkmark") - } + Button(action: { + store.send(.detachChatPanelToggleClicked) + }) { + Text("Detach Chat Panel") + if store.isChatPanelDetached { + Image(systemName: "checkmark") } } @@ -219,26 +201,24 @@ struct WidgetContextMenu: View { extension WidgetContextMenu { @ViewBuilder var enableSuggestionForProject: some View { - WithViewStore(store) { _ in - if let projectPath = xcodeInspector.activeProjectRootURL?.path, - disableSuggestionFeatureGlobally - { - let matchedPath = suggestionFeatureEnabledProjectList.first { path in - projectPath.hasPrefix(path) + if let projectPath = xcodeInspector.activeProjectRootURL?.path, + disableSuggestionFeatureGlobally + { + let matchedPath = suggestionFeatureEnabledProjectList.first { path in + projectPath.hasPrefix(path) + } + Button(action: { + if matchedPath != nil { + suggestionFeatureEnabledProjectList + .removeAll { path in path == matchedPath } + } else { + suggestionFeatureEnabledProjectList.append(projectPath) } - Button(action: { - if matchedPath != nil { - suggestionFeatureEnabledProjectList - .removeAll { path in path == matchedPath } - } else { - suggestionFeatureEnabledProjectList.append(projectPath) - } - }) { - if matchedPath == nil { - Text("Add to Suggestion-Enabled Project List") - } else { - Text("Remove from Suggestion-Enabled Project List") - } + }) { + if matchedPath == nil { + Text("Add to Suggestion-Enabled Project List") + } else { + Text("Remove from Suggestion-Enabled Project List") } } } @@ -246,24 +226,22 @@ extension WidgetContextMenu { @ViewBuilder var disableSuggestionForLanguage: some View { - WithViewStore(store) { _ in - let fileURL = xcodeInspector.activeDocumentURL - let fileLanguage = fileURL.map(languageIdentifierFromFileURL) ?? .plaintext - let matched = suggestionFeatureDisabledLanguageList.first { rawValue in - fileLanguage.rawValue == rawValue + let fileURL = xcodeInspector.activeDocumentURL + let fileLanguage = fileURL.map(languageIdentifierFromFileURL) ?? .plaintext + let matched = suggestionFeatureDisabledLanguageList.first { rawValue in + fileLanguage.rawValue == rawValue + } + Button(action: { + if let matched { + suggestionFeatureDisabledLanguageList.removeAll { $0 == matched } + } else { + suggestionFeatureDisabledLanguageList.append(fileLanguage.rawValue) } - Button(action: { - if let matched { - suggestionFeatureDisabledLanguageList.removeAll { $0 == matched } - } else { - suggestionFeatureDisabledLanguageList.append(fileLanguage.rawValue) - } - }) { - if matched == nil { - Text("Disable Suggestion for \"\(fileLanguage.rawValue.capitalized)\"") - } else { - Text("Enable Suggestion for \"\(fileLanguage.rawValue.capitalized)\"") - } + }) { + if matched == nil { + Text("Disable Suggestion for \"\(fileLanguage.rawValue.capitalized)\"") + } else { + Text("Enable Suggestion for \"\(fileLanguage.rawValue.capitalized)\"") } } } @@ -281,7 +259,7 @@ struct WidgetView_Preview: PreviewProvider { isChatPanelDetached: false, isChatOpen: false ), - reducer: CircularWidgetFeature() + reducer: { CircularWidgetFeature() } ), isHovering: false ) @@ -295,7 +273,7 @@ struct WidgetView_Preview: PreviewProvider { isChatPanelDetached: false, isChatOpen: false ), - reducer: CircularWidgetFeature() + reducer: { CircularWidgetFeature() } ), isHovering: true ) @@ -309,7 +287,7 @@ struct WidgetView_Preview: PreviewProvider { isChatPanelDetached: false, isChatOpen: false ), - reducer: CircularWidgetFeature() + reducer: { CircularWidgetFeature() } ), isHovering: false ) @@ -323,7 +301,7 @@ struct WidgetView_Preview: PreviewProvider { isChatPanelDetached: false, isChatOpen: false ), - reducer: CircularWidgetFeature() + reducer: { CircularWidgetFeature() } ), isHovering: false ) diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 77d29dcc..c6e3455f 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -599,6 +599,7 @@ public final class WidgetWindows { let store: StoreOf let chatTabPool: ChatTabPool weak var controller: WidgetWindowsController? + let cursorPositionTracker = CursorPositionTracker() // you should make these window `.transient` so they never show up in the mission control. @@ -637,8 +638,8 @@ public final class WidgetWindows { it.contentView = NSHostingView( rootView: WidgetView( store: store.scope( - state: \._circularWidgetState, - action: WidgetFeature.Action.circularWidget + state: \._internalCircularWidgetState, + action: \.circularWidget ) ) ) @@ -665,12 +666,12 @@ public final class WidgetWindows { rootView: SharedPanelView( store: store.scope( state: \.panelState, - action: WidgetFeature.Action.panel + action: \.panel ).scope( state: \.sharedPanelState, - action: PanelFeature.Action.sharedPanel + action: \.sharedPanel ) - ) + ).environment(cursorPositionTracker) ) it.setIsVisible(true) it.canBecomeKeyChecker = { [store] in @@ -699,12 +700,12 @@ public final class WidgetWindows { rootView: SuggestionPanelView( store: store.scope( state: \.panelState, - action: WidgetFeature.Action.panel + action: \.panel ).scope( state: \.suggestionPanelState, - action: PanelFeature.Action.suggestionPanel + action: \.suggestionPanel ) - ) + ).environment(cursorPositionTracker) ) it.canBecomeKeyChecker = { false } it.setIsVisible(true) @@ -716,7 +717,7 @@ public final class WidgetWindows { let it = ChatPanelWindow( store: store.scope( state: \.chatPanelState, - action: WidgetFeature.Action.chatPanel + action: \.chatPanel ), chatTabPool: chatTabPool, minimizeWindow: { [weak self] in @@ -744,7 +745,7 @@ public final class WidgetWindows { it.contentView = NSHostingView( rootView: ToastPanelView(store: store.scope( state: \.toastPanel, - action: WidgetFeature.Action.toastPanel + action: \.toastPanel )) ) it.setIsVisible(true) diff --git a/EditorExtension/AcceptSuggestionCommand.swift b/EditorExtension/AcceptSuggestionCommand.swift index 654f472b..81882ed9 100644 --- a/EditorExtension/AcceptSuggestionCommand.swift +++ b/EditorExtension/AcceptSuggestionCommand.swift @@ -31,32 +31,3 @@ class AcceptSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { } } -/// https://gist.github.com/swhitty/9be89dfe97dbb55c6ef0f916273bbb97 -extension Task where Failure == Error { - // Start a new Task with a timeout. If the timeout expires before the operation is - // completed then the task is cancelled and an error is thrown. - init( - priority: TaskPriority? = nil, - timeout: TimeInterval, - operation: @escaping @Sendable () async throws -> Success - ) { - self = Task(priority: priority) { - try await withThrowingTaskGroup(of: Success.self) { group -> Success in - group.addTask(operation: operation) - group.addTask { - try await _Concurrency.Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) - throw TimeoutError() - } - guard let success = try await group.next() else { - throw _Concurrency.CancellationError() - } - group.cancelAll() - return success - } - } - } -} - -private struct TimeoutError: LocalizedError { - var errorDescription: String? = "Task timed out before completion" -} diff --git a/EditorExtension/Helpers.swift b/EditorExtension/Helpers.swift index c524869a..924927a8 100644 --- a/EditorExtension/Helpers.swift +++ b/EditorExtension/Helpers.swift @@ -66,3 +66,33 @@ extension EditorContent { ) } } + +/// https://gist.github.com/swhitty/9be89dfe97dbb55c6ef0f916273bbb97 +extension Task where Failure == Error { + // Start a new Task with a timeout. If the timeout expires before the operation is + // completed then the task is cancelled and an error is thrown. + init( + priority: TaskPriority? = nil, + timeout: TimeInterval, + operation: @escaping @Sendable () async throws -> Success + ) { + self = Task(priority: priority) { + try await withThrowingTaskGroup(of: Success.self) { group -> Success in + group.addTask(operation: operation) + group.addTask { + try await _Concurrency.Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + throw TimeoutError() + } + guard let success = try await group.next() else { + throw _Concurrency.CancellationError() + } + group.cancelAll() + return success + } + } + } +} + +private struct TimeoutError: LocalizedError { + var errorDescription: String? = "Task timed out before completion" +} diff --git a/EditorExtension/ChatWithSelection.swift b/EditorExtension/OpenChat.swift similarity index 68% rename from EditorExtension/ChatWithSelection.swift rename to EditorExtension/OpenChat.swift index e1dd0b81..375f58a2 100644 --- a/EditorExtension/ChatWithSelection.swift +++ b/EditorExtension/OpenChat.swift @@ -3,7 +3,7 @@ import SuggestionModel import Foundation import XcodeKit -class ChatWithSelectionCommand: NSObject, XCSourceEditorCommand, CommandType { +class OpenChatCommand: NSObject, XCSourceEditorCommand, CommandType { var name: String { "Open Chat" } func perform( @@ -13,7 +13,7 @@ class ChatWithSelectionCommand: NSObject, XCSourceEditorCommand, CommandType { completionHandler(nil) Task { let service = try getService() - _ = try await service.chatWithSelection(editorContent: .init(invocation)) + _ = try await service.openChat(editorContent: .init(invocation)) } } } diff --git a/EditorExtension/RealtimeSuggestionCommand.swift b/EditorExtension/RealtimeSuggestionCommand.swift index daa79734..b2da0296 100644 --- a/EditorExtension/RealtimeSuggestionCommand.swift +++ b/EditorExtension/RealtimeSuggestionCommand.swift @@ -4,7 +4,7 @@ import Foundation import XcodeKit class RealtimeSuggestionsCommand: NSObject, XCSourceEditorCommand, CommandType { - var name: String { "Real-time Suggestions" } + var name: String { "Prepare for Real-time Suggestions" } func perform( with invocation: XCSourceEditorCommandInvocation, diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift index 4b3882e2..aedf6bf3 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -17,7 +17,8 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { PreviousSuggestionCommand(), PromptToCodeCommand(), AcceptPromptToCodeCommand(), - ChatWithSelectionCommand(), + OpenChatCommand(), + ToggleRealtimeSuggestionsCommand(), ].map(makeCommandDefinition) } diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 2190bbb2..3f5a8c3f 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -53,7 +53,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { @objc func quit() { Task { @MainActor in await service.prepareForExit() - exit(0) + await xpcController?.quit() + NSApp.terminate(self) } } diff --git a/ExtensionService/XPCController.swift b/ExtensionService/XPCController.swift index eafc3f69..5fdd4445 100644 --- a/ExtensionService/XPCController.swift +++ b/ExtensionService/XPCController.swift @@ -20,10 +20,16 @@ final class XPCController: XPCServiceDelegate { self.bridge = bridge Task { - await bridge.setDelegate(self) + bridge.setDelegate(self) createPingTask() } } + + func quit() async { + bridge.setDelegate(nil) + pingTask?.cancel() + try? await bridge.quit() + } deinit { xpcListener.invalidate() diff --git a/Makefile b/Makefile index c0fbddcf..b16f8417 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,25 @@ +GITHUB_URL := https://github.com/intitni/CopilotForXcode/ +ZIPNAME_BASE := Copilot.for.Xcode.app + setup: echo "Setup." -setup-langchain: - echo "Don't setup LangChain!" - cd Python; \ - curl -L https://github.com/beeware/Python-Apple-support/releases/download/3.11-b1/Python-3.11-macOS-support.b1.tar.gz -o Python-3.11-macOS-support.b1.tar.gz; \ - tar -xzvf Python-3.11-macOS-support.b1.tar.gz; \ - rm Python-3.11-macOS-support.b1.tar.gz; \ - cp module.modulemap.copy Python.xcframework/macos-arm64_x86_64/Headers/module.modulemap - cd Python/site-packages; \ - sh ./install.sh +# Usage: make appcast app=path/to/bundle.app tag=1.0.0 [channel=beta] [release=1] +appcast: + $(eval TMPDIR := ~/Library/Caches/CopilotForXcodeRelease/$(shell uuidgen)) + $(eval BUNDLENAME := $(shell basename "$(app)")) + $(eval WORKDIR := $(shell dirname "$(app)")) + $(eval ZIPNAME := $(ZIPNAME_BASE)$(if $(channel),.$(channel).$(if $(release),$(release),1))) + $(eval RELEASENOTELINK := $(GITHUB_URL)releases/tag/$(tag)) + mkdir -p $(TMPDIR) + cp appcast.xml $(TMPDIR)/appcast.xml + cd $(WORKDIR) && ditto -c -k --sequesterRsrc --keepParent "$(BUNDLENAME)" "$(ZIPNAME).zip" + cd $(WORKDIR) && cp "$(ZIPNAME).zip" $(TMPDIR)/ + touch $(TMPDIR)/$(ZIPNAME).html + echo "" > $(TMPDIR)/$(ZIPNAME).html + -Core/.build/artifacts/sparkle/bin/generate_appcast $(TMPDIR) --download-url-prefix "$(GITHUB_URL)releases/download/$(tag)/" --release-notes-url-prefix "$(RELEASENOTELINK)" $(if $(channel),--channel "$(channel)") + mv -f $(TMPDIR)/appcast.xml . + rm -rf $(TMPDIR) + sed -i '' 's/$(ZIPNAME).html/$(tag)/g' appcast.xml -.PHONY: setup setup-langchain +.PHONY: setup appcast \ No newline at end of file diff --git a/Pro b/Pro index a630ea2a..b915eb29 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit a630ea2ab3875353921013a20054ebc7d460bbfb +Subproject commit b915eb299fd44668902a3125c257af3e8f302d72 diff --git a/README.md b/README.md index cbff5a77..f162bdad 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ Whenever your code is updated, the app will automatically fetch suggestions for Commands called by the app: -- Real-time Suggestions: Call only by Copilot for Xcode. When suggestions are successfully fetched, Copilot for Xcode will run this command to present the suggestions. +- Prepare for Real-time Suggestions: Call only by Copilot for Xcode. When suggestions are successfully fetched, Copilot for Xcode will run this command to present the suggestions. - Prefetch Suggestions: Call only by Copilot for Xcode. In the background, Copilot for Xcode will occasionally run this command to prefetch real-time suggestions. ### Chat diff --git a/Tool/.swiftpm/xcode/xcshareddata/xcschemes/SuggestionModel.xcscheme b/Tool/.swiftpm/xcode/xcshareddata/xcschemes/SuggestionModel.xcscheme new file mode 100644 index 00000000..a1cb3d32 --- /dev/null +++ b/Tool/.swiftpm/xcode/xcshareddata/xcschemes/SuggestionModel.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tool/Package.swift b/Tool/Package.swift index c52689cc..36ecdb15 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -61,7 +61,7 @@ let package = Package( .package(url: "https://github.com/intitni/Highlightr", branch: "master"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", - from: "0.55.0" + from: "1.10.4" ), .package(url: "https://github.com/apple/swift-syntax.git", exact: "509.0.2"), .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), @@ -71,7 +71,7 @@ let package = Package( url: "https://github.com/intitni/generative-ai-swift", branch: "support-setting-base-url" ), - .package(url: "https://github.com/intitni/CopilotForXcodeKit", from: "0.4.0"), + .package(url: "https://github.com/intitni/CopilotForXcodeKit", branch: "develop"), // TreeSitter .package(url: "https://github.com/intitni/SwiftTreeSitter.git", branch: "main"), @@ -202,13 +202,24 @@ let package = Package( .target(name: "AsyncPassthroughSubject"), + .target( + name: "BuiltinExtension", + dependencies: [ + "SuggestionModel", + "Workspace", + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + ] + ), + .target( name: "SharedUIComponents", dependencies: [ "Highlightr", "Preferences", "SuggestionModel", + "DebounceFunction", .product(name: "STTextView", package: "STTextView"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), .testTarget(name: "SharedUIComponentsTests", dependencies: ["SharedUIComponents"]), @@ -239,6 +250,7 @@ let package = Package( "Workspace", "SuggestionProvider", "XPCShared", + "BuiltinExtension", ] ), @@ -285,9 +297,9 @@ let package = Package( .target(name: "BingSearchService"), .target(name: "SuggestionProvider", dependencies: [ - "GitHubCopilotService", - "CodeiumService", + "SuggestionModel", "UserDefaultsObserver", + "Preferences", .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ]), @@ -303,8 +315,11 @@ let package = Package( "Logger", "Preferences", "Terminal", + "BuiltinExtension", .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), - ] + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + ], + resources: [.copy("Resources/load-self-signed-cert.js")] ), .testTarget( name: "GitHubCopilotServiceTests", @@ -322,6 +337,8 @@ let package = Package( "Preferences", "Terminal", "XcodeInspector", + "BuiltinExtension", + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] ), diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift index 06d0a022..3695343a 100644 --- a/Tool/Sources/AIModel/ChatModel.swift +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -44,6 +44,15 @@ public struct ChatModel: Codable, Equatable, Identifiable { } } + public struct OpenAICompatibleInfo: Codable, Equatable { + @FallbackDecoding + public var enforceMessageOrder: Bool + + public init(enforceMessageOrder: Bool = false) { + self.enforceMessageOrder = enforceMessageOrder + } + } + public struct GoogleGenerativeAIInfo: Codable, Equatable { @FallbackDecoding public var apiVersion: String @@ -72,6 +81,8 @@ public struct ChatModel: Codable, Equatable, Identifiable { public var ollamaInfo: OllamaInfo @FallbackDecoding public var googleGenerativeAIInfo: GoogleGenerativeAIInfo + @FallbackDecoding + public var openAICompatibleInfo: OpenAICompatibleInfo public init( apiKeyName: String = "", @@ -82,7 +93,8 @@ public struct ChatModel: Codable, Equatable, Identifiable { modelName: String = "", openAIInfo: OpenAIInfo = OpenAIInfo(), ollamaInfo: OllamaInfo = OllamaInfo(), - googleGenerativeAIInfo: GoogleGenerativeAIInfo = GoogleGenerativeAIInfo() + googleGenerativeAIInfo: GoogleGenerativeAIInfo = GoogleGenerativeAIInfo(), + openAICompatibleInfo: OpenAICompatibleInfo = OpenAICompatibleInfo() ) { self.apiKeyName = apiKeyName self.baseURL = baseURL @@ -93,6 +105,7 @@ public struct ChatModel: Codable, Equatable, Identifiable { self.openAIInfo = openAIInfo self.ollamaInfo = ollamaInfo self.googleGenerativeAIInfo = googleGenerativeAIInfo + self.openAICompatibleInfo = openAICompatibleInfo } } @@ -148,3 +161,7 @@ public struct EmptyChatModelOpenAIInfo: FallbackValueProvider { public struct EmptyChatModelGoogleGenerativeAIInfo: FallbackValueProvider { public static var defaultValue: ChatModel.Info.GoogleGenerativeAIInfo { .init() } } + +public struct EmptyChatModelOpenAICompatibleInfo: FallbackValueProvider { + public static var defaultValue: ChatModel.Info.OpenAICompatibleInfo { .init() } +} diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift new file mode 100644 index 00000000..39ec18de --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift @@ -0,0 +1,13 @@ +import CopilotForXcodeKit +import Foundation +import Preferences + +public protocol BuiltinExtension: CopilotForXcodeExtensionCapability { + /// An id that let the extension manager determine whether the extension is in use. + var suggestionServiceId: BuiltInSuggestionFeatureProvider { get } + + /// It's usually called when the app is about to quit, + /// you should clean up all the resources here. + func terminate() +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift new file mode 100644 index 00000000..11dba564 --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift @@ -0,0 +1,46 @@ +import AppKit +import Combine +import Foundation +import XcodeInspector + +public final class BuiltinExtensionManager { + public static let shared: BuiltinExtensionManager = .init() + private(set) var extensions: [any BuiltinExtension] = [] + + private var cancellable: Set = [] + + init() { + XcodeInspector.shared.$activeApplication.sink { [weak self] app in + if let app, app.isXcode || app.isExtensionService { + self?.checkAppConfiguration() + } + }.store(in: &cancellable) + } + + public func setupExtensions(_ extensions: [any BuiltinExtension]) { + self.extensions = extensions + checkAppConfiguration() + } + + public func terminate() { + for ext in extensions { + ext.terminate() + } + } +} + +extension BuiltinExtensionManager { + func checkAppConfiguration() { + let suggestionFeatureProvider = UserDefaults.shared.value(for: \.suggestionFeatureProvider) + for ext in extensions { + let isSuggestionFeatureInUse = suggestionFeatureProvider == + .builtIn(ext.suggestionServiceId) + let isChatFeatureInUse = false + ext.extensionUsageDidChange(.init( + isSuggestionServiceInUse: isSuggestionFeatureInUse, + isChatServiceInUse: isChatFeatureInUse + )) + } + } +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift new file mode 100644 index 00000000..bc8f5ae1 --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift @@ -0,0 +1,177 @@ +import CopilotForXcodeKit +import Foundation +import Logger +import Preferences +import SuggestionModel +import SuggestionProvider + +public final class BuiltinExtensionSuggestionServiceProvider< + T: BuiltinExtension +>: SuggestionServiceProvider { + public var configuration: SuggestionServiceConfiguration { + guard let service else { + return .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ) + } + + return service.configuration + } + + let extensionManager: BuiltinExtensionManager + + public init( + extension: T.Type, + extensionManager: BuiltinExtensionManager = .shared + ) { + self.extensionManager = extensionManager + } + + var service: CopilotForXcodeKit.SuggestionServiceType? { + extensionManager.extensions.first { $0 is T }?.suggestionService + } + + struct BuiltinExtensionSuggestionServiceNotFoundError: Error, LocalizedError { + var errorDescription: String? { + "Builtin suggestion service not found." + } + } + + public func getSuggestions( + _ request: SuggestionProvider.SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async throws -> [SuggestionModel.CodeSuggestion] { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + throw BuiltinExtensionSuggestionServiceNotFoundError() + } + return try await service.getSuggestions( + .init( + fileURL: request.fileURL, + relativePath: request.relativePath, + language: .init( + rawValue: languageIdentifierFromFileURL(request.fileURL).rawValue + ) ?? .plaintext, + content: request.content, + originalContent: request.originalContent, + cursorPosition: .init( + line: request.cursorPosition.line, + character: request.cursorPosition.character + ), + tabSize: request.tabSize, + indentSize: request.indentSize, + usesTabsForIndentation: request.usesTabsForIndentation, + relevantCodeSnippets: request.relevantCodeSnippets.map { $0.converted } + ), + workspace: workspaceInfo + ).map { $0.converted } + } + + public func cancelRequest( + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + return + } + await service.cancelRequest(workspace: workspaceInfo) + } + + public func notifyAccepted( + _ suggestion: SuggestionModel.CodeSuggestion, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + return + } + await service.notifyAccepted(suggestion.converted, workspace: workspaceInfo) + } + + public func notifyRejected( + _ suggestions: [SuggestionModel.CodeSuggestion], + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + return + } + await service.notifyRejected(suggestions.map(\.converted), workspace: workspaceInfo) + } +} + +extension SuggestionProvider.SuggestionRequest { + var converted: CopilotForXcodeKit.SuggestionRequest { + .init( + fileURL: fileURL, + relativePath: relativePath, + language: .init(rawValue: languageIdentifierFromFileURL(fileURL).rawValue) + ?? .plaintext, + content: content, + originalContent: originalContent, + cursorPosition: .init( + line: cursorPosition.line, + character: cursorPosition.character + ), + tabSize: tabSize, + indentSize: indentSize, + usesTabsForIndentation: usesTabsForIndentation, + relevantCodeSnippets: relevantCodeSnippets.map(\.converted) + ) + } +} + +extension SuggestionModel.CodeSuggestion { + var converted: CopilotForXcodeKit.CodeSuggestion { + .init( + id: id, + text: text, + position: .init( + line: position.line, + character: position.character + ), + range: .init( + start: .init( + line: range.start.line, + character: range.start.character + ), + end: .init( + line: range.end.line, + character: range.end.character + ) + ) + ) + } +} + +extension CopilotForXcodeKit.CodeSuggestion { + var converted: SuggestionModel.CodeSuggestion { + .init( + id: id, + text: text, + position: .init( + line: position.line, + character: position.character + ), + range: .init( + start: .init( + line: range.start.line, + character: range.start.character + ), + end: .init( + line: range.end.line, + character: range.end.character + ) + ) + ) + } +} + +extension SuggestionProvider.RelevantCodeSnippet { + var converted: CopilotForXcodeKit.RelevantCodeSnippet { + .init(content: content, priority: priority, filePath: filePath) + } +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift new file mode 100644 index 00000000..f8471d12 --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift @@ -0,0 +1,75 @@ +import Foundation +import Workspace + +public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { + let extensionManager: BuiltinExtensionManager + + public init(workspace: Workspace, extensionManager: BuiltinExtensionManager = .shared) { + self.extensionManager = extensionManager + super.init(workspace: workspace) + } + + override public func didOpenFilespace(_ filespace: Filespace) { + notifyOpenFile(filespace: filespace) + } + + override public func didSaveFilespace(_ filespace: Filespace) { + notifySaveFile(filespace: filespace) + } + + override public func didUpdateFilespace(_ filespace: Filespace, content: String) { + notifyUpdateFile(filespace: filespace, content: content) + } + + override public func didCloseFilespace(_ fileURL: URL) { + Task { + for ext in extensionManager.extensions { + ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didCloseDocumentAt: fileURL + ) + } + } + } + + public func notifyOpenFile(filespace: Filespace) { + Task { + guard filespace.isTextReadable else { return } + guard !(await filespace.isGitIgnored) else { return } + for ext in extensionManager.extensions { + ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didOpenDocumentAt: filespace.fileURL + ) + } + } + } + + public func notifyUpdateFile(filespace: Filespace, content: String) { + Task { + guard filespace.isTextReadable else { return } + guard !(await filespace.isGitIgnored) else { return } + for ext in extensionManager.extensions { + ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didUpdateDocumentAt: filespace.fileURL, + content: content + ) + } + } + } + + public func notifySaveFile(filespace: Filespace) { + Task { + guard filespace.isTextReadable else { return } + guard !(await filespace.isGitIgnored) else { return } + for ext in extensionManager.extensions { + ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didSaveDocumentAt: filespace.fileURL + ) + } + } + } +} + diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index cc10c240..9396373e 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -3,6 +3,7 @@ import Foundation import SwiftUI /// The information of a tab. +@ObservableState public struct ChatTabInfo: Identifiable, Equatable { public var id: String public var title: String @@ -61,18 +62,22 @@ open class BaseChatTab { } } - public var id: String { chatTabViewStore.id } - public var title: String { chatTabViewStore.title } + public var id: String = "" + public var title: String = "" /// The store for chat tab info. You should only access it after `start` is called. public let chatTabStore: StoreOf - /// The view store for chat tab info. You should only access it after `start` is called. - public let chatTabViewStore: ViewStoreOf private var didStart = false + private let storeObserver = NSObject() public init(store: StoreOf) { chatTabStore = store - chatTabViewStore = ViewStore(store) + + storeObserver.observe { [weak self] in + guard let self else { return } + self.title = store.title + self.id = store.id + } } /// The view for this chat tab. @@ -220,12 +225,12 @@ public class EmptyChatTab: ChatTab { public convenience init(id: String) { self.init(store: .init( initialState: .init(id: id, title: "Empty-\(id)"), - reducer: ChatTabItem() + reducer: { ChatTabItem() } )) } public func start() { - chatTabViewStore.send(.updateTitle("Empty-\(id)")) + chatTabStore.send(.updateTitle("Empty-\(id)")) } } diff --git a/Tool/Sources/ChatTab/ChatTabItem.swift b/Tool/Sources/ChatTab/ChatTabItem.swift index f54f8085..abf7aaa2 100644 --- a/Tool/Sources/ChatTab/ChatTabItem.swift +++ b/Tool/Sources/ChatTab/ChatTabItem.swift @@ -13,7 +13,8 @@ public struct AnyChatTabBuilder: Equatable { } } -public struct ChatTabItem: ReducerProtocol { +@Reducer +public struct ChatTabItem { public typealias State = ChatTabInfo public enum Action: Equatable { @@ -26,7 +27,7 @@ public struct ChatTabItem: ReducerProtocol { public init() {} - public var body: some ReducerProtocol { + public var body: some ReducerOf { Reduce { state, action in switch action { case let .updateTitle(title): diff --git a/Tool/Sources/ChatTab/ChatTabPool.swift b/Tool/Sources/ChatTab/ChatTabPool.swift index db5424a2..fafa22cc 100644 --- a/Tool/Sources/ChatTab/ChatTabPool.swift +++ b/Tool/Sources/ChatTab/ChatTabPool.swift @@ -8,7 +8,7 @@ public final class ChatTabPool { public var createStore: (String) -> StoreOf = { id in .init( initialState: .init(id: id, title: ""), - reducer: ChatTabItem() + reducer: { ChatTabItem() } ) } @@ -52,3 +52,4 @@ public extension EnvironmentValues { set { self[ChatTabPoolEnvironmentKey.self] = newValue } } } + diff --git a/Tool/Sources/CodeiumService/CodeiumExtension.swift b/Tool/Sources/CodeiumService/CodeiumExtension.swift new file mode 100644 index 00000000..ee3f18f6 --- /dev/null +++ b/Tool/Sources/CodeiumService/CodeiumExtension.swift @@ -0,0 +1,125 @@ +import BuiltinExtension +import CopilotForXcodeKit +import Foundation +import Logger +import Preferences +import Workspace + +public final class CodeiumExtension: BuiltinExtension { + public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .codeium } + + public let suggestionService: CodeiumSuggestionService? + + private var extensionUsage = ExtensionUsage( + isSuggestionServiceInUse: false, + isChatServiceInUse: false + ) + private var isLanguageServerInUse: Bool { + extensionUsage.isSuggestionServiceInUse || extensionUsage.isChatServiceInUse + } + + let workspacePool: WorkspacePool + + let serviceLocator: ServiceLocator + + public init(workspacePool: WorkspacePool) { + self.workspacePool = workspacePool + serviceLocator = .init(workspacePool: workspacePool) + suggestionService = .init(serviceLocator: serviceLocator) + } + + public func workspaceDidOpen(_: WorkspaceInfo) {} + + public func workspaceDidClose(_: WorkspaceInfo) {} + + public func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } + // check if file size is larger than 15MB, if so, return immediately + if let attrs = try? FileManager.default + .attributesOfItem(atPath: documentURL.path), + let fileSize = attrs[FileAttributeKey.size] as? UInt64, + fileSize > 15 * 1024 * 1024 + { return } + + Task { + do { + let content = try String(contentsOf: documentURL, encoding: .utf8) + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func workspace(_ workspace: WorkspaceInfo, didSaveDocumentAt documentURL: URL) { + // unimplemented + } + + public func workspace(_ workspace: WorkspaceInfo, didCloseDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } + Task { + do { + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyCloseTextDocument(fileURL: documentURL) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String? + ) { + guard isLanguageServerInUse else { return } + // check if file size is larger than 15MB, if so, return immediately + if let attrs = try? FileManager.default + .attributesOfItem(atPath: documentURL.path), + let fileSize = attrs[FileAttributeKey.size] as? UInt64, + fileSize > 15 * 1024 * 1024 + { return } + + Task { + do { + guard let content else { return } + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyChangeTextDocument(fileURL: documentURL, content: content) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func extensionUsageDidChange(_ usage: ExtensionUsage) { + extensionUsage = usage + if !usage.isChatServiceInUse && !usage.isSuggestionServiceInUse { + terminate() + } + } + + public func terminate() { + for workspace in workspacePool.workspaces.values { + guard let plugin = workspace.plugin(for: CodeiumWorkspacePlugin.self) + else { continue } + plugin.terminate() + } + } +} + +final class ServiceLocator { + let workspacePool: WorkspacePool + + init(workspacePool: WorkspacePool) { + self.workspacePool = workspacePool + } + + func getService(from workspace: WorkspaceInfo) async -> CodeiumService? { + guard let workspace = workspacePool.workspaces[workspace.workspaceURL], + let plugin = workspace.plugin(for: CodeiumWorkspacePlugin.self) + else { return nil } + return plugin.codeiumService + } +} + diff --git a/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift index 9ea25108..2fc8f64a 100644 --- a/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift +++ b/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift @@ -3,7 +3,7 @@ import Terminal public struct CodeiumInstallationManager { private static var isInstalling = false - static let latestSupportedVersion = "1.8.5" + static let latestSupportedVersion = "1.8.8" public init() {} @@ -15,7 +15,7 @@ public struct CodeiumInstallationManager { } public func checkInstallation() -> InstallationStatus { - guard let urls = try? CodeiumSuggestionService.createFoldersIfNeeded() + guard let urls = try? CodeiumService.createFoldersIfNeeded() else { return .notInstalled } let executableFolderURL = urls.executableURL let binaryURL = executableFolderURL.appendingPathComponent("language_server") @@ -60,7 +60,7 @@ public struct CodeiumInstallationManager { defer { CodeiumInstallationManager.isInstalling = false } do { continuation.yield(.downloading) - let urls = try CodeiumSuggestionService.createFoldersIfNeeded() + let urls = try CodeiumService.createFoldersIfNeeded() let urlString = "https://github.com/Exafunction/codeium/releases/download/language-server-v\(Self.latestSupportedVersion)/language_server_macos_\(isAppleSilicon() ? "arm" : "x64").gz" guard let url = URL(string: urlString) else { return } @@ -108,7 +108,7 @@ public struct CodeiumInstallationManager { } public func uninstall() async throws { - guard let urls = try? CodeiumSuggestionService.createFoldersIfNeeded() + guard let urls = try? CodeiumService.createFoldersIfNeeded() else { return } let executableFolderURL = urls.executableURL let binaryURL = executableFolderURL.appendingPathComponent("language_server") diff --git a/Tool/Sources/CodeiumService/CodeiumService.swift b/Tool/Sources/CodeiumService/CodeiumService.swift index 8c0c0bec..d0706bf9 100644 --- a/Tool/Sources/CodeiumService/CodeiumService.swift +++ b/Tool/Sources/CodeiumService/CodeiumService.swift @@ -39,7 +39,7 @@ enum CodeiumError: Error, LocalizedError { } } -public class CodeiumSuggestionService { +public class CodeiumService { static let sessionId = UUID().uuidString let projectRootURL: URL var server: CodeiumLSP? @@ -70,7 +70,7 @@ public class CodeiumSuggestionService { public init(projectRootURL: URL, onServiceLaunched: @escaping () -> Void) throws { self.projectRootURL = projectRootURL self.onServiceLaunched = onServiceLaunched - let urls = try CodeiumSuggestionService.createFoldersIfNeeded() + let urls = try CodeiumService.createFoldersIfNeeded() languageServerURL = urls.executableURL.appendingPathComponent("language_server") supportURL = urls.supportURL Task { @@ -177,7 +177,7 @@ public class CodeiumSuggestionService { } } -extension CodeiumSuggestionService { +extension CodeiumService { func getMetadata() async throws -> Metadata { guard let key = authService.key else { struct E: Error, LocalizedError { @@ -198,7 +198,7 @@ extension CodeiumSuggestionService { ide_version: ideVersion, extension_version: languageServerVersion, api_key: key, - session_id: CodeiumSuggestionService.sessionId, + session_id: CodeiumService.sessionId, request_id: requestCounter ) } @@ -219,7 +219,7 @@ extension CodeiumSuggestionService { } } -extension CodeiumSuggestionService: CodeiumSuggestionServiceType { +extension CodeiumService: CodeiumSuggestionServiceType { public func getCompletions( fileURL: URL, content: String, @@ -298,7 +298,7 @@ extension CodeiumSuggestionService: CodeiumSuggestionServiceType { _ = try? await server?.sendRequest( CodeiumRequest.CancelRequest(requestBody: .init( request_id: requestCounter, - session_id: CodeiumSuggestionService.sessionId + session_id: CodeiumService.sessionId )) ) } diff --git a/Tool/Sources/CodeiumService/CodeiumSuggestionService.swift b/Tool/Sources/CodeiumService/CodeiumSuggestionService.swift new file mode 100644 index 00000000..1b9b3477 --- /dev/null +++ b/Tool/Sources/CodeiumService/CodeiumSuggestionService.swift @@ -0,0 +1,105 @@ +import CopilotForXcodeKit +import Foundation +import SuggestionModel +import Workspace + +public final class CodeiumSuggestionService: SuggestionServiceType { + public var configuration: SuggestionServiceConfiguration { + .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: false + ) + } + + let serviceLocator: ServiceLocator + + init(serviceLocator: ServiceLocator) { + self.serviceLocator = serviceLocator + } + + public func getSuggestions( + _ request: SuggestionRequest, + workspace: WorkspaceInfo + ) async throws -> [CopilotForXcodeKit.CodeSuggestion] { + guard let service = await serviceLocator.getService(from: workspace) else { return [] } + return try await service.getCompletions( + fileURL: request.fileURL, + content: request.content, + cursorPosition: .init( + line: request.cursorPosition.line, + character: request.cursorPosition.character + ), + tabSize: request.tabSize, + indentSize: request.indentSize, + usesTabsForIndentation: request.usesTabsForIndentation + ).map(Self.convert) + } + + public func notifyAccepted( + _ suggestion: CopilotForXcodeKit.CodeSuggestion, + workspace: WorkspaceInfo + ) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.notifyAccepted(Self.convert(suggestion)) + } + + public func notifyRejected( + _ suggestions: [CopilotForXcodeKit.CodeSuggestion], + workspace: WorkspaceInfo + ) async { + // unimplemented + } + + public func cancelRequest(workspace: WorkspaceInfo) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.cancelRequest() + } + + static func convert( + _ suggestion: SuggestionModel.CodeSuggestion + ) -> CopilotForXcodeKit.CodeSuggestion { + .init( + id: suggestion.id, + text: suggestion.text, + position: .init( + line: suggestion.position.line, + character: suggestion.position.character + ), + range: .init( + start: .init( + line: suggestion.range.start.line, + character: suggestion.range.start.character + ), + end: .init( + line: suggestion.range.end.line, + character: suggestion.range.end.character + ) + ) + ) + } + + static func convert( + _ suggestion: CopilotForXcodeKit.CodeSuggestion + ) -> SuggestionModel.CodeSuggestion { + .init( + id: suggestion.id, + text: suggestion.text, + position: .init( + line: suggestion.position.line, + character: suggestion.position.character + ), + range: .init( + start: .init( + line: suggestion.range.start.line, + character: suggestion.range.start.character + ), + end: .init( + line: suggestion.range.end.line, + character: suggestion.range.end.character + ) + ) + ) + } +} + diff --git a/Tool/Sources/CodeiumService/CodeiumWorkspacePlugin.swift b/Tool/Sources/CodeiumService/CodeiumWorkspacePlugin.swift new file mode 100644 index 00000000..62093950 --- /dev/null +++ b/Tool/Sources/CodeiumService/CodeiumWorkspacePlugin.swift @@ -0,0 +1,53 @@ +import Foundation +import Logger +import Workspace + +public final class CodeiumWorkspacePlugin: WorkspacePlugin { + var _codeiumService: CodeiumService? + var codeiumService: CodeiumService? { + if let service = _codeiumService { return service } + do { + return try createCodeiumService() + } catch { + Logger.codeium.error("Failed to create Codeium service: \(error)") + return nil + } + } + + deinit { + if let codeiumService { + codeiumService.terminate() + } + } + + func createCodeiumService() throws -> CodeiumService { + let newService = try CodeiumService( + projectRootURL: projectRootURL, + onServiceLaunched: { + [weak self] in + self?.finishLaunchingService() + } + ) + _codeiumService = newService + return newService + } + + func finishLaunchingService() { + guard let workspace, let _codeiumService else { return } + Task { + for (_, filespace) in workspace.filespaces { + let documentURL = filespace.fileURL + guard let content = try? String(contentsOf: documentURL) else { continue } + try? await _codeiumService.notifyOpenTextDocument( + fileURL: documentURL, + content: content + ) + } + } + } + + func terminate() { + _codeiumService = nil + } +} + diff --git a/Tool/Sources/DebounceFunction/DebounceFunction.swift b/Tool/Sources/DebounceFunction/DebounceFunction.swift index 3d6e26e5..66a5fdd1 100644 --- a/Tool/Sources/DebounceFunction/DebounceFunction.swift +++ b/Tool/Sources/DebounceFunction/DebounceFunction.swift @@ -11,6 +11,10 @@ public actor DebounceFunction { self.block = block } + public func cancel() { + task?.cancel() + } + public func callAsFunction(_ t: T) async { task?.cancel() task = Task { [block, duration] in @@ -20,3 +24,25 @@ public actor DebounceFunction { } } +public actor DebounceRunner { + let duration: TimeInterval + + var task: Task? + + public init(duration: TimeInterval) { + self.duration = duration + } + + public func cancel() { + task?.cancel() + } + + public func debounce(_ block: @escaping () async -> Void) { + task?.cancel() + task = Task { [duration] in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + await block() + } + } +} + diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift new file mode 100644 index 00000000..a9083fec --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -0,0 +1,147 @@ +import BuiltinExtension +import CopilotForXcodeKit +import Foundation +import LanguageServerProtocol +import Logger +import Preferences +import Workspace + +public final class GitHubCopilotExtension: BuiltinExtension { + public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .gitHubCopilot } + + public let suggestionService: GitHubCopilotSuggestionService? + + private var extensionUsage = ExtensionUsage( + isSuggestionServiceInUse: false, + isChatServiceInUse: false + ) + private var isLanguageServerInUse: Bool { + extensionUsage.isSuggestionServiceInUse || extensionUsage.isChatServiceInUse + } + + let workspacePool: WorkspacePool + + let serviceLocator: ServiceLocator + + public init(workspacePool: WorkspacePool) { + self.workspacePool = workspacePool + serviceLocator = .init(workspacePool: workspacePool) + suggestionService = .init(serviceLocator: serviceLocator) + } + + public func workspaceDidOpen(_: WorkspaceInfo) {} + + public func workspaceDidClose(_: WorkspaceInfo) {} + + public func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } + // check if file size is larger than 15MB, if so, return immediately + if let attrs = try? FileManager.default + .attributesOfItem(atPath: documentURL.path), + let fileSize = attrs[FileAttributeKey.size] as? UInt64, + fileSize > 15 * 1024 * 1024 + { return } + + Task { + do { + let content = try String(contentsOf: documentURL, encoding: .utf8) + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func workspace(_ workspace: WorkspaceInfo, didSaveDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } + Task { + do { + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifySaveTextDocument(fileURL: documentURL) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func workspace(_ workspace: WorkspaceInfo, didCloseDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } + Task { + do { + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyCloseTextDocument(fileURL: documentURL) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String? + ) { + guard isLanguageServerInUse else { return } + // check if file size is larger than 15MB, if so, return immediately + if let attrs = try? FileManager.default + .attributesOfItem(atPath: documentURL.path), + let fileSize = attrs[FileAttributeKey.size] as? UInt64, + fileSize > 15 * 1024 * 1024 + { return } + + Task { + guard let content else { return } + guard let service = await serviceLocator.getService(from: workspace) else { return } + do { + try await service.notifyChangeTextDocument( + fileURL: documentURL, + content: content, + version: 0 + ) + } catch let error as ServerError { + switch error { + case .serverError(-32602, _, _): // parameter incorrect + Logger.gitHubCopilot.error(error.localizedDescription) + // Reopen document if it's not found in the language server + self.workspace(workspace, didOpenDocumentAt: documentURL) + default: + Logger.gitHubCopilot.error(error.localizedDescription) + } + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func extensionUsageDidChange(_ usage: ExtensionUsage) { + extensionUsage = usage + if !usage.isChatServiceInUse && !usage.isSuggestionServiceInUse { + terminate() + } + } + + public func terminate() { + for workspace in workspacePool.workspaces.values { + guard let plugin = workspace.plugin(for: GitHubCopilotWorkspacePlugin.self) + else { continue } + plugin.terminate() + } + } +} + +final class ServiceLocator { + let workspacePool: WorkspacePool + + init(workspacePool: WorkspacePool) { + self.workspacePool = workspacePool + } + + func getService(from workspace: WorkspaceInfo) async -> GitHubCopilotService? { + guard let workspace = workspacePool.workspaces[workspace.workspaceURL], + let plugin = workspace.plugin(for: GitHubCopilotWorkspacePlugin.self) + else { return nil } + return plugin.gitHubCopilotService + } +} + diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift new file mode 100644 index 00000000..a5b71740 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift @@ -0,0 +1,51 @@ +import Foundation +import Logger +import Workspace + +public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { + var _gitHubCopilotService: GitHubCopilotService? + var gitHubCopilotService: GitHubCopilotService? { + if let service = _gitHubCopilotService { return service } + do { + return try createGitHubCopilotService() + } catch { + Logger.gitHubCopilot.error("Failed to create GitHub Copilot service: \(error)") + return nil + } + } + + deinit { + if let gitHubCopilotService { + Task { await gitHubCopilotService.terminate() } + } + } + + func createGitHubCopilotService() throws -> GitHubCopilotService { + let newService = try GitHubCopilotService(projectRootURL: projectRootURL) + _gitHubCopilotService = newService + Task { + try await Task.sleep(nanoseconds: 1_000_000_000) + finishLaunchingService() + } + return newService + } + + func finishLaunchingService() { + guard let workspace, let _gitHubCopilotService else { return } + Task { + for (_, filespace) in workspace.filespaces { + let documentURL = filespace.fileURL + guard let content = try? String(contentsOf: documentURL) else { continue } + try? await _gitHubCopilotService.notifyOpenTextDocument( + fileURL: documentURL, + content: content + ) + } + } + } + + func terminate() { + _gitHubCopilotService = nil + } +} + diff --git a/Tool/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift similarity index 96% rename from Tool/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index e655a91d..40e7ac4f 100644 --- a/Tool/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -241,6 +241,13 @@ extension CustomJSONRPCLanguageServer { } block(nil) return true + case "conversation/preconditionsNotification": + if UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) { + Logger.gitHubCopilot + .info("\(anyNotification.method): \(debugDescription)") + } + block(nil) + return true default: return false } diff --git a/Tool/Sources/GitHubCopilotService/CustomStdioTransport.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift similarity index 100% rename from Tool/Sources/GitHubCopilotService/CustomStdioTransport.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotAccountStatus.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotAccountStatus.swift similarity index 100% rename from Tool/Sources/GitHubCopilotService/GitHubCopilotAccountStatus.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotAccountStatus.swift diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift similarity index 98% rename from Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift index f7013f08..069d2c0b 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift @@ -5,12 +5,12 @@ public struct GitHubCopilotInstallationManager { private static var isInstalling = false static var downloadURL: URL { - let commitHash = "a4a6d6b3f9e284e7f5c849619e06cd228cad8abd" + let commitHash = "25feddf8e3aa79f0573c8f43ddb13c44c530cfa5" let link = "https://github.com/github/copilot.vim/archive/\(commitHash).zip" return URL(string: link)! } - static let latestSupportedVersion = "1.25.0" + static let latestSupportedVersion = "1.32.0" public init() {} diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift similarity index 85% rename from Tool/Sources/GitHubCopilotService/GitHubCopilotRequest.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index d9210485..2b1c107d 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -241,6 +241,56 @@ enum GitHubCopilotRequest { } } + struct InlineCompletion: GitHubCopilotRequestType { + struct Response: Codable { + var items: [InlineCompletionItem] + } + + struct InlineCompletionItem: Codable { + var insertText: String + var filterText: String? + var range: Range? + var command: Command? + + struct Range: Codable { + var start: Position + var end: Position + } + + struct Command: Codable { + var title: String + var command: String + var arguments: [String]? + } + } + + var doc: Input + + struct Input: Codable { + var textDocument: _TextDocument; struct _TextDocument: Codable { + var uri: String + var version: Int + } + + var position: Position + var formattingOptions: FormattingOptions + var context: _Context; struct _Context: Codable { + enum TriggerKind: Int, Codable { + case invoked = 1 + case automatic = 2 + } + + var triggerKind: TriggerKind + } + } + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(doc)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("textDocument/inlineCompletion", dict) + } + } + struct GetPanelCompletions: GitHubCopilotRequestType { struct Response: Codable { var completions: [GitHubCopilotCodeSuggestion] diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift similarity index 74% rename from Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 1d22116f..04bb977d 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -19,6 +19,7 @@ public protocol GitHubCopilotSuggestionServiceType { func getCompletions( fileURL: URL, content: String, + originalContent: String, cursorPosition: CursorPosition, tabSize: Int, indentSize: Int, @@ -27,7 +28,7 @@ public protocol GitHubCopilotSuggestionServiceType { func notifyAccepted(_ completion: CodeSuggestion) async func notifyRejected(_ completions: [CodeSuggestion]) async func notifyOpenTextDocument(fileURL: URL, content: String) async throws - func notifyChangeTextDocument(fileURL: URL, content: String) async throws + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws func notifyCloseTextDocument(fileURL: URL) async throws func notifySaveTextDocument(fileURL: URL) async throws func cancelRequest() async @@ -42,11 +43,14 @@ protocol GitHubCopilotLSP { enum GitHubCopilotError: Error, LocalizedError { case languageServerNotInstalled case languageServerError(ServerError) + case failedToInstallStartScript var errorDescription: String? { switch self { case .languageServerNotInstalled: return "Language server is not installed." + case .failedToInstallStartScript: + return "Failed to install start script." case let .languageServerError(error): switch error { case let .handlerUnavailable(handler): @@ -109,12 +113,32 @@ public class GitHubCopilotBaseService { throw GitHubCopilotError.languageServerNotInstalled } + let indexJSURL: URL = try { + if UserDefaults.shared.value(for: \.gitHubCopilotLoadKeyChainCertificates) { + let url = urls.executableURL.appendingPathComponent("load-self-signed-cert.js") + if !FileManager.default.fileExists(atPath: url.path) { + let file = Bundle.module.url( + forResource: "load-self-signed-cert", + withExtension: "js" + )! + do { + try FileManager.default.copyItem(at: file, to: url) + } catch { + throw GitHubCopilotError.failedToInstallStartScript + } + } + return url + } else { + return agentJSURL + } + }() + switch runner { case .bash: let nodePath = UserDefaults.shared.value(for: \.nodePath) let command = [ nodePath.isEmpty ? "node" : nodePath, - "\"\(agentJSURL.path)\"", + "\"\(indexJSURL.path)\"", "--stdio", ].joined(separator: " ") executionParams = Process.ExecutionParameters( @@ -128,7 +152,7 @@ public class GitHubCopilotBaseService { let nodePath = UserDefaults.shared.value(for: \.nodePath) let command = [ nodePath.isEmpty ? "node" : nodePath, - "\"\(agentJSURL.path)\"", + "\"\(indexJSURL.path)\"", "--stdio", ].joined(separator: " ") executionParams = Process.ExecutionParameters( @@ -146,7 +170,7 @@ public class GitHubCopilotBaseService { path: "/usr/bin/env", arguments: [ nodePath.isEmpty ? "node" : nodePath, - agentJSURL.path, + indexJSURL.path, "--stdio", ], environment: [ @@ -196,13 +220,12 @@ public class GitHubCopilotBaseService { self.server = server localProcessServer = localServer + let notifications = NotificationCenter.default + .notifications(named: .gitHubCopilotShouldRefreshEditorInformation) Task { [weak self] in _ = try? await server.sendRequest(GitHubCopilotRequest.SetEditorInfo()) - for await _ in NotificationCenter.default - .notifications(named: .gitHubCopilotShouldRefreshEditorInformation) - { - print("Yes!") + for await _ in notifications { guard self != nil else { return } _ = try? await server.sendRequest(GitHubCopilotRequest.SetEditorInfo()) } @@ -318,8 +341,7 @@ public final class GitHubCopilotAuthService: GitHubCopilotBaseService, public static let shared = TheActor() } -@GitHubCopilotSuggestionActor -public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, +public final class GitHubCopilotService: GitHubCopilotBaseService, GitHubCopilotSuggestionServiceType { private var ongoingTasks = Set>() @@ -332,60 +354,98 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, super.init(designatedServer: designatedServer) } + @GitHubCopilotSuggestionActor public func getCompletions( fileURL: URL, content: String, + originalContent: String, cursorPosition: CursorPosition, tabSize: Int, indentSize: Int, usesTabsForIndentation: Bool ) async throws -> [CodeSuggestion] { - let languageId = languageIdentifierFromFileURL(fileURL) - - let relativePath = { - let filePath = fileURL.path - let rootPath = projectRootURL.path - if let range = filePath.range(of: rootPath), - range.lowerBound == filePath.startIndex - { - let relativePath = filePath.replacingCharacters( - in: filePath.startIndex.. [CodeSuggestion] { + do { + let completions = try await server + .sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init( + textDocument: .init(uri: fileURL.path, version: 1), + position: cursorPosition, + formattingOptions: .init( + tabSize: tabSize, + insertSpaces: !usesTabsForIndentation + ), + context: .init(triggerKind: .invoked) + ))) + .items + .compactMap { (item: _) -> CodeSuggestion? in + guard let range = item.range else { return nil } + let suggestion = CodeSuggestion( + id: item.command?.arguments?.first ?? UUID().uuidString, + text: item.insertText, + position: cursorPosition, + range: .init(start: range.start, end: range.end) + ) + return suggestion + } + try Task.checkCancellation() + return completions + } catch let error as ServerError { + switch error { + case .serverError: + // sometimes the content inside language server is not new enough, which can + // lead to an version mismatch error. We can try a few times until the content + // is up to date. + if maxTry <= 0 { break } + Logger.gitHubCopilot.error( + "Try getting suggestions again: \(GitHubCopilotError.languageServerError(error).localizedDescription)" ) - return suggestion + try await Task.sleep(nanoseconds: 200_000_000) + return try await sendRequest(maxTry: maxTry - 1) + default: + break + } + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } + + func recoverContent() async { + try? await notifyChangeTextDocument( + fileURL: fileURL, + content: originalContent, + version: 0 + ) + } + + // since when the language server is no longer using the passed in content to generate + // suggestions, we will need to update the content to the file before we do any request. + // + // And sometimes the language server's content was not up to date and may generate + // weird result when the cursor position exceeds the line. + let task = Task { @GitHubCopilotSuggestionActor in + try? await notifyChangeTextDocument( + fileURL: fileURL, + content: content, + version: 1 + ) + + do { + try Task.checkCancellation() + return try await sendRequest() + } catch let error as CancellationError { + if ongoingTasks.isEmpty { + await recoverContent() } - try Task.checkCancellation() - return completions + throw error + } catch { + await recoverContent() + throw error + } } ongoingTasks.insert(task) @@ -393,22 +453,28 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, return try await task.value } + @GitHubCopilotSuggestionActor public func cancelRequest() async { + ongoingTasks.forEach { $0.cancel() } + ongoingTasks.removeAll() await localProcessServer?.cancelOngoingTasks() } + @GitHubCopilotSuggestionActor public func notifyAccepted(_ completion: CodeSuggestion) async { _ = try? await server.sendRequest( GitHubCopilotRequest.NotifyAccepted(completionUUID: completion.id) ) } + @GitHubCopilotSuggestionActor public func notifyRejected(_ completions: [CodeSuggestion]) async { _ = try? await server.sendRequest( GitHubCopilotRequest.NotifyRejected(completionUUIDs: completions.map(\.id)) ) } + @GitHubCopilotSuggestionActor public func notifyOpenTextDocument( fileURL: URL, content: String @@ -430,14 +496,19 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, ) } - public func notifyChangeTextDocument(fileURL: URL, content: String) async throws { + @GitHubCopilotSuggestionActor + public func notifyChangeTextDocument( + fileURL: URL, + content: String, + version: Int + ) async throws { let uri = "file://\(fileURL.path)" // Logger.service.debug("Change \(uri), \(content.count)") try await server.sendNotification( .didChangeTextDocument( DidChangeTextDocumentParams( uri: uri, - version: 0, + version: version, contentChange: .init( range: nil, rangeLength: nil, @@ -448,18 +519,21 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, ) } + @GitHubCopilotSuggestionActor public func notifySaveTextDocument(fileURL: URL) async throws { let uri = "file://\(fileURL.path)" // Logger.service.debug("Save \(uri)") try await server.sendNotification(.didSaveTextDocument(.init(uri: uri))) } + @GitHubCopilotSuggestionActor public func notifyCloseTextDocument(fileURL: URL) async throws { let uri = "file://\(fileURL.path)" // Logger.service.debug("Close \(uri)") try await server.sendNotification(.didCloseTextDocument(.init(uri: uri))) } + @GitHubCopilotSuggestionActor public func terminate() async { // automatically handled } diff --git a/Tool/Sources/GitHubCopilotService/Resources/load-self-signed-cert.js b/Tool/Sources/GitHubCopilotService/Resources/load-self-signed-cert.js new file mode 100644 index 00000000..112c99ac --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Resources/load-self-signed-cert.js @@ -0,0 +1,40 @@ +function initialize() { + if (process.platform !== "darwin") { + return; + } + + const splitPattern = /(?=-----BEGIN\sCERTIFICATE-----)/g; + const systemRootCertsPath = + "/System/Library/Keychains/SystemRootCertificates.keychain"; + const args = ["find-certificate", "-a", "-p"]; + + const childProcess = require("child_process"); + const allTrusted = childProcess + .spawnSync("/usr/bin/security", args) + .stdout.toString() + .split(splitPattern); + + const allRoot = childProcess + .spawnSync("/usr/bin/security", args.concat(systemRootCertsPath)) + .stdout.toString() + .split(splitPattern); + const all = allTrusted.concat(allRoot); + + const tls = require("tls"); + const origCreateSecureContext = tls.createSecureContext; + tls.createSecureContext = (options) => { + const ctx = origCreateSecureContext(options); + all.filter(duplicated).forEach((cert) => { + ctx.context.addCACert(cert.trim()); + }); + return ctx; + }; +} + +function duplicated(cert, index, arr) { + return arr.indexOf(cert) === index; +} + +initialize(); + +require("./copilot/dist/agent.js"); diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift new file mode 100644 index 00000000..7d7a5c3f --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift @@ -0,0 +1,107 @@ +import CopilotForXcodeKit +import Foundation +import SuggestionModel +import Workspace + +public final class GitHubCopilotSuggestionService: SuggestionServiceType { + public var configuration: SuggestionServiceConfiguration { + .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: false + ) + } + + let serviceLocator: ServiceLocator + + init(serviceLocator: ServiceLocator) { + self.serviceLocator = serviceLocator + } + + public func getSuggestions( + _ request: SuggestionRequest, + workspace: WorkspaceInfo + ) async throws -> [CopilotForXcodeKit.CodeSuggestion] { + guard let service = await serviceLocator.getService(from: workspace) else { return [] } + return try await service.getCompletions( + fileURL: request.fileURL, + content: request.content, + originalContent: request.originalContent, + cursorPosition: .init( + line: request.cursorPosition.line, + character: request.cursorPosition.character + ), + tabSize: request.tabSize, + indentSize: request.indentSize, + usesTabsForIndentation: request.usesTabsForIndentation + ).map(Self.convert) + } + + public func notifyAccepted( + _ suggestion: CopilotForXcodeKit.CodeSuggestion, + workspace: WorkspaceInfo + ) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.notifyAccepted(Self.convert(suggestion)) + } + + public func notifyRejected( + _ suggestions: [CopilotForXcodeKit.CodeSuggestion], + workspace: WorkspaceInfo + ) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.notifyRejected(suggestions.map(Self.convert)) + } + + public func cancelRequest(workspace: WorkspaceInfo) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.cancelRequest() + } + + static func convert( + _ suggestion: SuggestionModel.CodeSuggestion + ) -> CopilotForXcodeKit.CodeSuggestion { + .init( + id: suggestion.id, + text: suggestion.text, + position: .init( + line: suggestion.position.line, + character: suggestion.position.character + ), + range: .init( + start: .init( + line: suggestion.range.start.line, + character: suggestion.range.start.character + ), + end: .init( + line: suggestion.range.end.line, + character: suggestion.range.end.character + ) + ) + ) + } + + static func convert( + _ suggestion: CopilotForXcodeKit.CodeSuggestion + ) -> SuggestionModel.CodeSuggestion { + .init( + id: suggestion.id, + text: suggestion.text, + position: .init( + line: suggestion.position.line, + character: suggestion.position.character + ), + range: .init( + start: .init( + line: suggestion.range.start.line, + character: suggestion.range.start.character + ), + end: .init( + line: suggestion.range.end.line, + character: suggestion.range.end.character + ) + ) + ) + } +} + diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index f3fd8911..07b27ce2 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -220,7 +220,11 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI ) { self.apiKey = apiKey self.endpoint = endpoint - self.requestBody = .init(requestBody) + self.requestBody = .init( + requestBody, + enforceMessageOrder: model.info.openAICompatibleInfo.enforceMessageOrder, + canUseTool: model.info.supportsFunctionCalling + ) self.model = model } @@ -233,28 +237,9 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI let encoder = JSONEncoder() request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if !apiKey.isEmpty { - switch model.format { - case .openAI: - if !model.info.openAIInfo.organizationID.isEmpty { - request.setValue( - model.info.openAIInfo.organizationID, - forHTTPHeaderField: "OpenAI-Organization" - ) - } - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .openAICompatible: - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .azureOpenAI: - request.setValue(apiKey, forHTTPHeaderField: "api-key") - case .googleAI: - assertionFailure("Unsupported") - case .ollama: - assertionFailure("Unsupported") - case .claude: - assertionFailure("Unsupported") - } - } + + Self.setupAppInformation(&request) + Self.setupAPIKey(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.bytes(for: request) guard let response = response as? HTTPURLResponse else { @@ -297,19 +282,68 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } func callAsFunction() async throws -> ChatCompletionResponseBody { - requestBody.stream = false - var request = URLRequest(url: endpoint) - request.httpMethod = "POST" - let encoder = JSONEncoder() - request.httpBody = try encoder.encode(requestBody) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let stream: AsyncThrowingStream = + try await callAsFunction() + + var body = ChatCompletionResponseBody( + id: nil, + object: "", + model: "", + message: .init(role: .assistant, content: ""), + otherChoices: [], + finishReason: "" + ) + for try await chunk in stream { + if let id = chunk.id { + body.id = id + } + if let finishReason = chunk.finishReason { + body.finishReason = finishReason + } + if let model = chunk.model { + body.model = model + } + if let object = chunk.object { + body.object = object + } + if let role = chunk.message?.role { + body.message.role = role + } + if let text = chunk.message?.content { + body.message.content += text + } + } + return body + } + + static func setupAppInformation(_ request: inout URLRequest) { + if #available(macOS 13.0, *) { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } else { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } + } + + static func setupAPIKey(_ request: inout URLRequest, model: ChatModel, apiKey: String) { if !apiKey.isEmpty { switch model.format { case .openAI: if !model.info.openAIInfo.organizationID.isEmpty { request.setValue( - "OpenAI-Organization", - forHTTPHeaderField: model.info.openAIInfo.organizationID + model.info.openAIInfo.organizationID, + forHTTPHeaderField: "OpenAI-Organization" ) } request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") @@ -325,25 +359,6 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI assertionFailure("Unsupported") } } - - let (result, response) = try await URLSession.shared.data(for: request) - guard let response = response as? HTTPURLResponse else { - throw ChatGPTServiceError.responseInvalid - } - - guard response.statusCode == 200 else { - let error = try? JSONDecoder().decode(CompletionAPIError.self, from: result) - throw error ?? ChatGPTServiceError - .otherError(String(data: result, encoding: .utf8) ?? "Unknown Error") - } - - do { - let body = try JSONDecoder().decode(ResponseBody.self, from: result) - return body.formalized() - } catch { - dump(error) - throw error - } } } @@ -457,36 +472,94 @@ extension OpenAIChatCompletionsService.StreamDataChunk { } extension OpenAIChatCompletionsService.RequestBody { - init(_ body: ChatCompletionsRequestBody) { + init(_ body: ChatCompletionsRequestBody, enforceMessageOrder: Bool, canUseTool: Bool) { model = body.model - messages = body.messages.map { message in - .init( - role: { - switch message.role { - case .user: - return .user - case .assistant: - return .assistant - case .system: - return .system - case .tool: - return .tool + if enforceMessageOrder { + var systemPrompts = [String]() + var nonSystemMessages = [Message]() + + for message in body.messages { + switch (message.role, canUseTool) { + case (.system, _): + systemPrompts.append(message.content) + case (.tool, true): + if let last = nonSystemMessages.last, last.role == .tool { + nonSystemMessages[nonSystemMessages.endIndex - 1].content + += "\n\n\(message.content)" + } else { + nonSystemMessages.append(.init( + role: .tool, + content: message.content, + tool_calls: message.toolCalls?.map { tool in + MessageToolCall( + id: tool.id, + type: tool.type, + function: MessageFunctionCall( + name: tool.function.name, + arguments: tool.function.arguments + ) + ) + } + )) + } + case (.assistant, _), (.tool, false): + if let last = nonSystemMessages.last, last.role == .assistant { + nonSystemMessages[nonSystemMessages.endIndex - 1].content + += "\n\n\(message.content)" + } else { + nonSystemMessages.append(.init(role: .assistant, content: message.content)) + } + case (.user, _): + if let last = nonSystemMessages.last, last.role == .user { + nonSystemMessages[nonSystemMessages.endIndex - 1].content + += "\n\n\(message.content)" + } else { + nonSystemMessages.append(.init( + role: .user, + content: message.content, + name: message.name, + tool_call_id: message.toolCallId + )) } - }(), - content: message.content, - name: message.name, - tool_calls: message.toolCalls?.map { tool in - MessageToolCall( - id: tool.id, - type: tool.type, - function: MessageFunctionCall( - name: tool.function.name, - arguments: tool.function.arguments + } + } + messages = [ + .init( + role: .system, + content: systemPrompts.joined(separator: "\n\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + ), + ] + nonSystemMessages + } else { + messages = body.messages.map { message in + .init( + role: { + switch message.role { + case .user: + return .user + case .assistant: + return .assistant + case .system: + return .system + case .tool: + return .tool + } + }(), + content: message.content, + name: message.name, + tool_calls: message.toolCalls?.map { tool in + MessageToolCall( + id: tool.id, + type: tool.type, + function: MessageFunctionCall( + name: tool.function.name, + arguments: tool.function.arguments + ) ) - ) - }, - tool_call_id: message.toolCallId - ) + }, + tool_call_id: message.toolCallId + ) + } } temperature = body.temperature stream = body.stream diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift index 140e9d09..acd78b48 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift @@ -12,11 +12,11 @@ struct OpenAIEmbeddingService: EmbeddingAPI { var input: [[Int]] var model: String } - + let apiKey: String let model: EmbeddingModel let endpoint: String - + public func embed(text: String) async throws -> EmbeddingResponse { return try await embed(texts: [text]) } @@ -31,24 +31,9 @@ struct OpenAIEmbeddingService: EmbeddingAPI { model: model.info.modelName )) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if !apiKey.isEmpty { - switch model.format { - case .openAI: - if model.info.openAIInfo.organizationID.isEmpty { - request.setValue( - "OpenAI-Organization", - forHTTPHeaderField: model.info.openAIInfo.organizationID - ) - } - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .openAICompatible: - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .azureOpenAI: - request.setValue(apiKey, forHTTPHeaderField: "api-key") - case .ollama: - assertionFailure("Unsupported") - } - } + + Self.setupAppInformation(&request) + Self.setupAPIKey(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -87,24 +72,9 @@ struct OpenAIEmbeddingService: EmbeddingAPI { model: model.info.modelName )) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if !apiKey.isEmpty { - switch model.format { - case .openAI: - if model.info.openAIInfo.organizationID.isEmpty { - request.setValue( - "OpenAI-Organization", - forHTTPHeaderField: model.info.openAIInfo.organizationID - ) - } - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .openAICompatible: - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .azureOpenAI: - request.setValue(apiKey, forHTTPHeaderField: "api-key") - case .ollama: - assertionFailure("Unsupported") - } - } + + Self.setupAppInformation(&request) + Self.setupAPIKey(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -132,5 +102,46 @@ struct OpenAIEmbeddingService: EmbeddingAPI { #endif return embeddingResponse } + + static func setupAppInformation(_ request: inout URLRequest) { + if #available(macOS 13.0, *) { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } else { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } + } + + static func setupAPIKey(_ request: inout URLRequest, model: EmbeddingModel, apiKey: String) { + if !apiKey.isEmpty { + switch model.format { + case .openAI: + if model.info.openAIInfo.organizationID.isEmpty { + request.setValue( + model.info.openAIInfo.organizationID, + forHTTPHeaderField: "OpenAI-Organization" + ) + } + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .openAICompatible: + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .azureOpenAI: + request.setValue(apiKey, forHTTPHeaderField: "api-key") + case .ollama: + assertionFailure("Unsupported") + } + } + } } diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index f8650cb7..d96e754a 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -187,6 +187,10 @@ public extension UserDefaultPreferenceKeys { var runNodeWith: PreferenceKey { .init(defaultValue: .env, key: "RunNodeWith") } + + var gitHubCopilotLoadKeyChainCertificates: PreferenceKey { + .init(defaultValue: false, key: "GitHubCopilotLoadKeyChainCertificates") + } } // MARK: - Codeium Settings @@ -473,6 +477,18 @@ public extension UserDefaultPreferenceKeys { var keepFloatOnTopIfChatPanelAndXcodeOverlaps: PreferenceKey { .init(defaultValue: true, key: "KeepFloatOnTopIfChatPanelAndXcodeOverlaps") } + + var openChatMode: PreferenceKey { + .init(defaultValue: .chatPanel, key: "OpenChatMode") + } + + var openChatInBrowserURL: PreferenceKey { + .init(defaultValue: "", key: "OpenChatInBrowserURL") + } + + var openChatInBrowserInInAppBrowser: PreferenceKey { + .init(defaultValue: true, key: "OpenChatInBrowserInInAppBrowser") + } } // MARK: - Theme diff --git a/Tool/Sources/Preferences/Types/OpenChatMode.swift b/Tool/Sources/Preferences/Types/OpenChatMode.swift new file mode 100644 index 00000000..d983a5e0 --- /dev/null +++ b/Tool/Sources/Preferences/Types/OpenChatMode.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum OpenChatMode: String, CaseIterable { + case chatPanel + case browser +} diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift new file mode 100644 index 00000000..cd286f56 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -0,0 +1,242 @@ +import DebounceFunction +import Foundation +import Perception +import SwiftUI + +public struct AsyncCodeBlock: View { + @Perceptible + class Storage { + static let queue = DispatchQueue( + label: "code-block-highlight", + qos: .userInteractive, + attributes: .concurrent + ) + + var dimmedCharacterCount: Int = 0 + private var highlightedCode = [NSAttributedString]() + private var foregroundColor: Color = .primary + private(set) var commonPrecedingSpaceCount = 0 + var highlightedContent: [NSAttributedString] { + var highlightedCode = highlightedCode + if dimmedCharacterCount > commonPrecedingSpaceCount, + let firstLine = highlightedCode.first + { + let dimmedCount = dimmedCharacterCount - commonPrecedingSpaceCount + let mutable = NSMutableAttributedString(attributedString: firstLine) + let targetRange = NSRange( + location: 0, + length: min(firstLine.length, max(0, dimmedCount)) + ) + mutable.enumerateAttribute( + .foregroundColor, + in: NSRange(location: 0, length: firstLine.length) + ) { value, range, _ in + guard let color = value as? NSColor else { return } + let opacity = max(0.1, color.alphaComponent * 0.4) + if targetRange.upperBound >= range.upperBound { + mutable.addAttribute( + .foregroundColor, + value: color.withAlphaComponent(opacity), + range: range + ) + } else { + let intersection = NSIntersectionRange(targetRange, range) + guard !intersection.isEmpty else { return } + let rangeA = intersection + mutable.addAttribute( + .foregroundColor, + value: color.withAlphaComponent(opacity), + range: rangeA + ) + + let rangeB = NSRange( + location: intersection.upperBound, + length: range.upperBound - intersection.upperBound + ) + mutable.addAttribute( + .foregroundColor, + value: color, + range: rangeB + ) + } + } + + highlightedCode[0] = mutable + } + return highlightedCode + } + + @PerceptionIgnored private var debounceFunction: DebounceFunction? + @PerceptionIgnored private var highlightTask: Task? + + init() {} + + func highlight(debounce: Bool, for view: AsyncCodeBlock) { + if debounce { + Task { await debounceFunction?(view) } + } else { + highlight(for: view) + } + } + + private func highlight(for view: AsyncCodeBlock) { + highlightTask?.cancel() + let code = view.code + let language = view.language + let scenario = view.scenario + let brightMode = view.colorScheme != .dark + let droppingLeadingSpaces = view.droppingLeadingSpaces + let font = view.font + foregroundColor = view.foregroundColor + + if highlightedCode.isEmpty { + let content = CodeHighlighting.convertToCodeLines( + .init(string: code), + middleDotColor: brightMode + ? NSColor.black.withAlphaComponent(0.1) + : NSColor.white.withAlphaComponent(0.1), + droppingLeadingSpaces: droppingLeadingSpaces, + replaceSpacesWithMiddleDots: true + ) + highlightedCode = content.code + commonPrecedingSpaceCount = content.commonLeadingSpaceCount + } + + highlightTask = Task { + let result = await withUnsafeContinuation { continuation in + Self.queue.async { + let content = CodeHighlighting.highlighted( + code: code, + language: language, + scenario: scenario, + brightMode: brightMode, + droppingLeadingSpaces: droppingLeadingSpaces, + font: font + ) + continuation.resume(returning: content) + } + } + try Task.checkCancellation() + await MainActor.run { + self.highlightedCode = result.0 + self.commonPrecedingSpaceCount = result.1 + } + } + } + } + + @State var storage = Storage() + @Environment(\.colorScheme) var colorScheme + + let code: String + let language: String + let startLineIndex: Int + let scenario: String + let font: NSFont + let proposedForegroundColor: Color? + let dimmedCharacterCount: Int + let droppingLeadingSpaces: Bool + + public init( + code: String, + language: String, + startLineIndex: Int, + scenario: String, + font: NSFont, + droppingLeadingSpaces: Bool, + proposedForegroundColor: Color?, + dimmedCharacterCount: Int + ) { + self.code = code + self.startLineIndex = startLineIndex + self.language = language + self.scenario = scenario + self.font = font + self.proposedForegroundColor = proposedForegroundColor + self.dimmedCharacterCount = dimmedCharacterCount + self.droppingLeadingSpaces = droppingLeadingSpaces + } + + var foregroundColor: Color { + proposedForegroundColor ?? (colorScheme == .dark ? .white : .black) + } + + public var body: some View { + WithPerceptionTracking { + VStack(spacing: 2) { + let commonPrecedingSpaceCount = storage.commonPrecedingSpaceCount + ForEach(Array(storage.highlightedContent.enumerated()), id: \.0) { item in + let (index, attributedString) = item + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("\(index + startLineIndex + 1)") + .multilineTextAlignment(.trailing) + .foregroundColor(foregroundColor.opacity(0.5)) + .frame(minWidth: 40) + Text(AttributedString(attributedString)) + .foregroundColor(foregroundColor.opacity(0.3)) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .lineSpacing(4) + .overlay(alignment: .topLeading) { + if index == 0, commonPrecedingSpaceCount > 0 { + Text("\(commonPrecedingSpaceCount + 1)") + .padding(.top, -12) + .font(.footnote) + .foregroundStyle(foregroundColor) + .opacity(0.3) + } + } + } + } + } + .foregroundColor(.white) + .font(.init(font)) + .padding(.leading, 4) + .padding([.trailing, .top, .bottom]) + .onAppear { + storage.dimmedCharacterCount = dimmedCharacterCount + storage.highlight(debounce: false, for: self) + } + .onChange(of: code) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: colorScheme) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: droppingLeadingSpaces) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: scenario) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: language) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: proposedForegroundColor) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: dimmedCharacterCount) { value in + storage.dimmedCharacterCount = value + } + } + } + + static func highlight( + code: String, + language: String, + scenario: String, + colorScheme: ColorScheme, + font: NSFont, + droppingLeadingSpaces: Bool + ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { + return CodeHighlighting.highlighted( + code: code, + language: language, + scenario: scenario, + brightMode: colorScheme != .dark, + droppingLeadingSpaces: droppingLeadingSpaces, + font: font + ) + } +} + diff --git a/Tool/Sources/SharedUIComponents/CodeBlock.swift b/Tool/Sources/SharedUIComponents/CodeBlock.swift index 8fae5fc2..1208c14b 100644 --- a/Tool/Sources/SharedUIComponents/CodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/CodeBlock.swift @@ -95,7 +95,7 @@ public struct CodeBlock: View { font: NSFont, droppingLeadingSpaces: Bool ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { - return highlighted( + return CodeHighlighting.highlighted( code: code, language: language, scenario: scenario, diff --git a/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift b/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift index b455b9a0..c9c45c00 100644 --- a/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift @@ -77,7 +77,7 @@ struct _CodeBlock: View { font: NSFont, droppingLeadingSpaces: Bool ) -> (code: AttributedString, commonLeadingSpaceCount: Int) { - let (lines, commonLeadingSpaceCount) = highlighted( + let (lines, commonLeadingSpaceCount) = CodeHighlighting.highlighted( code: code, language: language, scenario: scenario, diff --git a/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift index b6dd0c02..6e096a05 100644 --- a/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift +++ b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift @@ -4,154 +4,156 @@ import Highlightr import SuggestionModel import SwiftUI -public func highlightedCodeBlock( - code: String, - language: String, - scenario: String, - brightMode: Bool, - font: NSFont -) -> NSAttributedString { - var language = language - // Workaround: Highlightr uses a different identifier for Objective-C. - if language.lowercased().hasPrefix("objective"), language.lowercased().hasSuffix("c") { - language = "objectivec" - } - func unhighlightedCode() -> NSAttributedString { - return NSAttributedString( - string: code, - attributes: [ - .foregroundColor: brightMode ? NSColor.black : NSColor.white, - .font: font, - ] - ) - } - guard let highlighter = Highlightr() else { - return unhighlightedCode() - } - highlighter.setTheme(to: { - let mode = brightMode ? "light" : "dark" - if scenario.isEmpty { - return mode +public enum CodeHighlighting { + public static func highlightedCodeBlock( + code: String, + language: String, + scenario: String, + brightMode: Bool, + font: NSFont + ) -> NSAttributedString { + var language = language + // Workaround: Highlightr uses a different identifier for Objective-C. + if language.lowercased().hasPrefix("objective"), language.lowercased().hasSuffix("c") { + language = "objectivec" } - return "\(scenario)-\(mode)" - }()) - highlighter.theme.setCodeFont(font) - guard let formatted = highlighter.highlight(code, as: language) else { - return unhighlightedCode() - } - if formatted.string == "undefined" { - return unhighlightedCode() + func unhighlightedCode() -> NSAttributedString { + return NSAttributedString( + string: code, + attributes: [ + .foregroundColor: brightMode ? NSColor.black : NSColor.white, + .font: font, + ] + ) + } + guard let highlighter = Highlightr() else { + return unhighlightedCode() + } + highlighter.setTheme(to: { + let mode = brightMode ? "light" : "dark" + if scenario.isEmpty { + return mode + } + return "\(scenario)-\(mode)" + }()) + highlighter.theme.setCodeFont(font) + guard let formatted = highlighter.highlight(code, as: language) else { + return unhighlightedCode() + } + if formatted.string == "undefined" { + return unhighlightedCode() + } + return formatted } - return formatted -} -public func highlighted( - code: String, - language: String, - scenario: String, - brightMode: Bool, - droppingLeadingSpaces: Bool, - font: NSFont, - replaceSpacesWithMiddleDots: Bool = true -) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { - let formatted = highlightedCodeBlock( - code: code, - language: language, - scenario: scenario, - brightMode: brightMode, - font: font - ) - let middleDotColor = brightMode - ? NSColor.black.withAlphaComponent(0.1) - : NSColor.white.withAlphaComponent(0.1) - return convertToCodeLines( - formatted, - middleDotColor: middleDotColor, - droppingLeadingSpaces: droppingLeadingSpaces, - replaceSpacesWithMiddleDots: replaceSpacesWithMiddleDots - ) -} - -func convertToCodeLines( - _ formattedCode: NSAttributedString, - middleDotColor: NSColor, - droppingLeadingSpaces: Bool, - replaceSpacesWithMiddleDots: Bool = true -) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { - let input = formattedCode.string - func isEmptyLine(_ line: String) -> Bool { - if line.isEmpty { return true } - guard let regex = try? NSRegularExpression(pattern: #"^\s*\n?$"#) else { return false } - if regex.firstMatch( - in: line, - options: [], - range: NSMakeRange(0, line.utf16.count) - ) != nil { - return true - } - return false + public static func highlighted( + code: String, + language: String, + scenario: String, + brightMode: Bool, + droppingLeadingSpaces: Bool, + font: NSFont, + replaceSpacesWithMiddleDots: Bool = true + ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { + let formatted = highlightedCodeBlock( + code: code, + language: language, + scenario: scenario, + brightMode: brightMode, + font: font + ) + let middleDotColor = brightMode + ? NSColor.black.withAlphaComponent(0.1) + : NSColor.white.withAlphaComponent(0.1) + return convertToCodeLines( + formatted, + middleDotColor: middleDotColor, + droppingLeadingSpaces: droppingLeadingSpaces, + replaceSpacesWithMiddleDots: replaceSpacesWithMiddleDots + ) } - let separatedInput = input.splitByNewLine(omittingEmptySubsequences: false) - .map { String($0) } - let commonLeadingSpaceCount = { - if !droppingLeadingSpaces { return 0 } - let split = separatedInput - var result = 0 - outerLoop: for i in stride(from: 40, through: 4, by: -4) { - for line in split { - if isEmptyLine(line) { continue } - if i >= line.count { continue outerLoop } - if !line.hasPrefix(.init(repeating: " ", count: i)) { continue outerLoop } + public static func convertToCodeLines( + _ formattedCode: NSAttributedString, + middleDotColor: NSColor, + droppingLeadingSpaces: Bool, + replaceSpacesWithMiddleDots: Bool = true + ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { + let input = formattedCode.string + func isEmptyLine(_ line: String) -> Bool { + if line.isEmpty { return true } + guard let regex = try? NSRegularExpression(pattern: #"^\s*\n?$"#) else { return false } + if regex.firstMatch( + in: line, + options: [], + range: NSMakeRange(0, line.utf16.count) + ) != nil { + return true } - result = i - break + return false } - return result - }() - var output = [NSAttributedString]() - var start = 0 - for sub in separatedInput { - let range = NSMakeRange(start, sub.utf16.count) - let attributedString = formattedCode.attributedSubstring(from: range) - let mutable = NSMutableAttributedString(attributedString: attributedString) - // remove leading spaces - if commonLeadingSpaceCount > 0 { - let leadingSpaces = String(repeating: " ", count: commonLeadingSpaceCount) - if mutable.string.hasPrefix(leadingSpaces) { - mutable.replaceCharacters( - in: NSRange(location: 0, length: commonLeadingSpaceCount), - with: "" - ) - } else if isEmptyLine(mutable.string) { - mutable.mutableString.setString("") + let separatedInput = input.splitByNewLine(omittingEmptySubsequences: false) + .map { String($0) } + let commonLeadingSpaceCount = { + if !droppingLeadingSpaces { return 0 } + let split = separatedInput + var result = 0 + outerLoop: for i in stride(from: 40, through: 4, by: -4) { + for line in split { + if isEmptyLine(line) { continue } + if i >= line.count { continue outerLoop } + if !line.hasPrefix(.init(repeating: " ", count: i)) { continue outerLoop } + } + result = i + break } - } + return result + }() + var output = [NSAttributedString]() + var start = 0 + for sub in separatedInput { + let range = NSMakeRange(start, sub.utf16.count) + let attributedString = formattedCode.attributedSubstring(from: range) + let mutable = NSMutableAttributedString(attributedString: attributedString) - if replaceSpacesWithMiddleDots { - // use regex to replace all spaces to a middle dot - do { - let regex = try NSRegularExpression(pattern: "[ ]*", options: []) - let result = regex.matches( - in: mutable.string, - range: NSRange(location: 0, length: mutable.mutableString.length) - ) - for r in result { - let range = r.range + // remove leading spaces + if commonLeadingSpaceCount > 0 { + let leadingSpaces = String(repeating: " ", count: commonLeadingSpaceCount) + if mutable.string.hasPrefix(leadingSpaces) { mutable.replaceCharacters( - in: range, - with: String(repeating: "·", count: range.length) + in: NSRange(location: 0, length: commonLeadingSpaceCount), + with: "" ) - mutable.addAttributes([ - .foregroundColor: middleDotColor, - ], range: range) + } else if isEmptyLine(mutable.string) { + mutable.mutableString.setString("") } - } catch {} + } + + if replaceSpacesWithMiddleDots { + // use regex to replace all spaces to a middle dot + do { + let regex = try NSRegularExpression(pattern: "[ ]*", options: []) + let result = regex.matches( + in: mutable.string, + range: NSRange(location: 0, length: mutable.mutableString.length) + ) + for r in result { + let range = r.range + mutable.replaceCharacters( + in: range, + with: String(repeating: "·", count: range.length) + ) + mutable.addAttributes([ + .foregroundColor: middleDotColor, + ], range: range) + } + } catch {} + } + output.append(mutable) + start += range.length + 1 } - output.append(mutable) - start += range.length + 1 + return (output, commonLeadingSpaceCount) } - return (output, commonLeadingSpaceCount) } diff --git a/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift b/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift deleted file mode 100644 index 3529b9e4..00000000 --- a/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift +++ /dev/null @@ -1,87 +0,0 @@ -import CodeiumService -import Foundation -import Preferences -import SuggestionModel - -public actor CodeiumSuggestionProvider: SuggestionServiceProvider { - public nonisolated var configuration: SuggestionServiceConfiguration { - .init( - acceptsRelevantCodeSnippets: true, - mixRelevantCodeSnippetsInSource: true, - acceptsRelevantSnippetsFromOpenedFiles: false - ) - } - - let projectRootURL: URL - let onServiceLaunched: (SuggestionServiceProvider) -> Void - var codeiumService: CodeiumSuggestionServiceType? - - public init( - projectRootURL: URL, - onServiceLaunched: @escaping (SuggestionServiceProvider) -> Void - ) { - self.projectRootURL = projectRootURL - self.onServiceLaunched = onServiceLaunched - } - - func createCodeiumServiceIfNeeded() throws -> CodeiumSuggestionServiceType { - if let codeiumService { return codeiumService } - let newService = try CodeiumSuggestionService( - projectRootURL: projectRootURL, - onServiceLaunched: { [weak self] in - if let self { self.onServiceLaunched(self) } - } - ) - codeiumService = newService - - return newService - } -} - -public extension CodeiumSuggestionProvider { - func getSuggestions(_ request: SuggestionRequest) async throws - -> [SuggestionModel.CodeSuggestion] - { - try await (createCodeiumServiceIfNeeded()).getCompletions( - fileURL: request.fileURL, - content: request.content, - cursorPosition: request.cursorPosition, - tabSize: request.tabSize, - indentSize: request.indentSize, - usesTabsForIndentation: request.usesTabsForIndentation - ) - } - - func notifyAccepted(_ suggestion: SuggestionModel.CodeSuggestion) async { - await (try? createCodeiumServiceIfNeeded())?.notifyAccepted(suggestion) - } - - func notifyRejected(_: [SuggestionModel.CodeSuggestion]) async {} - - func notifyOpenTextDocument(fileURL: URL, content: String) async throws { - try await (try? createCodeiumServiceIfNeeded())? - .notifyOpenTextDocument(fileURL: fileURL, content: content) - } - - func notifyChangeTextDocument(fileURL: URL, content: String) async throws { - try await (try? createCodeiumServiceIfNeeded())? - .notifyChangeTextDocument(fileURL: fileURL, content: content) - } - - func notifyCloseTextDocument(fileURL: URL) async throws { - try await (try? createCodeiumServiceIfNeeded())? - .notifyCloseTextDocument(fileURL: fileURL) - } - - func notifySaveTextDocument(fileURL: URL) async throws {} - - func cancelRequest() async { - await (try? createCodeiumServiceIfNeeded())? - .cancelRequest() - } - - func terminate() async { - (try? createCodeiumServiceIfNeeded())?.terminate() - } -} - diff --git a/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift b/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift deleted file mode 100644 index 61b0f02a..00000000 --- a/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Foundation -import GitHubCopilotService -import Preferences -import SuggestionModel - -public actor GitHubCopilotSuggestionProvider: SuggestionServiceProvider { - public nonisolated var configuration: SuggestionServiceConfiguration { - .init( - acceptsRelevantCodeSnippets: true, - mixRelevantCodeSnippetsInSource: true, - acceptsRelevantSnippetsFromOpenedFiles: false - ) - } - - let projectRootURL: URL - let onServiceLaunched: (SuggestionServiceProvider) -> Void - var gitHubCopilotService: GitHubCopilotSuggestionServiceType? - - public init( - projectRootURL: URL, - onServiceLaunched: @escaping (SuggestionServiceProvider) -> Void - ) { - self.projectRootURL = projectRootURL - self.onServiceLaunched = onServiceLaunched - } - - func createGitHubCopilotServiceIfNeeded() throws -> GitHubCopilotSuggestionServiceType { - if let gitHubCopilotService { return gitHubCopilotService } - let newService = try GitHubCopilotSuggestionService(projectRootURL: projectRootURL) - gitHubCopilotService = newService - Task { - try await Task.sleep(nanoseconds: 1_000_000_000) - onServiceLaunched(self) - } - return newService - } -} - -public extension GitHubCopilotSuggestionProvider { - func getSuggestions(_ request: SuggestionRequest) async throws - -> [SuggestionModel.CodeSuggestion] - { - try await (createGitHubCopilotServiceIfNeeded()).getCompletions( - fileURL: request.fileURL, - content: request.content, - cursorPosition: request.cursorPosition, - tabSize: request.tabSize, - indentSize: request.indentSize, - usesTabsForIndentation: request.usesTabsForIndentation - ) - } - - func notifyAccepted(_ suggestion: SuggestionModel.CodeSuggestion) async { - await (try? createGitHubCopilotServiceIfNeeded())?.notifyAccepted(suggestion) - } - - func notifyRejected(_ suggestions: [SuggestionModel.CodeSuggestion]) async { - await (try? createGitHubCopilotServiceIfNeeded())?.notifyRejected(suggestions) - } - - func notifyOpenTextDocument(fileURL: URL, content: String) async throws { - try await (try? createGitHubCopilotServiceIfNeeded())? - .notifyOpenTextDocument(fileURL: fileURL, content: content) - } - - func notifyChangeTextDocument(fileURL: URL, content: String) async throws { - try await (try? createGitHubCopilotServiceIfNeeded())? - .notifyChangeTextDocument(fileURL: fileURL, content: content) - } - - func notifyCloseTextDocument(fileURL: URL) async throws { - try await (try? createGitHubCopilotServiceIfNeeded())? - .notifyCloseTextDocument(fileURL: fileURL) - } - - func notifySaveTextDocument(fileURL: URL) async throws { - try await (try? createGitHubCopilotServiceIfNeeded())? - .notifySaveTextDocument(fileURL: fileURL) - } - - func cancelRequest() async { - await (try? createGitHubCopilotServiceIfNeeded())? - .cancelRequest() - } - - func terminate() async { - await (try? createGitHubCopilotServiceIfNeeded())?.terminate() - } -} - diff --git a/Tool/Sources/SuggestionProvider/SuggestionProvider.swift b/Tool/Sources/SuggestionProvider/SuggestionProvider.swift index 72da3eee..623e3ad5 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionProvider.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionProvider.swift @@ -1,5 +1,6 @@ import AppKit import struct CopilotForXcodeKit.SuggestionServiceConfiguration +import struct CopilotForXcodeKit.WorkspaceInfo import Foundation import Preferences import SuggestionModel @@ -9,6 +10,7 @@ public struct SuggestionRequest { public var fileURL: URL public var relativePath: String public var content: String + public var originalContent: String public var lines: [String] public var cursorPosition: CursorPosition public var cursorOffset: Int @@ -21,6 +23,7 @@ public struct SuggestionRequest { fileURL: URL, relativePath: String, content: String, + originalContent: String, lines: [String], cursorPosition: CursorPosition, cursorOffset: Int, @@ -32,6 +35,7 @@ public struct SuggestionRequest { self.fileURL = fileURL self.relativePath = relativePath self.content = content + self.originalContent = content self.lines = lines self.cursorPosition = cursorPosition self.cursorOffset = cursorOffset @@ -55,15 +59,19 @@ public struct RelevantCodeSnippet: Codable { } public protocol SuggestionServiceProvider { - func getSuggestions(_ request: SuggestionRequest) async throws -> [CodeSuggestion] - func notifyAccepted(_ suggestion: CodeSuggestion) async - func notifyRejected(_ suggestions: [CodeSuggestion]) async - func notifyOpenTextDocument(fileURL: URL, content: String) async throws - func notifyChangeTextDocument(fileURL: URL, content: String) async throws - func notifyCloseTextDocument(fileURL: URL) async throws - func notifySaveTextDocument(fileURL: URL) async throws - func cancelRequest() async - func terminate() async + func getSuggestions( + _ request: SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async throws -> [CodeSuggestion] + func notifyAccepted( + _ suggestion: CodeSuggestion, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async + func notifyRejected( + _ suggestions: [CodeSuggestion], + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async + func cancelRequest(workspaceInfo: CopilotForXcodeKit.WorkspaceInfo) async var configuration: SuggestionServiceConfiguration { get async } } diff --git a/Tool/Sources/Toast/Toast.swift b/Tool/Sources/Toast/Toast.swift index 553caa12..2abcca9a 100644 --- a/Tool/Sources/Toast/Toast.swift +++ b/Tool/Sources/Toast/Toast.swift @@ -81,8 +81,11 @@ public class ToastController: ObservableObject { } } -public struct Toast: ReducerProtocol { +@Reducer +public struct Toast { public typealias Message = ToastController.Message + + @ObservableState public struct State: Equatable { var isObservingToastController = false public var messages: [Message] = [] @@ -104,7 +107,7 @@ public struct Toast: ReducerProtocol { public init() {} - public var body: some ReducerProtocol { + public var body: some ReducerOf { Reduce { state, action in switch action { case .start: diff --git a/Tool/Sources/Workspace/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift index 264a3dae..1ce25d0e 100644 --- a/Tool/Sources/Workspace/Filespace.swift +++ b/Tool/Sources/Workspace/Filespace.swift @@ -121,6 +121,9 @@ public final class Filespace { return isIgnored } } + + @WorkspaceActor + public private(set) var version: Int = 0 // MARK: Methods @@ -181,5 +184,10 @@ public final class Filespace { suggestionIndex = suggestions.endIndex - 1 } } + + @WorkspaceActor + public func bumpVersion() { + version += 1 + } } diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index 40c87261..a7937756 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -150,6 +150,7 @@ public final class Workspace { public func didUpdateFilespace(fileURL: URL, content: String) { refreshUpdateTime() guard let filespace = filespaces[fileURL] else { return } + filespace.bumpVersion() filespace.refreshUpdateTime() for plugin in plugins.values { plugin.didUpdateFilespace(filespace, content: content) diff --git a/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift b/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift index 3e999628..7d60d924 100644 --- a/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift +++ b/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift @@ -1,3 +1,4 @@ +import BuiltinExtension import Foundation import Preferences import SuggestionModel @@ -5,25 +6,31 @@ import SuggestionProvider import UserDefaultsObserver import Workspace +#if canImport(ProExtension) +import ProExtension +#endif + public final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { - public typealias SuggestionServiceFactory = ( - _ projectRootURL: URL, - _ onServiceLaunched: @escaping (any SuggestionServiceProvider) -> Void - ) -> any SuggestionServiceProvider - - let userDefaultsObserver = UserDefaultsObserver( + public typealias SuggestionServiceFactory = () -> any SuggestionServiceProvider + let suggestionServiceFactory: SuggestionServiceFactory + + let suggestionFeatureUsabilityObserver = UserDefaultsObserver( object: UserDefaults.shared, forKeyPaths: [ UserDefaultPreferenceKeys().suggestionFeatureEnabledProjectList.key, UserDefaultPreferenceKeys().disableSuggestionFeatureGlobally.key, ], context: nil ) + let providerChangeObserver = UserDefaultsObserver( + object: UserDefaults.shared, + forKeyPaths: [UserDefaultPreferenceKeys().suggestionFeatureProvider.key], + context: nil + ) + public var isRealtimeSuggestionEnabled: Bool { UserDefaults.shared.value(for: \.realtimeSuggestionToggle) } - let suggestionServiceFactory: SuggestionServiceFactory - private var _suggestionService: SuggestionServiceProvider? public var suggestionService: SuggestionServiceProvider? { @@ -40,13 +47,7 @@ public final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { } if _suggestionService == nil { - _suggestionService = suggestionServiceFactory(projectRootURL) { - [weak self] _ in - guard let self else { return } - for (_, filespace) in filespaces { - notifyOpenFile(filespace: filespace) - } - } + _suggestionService = suggestionServiceFactory() } return _suggestionService } @@ -62,77 +63,37 @@ public final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { } return true } - + public init( workspace: Workspace, suggestionProviderFactory: @escaping SuggestionServiceFactory ) { - self.suggestionServiceFactory = suggestionProviderFactory + suggestionServiceFactory = suggestionProviderFactory super.init(workspace: workspace) - userDefaultsObserver.onChange = { [weak self] in + suggestionFeatureUsabilityObserver.onChange = { [weak self] in guard let self else { return } _ = self.suggestionService } - } - override public func didOpenFilespace(_ filespace: Filespace) { - notifyOpenFile(filespace: filespace) - } - - override public func didSaveFilespace(_ filespace: Filespace) { - notifySaveFile(filespace: filespace) - } - - override public func didUpdateFilespace(_ filespace: Filespace, content: String) { - notifyUpdateFile(filespace: filespace, content: content) - } - - override public func didCloseFilespace(_ fileURL: URL) { - Task { - try await suggestionService?.notifyCloseTextDocument(fileURL: fileURL) - } - } - - public func notifyOpenFile(filespace: Filespace) { - Task { - guard filespace.isTextReadable else { return } - guard !(await filespace.isGitIgnored) else { return } - // check if file size is larger than 15MB, if so, return immediately - if let attrs = try? FileManager.default - .attributesOfItem(atPath: filespace.fileURL.path), - let fileSize = attrs[FileAttributeKey.size] as? UInt64, - fileSize > 15 * 1024 * 1024 - { return } - - try await suggestionService?.notifyOpenTextDocument( - fileURL: filespace.fileURL, - content: String(contentsOf: filespace.fileURL, encoding: .utf8) - ) - } - } - - public func notifyUpdateFile(filespace: Filespace, content: String) { - Task { - guard filespace.isTextReadable else { return } - guard !(await filespace.isGitIgnored) else { return } - try await suggestionService?.notifyChangeTextDocument( - fileURL: filespace.fileURL, - content: content - ) + providerChangeObserver.onChange = { [weak self] in + guard let self else { return } + self._suggestionService = nil } } - public func notifySaveFile(filespace: Filespace) { - Task { - guard filespace.isTextReadable else { return } - guard !(await filespace.isGitIgnored) else { return } - try await suggestionService?.notifySaveTextDocument(fileURL: filespace.fileURL) - } + func notifyAccepted(_ suggestion: CodeSuggestion) async { + await suggestionService?.notifyAccepted( + suggestion, + workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) + ) } - public func terminateSuggestionService() async { - await _suggestionService?.terminate() + func notifyRejected(_ suggestions: [CodeSuggestion]) async { + await suggestionService?.notifyRejected( + suggestions, + workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) + ) } } diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift index 0259e068..7ffe7835 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift @@ -54,19 +54,22 @@ public extension Workspace { filespace.suggestionSourceSnapshot = snapshot guard let suggestionService else { throw SuggestionFeatureDisabledError() } + let content = editor.lines.joined(separator: "") let completions = try await suggestionService.getSuggestions( .init( fileURL: fileURL, relativePath: fileURL.path.replacingOccurrences(of: projectRootURL.path, with: ""), - content: editor.lines.joined(separator: ""), + content: content, + originalContent: content, lines: editor.lines, - cursorPosition: editor.cursorPosition, + cursorPosition: editor.cursorPosition, cursorOffset: editor.cursorOffset, tabSize: editor.tabSize, indentSize: editor.indentSize, usesTabsForIndentation: editor.usesTabsForIndentation, relevantCodeSnippets: [] - ) + ), + workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) ) filespace.setSuggestions(completions) @@ -102,9 +105,15 @@ public extension Workspace { filespaces[fileURL]?.codeMetadata.indentSize = editor.indentSize filespaces[fileURL]?.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation } - + Task { - await suggestionService?.notifyRejected(filespaces[fileURL]?.suggestions ?? []) + await suggestionService?.notifyRejected( + filespaces[fileURL]?.suggestions ?? [], + workspaceInfo: .init( + workspaceURL: workspaceURL, + projectURL: projectRootURL + ) + ) } filespaces[fileURL]?.reset() } @@ -128,9 +137,14 @@ public extension Workspace { var allSuggestions = filespace.suggestions let suggestion = allSuggestions.remove(at: filespace.suggestionIndex) - Task { [allSuggestions] in - await suggestionService?.notifyAccepted(suggestion) - await suggestionService?.notifyRejected(allSuggestions) + Task { + await suggestionService?.notifyAccepted( + suggestion, + workspaceInfo: .init( + workspaceURL: workspaceURL, + projectURL: projectRootURL + ) + ) } filespaces[fileURL]?.reset() diff --git a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift index 6e3b7eb2..d0a20b32 100644 --- a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift +++ b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift @@ -15,14 +15,13 @@ public enum XPCCommunicationBridgeError: Swift.Error, LocalizedError { } } -@XPCServiceActor public class XPCCommunicationBridge { let service: XPCService let logger: Logger + @XPCServiceActor var serviceEndpoint: NSXPCListenerEndpoint? - public nonisolated - init(logger: Logger) { + public init(logger: Logger) { service = .init( kind: .machService( identifier: Bundle(for: XPCService.self) @@ -35,7 +34,7 @@ public class XPCCommunicationBridge { self.logger = logger } - public func setDelegate(_ delegate: XPCServiceDelegate) { + public func setDelegate(_ delegate: XPCServiceDelegate?) { service.delegate = delegate } @@ -66,6 +65,7 @@ public class XPCCommunicationBridge { } extension XPCCommunicationBridge { + @XPCServiceActor func withXPCServiceConnected( _ fn: @escaping (CommunicationBridgeXPCServiceProtocol, AutoFinishContinuation) -> Void ) async throws -> T { diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index 1301ec8f..7b2ab067 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -18,9 +18,10 @@ public enum XPCExtensionServiceError: Swift.Error, LocalizedError { } } -@XPCServiceActor public class XPCExtensionService { + @XPCServiceActor var service: XPCService? + @XPCServiceActor var connection: NSXPCConnection? { service?.connection } let logger: Logger let bridge: XPCCommunicationBridge @@ -33,6 +34,7 @@ public class XPCExtensionService { /// Launches the extension service if it's not running, returns true if the service has finished /// launching and the communication becomes available. + @XPCServiceActor public func launchIfNeeded() async throws -> Bool { try await bridge.launchExtensionServiceIfNeeded() != nil } @@ -136,10 +138,10 @@ public class XPCExtensionService { } } - public func chatWithSelection(editorContent: EditorContent) async throws -> UpdatedContent? { + public func openChat(editorContent: EditorContent) async throws -> UpdatedContent? { try await suggestionRequest( editorContent, - { $0.chatWithSelection } + { $0.openChat } ) } @@ -203,24 +205,30 @@ public class XPCExtensionService { extension XPCExtensionService: XPCServiceDelegate { public func connectionDidInterrupt() async { - // do nothing + Task { @XPCServiceActor in + service = nil + } } public func connectionDidInvalidate() async { - service = nil + Task { @XPCServiceActor in + service = nil + } } } extension XPCExtensionService { + @XPCServiceActor private func updateEndpoint(_ endpoint: NSXPCListenerEndpoint) { service = XPCService( kind: .anonymous(endpoint: endpoint), interface: NSXPCInterface(with: XPCServiceProtocol.self), - logger: logger + logger: logger, + delegate: self ) - service?.delegate = self } + @XPCServiceActor private func withXPCServiceConnected( _ fn: @escaping (XPCServiceProtocol, AutoFinishContinuation) -> Void ) async throws -> T { @@ -247,6 +255,7 @@ extension XPCExtensionService { } } + @XPCServiceActor private func suggestionRequest( _ editorContent: EditorContent, _ fn: @escaping (any XPCServiceProtocol) -> (Data, @escaping (Data?, Error?) -> Void) diff --git a/Tool/Sources/XPCShared/XPCService.swift b/Tool/Sources/XPCShared/XPCService.swift index d44a1555..e2a05c42 100644 --- a/Tool/Sources/XPCShared/XPCService.swift +++ b/Tool/Sources/XPCShared/XPCService.swift @@ -7,7 +7,6 @@ public enum XPCServiceActor { public static let shared = TheActor() } -@XPCServiceActor class XPCService { enum Kind { case machService(identifier: String) @@ -18,17 +17,20 @@ class XPCService { let interface: NSXPCInterface let logger: Logger weak var delegate: XPCServiceDelegate? + + @XPCServiceActor private var isInvalidated = false + @XPCServiceActor private lazy var _connection: InvalidatingConnection? = buildConnection() + @XPCServiceActor var connection: NSXPCConnection? { if isInvalidated { _connection = nil } if _connection == nil { rebuildConnection() } return _connection?.connection } - nonisolated init( kind: Kind, interface: NSXPCInterface, @@ -41,7 +43,9 @@ class XPCService { self.delegate = delegate } + @XPCServiceActor private func buildConnection() -> InvalidatingConnection { + logger.info("Rebuilding connection") let connection = switch kind { case let .machService(name): NSXPCConnection(machServiceName: name) @@ -66,10 +70,12 @@ class XPCService { return .init(connection) } + @XPCServiceActor private func markAsInvalidated() { isInvalidated = true } + @XPCServiceActor private func rebuildConnection() { _connection = buildConnection() } diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift index 6ecd7ae5..d2992270 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -31,7 +31,7 @@ public protocol XPCServiceProtocol { editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void ) - func chatWithSelection( + func openChat( editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void ) diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 58c638dc..d6627b2b 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -22,6 +22,7 @@ public class SourceEditor { case selectedTextChanged case valueChanged case scrollPositionChanged + case evaluatedContentChanged } let runningApplication: NSRunningApplication @@ -31,6 +32,22 @@ public class SourceEditor { /// To prevent expensive calculations in ``getContent()``. private let cache = Cache() + + public func getLatestEvaluatedContent() -> Content { + let selectionRange = element.selectedTextRange + let (content, lines, selections) = cache.latest() + let lineAnnotationElements = element.children.filter { $0.identifier == "Line Annotation" } + let lineAnnotations = lineAnnotationElements.map(\.description) + + return .init( + content: content, + lines: lines, + selections: selections, + cursorPosition: selections.first?.start ?? .outOfScope, + cursorOffset: selectionRange?.lowerBound ?? 0, + lineAnnotations: lineAnnotations + ) + } /// Get the content of the source editor. /// @@ -44,6 +61,8 @@ public class SourceEditor { let lineAnnotationElements = element.children.filter { $0.identifier == "Line Annotation" } let lineAnnotations = lineAnnotationElements.map(\.description) + axNotifications.send(.init(kind: .evaluatedContentChanged, element: element)) + return .init( content: content, lines: lines, @@ -176,6 +195,12 @@ extension SourceEditor { return (lines, selections) } } + + func latest() -> (content: String, lines: [String], selections: [CursorRange]) { + Self.queue.sync { + (sourceContent ?? "", cachedLines, cachedSelections) + } + } } } diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index d7eaf53a..96187b30 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -18,6 +18,7 @@ public enum XcodeInspectorActor: GlobalActor { public static let shared = Actor() } +#warning("TODO: Consider rewriting it with Swift Observation") public final class XcodeInspector: ObservableObject { public static let shared = XcodeInspector() diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index d3178781..d2506822 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -95,7 +95,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { fileURLWithPath: path .replacingOccurrences(of: "file://", with: "") ) - return url + return adjustFileURL(url) } return nil } @@ -142,5 +142,14 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return lastGitDirectoryURL ?? firstDirectoryURL ?? workspaceURL } + + static func adjustFileURL(_ url: URL) -> URL { + if url.pathExtension == "playground", + FileManager.default.fileIsDirectory(atPath: url.path) + { + return url.appendingPathComponent("Contents.swift") + } + return url + } } diff --git a/Version.xcconfig b/Version.xcconfig index 67c8c108..5a72de69 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.33.0 -APP_BUILD = 376 +APP_VERSION = 0.33.1 +APP_BUILD = 382 diff --git a/appcast.xml b/appcast.xml index 592407e0..9aa6f8f5 100644 --- a/appcast.xml +++ b/appcast.xml @@ -4,23 +4,22 @@ Copilot for Xcode 0.33.1 - Sat, 25 May 2024 03:24:57 +0800 - https://github.com/intitni/CopilotForXcode/releases/tag/0.33.1.beta - beta - 380 + Mon, 27 May 2024 16:28:20 +0800 + 382 0.33.1 12.0 - + https://github.com/intitni/CopilotForXcode/releases/tag/0.33.1 + 0.33.1 - Thu, 23 May 2024 05:01:08 +0800 + Sat, 25 May 2024 03:24:57 +0800 https://github.com/intitni/CopilotForXcode/releases/tag/0.33.1.beta beta - 379 + 380 0.33.1 12.0 - + 0.33.0 @@ -33,30 +32,6 @@ - - 0.33.0 - Tue, 14 May 2024 01:18:40 +0800 - 374 - beta - 0.33.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.33.0.beta - - - - - 0.33.0 - Fri, 10 May 2024 14:54:26 +0800 - 372 - beta - 0.33.0 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.33.0.beta - - - 0.32.3 Wed, 01 May 2024 15:35:26 +0800 @@ -68,16 +43,5 @@ - - 0.32.2 - Sat, 20 Apr 2024 20:31:36 +0800 - 363 - 0.32.2 - 12.0 - - https://github.com/intitni/CopilotForXcode/releases/tag/0.32.2 - - - \ No newline at end of file