From e6685e9a1a61c570e8dada2d0294cc147cbfd4ef Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 14 May 2024 16:30:19 +0800 Subject: [PATCH 01/90] Migrate HostApp and dependencies to new version of TCA --- Core/Package.swift | 4 +- .../APIKeyManagementView.swift | 148 ++++++++-------- .../APIKeyManagement/APIKeyManangement.swift | 10 +- .../APIKeyManagement/APIKeyPicker.swift | 22 +-- .../APIKeyManagement/APIKeySelection.swift | 12 +- .../APIKeyManagement/APIKeySubmission.swift | 10 +- .../ChatModelManagement/ChatModelEdit.swift | 66 +++---- .../ChatModelEditView.swift | 161 +++++++----------- .../ChatModelManagement.swift | 10 +- .../ChatModelManagementView.swift | 47 ++--- .../EmbeddingModelEdit.swift | 64 +++---- .../EmbeddingModelEditView.swift | 126 +++++--------- .../EmbeddingModelManagement.swift | 10 +- .../EmbeddingModelManagementView.swift | 42 ++--- .../AIModelManagementVIew.swift | 56 +++--- .../SharedModelManagement/BaseURLPicker.swift | 29 ++-- .../BaseURLSelection.swift | 10 +- .../CustomCommandSettings/CustomCommand.swift | 8 +- .../CustomCommandView.swift | 31 ++-- .../EditCustomCommand.swift | 68 ++++---- .../EditCustomCommandView.swift | 99 +++++------ Core/Sources/HostApp/General.swift | 6 +- Core/Sources/HostApp/GeneralView.swift | 85 +++++---- Core/Sources/HostApp/HostApp.swift | 6 +- Core/Sources/HostApp/ServiceView.swift | 21 +-- Core/Sources/HostApp/TabContainer.swift | 6 +- Pro | 2 +- Tool/Package.swift | 2 +- Tool/Sources/Toast/Toast.swift | 7 +- 29 files changed, 555 insertions(+), 613 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index 1e1a1fcd..b81cb5c6 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 diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift index 48bd8632..bde5e4a5 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift @@ -3,104 +3,104 @@ 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)) - - 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)) + .background(Color(nsColor: .separatorColor)) + + List { + WithPerceptionTracking { + ForEach(store.availableAPIKeyNames, id: \.self) { name in + HStack { + Text(name) + .contextMenu { + Button("Remove") { + store.send(.deleteButtonClicked(name: name)) + } } + Spacer() + + Button(action: { + store.send(.deleteButtonClicked(name: name)) + }) { + Image(systemName: "trash.fill") + .foregroundStyle(.secondary) } - Spacer() - - Button(action: { - viewStore.send(.deleteButtonClicked(name: name)) - }) { - Image(systemName: "trash.fill") - .foregroundStyle(.secondary) + .buttonStyle(.plain) } - .buttonStyle(.plain) } - } - .modify { view in - if #available(macOS 13.0, *) { - view.listRowSeparator(.hidden).listSectionSeparator(.hidden) - } else { - view + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view + } } } } - } - .removeBackground() - .overlay { - WithViewStore(store, observe: { $0.availableAPIKeyNames }) { viewStore in - if viewStore.state.isEmpty { - Text(""" + .removeBackground() + .overlay { + WithPerceptionTracking { + if store.availableAPIKeyNames.isEmpty { + Text(""" Empty Add a new key by clicking the add button """) - .multilineTextAlignment(.center) - .padding() + .multilineTextAlignment(.center) + .padding() + } } } } - } - .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 { + TextField("Name", text: $store.name) + SecureField("Key", text: $store.key) } }.padding() @@ -128,7 +128,7 @@ class APIKeyManagementView_Preview: PreviewProvider { initialState: .init( availableAPIKeyNames: ["test1", "test2"] ), - reducer: APIKeyManagement() + reducer: { APIKeyManagement() } ) ) } @@ -139,7 +139,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..b11719e7 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift @@ -2,26 +2,26 @@ 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,10 +32,10 @@ struct APIKeyPicker: View { Button(action: { store.send(.manageAPIKeysButtonClicked) }) { Text(Image(systemName: "key")) } - }.sheet(isPresented: viewStore.$isAPIKeyManagementPresented) { + }.sheet(isPresented: $store.isAPIKeyManagementPresented) { APIKeyManagementView(store: store.scope( state: \.apiKeyManagement, - action: APIKeySelection.Action.apiKeyManagement + action: \.apiKeyManagement )) } } 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..8aeed02f 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 } @@ -51,14 +53,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 +158,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 +176,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( @@ -219,5 +201,23 @@ extension ChatModel { ) ) } + + 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) + ) + } } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index 2fe21715..58a46e2d 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -6,7 +6,7 @@ import SwiftUI @MainActor struct ChatModelEditView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { ScrollView { @@ -15,8 +15,8 @@ struct ChatModelEditView: View { nameTextField formatPicker - WithViewStore(store, observe: { $0.format }) { viewStore in - switch viewStore.state { + WithPerceptionTracking { + switch store.format { case .openAI: openAI case .azureOpenAI: @@ -37,14 +37,14 @@ struct ChatModelEditView: View { Divider() HStack { - WithViewStore(store, observe: { $0.isTesting }) { viewStore in + WithPerceptionTracking { HStack(spacing: 8) { Button("Test") { store.send(.testButtonClicked) } - .disabled(viewStore.state) + .disabled(store.isTesting) - if viewStore.state { + if store.isTesting { ProgressView() .controlSize(.small) } @@ -75,15 +75,15 @@ struct ChatModelEditView: View { } var nameTextField: some View { - WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in - TextField("Name", text: viewStore.$name) + WithPerceptionTracking { + TextField("Name", text: $store.name) } } var formatPicker: some View { - WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in + WithPerceptionTracking { Picker( - selection: viewStore.$format, + selection: $store.format, content: { ForEach( ChatModel.Format.allCases, @@ -121,7 +121,7 @@ struct ChatModelEditView: View { prompt: prompt, store: store.scope( state: \.baseURLSelection, - action: ChatModelEdit.Action.baseURLSelection + action: \.baseURLSelection ), trailingContent: trailingContent ) @@ -135,13 +135,10 @@ struct ChatModelEditView: View { } var supportsFunctionCallingToggle: some View { - WithViewStore( - store, - removeDuplicates: { $0.supportsFunctionCalling == $1.supportsFunctionCalling } - ) { viewStore in + WithPerceptionTracking { Toggle( "Supports Function Calling", - isOn: viewStore.$supportsFunctionCalling + isOn: $store.supportsFunctionCalling ) Text( @@ -153,29 +150,16 @@ struct ChatModelEditView: View { } } - struct MaxTokensTextField: Equatable { - @BindingViewState var maxTokens: Int - var suggestedMaxTokens: Int? - } - var maxTokensTextField: some View { - WithViewStore( - store, - observe: { - MaxTokensTextField( - maxTokens: $0.$maxTokens, - suggestedMaxTokens: $0.suggestedMaxTokens - ) - } - ) { viewStore in + WithPerceptionTracking { HStack { let textFieldBinding = Binding( - get: { String(viewStore.state.maxTokens) }, + get: { String(store.maxTokens) }, set: { if let selectionMaxToken = Int($0) { - viewStore.$maxTokens.wrappedValue = selectionMaxToken + $store.maxTokens.wrappedValue = selectionMaxToken } else { - viewStore.$maxTokens.wrappedValue = 0 + $store.maxTokens.wrappedValue = 0 } } ) @@ -186,7 +170,7 @@ struct ChatModelEditView: View { } .overlay(alignment: .trailing) { Stepper( - value: viewStore.$maxTokens, + value: $store.maxTokens, in: 0...Int.max, step: 100 ) { @@ -194,32 +178,27 @@ struct ChatModelEditView: View { } } .foregroundColor({ - guard let max = viewStore.state.suggestedMaxTokens else { + guard let max = store.suggestedMaxTokens else { return .primary } - if viewStore.state.maxTokens > max { + if store.maxTokens > max { return .red } return .primary }() as Color) - if let max = viewStore.state.suggestedMaxTokens { + 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 + action: \.apiKeySelection )) } @@ -230,18 +209,15 @@ struct ChatModelEditView: View { } apiKeyNamePicker - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) + WithPerceptionTracking { + TextField("Model Name", text: $store.modelName) .overlay(alignment: .trailing) { Picker( "", - selection: viewStore.$modelName, + selection: $store.modelName, content: { - if ChatGPTModel(rawValue: viewStore.state.modelName) == nil { - Text("Custom Model").tag(viewStore.state.modelName) + 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) @@ -272,11 +248,8 @@ struct ChatModelEditView: 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) + WithPerceptionTracking { + TextField("Deployment Name", text: $store.modelName) } maxTokensTextField @@ -285,12 +258,9 @@ struct ChatModelEditView: View { @ViewBuilder var openAICompatible: some View { - WithViewStore(store.scope( - state: \.baseURLSelection, - action: ChatModelEdit.Action.baseURLSelection - ), removeDuplicates: { $0.isFullURL != $1.isFullURL }) { viewStore in + WithPerceptionTracking { Picker( - selection: viewStore.$isFullURL, + selection: $store.baseURLSelection.isFullURL, content: { Text("Base URL").tag(false) Text("Full URL").tag(true) @@ -298,16 +268,14 @@ struct ChatModelEditView: View { label: { Text("URL") } ) .pickerStyle(.segmented) - } - WithViewStore(store, observe: \.isFullURL) { viewStore in baseURLTextField( title: "", - prompt: viewStore.state + prompt: store.isFullURL ? Text("https://api.openai.com/v1/chat/completions") : Text("https://api.openai.com") ) { - if !viewStore.state { + if !store.isFullURL { Text("/v1/chat/completions") } } @@ -315,11 +283,8 @@ struct ChatModelEditView: View { apiKeyNamePicker - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) + WithPerceptionTracking { + TextField("Model Name", text: $store.modelName) } maxTokensTextField @@ -334,18 +299,15 @@ struct ChatModelEditView: View { apiKeyNamePicker - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) + WithPerceptionTracking { + TextField("Model Name", text: $store.modelName) .overlay(alignment: .trailing) { Picker( "", - selection: viewStore.$modelName, + selection: $store.modelName, content: { - if GoogleGenerativeAIModel(rawValue: viewStore.state.modelName) == nil { - Text("Custom Model").tag(viewStore.state.modelName) + 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) @@ -358,8 +320,8 @@ struct ChatModelEditView: View { maxTokensTextField - WithViewStore(store, removeDuplicates: { $0.apiVersion == $1.apiVersion }) { viewStore in - TextField("API Version", text: viewStore.$apiVersion, prompt: Text("v1")) + WithPerceptionTracking { + TextField("API Version", text: $store.apiVersion, prompt: Text("v1")) } } @@ -369,20 +331,14 @@ struct ChatModelEditView: View { Text("/api/chat") } - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) + WithPerceptionTracking { + TextField("Model Name", text: $store.modelName) } maxTokensTextField - WithViewStore( - store, - removeDuplicates: { $0.ollamaKeepAlive == $1.ollamaKeepAlive } - ) { viewStore in - TextField(text: viewStore.$ollamaKeepAlive, prompt: Text("Default Value")) { + WithPerceptionTracking { + TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) { Text("Keep Alive") } } @@ -403,20 +359,17 @@ struct ChatModelEditView: View { apiKeyNamePicker - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) + WithPerceptionTracking { + TextField("Model Name", text: $store.modelName) .overlay(alignment: .trailing) { Picker( "", - selection: viewStore.$modelName, + selection: $store.modelName, content: { if ClaudeChatCompletionsService - .KnownModel(rawValue: viewStore.state.modelName) == nil + .KnownModel(rawValue: store.modelName) == nil { - Text("Custom Model").tag(viewStore.state.modelName) + Text("Custom Model").tag(store.modelName) } ForEach( ClaudeChatCompletionsService.KnownModel.allCases, @@ -444,7 +397,7 @@ struct ChatModelEditView: View { #Preview("OpenAI") { ChatModelEditView( store: .init( - initialState: .init(model: ChatModel( + initialState: ChatModel( id: "3", name: "Test Model 3", format: .openAI, @@ -455,8 +408,8 @@ struct ChatModelEditView: View { supportsFunctionCalling: false, modelName: "gpt-3.5-turbo" ) - )), - reducer: ChatModelEdit() + ).toState(), + reducer: { ChatModelEdit() } ) ) } @@ -464,7 +417,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 +429,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..bb33f4d5 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..d53e3700 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -5,7 +5,7 @@ import SwiftUI @MainActor struct EmbeddingModelEditView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { ScrollView { @@ -14,8 +14,8 @@ struct EmbeddingModelEditView: View { nameTextField formatPicker - WithViewStore(store, observe: { $0.format }) { viewStore in - switch viewStore.state { + WithPerceptionTracking { + switch store.format { case .openAI: openAI case .azureOpenAI: @@ -32,14 +32,14 @@ struct EmbeddingModelEditView: View { Divider() HStack { - WithViewStore(store, observe: { $0.isTesting }) { viewStore in + WithPerceptionTracking { HStack(spacing: 8) { Button("Test") { store.send(.testButtonClicked) } - .disabled(viewStore.state) + .disabled(store.isTesting) - if viewStore.state { + if store.isTesting { ProgressView() .controlSize(.small) } @@ -70,15 +70,15 @@ struct EmbeddingModelEditView: View { } var nameTextField: some View { - WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in - TextField("Name", text: viewStore.$name) + WithPerceptionTracking { + TextField("Name", text: $store.name) } } var formatPicker: some View { - WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in + WithPerceptionTracking { Picker( - selection: viewStore.$format, + selection: $store.format, content: { ForEach( EmbeddingModel.Format.allCases, @@ -125,29 +125,16 @@ struct EmbeddingModelEditView: View { baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() }) } - struct MaxTokensTextField: Equatable { - @BindingViewState var maxTokens: Int - var suggestedMaxTokens: Int? - } - var maxTokensTextField: some View { - WithViewStore( - store, - observe: { - MaxTokensTextField( - maxTokens: $0.$maxTokens, - suggestedMaxTokens: $0.suggestedMaxTokens - ) - } - ) { viewStore in + WithPerceptionTracking { HStack { let textFieldBinding = Binding( - get: { String(viewStore.state.maxTokens) }, + get: { String(store.maxTokens) }, set: { if let selectionMaxToken = Int($0) { - viewStore.$maxTokens.wrappedValue = selectionMaxToken + $store.maxTokens.wrappedValue = selectionMaxToken } else { - viewStore.$maxTokens.wrappedValue = 0 + $store.maxTokens.wrappedValue = 0 } } ) @@ -158,7 +145,7 @@ struct EmbeddingModelEditView: View { } .overlay(alignment: .trailing) { Stepper( - value: viewStore.$maxTokens, + value: $store.maxTokens, in: 0...Int.max, step: 100 ) { @@ -166,32 +153,27 @@ struct EmbeddingModelEditView: View { } } .foregroundColor({ - guard let max = viewStore.state.suggestedMaxTokens else { + guard let max = store.suggestedMaxTokens else { return .primary } - if viewStore.state.maxTokens > max { + if store.maxTokens > max { return .red } return .primary }() as Color) - if let max = viewStore.state.suggestedMaxTokens { + 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: EmbeddingModelEdit.Action.apiKeySelection + action: \.apiKeySelection )) } @@ -202,18 +184,15 @@ struct EmbeddingModelEditView: View { } apiKeyNamePicker - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) + WithPerceptionTracking { + TextField("Model Name", text: $store.modelName) .overlay(alignment: .trailing) { Picker( "", - selection: viewStore.$modelName, + selection: $store.modelName, content: { - if OpenAIEmbeddingModel(rawValue: viewStore.state.modelName) == nil { - Text("Custom Model").tag(viewStore.state.modelName) + 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) @@ -225,7 +204,7 @@ struct EmbeddingModelEditView: View { } 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)" @@ -243,11 +222,8 @@ struct EmbeddingModelEditView: 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) + WithPerceptionTracking { + TextField("Deployment Name", text: $store.modelName) } maxTokensTextField @@ -255,12 +231,9 @@ struct EmbeddingModelEditView: View { @ViewBuilder var openAICompatible: some View { - WithViewStore(store.scope( - state: \.baseURLSelection, - action: EmbeddingModelEdit.Action.baseURLSelection - ), removeDuplicates: { $0.isFullURL != $1.isFullURL }) { viewStore in + WithPerceptionTracking { Picker( - selection: viewStore.$isFullURL, + selection: $store.baseURLSelection.isFullURL, content: { Text("Base URL").tag(false) Text("Full URL").tag(true) @@ -268,16 +241,14 @@ struct EmbeddingModelEditView: View { label: { Text("URL") } ) .pickerStyle(.segmented) - } - - WithViewStore(store, observe: \.isFullURL) { viewStore in + baseURLTextField( title: "", - prompt: viewStore.state + prompt: store.isFullURL ? Text("https://api.openai.com/v1/embeddings") : Text("https://api.openai.com") ) { - if !viewStore.state { + if !store.isFullURL { Text("/v1/embeddings") } } @@ -285,40 +256,31 @@ struct EmbeddingModelEditView: View { apiKeyNamePicker - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) + WithPerceptionTracking { + TextField("Model Name", text: $store.modelName) } maxTokensTextField } - + @ViewBuilder var ollama: some View { baseURLTextField(prompt: Text("http://127.0.0.1:11434")) { Text("/api/embeddings") } - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) + WithPerceptionTracking { + TextField("Model Name", text: $store.modelName) } maxTokensTextField - - WithViewStore( - store, - removeDuplicates: { $0.ollamaKeepAlive == $1.ollamaKeepAlive } - ) { viewStore in - TextField(text: viewStore.$ollamaKeepAlive, prompt: Text("Default Value")) { + + WithPerceptionTracking { + 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)." @@ -332,7 +294,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 +304,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..aa17cb98 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift @@ -6,14 +6,16 @@ struct EmbeddingModelManagementView: View { let 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,21 +61,19 @@ 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/SharedModelManagement/AIModelManagementVIew.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift index 621ed75d..6805290c 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,7 +39,7 @@ 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) { @@ -50,11 +50,11 @@ struct AIModelManagementView= 2 + let disabled = store.models.count >= 2 Button(disabled ? "Add More Model (Plus)" : "Add Model") { store.send(.createModel) @@ -73,18 +73,18 @@ struct AIModelManagementView + @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 + ForEach(store.models) { model in + let isSelected = store.selectedModelId == model.id HStack(spacing: 4) { Image(systemName: "line.3.horizontal") Button(action: { - viewStore.send(.selectModel(id: model.id)) + store.send(.selectModel(id: model.id)) }) { Cell(model: model, isSelected: isSelected) .contentShape(Rectangle()) @@ -101,7 +101,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..c24e835f 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 { @@ -108,15 +106,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,7 +123,7 @@ struct CustomCommandView: View { } struct CommandButton: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf let command: CustomCommand var body: some View { @@ -158,10 +157,10 @@ struct CustomCommandView: View { } .padding(4) .background { - WithViewStore(store, observe: { $0.editCustomCommand?.commandId }) { viewStore in + WithPerceptionTracking { RoundedRectangle(cornerRadius: 4) .fill( - viewStore.state == command.id + store.editCustomCommand?.commandId == command.id ? Color.primary.opacity(0.05) : Color.clear ) @@ -183,7 +182,7 @@ struct CustomCommandView: View { var rightPane: some View { IfLetStore(store.scope( state: \.editCustomCommand, - action: CustomCommandFeature.Action.editCustomCommand + action: \.editCustomCommand )) { store in EditCustomCommandView(store: store) } else: { @@ -292,7 +291,7 @@ struct CustomCommandView_Preview: PreviewProvider { ) ))) ), - reducer: CustomCommandFeature(settings: settings) + reducer: { CustomCommandFeature(settings: settings) } ), settings: settings ) @@ -328,7 +327,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..08ecafd8 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 ) ) } @@ -103,15 +100,13 @@ struct EditCustomCommandView: View { store.send(.close) } - WithViewStore(store, observe: { $0.isNewCommand }) { viewStore in - if viewStore.state { - Button("Add") { - store.send(.saveCommand) - } - } else { - Button("Save") { - store.send(.saveCommand) - } + if store.isNewCommand { + Button("Add") { + store.send(.saveCommand) + } + } else { + Button("Save") { + store.send(.saveCommand) } } } @@ -124,19 +119,19 @@ struct EditCustomCommandView: View { } 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 +139,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 +162,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 +182,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 +202,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 +216,6 @@ struct EditSingleRoundDialogCommandView: View { } } - - // MARK: - Preview struct EditCustomCommandView_Preview: PreviewProvider { @@ -239,12 +232,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 +250,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/General.swift b/Core/Sources/HostApp/General.swift index cb5165da..1404de3b 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? @@ -23,7 +25,7 @@ struct General: ReducerProtocol { @Dependency(\.toast) var toast - var body: some ReducerProtocol { + var body: some ReducerOf { Reduce { state, action in switch action { case .appear: 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..4eb98e85 100644 --- a/Core/Sources/HostApp/ServiceView.swift +++ b/Core/Sources/HostApp/ServiceView.swift @@ -1,10 +1,10 @@ -import SwiftUI import ComposableArchitecture +import SwiftUI struct ServiceView: View { let store: StoreOf @State var tag = 0 - + var body: some View { SidebarTabView(tag: $tag) { ScrollView { @@ -15,7 +15,7 @@ struct ServiceView: View { subtitle: "Suggestion", image: "globe" ) - + ScrollView { CodeiumView().padding() }.sidebarItem( @@ -24,27 +24,27 @@ struct ServiceView: View { subtitle: "Suggestion", image: "globe" ) - + ChatModelManagementView(store: store.scope( state: \.chatModelManagement, - action: HostApp.Action.chatModelManagement + 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 + action: \.embeddingModelManagement )).sidebarItem( tag: 3, title: "Embedding Models", subtitle: "Chat, Prompt to Code", image: "globe" ) - + ScrollView { BingSearchView().padding() }.sidebarItem( @@ -53,7 +53,7 @@ struct ServiceView: View { subtitle: "Search Chat Plugin", image: "globe" ) - + ScrollView { OtherSuggestionServicesView().padding() }.sidebarItem( @@ -68,6 +68,7 @@ struct ServiceView: View { 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..9fdf3187 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 @@ -37,7 +37,7 @@ public struct TabContainer: View { Divider() ZStack(alignment: .center) { - GeneralView(store: store.scope(state: \.general, action: HostApp.Action.general)) + GeneralView(store: store.scope(state: \.general, action: \.general)) .tabBarItem( tag: 0, title: "General", @@ -235,7 +235,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/Pro b/Pro index a630ea2a..ce590007 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit a630ea2ab3875353921013a20054ebc7d460bbfb +Subproject commit ce590007d078f36c09bcd33a0e8c087a1e83959a diff --git a/Tool/Package.swift b/Tool/Package.swift index c52689cc..b3353c7d 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"), 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: From 18a702079b13b6aa56b85dbc0fe89f31f7356b4c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 14 May 2024 21:52:10 +0800 Subject: [PATCH 02/90] Fix migration --- .../ChatModelManagement/ChatModelManagementView.swift | 2 +- .../EmbeddingModelManagementView.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift index bb33f4d5..e81b4a97 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift @@ -7,7 +7,7 @@ struct ChatModelManagementView: View { var body: some View { WithPerceptionTracking { - AIModelManagementView(store: store) + AIModelManagementView(store: store) .sheet(item: $store.scope( state: \.editingModel, action: \.chatModelItem diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift index aa17cb98..e251af10 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift @@ -3,7 +3,7 @@ import ComposableArchitecture import SwiftUI struct EmbeddingModelManagementView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { WithPerceptionTracking { @@ -78,3 +78,4 @@ class EmbeddingModelManagementView_Previews: PreviewProvider { ) } } + From 68a640db341b99ce0495ef209922497dd3af770e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 14 May 2024 21:52:27 +0800 Subject: [PATCH 03/90] Migrate ChatTab to latest TCA --- Tool/Sources/ChatTab/ChatTab.swift | 22 ++++++++++++++-------- Tool/Sources/ChatTab/ChatTabItem.swift | 5 +++-- Tool/Sources/ChatTab/ChatTabPool.swift | 3 ++- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index cc10c240..91c1a731 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 @@ -52,7 +53,7 @@ public protocol ChatTabType { } /// The base class for all chat tabs. -open class BaseChatTab { +open class BaseChatTab: NSObject { /// A wrapper to support dynamic update of title in view. struct ContentView: View { var buildView: () -> any View @@ -61,18 +62,23 @@ 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 var storeObservation: ObservationToken? public init(store: StoreOf) { chatTabStore = store - chatTabViewStore = ViewStore(store) + super.init() + + storeObservation = 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 +226,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 } } } + From 2ee99d007d35161468abe4ded8970f425eff3e3f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 14 May 2024 21:52:31 +0800 Subject: [PATCH 04/90] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index ce590007..3444050d 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit ce590007d078f36c09bcd33a0e8c087a1e83959a +Subproject commit 3444050dd846591475ff2ff508a75d3b52b2cb9f From 50171acc6d2ec72caaecef4b36686a7568116097 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 15 May 2024 16:39:08 +0800 Subject: [PATCH 05/90] Use an NSObject to observe changes in store --- Tool/Sources/ChatTab/ChatTab.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 91c1a731..9396373e 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -53,7 +53,7 @@ public protocol ChatTabType { } /// The base class for all chat tabs. -open class BaseChatTab: NSObject { +open class BaseChatTab { /// A wrapper to support dynamic update of title in view. struct ContentView: View { var buildView: () -> any View @@ -68,13 +68,12 @@ open class BaseChatTab: NSObject { public let chatTabStore: StoreOf private var didStart = false - private var storeObservation: ObservationToken? + private let storeObserver = NSObject() public init(store: StoreOf) { chatTabStore = store - super.init() - storeObservation = observe { [weak self] in + storeObserver.observe { [weak self] in guard let self else { return } self.title = store.title self.id = store.id From 6b5ac69001509c0ec58d1e393353a8eb229e8941 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 May 2024 19:04:36 +0800 Subject: [PATCH 06/90] Migrate ChatGPTChatTab to latest TCA --- Core/Sources/ChatGPTChatTab/Chat.swift | 18 +- .../ChatGPTChatTab/ChatContextMenu.swift | 162 +++---- .../ChatGPTChatTab/ChatGPTChatTab.swift | 73 ++- Core/Sources/ChatGPTChatTab/ChatPanel.swift | 456 +++++++++--------- .../ChatGPTChatTab/Views/BotMessage.swift | 4 +- .../ChatGPTChatTab/Views/Instructions.swift | 57 +-- .../ChatGPTChatTab/Views/UserMessage.swift | 6 +- 7 files changed, 402 insertions(+), 374 deletions(-) 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..f08903db 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -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 @@ -229,14 +233,9 @@ struct ChatPanelMessages: View { } 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 +246,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 +274,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 +289,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 +315,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 +328,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 +368,7 @@ struct ChatPanelInputArea: View { var body: some View { HStack { clearButton - textEditor + InputAreaTextEditor(chat: chat, focusedField: $focusedField) } .padding(8) .background(.ultraThickMaterial) @@ -384,89 +397,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 +563,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 +573,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 +586,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 +604,7 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider { history: ChatPanel_Preview.history, isReceivingMessage: false ), - reducer: Chat(service: .init()) + reducer: { Chat(service: .init()) } ) ) .padding() @@ -607,7 +617,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/Views/BotMessage.swift b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift index 5d202678..ed3e59a0 100644 --- a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift +++ b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift @@ -220,7 +220,7 @@ struct ReferenceIcon: View { startLine: nil, kind: .class ), count: 20), - chat: .init(initialState: .init(), reducer: Chat(service: .init())) + chat: .init(initialState: .init(), reducer: { Chat(service: .init()) }) ) .padding() .fixedSize(horizontal: true, vertical: true) @@ -270,6 +270,6 @@ struct ReferenceIcon: View { startLine: nil, kind: .webpage ), - ], chat: .init(initialState: .init(), reducer: Chat(service: .init()))) + ], chat: .init(initialState: .init(), reducer: { Chat(service: .init()) })) } diff --git a/Core/Sources/ChatGPTChatTab/Views/Instructions.swift b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift index 35097d08..e2f9b2f0 100644 --- a/Core/Sources/ChatGPTChatTab/Views/Instructions.swift +++ b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift @@ -7,11 +7,12 @@ struct Instruction: View { let chat: StoreOf var body: some View { - Group { - Markdown( + WithPerceptionTracking { + Group { + Markdown( """ You can use plugins to perform various tasks. - + | Plugin Name | Description | | --- | --- | | `/run` | Runs a command under the project root | @@ -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) } From d95a280469c5c9652b57a070fdd20a1218d5f475 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 May 2024 19:04:54 +0800 Subject: [PATCH 07/90] Migrate HostApp to latest TCA --- .../APIKeyManagementView.swift | 59 +- .../APIKeyManagement/APIKeyPicker.swift | 23 +- .../ChatModelEditView.swift | 605 +++++++++--------- .../EmbeddingModelEditView.swift | 428 +++++++------ .../AIModelManagementVIew.swift | 64 +- .../CustomCommandView.swift | 231 +++---- .../EditCustomCommandView.swift | 50 +- ...stionFeatureDisabledLanguageListView.swift | 11 +- Core/Sources/HostApp/ServiceView.swift | 116 ++-- Core/Sources/HostApp/TabContainer.swift | 110 ++-- .../DebounceFunction/DebounceFunction.swift | 17 + 11 files changed, 897 insertions(+), 817 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift index bde5e4a5..8c06d2dc 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift @@ -29,10 +29,10 @@ struct APIKeyManagementView: View { .buttonStyle(.plain) } .background(Color(nsColor: .separatorColor)) - + List { - WithPerceptionTracking { - ForEach(store.availableAPIKeyNames, id: \.self) { name in + ForEach(store.availableAPIKeyNames, id: \.self) { name in + WithPerceptionTracking { HStack { Text(name) .contextMenu { @@ -41,7 +41,7 @@ struct APIKeyManagementView: View { } } Spacer() - + Button(action: { store.send(.deleteButtonClicked(name: name)) }) { @@ -51,26 +51,24 @@ struct APIKeyManagementView: View { .buttonStyle(.plain) } } - .modify { view in - if #available(macOS 13.0, *) { - view.listRowSeparator(.hidden).listSectionSeparator(.hidden) - } else { - view - } + } + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view } } } .removeBackground() .overlay { - WithPerceptionTracking { - if store.availableAPIKeyNames.isEmpty { - Text(""" + if store.availableAPIKeyNames.isEmpty { + Text(""" Empty Add a new key by clicking the add button """) - .multilineTextAlignment(.center) - .padding() - } + .multilineTextAlignment(.center) + .padding() } } } @@ -95,29 +93,30 @@ struct APIKeySubmissionView: View { @Perception.Bindable var store: StoreOf var body: some View { - ScrollView { - VStack(spacing: 0) { - Form { - WithPerceptionTracking { + 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) } } diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift index b11719e7..57e853d4 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift @@ -14,13 +14,14 @@ struct APIKeyPicker: View { if store.availableAPIKeyNames.isEmpty { Text("No API key found, please add a new one →") } - + if !store.availableAPIKeyNames.contains(store.apiKeyName), - !store.apiKeyName.isEmpty { + !store.apiKeyName.isEmpty + { Text("Key not found: \(store.apiKeyName)") .tag(store.apiKeyName) } - + ForEach(store.availableAPIKeyNames, id: \.self) { name in Text(name).tag(name) } @@ -33,14 +34,16 @@ struct APIKeyPicker: View { Text(Image(systemName: "key")) } }.sheet(isPresented: $store.isAPIKeyManagementPresented) { - APIKeyManagementView(store: store.scope( - state: \.apiKeyManagement, - action: \.apiKeyManagement - )) + WithPerceptionTracking { + APIKeyManagementView(store: store.scope( + state: \.apiKeyManagement, + action: \.apiKeyManagement + )) + } + } + .onAppear { + store.send(.appear) } - } - .onAppear { - store.send(.appear) } } } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index 58a46e2d..fecb5c9f 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -9,35 +9,33 @@ struct ChatModelEditView: View { @Perception.Bindable var store: StoreOf var body: some View { - ScrollView { - VStack(spacing: 0) { - Form { - nameTextField - formatPicker + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + NameTextField(store: store) + FormatPicker(store: store) - WithPerceptionTracking { 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 { - WithPerceptionTracking { + HStack { HStack(spacing: 8) { Button("Test") { store.send(.testButtonClicked) @@ -49,348 +47,377 @@ struct ChatModelEditView: View { .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 { - WithPerceptionTracking { - TextField("Name", text: $store.name) + struct NameTextField: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + TextField("Name", text: $store.name) + } } } - var formatPicker: 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) + 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: \.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 { - WithPerceptionTracking { - Toggle( - "Supports Function Calling", - isOn: $store.supportsFunctionCalling - ) - - Text( - "Function calling is required by some features, if this model doesn't support function calling, you should turn it off to avoid undefined behaviors." - ) - .foregroundColor(.secondary) - .font(.callout) - .dynamicHeightTextInFormWorkaround() + 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() + } } } - var maxTokensTextField: 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 + struct MaxTokensTextField: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + HStack { + let textFieldBinding = Binding( + get: { String(store.maxTokens) }, + set: { + if let selectionMaxToken = Int($0) { + $store.maxTokens.wrappedValue = selectionMaxToken + } else { + $store.maxTokens.wrappedValue = 0 + } } - } - ) + ) - TextField(text: textFieldBinding) { - Text("Context Window") - .multilineTextAlignment(.trailing) - } - .overlay(alignment: .trailing) { - Stepper( - value: $store.maxTokens, - in: 0...Int.max, - step: 100 - ) { - EmptyView() + TextField(text: textFieldBinding) { + Text("Context Window") + .multilineTextAlignment(.trailing) } - } - .foregroundColor({ - guard let max = store.suggestedMaxTokens else { - return .primary - } - if store.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 = store.suggestedMaxTokens { - Text("Max: \(max)") + if let max = store.suggestedMaxTokens { + Text("Max: \(max)") + } } } } } - @ViewBuilder - var apiKeyNamePicker: some View { - APIKeyPicker(store: store.scope( - state: \.apiKeySelection, - action: \.apiKeySelection - )) - } - - @ViewBuilder - var openAI: some View { - baseURLTextField(prompt: Text("https://api.openai.com")) { - Text("/v1/chat/completions") + struct ApiKeyNamePicker: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + } } - apiKeyNamePicker + } - WithPerceptionTracking { - 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) + 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 - supportsFunctionCallingToggle + 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)" - ) + 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." - ) + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API." + ) + } + .padding(.vertical) + } } - .padding(.vertical) } - @ViewBuilder - var azureOpenAI: some View { - baseURLTextField(prompt: Text("https://xxxx.openai.azure.com")) - apiKeyNamePicker + struct AzureOpenAIForm: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("https://xxxx.openai.azure.com")) { + EmptyView() + } + ApiKeyNamePicker(store: store) - WithPerceptionTracking { - TextField("Deployment Name", text: $store.modelName) - } + TextField("Deployment Name", text: $store.modelName) - maxTokensTextField - supportsFunctionCallingToggle + MaxTokensTextField(store: store) + SupportsFunctionCallingToggle(store: store) + } + } } - @ViewBuilder - var openAICompatible: 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( - title: "", - prompt: store.isFullURL - ? Text("https://api.openai.com/v1/chat/completions") - : Text("https://api.openai.com") - ) { - if !store.isFullURL { - Text("/v1/chat/completions") + struct OpenAICompatibleForm: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + Picker( + selection: $store.baseURLSelection.isFullURL, + content: { + Text("Base URL").tag(false) + Text("Full URL").tag(true) + }, + label: { Text("URL") } + ) + .pickerStyle(.segmented) + + BaseURLTextField( + store: store, + title: "", + prompt: store.isFullURL + ? Text("https://api.openai.com/v1/chat/completions") + : Text("https://api.openai.com") + ) { + if !store.isFullURL { + Text("/v1/chat/completions") + } } - } - } - apiKeyNamePicker + ApiKeyNamePicker(store: store) - WithPerceptionTracking { - TextField("Model Name", text: $store.modelName) - } + TextField("Model Name", text: $store.modelName) - maxTokensTextField - supportsFunctionCallingToggle + MaxTokensTextField(store: store) + SupportsFunctionCallingToggle(store: store) + } + } } - @ViewBuilder - var googleAI: some View { - baseURLTextField(prompt: Text("https://generativelanguage.googleapis.com")) { - Text("/v1") - } + struct GoogleAIForm: View { + @Perception.Bindable var store: StoreOf - apiKeyNamePicker + var body: some View { + WithPerceptionTracking { + BaseURLTextField( + store: store, + prompt: Text("https://generativelanguage.googleapis.com") + ) { + Text("/v1") + } - WithPerceptionTracking { - 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) + 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) + } } - ForEach(GoogleGenerativeAIModel.allCases, id: \.self) { model in - Text(model.rawValue).tag(model.rawValue) - } - } - ) - .frame(width: 20) - } - } + ) + .frame(width: 20) + } - maxTokensTextField + MaxTokensTextField(store: store) - WithPerceptionTracking { - TextField("API Version", text: $store.apiVersion, prompt: Text("v1")) + 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") + } - WithPerceptionTracking { - TextField("Model Name", text: $store.modelName) - } + TextField("Model Name", text: $store.modelName) - maxTokensTextField + MaxTokensTextField(store: store) - WithPerceptionTracking { - TextField(text: $store.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 - - WithPerceptionTracking { - 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) + 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) } } diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift index d53e3700..76f8a27d 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -8,31 +8,29 @@ struct EmbeddingModelEditView: View { @Perception.Bindable var store: StoreOf var body: some View { - ScrollView { - VStack(spacing: 0) { - Form { - nameTextField - formatPicker + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + NameTextField(store: store) + FormatPicker(store: store) - WithPerceptionTracking { 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 { - WithPerceptionTracking { + HStack { HStack(spacing: 8) { Button("Test") { store.send(.testButtonClicked) @@ -44,249 +42,271 @@ struct EmbeddingModelEditView: View { .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 { - WithPerceptionTracking { - TextField("Name", text: $store.name) + struct NameTextField: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + TextField("Name", text: $store.name) + } } } - var formatPicker: 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) + 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 - ) + struct BaseURLTextField: View { + let store: StoreOf + var title: String = "Base URL" + let prompt: Text? + @ViewBuilder var trailingContent: () -> V + + var body: some View { + WithPerceptionTracking { + BaseURLPicker( + title: title, + prompt: prompt, + store: store.scope( + state: \.baseURLSelection, + action: \.baseURLSelection + ), + trailingContent: trailingContent + ) + } + } } - func baseURLTextField( - title: String = "Base URL", - prompt: Text? - ) -> some View { - baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() }) - } + struct MaxTokensTextField: View { + @Perception.Bindable var store: StoreOf - var maxTokensTextField: some View { - WithPerceptionTracking { - HStack { - let textFieldBinding = Binding( - get: { String(store.maxTokens) }, - set: { - if let selectionMaxToken = Int($0) { - $store.maxTokens.wrappedValue = selectionMaxToken - } else { - $store.maxTokens.wrappedValue = 0 + var body: some View { + WithPerceptionTracking { + HStack { + let textFieldBinding = Binding( + get: { String(store.maxTokens) }, + set: { + if let selectionMaxToken = Int($0) { + $store.maxTokens.wrappedValue = selectionMaxToken + } else { + $store.maxTokens.wrappedValue = 0 + } } - } - ) + ) - TextField(text: textFieldBinding) { - Text("Max Input Tokens") - .multilineTextAlignment(.trailing) - } - .overlay(alignment: .trailing) { - Stepper( - value: $store.maxTokens, - in: 0...Int.max, - step: 100 - ) { - EmptyView() + TextField(text: textFieldBinding) { + Text("Max Input Tokens") + .multilineTextAlignment(.trailing) } - } - .foregroundColor({ - guard let max = store.suggestedMaxTokens else { - return .primary - } - if store.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 = store.suggestedMaxTokens { - Text("Max: \(max)") + if let max = store.suggestedMaxTokens { + Text("Max: \(max)") + } } } } } - @ViewBuilder - var apiKeyNamePicker: some View { - APIKeyPicker(store: store.scope( - state: \.apiKeySelection, - 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/embeddings") - } - apiKeyNamePicker + struct OpenAIForm: View { + @Perception.Bindable var store: StoreOf - WithPerceptionTracking { - TextField("Model Name", text: $store.modelName) - .overlay(alignment: .trailing) { - Picker( - "", - selection: $store.modelName, - content: { - if OpenAIEmbeddingModel(rawValue: store.modelName) == nil { - Text("Custom Model").tag(store.modelName) - } - ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in - Text(model.rawValue).tag(model.rawValue) - } - } - ) - .frame(width: 20) + 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 + 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)" - ) + 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." - ) + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API." + ) + } + .padding(.vertical) + } } - .padding(.vertical) } - @ViewBuilder - var azureOpenAI: some View { - baseURLTextField(prompt: Text("https://xxxx.openai.azure.com")) - apiKeyNamePicker + struct AzureOpenAIForm: View { + @Perception.Bindable var store: StoreOf + var body: some View { + WithPerceptionTracking { + BaseURLTextField(store: store, prompt: Text("https://xxxx.openai.azure.com")) { + EmptyView() + } + ApiKeyNamePicker(store: store) - WithPerceptionTracking { - TextField("Deployment Name", text: $store.modelName) - } + TextField("Deployment Name", text: $store.modelName) - maxTokensTextField + MaxTokensTextField(store: store) + } + } } - @ViewBuilder - var openAICompatible: 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( - title: "", - prompt: store.isFullURL - ? Text("https://api.openai.com/v1/embeddings") - : Text("https://api.openai.com") - ) { - if !store.isFullURL { - Text("/v1/embeddings") + 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") + } } - } - } - apiKeyNamePicker + ApiKeyNamePicker(store: store) - WithPerceptionTracking { - TextField("Model Name", text: $store.modelName) - } + TextField("Model Name", text: $store.modelName) - maxTokensTextField + MaxTokensTextField(store: store) + } + } } - @ViewBuilder - var ollama: some View { - baseURLTextField(prompt: Text("http://127.0.0.1:11434")) { - Text("/api/embeddings") - } + 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) - WithPerceptionTracking { - TextField("Model Name", text: $store.modelName) - } + MaxTokensTextField(store: store) - maxTokensTextField + WithPerceptionTracking { + TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) { + Text("Keep Alive") + } + } - WithPerceptionTracking { - 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)." + ) + } + .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) } } diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift index 6805290c..2c1fd2d7 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift @@ -42,15 +42,15 @@ struct AIModelManagementView var body: some View { - VStack(spacing: 0) { - HStack { - Spacer() - if isFeatureAvailable(\.unlimitedChatAndEmbeddingModels) { - Button("Add Model") { - store.send(.createModel) - } - } else { - WithPerceptionTracking { + WithPerceptionTracking { + VStack(spacing: 0) { + HStack { + Spacer() + if isFeatureAvailable(\.unlimitedChatAndEmbeddingModels) { + Button("Add Model") { + store.send(.createModel) + } + } else { Text("\(store.models.count) / 2") .foregroundColor(.secondary) @@ -60,15 +60,15 @@ struct AIModelManagementView + @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 { @@ -127,37 +134,37 @@ struct CustomCommandView: View { 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 { - WithPerceptionTracking { + .padding(4) + .background { RoundedRectangle(cornerRadius: 4) .fill( store.editCustomCommand?.commandId == command.id @@ -165,50 +172,54 @@ struct CustomCommandView: View { : 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: \.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() } } } diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift index 08ecafd8..c2a0f5c0 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift @@ -85,36 +85,38 @@ 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) - } - - if store.isNewCommand { - Button("Add") { - store.send(.saveCommand) + 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) } - } else { - Button("Save") { - store.send(.saveCommand) + + if store.isNewCommand { + Button("Add") { + store.send(.saveCommand) + } + } else { + Button("Save") { + store.send(.saveCommand) + } } } } + .padding(.horizontal) } - .padding(.horizontal) + .padding(.bottom) + .background(.regularMaterial) } - .padding(.bottom) - .background(.regularMaterial) } } 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/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift index 4eb98e85..07f8af77 100644 --- a/Core/Sources/HostApp/ServiceView.swift +++ b/Core/Sources/HostApp/ServiceView.swift @@ -6,62 +6,66 @@ struct ServiceView: View { @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: \.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" - ) + 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" + ) + } + } } } } diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 9fdf3187..752158ea 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -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: \.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) } } } diff --git a/Tool/Sources/DebounceFunction/DebounceFunction.swift b/Tool/Sources/DebounceFunction/DebounceFunction.swift index 3d6e26e5..a522740f 100644 --- a/Tool/Sources/DebounceFunction/DebounceFunction.swift +++ b/Tool/Sources/DebounceFunction/DebounceFunction.swift @@ -20,3 +20,20 @@ public actor DebounceFunction { } } +public actor DebounceRunner { + let duration: TimeInterval + + var task: Task? + + public init(duration: TimeInterval) { + self.duration = duration + } + + 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() + } + } +} From 8f71a6ea1a2fc9cb30ce00f9f6a49e3dfa8f1990 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 May 2024 19:05:13 +0800 Subject: [PATCH 08/90] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 3444050d..faf8f93a 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 3444050dd846591475ff2ff508a75d3b52b2cb9f +Subproject commit faf8f93a2d9b97c73ae63a262487b91e5b9abb97 From 8e32d138185c74bca610da55c5ad0653d9301100 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 May 2024 22:59:56 +0800 Subject: [PATCH 09/90] Add missing WithPerceptionTracking --- Core/Sources/ChatGPTChatTab/ChatPanel.swift | 7 +-- .../ChatGPTChatTab/Views/BotMessage.swift | 62 ++++++++++--------- 2 files changed, 34 insertions(+), 35 deletions(-) diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index f08903db..c842504e 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -226,12 +226,7 @@ struct ChatPanelMessages: View { let scrollToBottom: () -> Void @State var isInitialLoad = true - - struct PinToBottomRelatedState: Equatable { - var isReceivingMessage: Bool - var lastMessage: DisplayedChatMessage? - } - + var body: some View { WithPerceptionTracking { EmptyView() diff --git a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift index ed3e59a0..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.. Date: Thu, 16 May 2024 23:00:06 +0800 Subject: [PATCH 10/90] Migrate to observation --- .../ChatGPTChatTab/CodeBlockHighlighter.swift | 75 ++++++++++--------- Pro | 2 +- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift b/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift index f107534d..714a5eb2 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 @@ -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/Pro b/Pro index faf8f93a..ce590007 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit faf8f93a2d9b97c73ae63a262487b91e5b9abb97 +Subproject commit ce590007d078f36c09bcd33a0e8c087a1e83959a From 88cfef77bbb972d88e2d1c9bd9ce9cc4060da05d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 May 2024 23:00:27 +0800 Subject: [PATCH 11/90] Add cancel --- Tool/Sources/DebounceFunction/DebounceFunction.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Tool/Sources/DebounceFunction/DebounceFunction.swift b/Tool/Sources/DebounceFunction/DebounceFunction.swift index a522740f..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 @@ -29,6 +33,10 @@ public actor DebounceRunner { self.duration = duration } + public func cancel() { + task?.cancel() + } + public func debounce(_ block: @escaping () async -> Void) { task?.cancel() task = Task { [duration] in @@ -37,3 +45,4 @@ public actor DebounceRunner { } } } + From 121d173bece98e009951779f729b1b238cf3e568 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 May 2024 23:00:42 +0800 Subject: [PATCH 12/90] Migrate SuggestionWidget to latest TCA --- .../SuggestionWidget/ChatPanelWindow.swift | 16 +- .../SuggestionWidget/ChatWindowView.swift | 241 +++++----- .../FeatureReducers/ChatPanelFeature.swift | 6 +- .../CircularWidgetFeature.swift | 88 ++-- .../FeatureReducers/PanelFeature.swift | 10 +- .../FeatureReducers/PromptToCode.swift | 14 +- .../FeatureReducers/PromptToCodeGroup.swift | 8 +- .../FeatureReducers/SharedPanelFeature.swift | 8 +- .../SuggestionPanelFeature.swift | 6 +- .../FeatureReducers/ToastPanel.swift | 8 +- .../FeatureReducers/WidgetFeature.swift | 20 +- .../SuggestionWidget/SharedPanelView.swift | 111 +++-- .../PromptToCodePanel.swift | 449 +++++++++--------- .../ToastPanelView.swift | 58 +-- .../SuggestionPanelView.swift | 69 ++- .../SuggestionWidgetController.swift | 2 - .../Sources/SuggestionWidget/WidgetView.swift | 172 +++---- .../WidgetWindowsController.swift | 16 +- 18 files changed, 648 insertions(+), 654 deletions(-) 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/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/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/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 49a8391d..370f2b31 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -30,28 +30,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 +69,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 +92,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 +148,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 +205,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 +223,7 @@ extension PromptToCodePanel { } return nil } - + var codeBackgroundColor: Color { if syncHighlightTheme { if colorScheme == .light, @@ -249,98 +241,133 @@ extension PromptToCodePanel { ScrollView { VStack(spacing: 0) { Spacer(minLength: 60) + ErrorMessage(store: store) + DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + } + } + .background(codeBackgroundColor) + .scaleEffect(x: 1, y: -1, anchor: .center) + } - 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) - } - } + struct ErrorMessage: View { + let store: StoreOf - 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) - } + 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) + ) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.primary.opacity(0.2), lineWidth: 1) + } + .scaleEffect(x: 1, y: -1, anchor: .center) } + } + } + } - WithViewStore(store, observe: { - CodeContent( - code: $0.code, - language: $0.language.rawValue, - startLineIndex: $0.selectionRange?.start.line ?? 0, - firstLinePrecedingSpaceCount: $0.selectionRange?.start - .character ?? 0, - isResponding: $0.isResponding - ) - }) { viewStore in - if viewStore.state.code.isEmpty { - Text( - viewStore.state.isResponding - ? "Thinking..." - : "Enter your requirement to generate code." - ) - .foregroundColor(codeForegroundColor?.opacity(0.7) ?? .secondary) + 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 +380,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 +414,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: []) } } } @@ -479,7 +500,7 @@ struct PromptToCodePanel_Preview: PreviewProvider { projectRootURL: URL(fileURLWithPath: "path/to/file.txt"), documentURL: URL(fileURLWithPath: "path/to/file.txt"), allCode: "", - allLines: [], + allLines: [String](), commandName: "Generate Code", description: "Hello world", isResponding: false, @@ -488,7 +509,7 @@ struct PromptToCodePanel_Preview: PreviewProvider { start: .init(line: 8, character: 0), end: .init(line: 12, character: 2) ) - ), reducer: PromptToCode())) + ), reducer: { PromptToCode() })) .frame(width: 450, height: 400) } } @@ -511,7 +532,7 @@ struct PromptToCodePanel_Preview: PreviewProvider { fileURLWithPath: "path/to/file-name-is-super-long-what-should-we-do-with-it-hah.txt" ), allCode: "", - allLines: [], + allLines: [String](), commandName: "Generate Code", description: "Hello world", isResponding: false, @@ -520,7 +541,7 @@ struct PromptToCodePanel_Preview: PreviewProvider { start: .init(line: 8, character: 0), end: .init(line: 12, character: 2) ) - ), reducer: PromptToCode())) + ), reducer: { PromptToCode() })) .frame(width: 450, height: 400) } @@ -541,7 +562,7 @@ struct PromptToCodePanel_Error_Detached_Preview: PreviewProvider { projectRootURL: URL(fileURLWithPath: "path/to/file.txt"), documentURL: URL(fileURLWithPath: "path/to/file.txt"), allCode: "", - allLines: [], + allLines: [String](), commandName: "Generate Code", description: "Hello world", isResponding: false, @@ -551,7 +572,7 @@ struct PromptToCodePanel_Error_Detached_Preview: PreviewProvider { start: .init(line: 8, character: 0), end: .init(line: 12, character: 2) ) - ), reducer: PromptToCode())) + ), reducer: { PromptToCode() })) .frame(width: 450, height: 400) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift index d759c298..6e9ffab9 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift @@ -7,51 +7,39 @@ import Toast struct ToastPanelView: View { let store: StoreOf - struct ViewState: Equatable { - let colorScheme: ColorScheme - let alignTopToAnchor: Bool - } - var body: some View { - WithViewStore(store, observe: { - ViewState( - colorScheme: $0.colorScheme, - alignTopToAnchor: $0.alignTopToAnchor - ) - }) { viewStore in + WithPerceptionTracking { VStack(spacing: 4) { - if !viewStore.alignTopToAnchor { + if !store.alignTopToAnchor { Spacer() } - - 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..b150e387 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -637,8 +637,8 @@ public final class WidgetWindows { it.contentView = NSHostingView( rootView: WidgetView( store: store.scope( - state: \._circularWidgetState, - action: WidgetFeature.Action.circularWidget + state: \._internalCircularWidgetState, + action: \.circularWidget ) ) ) @@ -665,10 +665,10 @@ 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 ) ) ) @@ -699,10 +699,10 @@ 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 ) ) ) @@ -716,7 +716,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 +744,7 @@ public final class WidgetWindows { it.contentView = NSHostingView( rootView: ToastPanelView(store: store.scope( state: \.toastPanel, - action: WidgetFeature.Action.toastPanel + action: \.toastPanel )) ) it.setIsVisible(true) From 653d128800302f9a000f5a345b69314085d498e2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 May 2024 23:00:51 +0800 Subject: [PATCH 13/90] Migrate Service to latest TCA --- .../GraphicalUserInterfaceController.swift | 33 +++++++++---------- .../Service/GlobalShortcutManager.swift | 6 ++-- Core/Sources/Service/ScheduledCleaner.swift | 4 +-- .../WindowBaseCommandHandler.swift | 20 +++++------ 4 files changed, 31 insertions(+), 32 deletions(-) diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 4f17fc81..86dc95d8 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() @@ -75,15 +77,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 +117,7 @@ struct GUI: ReducerProtocol { } #if canImport(ChatTabPersistent) - Scope(state: \.persistentState, action: /Action.persistent) { + Scope(state: \.persistentState, action: \.persistent) { ChatTabPersistent() } #endif @@ -251,10 +253,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 +290,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 +309,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 +320,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(.createChatGPTChatTabIfNeeded).finish() + self?.store.send(.openChatPanel(forceDetach: false)) } } suggestionDependency.onCustomCommandClicked = { command in @@ -339,8 +338,8 @@ public final class GraphicalUserInterfaceController { public func openGlobalChat() { Task { - await self.viewStore.send(.createChatGPTChatTabIfNeeded).finish() - viewStore.send(.openChatPanel(forceDetach: true)) + await self.store.send(.createChatGPTChatTabIfNeeded).finish() + store.send(.openChatPanel(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/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 7e96e113..6a521bb5 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -52,7 +52,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 +72,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 diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index a8d10385..6d42178a 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 )) @@ -264,9 +264,9 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { func chatWithSelection(editor: EditorContent) async throws -> UpdatedContent? { Task { @MainActor in - let viewStore = Service.shared.guiController.viewStore - viewStore.send(.createChatGPTChatTabIfNeeded) - viewStore.send(.openChatPanel(forceDetach: false)) + let store = Service.shared.guiController.store + store.send(.createChatGPTChatTabIfNeeded) + store.send(.openChatPanel(forceDetach: false)) } return nil } @@ -314,7 +314,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 +397,7 @@ extension WindowBaseCommandHandler { ) }() as (String, CursorRange) - let viewStore = Service.shared.guiController.viewStore + let store = Service.shared.guiController.store let customCommandTemplateProcessor = CustomCommandTemplateProcessor() @@ -415,7 +415,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, From 4f7bf0025f6ea23c7253c9cff835836ba08cab73 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 May 2024 23:00:55 +0800 Subject: [PATCH 14/90] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index ce590007..81f8252b 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit ce590007d078f36c09bcd33a0e8c087a1e83959a +Subproject commit 81f8252b69a456217428cc8c5e0647db68a061ce From 53d1445eea85731c5f903c1bf52db9e5b8244f1d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 May 2024 23:06:12 +0800 Subject: [PATCH 15/90] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 81f8252b..9a48b5a0 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 81f8252b69a456217428cc8c5e0647db68a061ce +Subproject commit 9a48b5a04bd368469b46409e5f3bf43aa4f546eb From da8e49e2cc014c06259af84761c7a69a67e08f3c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 May 2024 23:40:08 +0800 Subject: [PATCH 16/90] Bump version --- Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Version.xcconfig b/Version.xcconfig index 67c8c108..aad26f66 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 = 378 From 8024f02e6d99c91bbc82479be93788327b46882a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 May 2024 23:52:57 +0800 Subject: [PATCH 17/90] Add X-Title field to OpenRouter requests --- .../APIs/OpenAIChatCompletionsService.swift | 85 +++++++++---------- .../APIs/OpenAIEmbeddingService.swift | 75 ++++++++-------- 2 files changed, 81 insertions(+), 79 deletions(-) diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index f3fd8911..7a29c39b 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -233,28 +233,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.setupRequestTitle(&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 { @@ -303,13 +284,50 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI let encoder = JSONEncoder() request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + Self.setupRequestTitle(&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 { + 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 + } + } + + static func setupRequestTitle(_ request: inout URLRequest) { + if #available(macOS 13.0, *) { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + } + } else { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + } + } + } + + 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 +343,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 - } } } diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift index 140e9d09..3f2a89ee 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift @@ -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.setupRequestTitle(&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.setupRequestTitle(&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,38 @@ struct OpenAIEmbeddingService: EmbeddingAPI { #endif return embeddingResponse } + + static func setupRequestTitle(_ request: inout URLRequest) { + if #available(macOS 13.0, *) { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + } + } else { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + } + } + } + + 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") + } + } + } } From b120f3834c8164a8f05c604b8bfe341d57973895 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 May 2024 23:56:08 +0800 Subject: [PATCH 18/90] Add HTTP-Referer field --- .../APIs/OpenAIChatCompletionsService.swift | 22 +++++++++++----- .../APIs/OpenAIEmbeddingService.swift | 26 ++++++++++++------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index 7a29c39b..6a8ffe59 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -233,8 +233,8 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI let encoder = JSONEncoder() request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - Self.setupRequestTitle(&request) + + Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.bytes(for: request) @@ -284,8 +284,8 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI let encoder = JSONEncoder() request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - Self.setupRequestTitle(&request) + + Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.data(for: request) @@ -307,19 +307,27 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI throw error } } - - static func setupRequestTitle(_ request: inout URLRequest) { + + 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 { diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift index 3f2a89ee..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,8 +31,8 @@ struct OpenAIEmbeddingService: EmbeddingAPI { model: model.info.modelName )) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - Self.setupRequestTitle(&request) + + Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.data(for: request) @@ -72,8 +72,8 @@ struct OpenAIEmbeddingService: EmbeddingAPI { model: model.info.modelName )) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - Self.setupRequestTitle(&request) + + Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.data(for: request) @@ -102,19 +102,27 @@ struct OpenAIEmbeddingService: EmbeddingAPI { #endif return embeddingResponse } - - static func setupRequestTitle(_ request: inout URLRequest) { + + 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 { From 2a3c412e80d6d19f76a5a4e55937619b752708da Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 17:21:26 +0800 Subject: [PATCH 19/90] Fix tests --- .../PromptToCodePanel.swift | 162 +++++++++--------- 1 file changed, 81 insertions(+), 81 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 370f2b31..6e39ae1e 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() } } @@ -238,15 +240,17 @@ extension PromptToCodePanel { } var body: some View { - ScrollView { - VStack(spacing: 0) { - Spacer(minLength: 60) - ErrorMessage(store: store) - DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Spacer(minLength: 60) + ErrorMessage(store: store) + DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + } } + .background(codeBackgroundColor) + .scaleEffect(x: 1, y: -1, anchor: .center) } - .background(codeBackgroundColor) - .scaleEffect(x: 1, y: -1, anchor: .center) } struct ErrorMessage: View { @@ -298,7 +302,7 @@ extension PromptToCodePanel { struct CodeContent: View { let store: StoreOf let codeForegroundColor: Color? - + @AppStorage(\.wrapCodeInPromptToCode) var wrapCode var body: some View { @@ -442,7 +446,7 @@ extension PromptToCodePanel { struct InputField: View { @Perception.Bindable var store: StoreOf var focusField: FocusState.Binding - + var body: some View { WithPerceptionTracking { AutoresizingCustomTextEditor( @@ -483,38 +487,36 @@ extension PromptToCodePanel { // MARK: - Previews -struct PromptToCodePanel_Preview: PreviewProvider { - static var previews: some View { - PromptToCodePanel(store: .init(initialState: .init( - code: """ - ForEach(0.. Date: Mon, 20 May 2024 17:33:33 +0800 Subject: [PATCH 20/90] Fix that code was not displaying in prompt to code panel --- .../SuggestionPanelContent/PromptToCodePanel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 6e39ae1e..275ea26f 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -246,6 +246,7 @@ extension PromptToCodePanel { Spacer(minLength: 60) ErrorMessage(store: store) DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + CodeContent(store: store, codeForegroundColor: codeForegroundColor) } } .background(codeBackgroundColor) From 3c8a745b192dac24988c7cb1e44a385e11a81ceb Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 17:34:17 +0800 Subject: [PATCH 21/90] Fix tests --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 9a48b5a0..ae22bdb8 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 9a48b5a04bd368469b46409e5f3bf43aa4f546eb +Subproject commit ae22bdb83673430a4eb495ed3cd8837553004eb4 From 8261facf7ce5cb7a092485e268e4eb94e0b5a265 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 15:33:49 +0800 Subject: [PATCH 22/90] Adjust definition of SuggestionServiceProvider --- .../SuggestionProvider.swift | 23 ++-- .../SuggestionWorkspacePlugin.swift | 103 ++++++------------ .../Workspace+SuggestionService.swift | 26 +++-- 3 files changed, 65 insertions(+), 87 deletions(-) diff --git a/Tool/Sources/SuggestionProvider/SuggestionProvider.swift b/Tool/Sources/SuggestionProvider/SuggestionProvider.swift index 72da3eee..5b529cae 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 @@ -55,15 +56,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/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..ad655bbb 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift @@ -60,13 +60,14 @@ public extension Workspace { relativePath: fileURL.path.replacingOccurrences(of: projectRootURL.path, with: ""), content: editor.lines.joined(separator: ""), 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: projectRootURL, projectURL: projectRootURL) ) filespace.setSuggestions(completions) @@ -102,9 +103,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: projectRootURL, + projectURL: projectRootURL + ) + ) } filespaces[fileURL]?.reset() } @@ -128,9 +135,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: projectRootURL, + projectURL: projectRootURL + ) + ) } filespaces[fileURL]?.reset() From 8a42e7abe5560b46d6abfe48874927250f2bd9b2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 15:35:12 +0800 Subject: [PATCH 23/90] Add new target BuiltinExtension --- .../xcschemes/SuggestionModel.xcscheme | 67 ++++++++ Tool/Package.swift | 17 +- .../BuiltinExtension/BuiltinExtension.swift | 7 + .../BuiltinExtensionManager.swift | 16 ++ ...inExtensionSuggestionServiceProvider.swift | 156 ++++++++++++++++++ .../BuiltinExtensionWorkspacePlugin.swift | 75 +++++++++ 6 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 Tool/.swiftpm/xcode/xcshareddata/xcschemes/SuggestionModel.xcscheme create mode 100644 Tool/Sources/BuiltinExtension/BuiltinExtension.swift create mode 100644 Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift create mode 100644 Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift create mode 100644 Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift 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 b3353c7d..718acc58 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -202,6 +202,15 @@ let package = Package( .target(name: "AsyncPassthroughSubject"), + .target( + name: "BuiltinExtension", + dependencies: [ + "SuggestionModel", + "Workspace", + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit") + ] + ), + .target( name: "SharedUIComponents", dependencies: [ @@ -239,6 +248,7 @@ let package = Package( "Workspace", "SuggestionProvider", "XPCShared", + "BuiltinExtension" ] ), @@ -285,9 +295,8 @@ let package = Package( .target(name: "BingSearchService"), .target(name: "SuggestionProvider", dependencies: [ - "GitHubCopilotService", - "CodeiumService", "UserDefaultsObserver", + "Preferences", .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ]), @@ -303,7 +312,9 @@ let package = Package( "Logger", "Preferences", "Terminal", + "BuiltinExtension", .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] ), .testTarget( @@ -322,6 +333,8 @@ let package = Package( "Preferences", "Terminal", "XcodeInspector", + "BuiltinExtension", + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] ), diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift new file mode 100644 index 00000000..47c90e21 --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift @@ -0,0 +1,7 @@ +import CopilotForXcodeKit +import Foundation + +public protocol BuiltinExtension: CopilotForXcodeExtensionCapability { + func terminate() +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift new file mode 100644 index 00000000..f9f03a30 --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift @@ -0,0 +1,16 @@ +import Foundation + +public final class BuiltinExtensionManager { + public static let shared: BuiltinExtensionManager = .init() + private(set) var extensions: [BuiltinExtension] = [] + + public func setupExtensions(_ extensions: [BuiltinExtension]) { + self.extensions = extensions + } + + public func terminate() { + for ext in extensions { + ext.terminate() + } + } +} diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift new file mode 100644 index 00000000..edcad79a --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift @@ -0,0 +1,156 @@ +import CopilotForXcodeKit +import Foundation +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 + } + + public func getSuggestions( + _ request: SuggestionProvider.SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async throws -> [SuggestionModel.CodeSuggestion] { + guard let service else { return [] } + return try await service.getSuggestions( + .init( + fileURL: request.fileURL, + relativePath: request.relativePath, + language: .init( + rawValue: languageIdentifierFromFileURL(request.fileURL).rawValue + ) ?? .plaintext, + content: request.content, + 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 { return } + await service.cancelRequest(workspace: workspaceInfo) + } + + public func notifyAccepted( + _ suggestion: SuggestionModel.CodeSuggestion, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + guard let service else { return } + await service.notifyAccepted(suggestion.converted, workspace: workspaceInfo) + } + + public func notifyRejected( + _ suggestions: [SuggestionModel.CodeSuggestion], + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + guard let service else { 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, + 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..f8ee412a --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift @@ -0,0 +1,75 @@ +import Foundation +import Workspace + +final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { + let extensionManager: BuiltinExtensionManager + + public init(workspace: Workspace, extensionManager: BuiltinExtensionManager) { + 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 + ) + } + } + } +} + From cb901f9c58726229060efedfbb495101af8203ec Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 15:36:12 +0800 Subject: [PATCH 24/90] Convert GitHub Copilot to a builtin extension --- .../GitHubCopilotExtension.swift | 123 ++++++++++++++++++ .../GitHubCopilotWorkspacePlugin.swift | 49 +++++++ .../CopilotLocalProcessServer.swift | 0 .../CustomStdioTransport.swift | 0 .../GitHubCopilotAccountStatus.swift | 0 .../GitHubCopilotInstallationManager.swift | 0 .../GitHubCopilotRequest.swift | 0 .../GitHubCopilotService.swift | 19 ++- .../GitHubCopilotSuggestionService.swift | 106 +++++++++++++++ 9 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift create mode 100644 Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift rename Tool/Sources/GitHubCopilotService/{ => LanguageServer}/CopilotLocalProcessServer.swift (100%) rename Tool/Sources/GitHubCopilotService/{ => LanguageServer}/CustomStdioTransport.swift (100%) rename Tool/Sources/GitHubCopilotService/{ => LanguageServer}/GitHubCopilotAccountStatus.swift (100%) rename Tool/Sources/GitHubCopilotService/{ => LanguageServer}/GitHubCopilotInstallationManager.swift (100%) rename Tool/Sources/GitHubCopilotService/{ => LanguageServer}/GitHubCopilotRequest.swift (100%) rename Tool/Sources/GitHubCopilotService/{ => LanguageServer}/GitHubCopilotService.swift (97%) create mode 100644 Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift new file mode 100644 index 00000000..9f7fd075 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -0,0 +1,123 @@ +import CopilotForXcodeKit +import Foundation +import Workspace +import Logger +import BuiltinExtension + +public final class GitHubCopilotExtension: BuiltinExtension { + public var suggestionService: SuggestionServiceType? { _suggestionService } + public var chatService: ChatServiceType? { nil } + public var promptToCodeService: PromptToCodeServiceType? { nil } + let workspacePool: WorkspacePool + + let serviceLocator: ServiceLocator + let _suggestionService: GitHubCopilotSuggestionService + + 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) { + // 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) { + 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) { + 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 + ) { + // 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 appConfigurationDidChange(_ configuration: AppConfiguration) { + if !configuration.chatServiceInUse && !configuration.suggestionServiceInUse { + for workspace in workspacePool.workspaces.values { + guard let plugin = workspace.plugin(for: GitHubCopilotWorkspacePlugin.self) + else { continue } + plugin.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..a2daeef4 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift @@ -0,0 +1,49 @@ +import Foundation +import Workspace + +public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { + var _gitHubCopilotService: GitHubCopilotService? + var gitHubCopilotService: GitHubCopilotService? { + if let service = _gitHubCopilotService { return service } + do { + return try createGitHubCopilotService() + } catch { + 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 100% rename from Tool/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift 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 100% rename from Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift similarity index 100% rename from Tool/Sources/GitHubCopilotService/GitHubCopilotRequest.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift similarity index 97% rename from Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 1d22116f..71cc04fe 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -196,13 +196,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 +317,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,6 +330,7 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, super.init(designatedServer: designatedServer) } + @GitHubCopilotSuggestionActor public func getCompletions( fileURL: URL, content: String, @@ -393,22 +392,26 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, return try await task.value } + @GitHubCopilotSuggestionActor public func cancelRequest() async { 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,6 +433,7 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, ) } + @GitHubCopilotSuggestionActor public func notifyChangeTextDocument(fileURL: URL, content: String) async throws { let uri = "file://\(fileURL.path)" // Logger.service.debug("Change \(uri), \(content.count)") @@ -448,18 +452,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/Services/GitHubCopilotSuggestionService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift new file mode 100644 index 00000000..77f49b2a --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift @@ -0,0 +1,106 @@ +import CopilotForXcodeKit +import Foundation +import SuggestionModel +import Workspace + +class GitHubCopilotSuggestionService: SuggestionServiceType { + var configuration: SuggestionServiceConfiguration { + .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: false + ) + } + + let serviceLocator: ServiceLocator + + init(serviceLocator: ServiceLocator) { + self.serviceLocator = serviceLocator + } + + 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) + } + + func notifyAccepted( + _ suggestion: CopilotForXcodeKit.CodeSuggestion, + workspace: WorkspaceInfo + ) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.notifyAccepted(Self.convert(suggestion)) + } + + 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)) + } + + 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 + ) + ) + ) + } +} + From 8105b9c408e7cf1a69c19a4102dab5e49b3a4a8d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 15:37:02 +0800 Subject: [PATCH 25/90] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index ae22bdb8..9417c6ae 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit ae22bdb83673430a4eb495ed3cd8837553004eb4 +Subproject commit 9417c6aec77fed7b0923f4466546e54f96b087c3 From 65a07efcbb7e2485a7710a2a2b7ed7bf2da1294c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 15:37:40 +0800 Subject: [PATCH 26/90] Update SuggestionService to use builtin extensions --- .../SuggestionService/SuggestionService.swift | 118 +++++++----------- 1 file changed, 46 insertions(+), 72 deletions(-) diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index d20b3f1b..3130920a 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -1,8 +1,12 @@ +import BuiltinExtension +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 +15,89 @@ 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 - ) + fatalError() +// let provider = CodeiumSuggestionProvider( +// projectRootURL: projectRootURL, +// onServiceLaunched: onServiceLaunched +// ) +// 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) } } From edb1531272b6b92d70e071e90ffb97e59cd67a14 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 15:38:17 +0800 Subject: [PATCH 27/90] Update cleaner to handle builtin extensions --- Core/Package.swift | 2 ++ Core/Sources/Service/ScheduledCleaner.swift | 10 ++++------ .../WorkspaceExtension/Workspace+Cleanup.swift | 15 ++++++--------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index b81cb5c6..a43e05ed 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -184,6 +184,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/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 6a521bb5..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: @@ -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/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 + )) } } + From 11be82898fca9279210f1b73469e928d0a88b49b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 15:38:35 +0800 Subject: [PATCH 28/90] Update SuggestionServiceWorkspacePlugin setup --- Core/Sources/Service/Service.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 9c977054..92e7afec 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -41,9 +41,7 @@ public final class Service { scheduledCleaner = .init() workspacePool.registerPlugin { - SuggestionServiceWorkspacePlugin(workspace: $0) { projectRootURL, onLaunched in - SuggestionService(projectRootURL: projectRootURL, onServiceLaunched: onLaunched) - } + SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } } self.workspacePool = workspacePool globalShortcutManager = .init(guiController: guiController) From d62284ff4caf739d2ab10b824a28460cb1b90c2e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 15:39:54 +0800 Subject: [PATCH 29/90] Setup builtin extensions --- Core/Sources/Service/Service.swift | 7 ++++++- .../GitHubCopilotService/GitHubCopilotExtension.swift | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 92e7afec..a9be0f7f 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -1,6 +1,8 @@ +import BuiltinExtension import Combine import Dependencies import Foundation +import GitHubCopilotService import SuggestionService import Toast import Workspace @@ -39,6 +41,9 @@ public final class Service { private init() { @Dependency(\.workspacePool) var workspacePool + BuiltinExtensionManager.shared.setupExtensions([ + GitHubCopilotExtension(workspacePool: workspacePool), + ]) scheduledCleaner = .init() workspacePool.registerPlugin { SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } @@ -84,7 +89,7 @@ public final class Service { }.store(in: &cancellable) } } - + @MainActor public func prepareForExit() async { #if canImport(ProService) diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index 9f7fd075..1c56d702 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -13,7 +13,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { let serviceLocator: ServiceLocator let _suggestionService: GitHubCopilotSuggestionService - init(workspacePool: WorkspacePool) { + public init(workspacePool: WorkspacePool) { self.workspacePool = workspacePool serviceLocator = .init(workspacePool: workspacePool) _suggestionService = .init(serviceLocator: serviceLocator) From c3bae3cf8612e7f2a7d5e8a0f9422d1d387201d1 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 15:54:15 +0800 Subject: [PATCH 30/90] Add CodeiumExtension --- Core/Sources/Service/Service.swift | 2 + .../SuggestionService/SuggestionService.swift | 11 +- .../CodeiumService/CodeiumExtension.swift | 116 +++++++++++ .../CodeiumInstallationManager.swift | 6 +- .../CodeiumService/CodeiumService.swift | 12 +- .../CodeiumSuggestionService.swift | 105 ++++++++++ .../CodeiumWorkspacePlugin.swift | 53 +++++ .../GitHubCopilotWorkspacePlugin.swift | 2 + .../CodeiumSuggestionProvider.swift | 178 +++++++++-------- .../GitHubCopilotSuggestionProvider.swift | 186 +++++++++--------- 10 files changed, 479 insertions(+), 192 deletions(-) create mode 100644 Tool/Sources/CodeiumService/CodeiumExtension.swift create mode 100644 Tool/Sources/CodeiumService/CodeiumSuggestionService.swift create mode 100644 Tool/Sources/CodeiumService/CodeiumWorkspacePlugin.swift diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index a9be0f7f..7cc950f8 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -1,4 +1,5 @@ import BuiltinExtension +import CodeiumService import Combine import Dependencies import Foundation @@ -43,6 +44,7 @@ public final class Service { BuiltinExtensionManager.shared.setupExtensions([ GitHubCopilotExtension(workspacePool: workspacePool), + CodeiumExtension(workspacePool: workspacePool), ]) scheduledCleaner = .init() workspacePool.registerPlugin { diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index 3130920a..5fdd6ccc 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -1,4 +1,5 @@ import BuiltinExtension +import CodeiumService import struct CopilotForXcodeKit.WorkspaceInfo import Foundation import GitHubCopilotService @@ -44,12 +45,10 @@ public actor SuggestionService: SuggestionServiceType { switch serviceType { case .builtIn(.codeium): - fatalError() -// let provider = CodeiumSuggestionProvider( -// projectRootURL: projectRootURL, -// onServiceLaunched: onServiceLaunched -// ) -// return SuggestionService(provider: provider) + let provider = BuiltinExtensionSuggestionServiceProvider( + extension: CodeiumExtension.self + ) + return SuggestionService(provider: provider) case .builtIn(.gitHubCopilot), .extension: let provider = BuiltinExtensionSuggestionServiceProvider( extension: GitHubCopilotExtension.self diff --git a/Tool/Sources/CodeiumService/CodeiumExtension.swift b/Tool/Sources/CodeiumService/CodeiumExtension.swift new file mode 100644 index 00000000..1f98c362 --- /dev/null +++ b/Tool/Sources/CodeiumService/CodeiumExtension.swift @@ -0,0 +1,116 @@ +import CopilotForXcodeKit +import Foundation +import Workspace +import Logger +import BuiltinExtension + +public final class CodeiumExtension: BuiltinExtension { + public var suggestionService: SuggestionServiceType? { _suggestionService } + public var chatService: ChatServiceType? { nil } + public var promptToCodeService: PromptToCodeServiceType? { nil } + let workspacePool: WorkspacePool + + let serviceLocator: ServiceLocator + let _suggestionService: CodeiumSuggestionService + + 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) { + // 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) { + 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 + ) { + // 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 appConfigurationDidChange(_ configuration: AppConfiguration) { + if !configuration.chatServiceInUse && !configuration.suggestionServiceInUse { + for workspace in workspacePool.workspaces.values { + guard let plugin = workspace.plugin(for: CodeiumWorkspacePlugin.self) + else { continue } + plugin.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..c6c2e4d0 100644 --- a/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift +++ b/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift @@ -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..cfc0994f --- /dev/null +++ b/Tool/Sources/CodeiumService/CodeiumSuggestionService.swift @@ -0,0 +1,105 @@ +import CopilotForXcodeKit +import Foundation +import SuggestionModel +import Workspace + +class CodeiumSuggestionService: SuggestionServiceType { + var configuration: SuggestionServiceConfiguration { + .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: false + ) + } + + let serviceLocator: ServiceLocator + + init(serviceLocator: ServiceLocator) { + self.serviceLocator = serviceLocator + } + + 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) + } + + func notifyAccepted( + _ suggestion: CopilotForXcodeKit.CodeSuggestion, + workspace: WorkspaceInfo + ) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.notifyAccepted(Self.convert(suggestion)) + } + + func notifyRejected( + _ suggestions: [CopilotForXcodeKit.CodeSuggestion], + workspace: WorkspaceInfo + ) async { + // unimplemented + } + + 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/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift index a2daeef4..a5b71740 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift @@ -1,4 +1,5 @@ import Foundation +import Logger import Workspace public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { @@ -8,6 +9,7 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { do { return try createGitHubCopilotService() } catch { + Logger.gitHubCopilot.error("Failed to create GitHub Copilot service: \(error)") return nil } } diff --git a/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift b/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift index 3529b9e4..0c0c9a07 100644 --- a/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift +++ b/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift @@ -1,87 +1,91 @@ -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() - } -} - +//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 +// } +// +// deinit { +// codeiumService?.terminate() +// } +// +// 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 { +// codeiumService?.terminate() +// } +//} +// diff --git a/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift b/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift index 61b0f02a..0520ac70 100644 --- a/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift +++ b/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift @@ -1,90 +1,96 @@ -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() - } -} - +//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 +// } +// +// deinit { +// if let gitHubCopilotService { +// Task { await gitHubCopilotService.terminate() } +// } +// } +// +// func createGitHubCopilotServiceIfNeeded() throws -> GitHubCopilotSuggestionServiceType { +// if let gitHubCopilotService { return gitHubCopilotService } +// let newService = try GitHubCopilotService(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 gitHubCopilotService?.terminate() +// } +//} +// From f31aa6394b73f3df676a1660cdcf36889cbf5c01 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 15:55:34 +0800 Subject: [PATCH 31/90] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 9417c6ae..63724b64 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 9417c6aec77fed7b0923f4466546e54f96b087c3 +Subproject commit 63724b644ace1a24224e237a22146c853db7e326 From 94f8e7c021ad528a7df089524e4a8da86baf6c9c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 16:40:17 +0800 Subject: [PATCH 32/90] Add todo --- Tool/Sources/XcodeInspector/XcodeInspector.swift | 1 + 1 file changed, 1 insertion(+) 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() From 20c71bda0ab206bf268743feb22dab4ac050c86d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 16:40:37 +0800 Subject: [PATCH 33/90] Update to check app configurations for extensions --- Pro | 2 +- .../BuiltinExtensionManager.swift | 33 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Pro b/Pro index 63724b64..8ade47a2 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 63724b644ace1a24224e237a22146c853db7e326 +Subproject commit 8ade47a242d5b8376ed3d88e961d95d25bb04804 diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift index f9f03a30..f77cae71 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift @@ -1,16 +1,45 @@ +import AppKit +import Combine import Foundation +import XcodeInspector public final class BuiltinExtensionManager { public static let shared: BuiltinExtensionManager = .init() private(set) var extensions: [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: [BuiltinExtension]) { self.extensions = extensions } - + 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.appConfigurationDidChange(.init( + suggestionServiceInUse: isSuggestionFeatureInUse, + chatServiceInUse: isChatFeatureInUse + )) + } + } +} + From 4b3e48dbba1265cd47673fb9c55ce5c2d2ae3cf1 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 16:40:51 +0800 Subject: [PATCH 34/90] Add docs --- Tool/Sources/BuiltinExtension/BuiltinExtension.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift index 47c90e21..39ec18de 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift @@ -1,7 +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() } From 2ca2473e3a86e420280af18551ad61107488a0f1 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 17:35:10 +0800 Subject: [PATCH 35/90] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 8ade47a2..86c96a7f 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 8ade47a242d5b8376ed3d88e961d95d25bb04804 +Subproject commit 86c96a7fc7aac52c8c42d3f1b64a7bae305570dc From 9cf8a3e99636f15bd005e2d6a828356d717965fb Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 18:02:47 +0800 Subject: [PATCH 36/90] Fix incorrect arguments --- .../Workspace+SuggestionService.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift index ad655bbb..81af150a 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift @@ -67,7 +67,7 @@ public extension Workspace { usesTabsForIndentation: editor.usesTabsForIndentation, relevantCodeSnippets: [] ), - workspaceInfo: .init(workspaceURL: projectRootURL, projectURL: projectRootURL) + workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) ) filespace.setSuggestions(completions) @@ -108,7 +108,7 @@ public extension Workspace { await suggestionService?.notifyRejected( filespaces[fileURL]?.suggestions ?? [], workspaceInfo: .init( - workspaceURL: projectRootURL, + workspaceURL: workspaceURL, projectURL: projectRootURL ) ) @@ -139,7 +139,7 @@ public extension Workspace { await suggestionService?.notifyAccepted( suggestion, workspaceInfo: .init( - workspaceURL: projectRootURL, + workspaceURL: workspaceURL, projectURL: projectRootURL ) ) From 489fab2ce984777f157356cfd76c2dd6eb2896e8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 18:03:14 +0800 Subject: [PATCH 37/90] Add suggestion feature provider id --- Tool/Sources/CodeiumService/CodeiumExtension.swift | 11 +++++++---- .../GitHubCopilotService/GitHubCopilotExtension.swift | 11 +++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Tool/Sources/CodeiumService/CodeiumExtension.swift b/Tool/Sources/CodeiumService/CodeiumExtension.swift index 1f98c362..f38228e0 100644 --- a/Tool/Sources/CodeiumService/CodeiumExtension.swift +++ b/Tool/Sources/CodeiumService/CodeiumExtension.swift @@ -1,10 +1,13 @@ +import BuiltinExtension import CopilotForXcodeKit import Foundation -import Workspace import Logger -import BuiltinExtension +import Preferences +import Workspace public final class CodeiumExtension: BuiltinExtension { + public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .codeium } + public var suggestionService: SuggestionServiceType? { _suggestionService } public var chatService: ChatServiceType? { nil } public var promptToCodeService: PromptToCodeServiceType? { nil } @@ -68,7 +71,7 @@ public final class CodeiumExtension: BuiltinExtension { let fileSize = attrs[FileAttributeKey.size] as? UInt64, fileSize > 15 * 1024 * 1024 { return } - + Task { do { let content = try String(contentsOf: documentURL, encoding: .utf8) @@ -89,7 +92,7 @@ public final class CodeiumExtension: BuiltinExtension { } } } - + public func terminate() { for workspace in workspacePool.workspaces.values { guard let plugin = workspace.plugin(for: CodeiumWorkspacePlugin.self) diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index 1c56d702..ac3aeca8 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -1,10 +1,13 @@ +import BuiltinExtension import CopilotForXcodeKit import Foundation -import Workspace import Logger -import BuiltinExtension +import Preferences +import Workspace public final class GitHubCopilotExtension: BuiltinExtension { + public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .gitHubCopilot } + public var suggestionService: SuggestionServiceType? { _suggestionService } public var chatService: ChatServiceType? { nil } public var promptToCodeService: PromptToCodeServiceType? { nil } @@ -75,7 +78,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { let fileSize = attrs[FileAttributeKey.size] as? UInt64, fileSize > 15 * 1024 * 1024 { return } - + Task { do { let content = try String(contentsOf: documentURL, encoding: .utf8) @@ -96,7 +99,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { } } } - + public func terminate() { for workspace in workspacePool.workspaces.values { guard let plugin = workspace.plugin(for: GitHubCopilotWorkspacePlugin.self) From d0b53557e9b34771639ac5a8b15e5d64a6b447cd Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 18:03:38 +0800 Subject: [PATCH 38/90] Add default values --- .../BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift index f8ee412a..9906b82c 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift @@ -1,10 +1,10 @@ import Foundation import Workspace -final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { +public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { let extensionManager: BuiltinExtensionManager - public init(workspace: Workspace, extensionManager: BuiltinExtensionManager) { + public init(workspace: Workspace, extensionManager: BuiltinExtensionManager = .shared) { self.extensionManager = extensionManager super.init(workspace: workspace) } From d6356197bff335a74eeb6ba7944d37f01ad44fea Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 18:03:43 +0800 Subject: [PATCH 39/90] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 86c96a7f..f3c9528e 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 86c96a7fc7aac52c8c42d3f1b64a7bae305570dc +Subproject commit f3c9528e7685eb2bcb6119ad32fc1380d94bb3b2 From 111ebf29f150c5cf2f741f86815bf4e11294c11d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 18:03:52 +0800 Subject: [PATCH 40/90] Register workspace plugins --- Core/Sources/Service/Service.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 7cc950f8..fa62b47e 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -50,6 +50,15 @@ public final class Service { workspacePool.registerPlugin { 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) From 25c8b6be3d9caeee3cd726c773a0def2d8887988 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 21:19:26 +0800 Subject: [PATCH 41/90] Prevent unused language servers from loading --- .../BuiltinExtension/BuiltinExtensionManager.swift | 1 + Tool/Sources/CodeiumService/CodeiumExtension.swift | 12 ++++++++++++ .../GitHubCopilotExtension.swift | 12 ++++++++++++ 3 files changed, 25 insertions(+) diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift index f77cae71..9272a27b 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift @@ -19,6 +19,7 @@ public final class BuiltinExtensionManager { public func setupExtensions(_ extensions: [BuiltinExtension]) { self.extensions = extensions + checkAppConfiguration() } public func terminate() { diff --git a/Tool/Sources/CodeiumService/CodeiumExtension.swift b/Tool/Sources/CodeiumService/CodeiumExtension.swift index f38228e0..6185f2ea 100644 --- a/Tool/Sources/CodeiumService/CodeiumExtension.swift +++ b/Tool/Sources/CodeiumService/CodeiumExtension.swift @@ -11,6 +11,14 @@ public final class CodeiumExtension: BuiltinExtension { public var suggestionService: SuggestionServiceType? { _suggestionService } public var chatService: ChatServiceType? { nil } public var promptToCodeService: PromptToCodeServiceType? { nil } + private var appConfiguration = AppConfiguration( + suggestionServiceInUse: false, + chatServiceInUse: false + ) + private var isLanguageServerInUse: Bool { + appConfiguration.suggestionServiceInUse || appConfiguration.chatServiceInUse + } + let workspacePool: WorkspacePool let serviceLocator: ServiceLocator @@ -27,6 +35,7 @@ public final class CodeiumExtension: BuiltinExtension { 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), @@ -50,6 +59,7 @@ public final class CodeiumExtension: BuiltinExtension { } public func workspace(_ workspace: WorkspaceInfo, didCloseDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } Task { do { guard let service = await serviceLocator.getService(from: workspace) else { return } @@ -65,6 +75,7 @@ public final class CodeiumExtension: BuiltinExtension { 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), @@ -84,6 +95,7 @@ public final class CodeiumExtension: BuiltinExtension { } public func appConfigurationDidChange(_ configuration: AppConfiguration) { + appConfiguration = configuration if !configuration.chatServiceInUse && !configuration.suggestionServiceInUse { for workspace in workspacePool.workspaces.values { guard let plugin = workspace.plugin(for: CodeiumWorkspacePlugin.self) diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index ac3aeca8..9b1eceda 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -11,6 +11,13 @@ public final class GitHubCopilotExtension: BuiltinExtension { public var suggestionService: SuggestionServiceType? { _suggestionService } public var chatService: ChatServiceType? { nil } public var promptToCodeService: PromptToCodeServiceType? { nil } + private var appConfiguration = AppConfiguration( + suggestionServiceInUse: false, + chatServiceInUse: false + ) + private var isLanguageServerInUse: Bool { + appConfiguration.suggestionServiceInUse || appConfiguration.chatServiceInUse + } let workspacePool: WorkspacePool let serviceLocator: ServiceLocator @@ -27,6 +34,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { 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), @@ -46,6 +54,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { } public func workspace(_ workspace: WorkspaceInfo, didSaveDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } Task { do { guard let service = await serviceLocator.getService(from: workspace) else { return } @@ -57,6 +66,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { } public func workspace(_ workspace: WorkspaceInfo, didCloseDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } Task { do { guard let service = await serviceLocator.getService(from: workspace) else { return } @@ -72,6 +82,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { 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), @@ -91,6 +102,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { } public func appConfigurationDidChange(_ configuration: AppConfiguration) { + appConfiguration = configuration if !configuration.chatServiceInUse && !configuration.suggestionServiceInUse { for workspace in workspacePool.workspaces.values { guard let plugin = workspace.plugin(for: GitHubCopilotWorkspacePlugin.self) From 57b9512eca58a950702610bb0b166b5c2f5b5112 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 May 2024 22:56:24 +0800 Subject: [PATCH 42/90] Match the develop branch CopilotForXcodeKit --- Pro | 2 +- Tool/Package.swift | 2 +- .../BuiltinExtensionManager.swift | 10 +++++----- .../BuiltinExtensionWorkspacePlugin.swift | 2 +- .../CodeiumService/CodeiumExtension.swift | 20 +++++++++---------- .../GitHubCopilotExtension.swift | 19 +++++++++--------- 6 files changed, 28 insertions(+), 27 deletions(-) diff --git a/Pro b/Pro index f3c9528e..23dc1725 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit f3c9528e7685eb2bcb6119ad32fc1380d94bb3b2 +Subproject commit 23dc1725a2db0874b922e2dde9f086a464e0517d diff --git a/Tool/Package.swift b/Tool/Package.swift index 718acc58..bca0b589 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -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"), diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift index 9272a27b..11dba564 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift @@ -5,7 +5,7 @@ import XcodeInspector public final class BuiltinExtensionManager { public static let shared: BuiltinExtensionManager = .init() - private(set) var extensions: [BuiltinExtension] = [] + private(set) var extensions: [any BuiltinExtension] = [] private var cancellable: Set = [] @@ -17,7 +17,7 @@ public final class BuiltinExtensionManager { }.store(in: &cancellable) } - public func setupExtensions(_ extensions: [BuiltinExtension]) { + public func setupExtensions(_ extensions: [any BuiltinExtension]) { self.extensions = extensions checkAppConfiguration() } @@ -36,9 +36,9 @@ extension BuiltinExtensionManager { let isSuggestionFeatureInUse = suggestionFeatureProvider == .builtIn(ext.suggestionServiceId) let isChatFeatureInUse = false - ext.appConfigurationDidChange(.init( - suggestionServiceInUse: isSuggestionFeatureInUse, - chatServiceInUse: isChatFeatureInUse + ext.extensionUsageDidChange(.init( + isSuggestionServiceInUse: isSuggestionFeatureInUse, + isChatServiceInUse: isChatFeatureInUse )) } } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift index 9906b82c..f8471d12 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift @@ -52,7 +52,7 @@ public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { for ext in extensionManager.extensions { ext.workspace( .init(workspaceURL: workspaceURL, projectURL: projectRootURL), - didUpdateDocumentAt: filespace.fileURL, + didUpdateDocumentAt: filespace.fileURL, content: content ) } diff --git a/Tool/Sources/CodeiumService/CodeiumExtension.swift b/Tool/Sources/CodeiumService/CodeiumExtension.swift index 6185f2ea..5dfad721 100644 --- a/Tool/Sources/CodeiumService/CodeiumExtension.swift +++ b/Tool/Sources/CodeiumService/CodeiumExtension.swift @@ -11,14 +11,14 @@ public final class CodeiumExtension: BuiltinExtension { public var suggestionService: SuggestionServiceType? { _suggestionService } public var chatService: ChatServiceType? { nil } public var promptToCodeService: PromptToCodeServiceType? { nil } - private var appConfiguration = AppConfiguration( - suggestionServiceInUse: false, - chatServiceInUse: false + private var extensionUsage = ExtensionUsage( + isSuggestionServiceInUse: false, + isChatServiceInUse: false ) private var isLanguageServerInUse: Bool { - appConfiguration.suggestionServiceInUse || appConfiguration.chatServiceInUse + extensionUsage.isSuggestionServiceInUse || extensionUsage.isChatServiceInUse } - + let workspacePool: WorkspacePool let serviceLocator: ServiceLocator @@ -73,7 +73,7 @@ public final class CodeiumExtension: BuiltinExtension { public func workspace( _ workspace: WorkspaceInfo, didUpdateDocumentAt documentURL: URL, - content: String + content: String? ) { guard isLanguageServerInUse else { return } // check if file size is larger than 15MB, if so, return immediately @@ -85,7 +85,7 @@ public final class CodeiumExtension: BuiltinExtension { Task { do { - let content = try String(contentsOf: documentURL, encoding: .utf8) + guard let content else { return } guard let service = await serviceLocator.getService(from: workspace) else { return } try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) } catch { @@ -94,9 +94,9 @@ public final class CodeiumExtension: BuiltinExtension { } } - public func appConfigurationDidChange(_ configuration: AppConfiguration) { - appConfiguration = configuration - if !configuration.chatServiceInUse && !configuration.suggestionServiceInUse { + public func extensionUsageDidChange(_ usage: ExtensionUsage) { + extensionUsage = usage + if !usage.isChatServiceInUse && !usage.isSuggestionServiceInUse { for workspace in workspacePool.workspaces.values { guard let plugin = workspace.plugin(for: CodeiumWorkspacePlugin.self) else { continue } diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index 9b1eceda..95c4b168 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -11,13 +11,14 @@ public final class GitHubCopilotExtension: BuiltinExtension { public var suggestionService: SuggestionServiceType? { _suggestionService } public var chatService: ChatServiceType? { nil } public var promptToCodeService: PromptToCodeServiceType? { nil } - private var appConfiguration = AppConfiguration( - suggestionServiceInUse: false, - chatServiceInUse: false + private var extensionUsage = ExtensionUsage( + isSuggestionServiceInUse: false, + isChatServiceInUse: false ) private var isLanguageServerInUse: Bool { - appConfiguration.suggestionServiceInUse || appConfiguration.chatServiceInUse + extensionUsage.isSuggestionServiceInUse || extensionUsage.isChatServiceInUse } + let workspacePool: WorkspacePool let serviceLocator: ServiceLocator @@ -80,7 +81,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { public func workspace( _ workspace: WorkspaceInfo, didUpdateDocumentAt documentURL: URL, - content: String + content: String? ) { guard isLanguageServerInUse else { return } // check if file size is larger than 15MB, if so, return immediately @@ -92,7 +93,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { Task { do { - let content = try String(contentsOf: documentURL, encoding: .utf8) + guard let content else { return } guard let service = await serviceLocator.getService(from: workspace) else { return } try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) } catch { @@ -101,9 +102,9 @@ public final class GitHubCopilotExtension: BuiltinExtension { } } - public func appConfigurationDidChange(_ configuration: AppConfiguration) { - appConfiguration = configuration - if !configuration.chatServiceInUse && !configuration.suggestionServiceInUse { + public func extensionUsageDidChange(_ usage: ExtensionUsage) { + extensionUsage = usage + if !usage.isChatServiceInUse && !usage.isSuggestionServiceInUse { for workspace in workspacePool.workspaces.values { guard let plugin = workspace.plugin(for: GitHubCopilotWorkspacePlugin.self) else { continue } From a9a812e65df22980488e9a1f348a983afff683e6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 May 2024 02:32:59 +0800 Subject: [PATCH 43/90] Fix dependencies --- Tool/Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tool/Package.swift b/Tool/Package.swift index bca0b589..7cfdd35f 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -295,6 +295,7 @@ let package = Package( .target(name: "BingSearchService"), .target(name: "SuggestionProvider", dependencies: [ + "SuggestionModel", "UserDefaultsObserver", "Preferences", .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), From 41256ae1a66eeab58ffe3ee9932b7acb11489d4d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 May 2024 02:33:06 +0800 Subject: [PATCH 44/90] Update schemes --- .../xcschemes/CommunicationBridge.xcscheme | 79 +++++++++++++++++++ .../xcschemes/SandboxedClientTester.xcscheme | 78 ++++++++++++++++++ ...{Core-Package.xcscheme => Client.xcscheme} | 17 ++-- .../xcshareddata/xcschemes/HostApp.xcscheme | 17 ++-- Pro | 2 +- 5 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/CommunicationBridge.xcscheme create mode 100644 Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/SandboxedClientTester.xcscheme rename Core/.swiftpm/xcode/xcshareddata/xcschemes/{Core-Package.xcscheme => Client.xcscheme} (84%) rename Tool/.swiftpm/xcode/xcshareddata/xcschemes/Tool-Package.xcscheme => Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme (84%) 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/Pro b/Pro index 23dc1725..ff11260e 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 23dc1725a2db0874b922e2dde9f086a464e0517d +Subproject commit ff11260ec77e2239b18beb7e900a20d46f81136c From b39a32b5abc55eba0d19dc6b1739e586a4a5693b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 May 2024 03:16:33 +0800 Subject: [PATCH 45/90] Fix builtin extensions --- ...inExtensionSuggestionServiceProvider.swift | 27 ++++++++++++++++--- .../CodeiumService/CodeiumExtension.swift | 8 +++--- .../CodeiumSuggestionService.swift | 12 ++++----- .../GitHubCopilotExtension.swift | 8 +++--- .../GitHubCopilotSuggestionService.swift | 12 ++++----- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift index edcad79a..6437f137 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift @@ -1,5 +1,6 @@ import CopilotForXcodeKit import Foundation +import Logger import Preferences import SuggestionModel import SuggestionProvider @@ -31,12 +32,21 @@ public final class BuiltinExtensionSuggestionServiceProvider< 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 { return [] } + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + throw BuiltinExtensionSuggestionServiceNotFoundError() + } return try await service.getSuggestions( .init( fileURL: request.fileURL, @@ -61,7 +71,10 @@ public final class BuiltinExtensionSuggestionServiceProvider< public func cancelRequest( workspaceInfo: CopilotForXcodeKit.WorkspaceInfo ) async { - guard let service else { return } + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + return + } await service.cancelRequest(workspace: workspaceInfo) } @@ -69,7 +82,10 @@ public final class BuiltinExtensionSuggestionServiceProvider< _ suggestion: SuggestionModel.CodeSuggestion, workspaceInfo: CopilotForXcodeKit.WorkspaceInfo ) async { - guard let service else { return } + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + return + } await service.notifyAccepted(suggestion.converted, workspace: workspaceInfo) } @@ -77,7 +93,10 @@ public final class BuiltinExtensionSuggestionServiceProvider< _ suggestions: [SuggestionModel.CodeSuggestion], workspaceInfo: CopilotForXcodeKit.WorkspaceInfo ) async { - guard let service else { return } + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + return + } await service.notifyRejected(suggestions.map(\.converted), workspace: workspaceInfo) } } diff --git a/Tool/Sources/CodeiumService/CodeiumExtension.swift b/Tool/Sources/CodeiumService/CodeiumExtension.swift index 5dfad721..2d49e94b 100644 --- a/Tool/Sources/CodeiumService/CodeiumExtension.swift +++ b/Tool/Sources/CodeiumService/CodeiumExtension.swift @@ -8,9 +8,8 @@ import Workspace public final class CodeiumExtension: BuiltinExtension { public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .codeium } - public var suggestionService: SuggestionServiceType? { _suggestionService } - public var chatService: ChatServiceType? { nil } - public var promptToCodeService: PromptToCodeServiceType? { nil } + public let suggestionService: CodeiumSuggestionService? + private var extensionUsage = ExtensionUsage( isSuggestionServiceInUse: false, isChatServiceInUse: false @@ -22,12 +21,11 @@ public final class CodeiumExtension: BuiltinExtension { let workspacePool: WorkspacePool let serviceLocator: ServiceLocator - let _suggestionService: CodeiumSuggestionService public init(workspacePool: WorkspacePool) { self.workspacePool = workspacePool serviceLocator = .init(workspacePool: workspacePool) - _suggestionService = .init(serviceLocator: serviceLocator) + suggestionService = .init(serviceLocator: serviceLocator) } public func workspaceDidOpen(_: WorkspaceInfo) {} diff --git a/Tool/Sources/CodeiumService/CodeiumSuggestionService.swift b/Tool/Sources/CodeiumService/CodeiumSuggestionService.swift index cfc0994f..1b9b3477 100644 --- a/Tool/Sources/CodeiumService/CodeiumSuggestionService.swift +++ b/Tool/Sources/CodeiumService/CodeiumSuggestionService.swift @@ -3,8 +3,8 @@ import Foundation import SuggestionModel import Workspace -class CodeiumSuggestionService: SuggestionServiceType { - var configuration: SuggestionServiceConfiguration { +public final class CodeiumSuggestionService: SuggestionServiceType { + public var configuration: SuggestionServiceConfiguration { .init( acceptsRelevantCodeSnippets: true, mixRelevantCodeSnippetsInSource: true, @@ -18,7 +18,7 @@ class CodeiumSuggestionService: SuggestionServiceType { self.serviceLocator = serviceLocator } - func getSuggestions( + public func getSuggestions( _ request: SuggestionRequest, workspace: WorkspaceInfo ) async throws -> [CopilotForXcodeKit.CodeSuggestion] { @@ -36,7 +36,7 @@ class CodeiumSuggestionService: SuggestionServiceType { ).map(Self.convert) } - func notifyAccepted( + public func notifyAccepted( _ suggestion: CopilotForXcodeKit.CodeSuggestion, workspace: WorkspaceInfo ) async { @@ -44,14 +44,14 @@ class CodeiumSuggestionService: SuggestionServiceType { await service.notifyAccepted(Self.convert(suggestion)) } - func notifyRejected( + public func notifyRejected( _ suggestions: [CopilotForXcodeKit.CodeSuggestion], workspace: WorkspaceInfo ) async { // unimplemented } - func cancelRequest(workspace: WorkspaceInfo) async { + public func cancelRequest(workspace: WorkspaceInfo) async { guard let service = await serviceLocator.getService(from: workspace) else { return } await service.cancelRequest() } diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index 95c4b168..ac7b1ad0 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -8,9 +8,8 @@ import Workspace public final class GitHubCopilotExtension: BuiltinExtension { public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .gitHubCopilot } - public var suggestionService: SuggestionServiceType? { _suggestionService } - public var chatService: ChatServiceType? { nil } - public var promptToCodeService: PromptToCodeServiceType? { nil } + public let suggestionService: GitHubCopilotSuggestionService? + private var extensionUsage = ExtensionUsage( isSuggestionServiceInUse: false, isChatServiceInUse: false @@ -22,12 +21,11 @@ public final class GitHubCopilotExtension: BuiltinExtension { let workspacePool: WorkspacePool let serviceLocator: ServiceLocator - let _suggestionService: GitHubCopilotSuggestionService public init(workspacePool: WorkspacePool) { self.workspacePool = workspacePool serviceLocator = .init(workspacePool: workspacePool) - _suggestionService = .init(serviceLocator: serviceLocator) + suggestionService = .init(serviceLocator: serviceLocator) } public func workspaceDidOpen(_: WorkspaceInfo) {} diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift index 77f49b2a..37bc00b5 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift @@ -3,8 +3,8 @@ import Foundation import SuggestionModel import Workspace -class GitHubCopilotSuggestionService: SuggestionServiceType { - var configuration: SuggestionServiceConfiguration { +public final class GitHubCopilotSuggestionService: SuggestionServiceType { + public var configuration: SuggestionServiceConfiguration { .init( acceptsRelevantCodeSnippets: true, mixRelevantCodeSnippetsInSource: true, @@ -18,7 +18,7 @@ class GitHubCopilotSuggestionService: SuggestionServiceType { self.serviceLocator = serviceLocator } - func getSuggestions( + public func getSuggestions( _ request: SuggestionRequest, workspace: WorkspaceInfo ) async throws -> [CopilotForXcodeKit.CodeSuggestion] { @@ -36,7 +36,7 @@ class GitHubCopilotSuggestionService: SuggestionServiceType { ).map(Self.convert) } - func notifyAccepted( + public func notifyAccepted( _ suggestion: CopilotForXcodeKit.CodeSuggestion, workspace: WorkspaceInfo ) async { @@ -44,7 +44,7 @@ class GitHubCopilotSuggestionService: SuggestionServiceType { await service.notifyAccepted(Self.convert(suggestion)) } - func notifyRejected( + public func notifyRejected( _ suggestions: [CopilotForXcodeKit.CodeSuggestion], workspace: WorkspaceInfo ) async { @@ -52,7 +52,7 @@ class GitHubCopilotSuggestionService: SuggestionServiceType { await service.notifyRejected(suggestions.map(Self.convert)) } - func cancelRequest(workspace: WorkspaceInfo) async { + public func cancelRequest(workspace: WorkspaceInfo) async { guard let service = await serviceLocator.getService(from: workspace) else { return } await service.cancelRequest() } From 1cd11f3e4380d9dd16125ba91abf3a23ee2faf3b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 May 2024 02:42:08 +0800 Subject: [PATCH 46/90] Move open chat to PseudoCommandHandler --- .../SuggestionCommandHandler/PseudoCommandHandler.swift | 8 ++++++++ .../SuggestionCommandHandler.swift | 2 -- .../WindowBaseCommandHandler.swift | 9 --------- Core/Sources/Service/XPCService.swift | 6 +++--- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index ebbbebb1..5844d4b2 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -301,6 +301,14 @@ struct PseudoCommandHandler { await filespace.reset() PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL) } + + func openChat() { + Task { @MainActor in + let store = Service.shared.guiController.store + await store.send(.createChatGPTChatTabIfNeeded) + await store.send(.openChatPanel(forceDetach: false)) + } + } } 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 6d42178a..d493dded 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -262,15 +262,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return try await presentSuggestions(editor: editor) } - func chatWithSelection(editor: EditorContent) async throws -> UpdatedContent? { - Task { @MainActor in - let store = Service.shared.guiController.store - store.send(.createChatGPTChatTabIfNeeded) - store.send(.openChatPanel(forceDetach: false)) - } - return nil - } - func promptToCode(editor: EditorContent) async throws -> UpdatedContent? { Task { do { diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 3c2cf47b..30b98801 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -144,9 +144,9 @@ public class XPCService: NSObject, XPCServiceProtocol { 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() + reply(nil, nil) } public func promptToCode( From 947e7879ddb9ac4c3291ba00c5721f04e94e0b08 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 May 2024 02:43:42 +0800 Subject: [PATCH 47/90] Rename to OpenChatCommand --- Copilot for Xcode.xcodeproj/project.pbxproj | 8 ++++---- .../{ChatWithSelection.swift => OpenChat.swift} | 2 +- EditorExtension/SourceEditorExtension.swift | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename EditorExtension/{ChatWithSelection.swift => OpenChat.swift} (84%) 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/EditorExtension/ChatWithSelection.swift b/EditorExtension/OpenChat.swift similarity index 84% rename from EditorExtension/ChatWithSelection.swift rename to EditorExtension/OpenChat.swift index e1dd0b81..3c9f6cde 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( diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift index 4b3882e2..71ec0d8b 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -17,7 +17,7 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { PreviousSuggestionCommand(), PromptToCodeCommand(), AcceptPromptToCodeCommand(), - ChatWithSelectionCommand(), + OpenChatCommand(), ].map(makeCommandDefinition) } From fff67bfa7d52ac40c4c855530a969457209b3c24 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 May 2024 02:46:29 +0800 Subject: [PATCH 48/90] Rename to openChat --- Core/Sources/Service/XPCService.swift | 2 +- EditorExtension/OpenChat.swift | 2 +- Tool/Sources/XPCShared/XPCExtensionService.swift | 4 ++-- Tool/Sources/XPCShared/XPCServiceProtocol.swift | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 30b98801..12dbc7aa 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -140,7 +140,7 @@ public class XPCService: NSObject, XPCServiceProtocol { } } - public func chatWithSelection( + public func openChat( editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void ) { diff --git a/EditorExtension/OpenChat.swift b/EditorExtension/OpenChat.swift index 3c9f6cde..375f58a2 100644 --- a/EditorExtension/OpenChat.swift +++ b/EditorExtension/OpenChat.swift @@ -13,7 +13,7 @@ class OpenChatCommand: 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/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index 1301ec8f..3907714d 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -136,10 +136,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 } ) } 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 ) From 2a2b5dfba3a773d6038ed8fed0ff28d130ceafdd Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 May 2024 03:02:20 +0800 Subject: [PATCH 49/90] Update open chat to change it's behavior according to settings --- .../GraphicalUserInterfaceController.swift | 5 +- .../PseudoCommandHandler.swift | 52 ++++++++++++++++--- Core/Sources/Service/XPCService.swift | 2 +- Tool/Sources/Preferences/Keys.swift | 12 +++++ .../Preferences/Types/OpenChatMode.swift | 6 +++ 5 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 Tool/Sources/Preferences/Types/OpenChatMode.swift diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 86dc95d8..329cd63d 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -337,10 +337,7 @@ public final class GraphicalUserInterfaceController { } public func openGlobalChat() { - Task { - await self.store.send(.createChatGPTChatTabIfNeeded).finish() - store.send(.openChatPanel(forceDetach: true)) - } + PseudoCommandHandler().openChat(forceDetach: true) } } diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 5844d4b2..cc1c5b48 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -1,5 +1,6 @@ import ActiveApplicationMonitor import AppKit +import Dependencies import Preferences import SuggestionInjector import SuggestionModel @@ -210,7 +211,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 +273,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,12 +314,35 @@ struct PseudoCommandHandler { await filespace.reset() PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL) } - - func openChat() { - Task { @MainActor in - let store = Service.shared.guiController.store - await store.send(.createChatGPTChatTabIfNeeded) - await store.send(.openChatPanel(forceDetach: false)) + + func openChat(forceDetach: Bool) { + switch UserDefaults.shared.value(for: \.openChatMode) { + case .chatPanel: + Task { @MainActor in + let store = Service.shared.guiController.store + await store.send(.createChatGPTChatTabIfNeeded).finish() + store.send(.openChatPanel(forceDetach: false)) + } + case .browser: + let urlString = UserDefaults.shared.value(for: \.openChatInBrowserURL) + let openInApp = UserDefaults.shared.value(for: \.openChatInBrowserInInAppBrowser) + 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 { + return + } else { + Task { + @Dependency(\.openURL) var openURL + await openURL(url) + } + } } } } diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 12dbc7aa..f155badd 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -145,7 +145,7 @@ public class XPCService: NSObject, XPCServiceProtocol { withReply reply: @escaping (Data?, Error?) -> Void ) { let handler = PseudoCommandHandler() - handler.openChat() + handler.openChat(forceDetach: false) reply(nil, nil) } diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index f8650cb7..eec7d42e 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -473,6 +473,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..9944bb83 --- /dev/null +++ b/Tool/Sources/Preferences/Types/OpenChatMode.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum OpenChatMode: String { + case chatPanel + case browser +} From c756ebb0b684c72ccb8b67ab06e0871c6c4a9126 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 May 2024 16:33:19 +0800 Subject: [PATCH 50/90] Add settings for open chat mode --- .../Chat/ChatSettingsGeneralSectionView.swift | 44 +++++++++++++++++++ .../Preferences/Types/OpenChatMode.swift | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) 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/Tool/Sources/Preferences/Types/OpenChatMode.swift b/Tool/Sources/Preferences/Types/OpenChatMode.swift index 9944bb83..d983a5e0 100644 --- a/Tool/Sources/Preferences/Types/OpenChatMode.swift +++ b/Tool/Sources/Preferences/Types/OpenChatMode.swift @@ -1,6 +1,6 @@ import Foundation -public enum OpenChatMode: String { +public enum OpenChatMode: String, CaseIterable { case chatPanel case browser } From 6759c8c145d98d2d81da2da5a89c9b742b75728a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 May 2024 16:49:40 +0800 Subject: [PATCH 51/90] Support opening chat web page in chat tab --- Core/Package.swift | 1 + .../GraphicalUserInterfaceController.swift | 69 +++++++++++++++++-- .../PseudoCommandHandler.swift | 18 +++-- Pro | 2 +- 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index a43e05ed..adef784a 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -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"), diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 329cd63d..58bbb1b5 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -55,7 +55,8 @@ struct GUI { enum Action { case start case openChatPanel(forceDetach: Bool) - case createChatGPTChatTabIfNeeded + case createAndSwitchToChatGPTChatTabIfNeeded + case createAndSwitchToBrowserTabIfNeeded(url: URL) case sendCustomCommandToActiveChat(CustomCommand) case toggleWidgetsHotkeyPressed @@ -145,11 +146,22 @@ struct GUI { 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) { @@ -159,6 +171,53 @@ struct GUI { } } + 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 { @@ -320,7 +379,7 @@ public final class GraphicalUserInterfaceController { suggestionDependency.suggestionWidgetDataSource = widgetDataSource suggestionDependency.onOpenChatClicked = { [weak self] in Task { [weak self] in - await self?.store.send(.createChatGPTChatTabIfNeeded).finish() + await self?.store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish() self?.store.send(.openChatPanel(forceDetach: false)) } } diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index cc1c5b48..2f23a218 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -1,6 +1,7 @@ import ActiveApplicationMonitor import AppKit import Dependencies +import PlusFeatureFlag import Preferences import SuggestionInjector import SuggestionModel @@ -318,14 +319,19 @@ struct PseudoCommandHandler { func openChat(forceDetach: Bool) { switch UserDefaults.shared.value(for: \.openChatMode) { case .chatPanel: + let store = Service.shared.guiController.store Task { @MainActor in - let store = Service.shared.guiController.store - await store.send(.createChatGPTChatTabIfNeeded).finish() + await store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish() store.send(.openChatPanel(forceDetach: false)) } case .browser: let urlString = UserDefaults.shared.value(for: \.openChatInBrowserURL) - let openInApp = UserDefaults.shared.value(for: \.openChatInBrowserInInAppBrowser) + 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" @@ -336,7 +342,11 @@ struct PseudoCommandHandler { } if openInApp { - return + let store = Service.shared.guiController.store + Task { @MainActor in + await store.send(.createAndSwitchToBrowserTabIfNeeded(url: url)).finish() + store.send(.openChatPanel(forceDetach: false)) + } } else { Task { @Dependency(\.openURL) var openURL diff --git a/Pro b/Pro index ff11260e..2da51b1c 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit ff11260ec77e2239b18beb7e900a20d46f81136c +Subproject commit 2da51b1cd730b3a51781a0e827ae87e509e653f7 From 9f430dd533175997cfc0b7a7fb6fdd8e1a5c250f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 May 2024 17:07:30 +0800 Subject: [PATCH 52/90] Fix that forceDetached is not used --- .../SuggestionCommandHandler/PseudoCommandHandler.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 2f23a218..66f6b2eb 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -322,7 +322,7 @@ struct PseudoCommandHandler { let store = Service.shared.guiController.store Task { @MainActor in await store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish() - store.send(.openChatPanel(forceDetach: false)) + store.send(.openChatPanel(forceDetach: forceDetach)) } case .browser: let urlString = UserDefaults.shared.value(for: \.openChatInBrowserURL) @@ -345,7 +345,7 @@ struct PseudoCommandHandler { let store = Service.shared.guiController.store Task { @MainActor in await store.send(.createAndSwitchToBrowserTabIfNeeded(url: url)).finish() - store.send(.openChatPanel(forceDetach: false)) + store.send(.openChatPanel(forceDetach: forceDetach)) } } else { Task { From 14ebbaa73fe35cd47746f3515462d7263440c4a6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 May 2024 17:08:11 +0800 Subject: [PATCH 53/90] Remove duplication --- Tool/Sources/CodeiumService/CodeiumExtension.swift | 6 +----- .../GitHubCopilotService/GitHubCopilotExtension.swift | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/Tool/Sources/CodeiumService/CodeiumExtension.swift b/Tool/Sources/CodeiumService/CodeiumExtension.swift index 2d49e94b..d2c113ef 100644 --- a/Tool/Sources/CodeiumService/CodeiumExtension.swift +++ b/Tool/Sources/CodeiumService/CodeiumExtension.swift @@ -95,11 +95,7 @@ public final class CodeiumExtension: BuiltinExtension { public func extensionUsageDidChange(_ usage: ExtensionUsage) { extensionUsage = usage if !usage.isChatServiceInUse && !usage.isSuggestionServiceInUse { - for workspace in workspacePool.workspaces.values { - guard let plugin = workspace.plugin(for: CodeiumWorkspacePlugin.self) - else { continue } - plugin.terminate() - } + terminate() } } diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index ac7b1ad0..d948d22c 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -103,11 +103,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { public func extensionUsageDidChange(_ usage: ExtensionUsage) { extensionUsage = usage if !usage.isChatServiceInUse && !usage.isSuggestionServiceInUse { - for workspace in workspacePool.workspaces.values { - guard let plugin = workspace.plugin(for: GitHubCopilotWorkspacePlugin.self) - else { continue } - plugin.terminate() - } + terminate() } } From 45c209ec5da89704c8491ebedfb4dcba6b94b3e8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 May 2024 17:08:34 +0800 Subject: [PATCH 54/90] Remove unused files --- .../CodeiumSuggestionProvider.swift | 91 ------------------ .../GitHubCopilotSuggestionProvider.swift | 96 ------------------- 2 files changed, 187 deletions(-) delete mode 100644 Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift delete mode 100644 Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift diff --git a/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift b/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift deleted file mode 100644 index 0c0c9a07..00000000 --- a/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift +++ /dev/null @@ -1,91 +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 -// } -// -// deinit { -// codeiumService?.terminate() -// } -// -// 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 { -// codeiumService?.terminate() -// } -//} -// diff --git a/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift b/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift deleted file mode 100644 index 0520ac70..00000000 --- a/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift +++ /dev/null @@ -1,96 +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 -// } -// -// deinit { -// if let gitHubCopilotService { -// Task { await gitHubCopilotService.terminate() } -// } -// } -// -// func createGitHubCopilotServiceIfNeeded() throws -> GitHubCopilotSuggestionServiceType { -// if let gitHubCopilotService { return gitHubCopilotService } -// let newService = try GitHubCopilotService(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 gitHubCopilotService?.terminate() -// } -//} -// From 8efa24d043169c46be5bf109245b8ba53635670a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 May 2024 23:32:43 +0800 Subject: [PATCH 55/90] Add appcast task to make file --- Makefile | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index c0fbddcf..6def12b9 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,20 @@ +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 ZIPNAME := $(ZIPNAME_BASE).$(if $(channel),$(channel).)$(if $(release),$(release),1).zip) + mkdir -p $(TMPDIR) + cp appcast.xml $(TMPDIR)/appcast.xml + cd "$(app)" && cd .. && zip -r "$(ZIPNAME)" "$(BUNDLENAME)" + cd "$(app)" && cd .. && cp "$(ZIPNAME)" $(TMPDIR)/ + -Core/.build/artifacts/sparkle/bin/generate_appcast $(TMPDIR) --download-url-prefix "$(GITHUB_URL)releases/download/$(tag)/" --full-release-notes-url "$(GITHUB_URL)releases/tag/$(tag)" $(if $(channel),--channel "$(channel)") + mv -f $(TMPDIR)/appcast.xml . + rm -rf $(TMPDIR) -.PHONY: setup setup-langchain +.PHONY: setup appcast \ No newline at end of file From 36449ca4740f17504d4ea2ad28996cb99d1acc20 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 May 2024 21:03:24 +0800 Subject: [PATCH 56/90] Support self signed certificates in GitHub Copilot --- .../AccountSettings/GitHubCopilotView.swift | 6 +++ Tool/Package.swift | 3 +- .../LanguageServer/GitHubCopilotService.swift | 29 ++++++++++++-- .../Resources/load-self-signed-cert.js | 40 +++++++++++++++++++ Tool/Sources/Preferences/Keys.swift | 4 ++ 5 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 Tool/Sources/GitHubCopilotService/Resources/load-self-signed-cert.js 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/Tool/Package.swift b/Tool/Package.swift index 7cfdd35f..553b8439 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -316,7 +316,8 @@ let package = Package( "BuiltinExtension", .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), - ] + ], + resources: [.copy("Resources/load-self-signed-cert.js")] ), .testTarget( name: "GitHubCopilotServiceTests", diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 71cc04fe..384dfc4b 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -42,11 +42,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 +112,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 +151,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 +169,7 @@ public class GitHubCopilotBaseService { path: "/usr/bin/env", arguments: [ nodePath.isEmpty ? "node" : nodePath, - agentJSURL.path, + indexJSURL.path, "--stdio", ], environment: [ 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/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index eec7d42e..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 From ad2f0cbd93de43adc554558f5891571067757415 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 May 2024 21:35:58 +0800 Subject: [PATCH 57/90] Bump GitHub Copilot language server to 1.32.0 --- .../LanguageServer/CopilotLocalProcessServer.swift | 7 +++++++ .../LanguageServer/GitHubCopilotInstallationManager.swift | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index e655a91d..40e7ac4f 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/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/LanguageServer/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift index f7013f08..069d2c0b 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/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() {} From 0b39625569052ffb8280193e57de8c75f8d03363 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 May 2024 21:41:30 +0800 Subject: [PATCH 58/90] Bump Codeium language server to 1.8.8 --- Tool/Sources/CodeiumService/CodeiumInstallationManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift index c6c2e4d0..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() {} From 1a740cecb9b8e174080fefb81e94708a1e4e4271 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 May 2024 21:48:26 +0800 Subject: [PATCH 59/90] Move code to helper --- EditorExtension/AcceptSuggestionCommand.swift | 29 ------------------ EditorExtension/Helpers.swift | 30 +++++++++++++++++++ 2 files changed, 30 insertions(+), 29 deletions(-) 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" +} From 103a916804114d22099ba8151a3cfe5734d1eae4 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 May 2024 21:51:54 +0800 Subject: [PATCH 60/90] Bring back toggle realtime suggestion command --- EditorExtension/SourceEditorExtension.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift index 71ec0d8b..aedf6bf3 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -18,6 +18,7 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { PromptToCodeCommand(), AcceptPromptToCodeCommand(), OpenChatCommand(), + ToggleRealtimeSuggestionsCommand(), ].map(makeCommandDefinition) } From ecc79e1e1141b1b5e67e0761efac0bb22ccb8ec2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 May 2024 21:52:24 +0800 Subject: [PATCH 61/90] Rename Real-time Suggestion command to Prepare for Real-time Suggestion --- Core/Sources/Service/RealtimeSuggestionController.swift | 2 +- EditorExtension/RealtimeSuggestionCommand.swift | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/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/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 From a4e42889229ddffa15b2d20002976f9f771604bb Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 May 2024 21:59:19 +0800 Subject: [PATCH 62/90] Add toast about the real-time suggestion state change --- Core/Sources/Service/XPCService.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index f155badd..1cffcc7a 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -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) } } From 82b970a5be515b65161e8a39149e19204338f95d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 May 2024 01:02:40 +0800 Subject: [PATCH 63/90] Add InlineCompletion request --- .../LanguageServer/GitHubCopilotRequest.swift | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index d9210485..86f303d9 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -241,6 +241,60 @@ 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: GitHubCopilotDoc + + struct Input: Codable { + var textDocument: _TextDocument; struct _TextDocument: Codable { + var uri: String + } + + 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(Input( + textDocument: .init(uri: doc.uri), + position: doc.position, + formattingOptions: .init(tabSize: doc.tabSize, insertSpaces: doc.insertSpaces), + context: .init(triggerKind: .invoked) + ))) ?? 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] From cbeaae6283c3e261c68b948a80cb29a252930a2f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 May 2024 01:03:27 +0800 Subject: [PATCH 64/90] Use InlineCompletion request instead of GetCompletionsCycling --- .../LanguageServer/GitHubCopilotService.swift | 67 +++++++++++++------ 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 384dfc4b..86062535 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/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, @@ -357,6 +358,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, public func getCompletions( fileURL: URL, content: String, + originalContent: String, cursorPosition: CursorPosition, tabSize: Int, indentSize: Int, @@ -384,30 +386,51 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, await localProcessServer?.cancelOngoingTasks() let task = Task { - let completions = try await server - .sendRequest(GitHubCopilotRequest.GetCompletionsCycling(doc: .init( - source: content, - tabSize: tabSize, - indentSize: indentSize, - insertSpaces: !usesTabsForIndentation, - path: fileURL.path, - uri: fileURL.path, - relativePath: relativePath, - languageId: languageId, - position: cursorPosition - ))) - .completions - .map { - let suggestion = CodeSuggestion( - id: $0.uuid, - text: $0.text, - position: $0.position, - range: $0.range - ) - return suggestion + // since when the language server is no longer using the passed in content to generate + // suggestions, therefore, 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. + try? await notifyChangeTextDocument(fileURL: fileURL, content: content) + defer { + // recover the content. + Task { + try? await notifyChangeTextDocument(fileURL: fileURL, content: originalContent) } + } try Task.checkCancellation() - return completions + do { + let completions = try await server + .sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init( + source: content, + tabSize: tabSize, + indentSize: indentSize, + insertSpaces: !usesTabsForIndentation, + path: fileURL.path, + uri: fileURL.path, + relativePath: relativePath, + languageId: languageId, + position: cursorPosition + ))) + .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 { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } } ongoingTasks.insert(task) From f8a241335ca9760c42d4cd4cad5bb57225146dd3 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 May 2024 01:03:44 +0800 Subject: [PATCH 65/90] Add originalContent to SuggestionRequest --- Pro | 2 +- .../BuiltinExtensionSuggestionServiceProvider.swift | 4 +++- .../Services/GitHubCopilotSuggestionService.swift | 1 + Tool/Sources/SuggestionProvider/SuggestionProvider.swift | 3 +++ .../Workspace+SuggestionService.swift | 4 +++- 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Pro b/Pro index 2da51b1c..1536c906 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 2da51b1cd730b3a51781a0e827ae87e509e653f7 +Subproject commit 1536c906cd3c64eeaf29f3eba0417bcde35296e9 diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift index 6437f137..bc8f5ae1 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift @@ -54,7 +54,8 @@ public final class BuiltinExtensionSuggestionServiceProvider< language: .init( rawValue: languageIdentifierFromFileURL(request.fileURL).rawValue ) ?? .plaintext, - content: request.content, + content: request.content, + originalContent: request.originalContent, cursorPosition: .init( line: request.cursorPosition.line, character: request.cursorPosition.character @@ -109,6 +110,7 @@ extension SuggestionProvider.SuggestionRequest { language: .init(rawValue: languageIdentifierFromFileURL(fileURL).rawValue) ?? .plaintext, content: content, + originalContent: originalContent, cursorPosition: .init( line: cursorPosition.line, character: cursorPosition.character diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift index 37bc00b5..7d7a5c3f 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift @@ -26,6 +26,7 @@ public final class GitHubCopilotSuggestionService: SuggestionServiceType { return try await service.getCompletions( fileURL: request.fileURL, content: request.content, + originalContent: request.originalContent, cursorPosition: .init( line: request.cursorPosition.line, character: request.cursorPosition.character diff --git a/Tool/Sources/SuggestionProvider/SuggestionProvider.swift b/Tool/Sources/SuggestionProvider/SuggestionProvider.swift index 5b529cae..623e3ad5 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionProvider.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionProvider.swift @@ -10,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 @@ -22,6 +23,7 @@ public struct SuggestionRequest { fileURL: URL, relativePath: String, content: String, + originalContent: String, lines: [String], cursorPosition: CursorPosition, cursorOffset: Int, @@ -33,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 diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift index 81af150a..7ffe7835 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift @@ -54,11 +54,13 @@ 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, cursorOffset: editor.cursorOffset, From 961d9f61b21cc3b29a3a358475b46caab4979ba4 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 May 2024 01:41:43 +0800 Subject: [PATCH 66/90] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 1536c906..9fc4c31e 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 1536c906cd3c64eeaf29f3eba0417bcde35296e9 +Subproject commit 9fc4c31e3380f33e30d192f0deab512ba66e63b5 From 22c96248b6291f41b671642afd4a42dfe345f7ab Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 May 2024 02:27:06 +0800 Subject: [PATCH 67/90] Update --- CommunicationBridge/ServiceDelegate.swift | 1 + Tool/Sources/XPCShared/XPCExtensionService.swift | 2 +- Tool/Sources/XPCShared/XPCService.swift | 10 ++++++++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift index f7664357..b56f8645 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 diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index 3907714d..64aacb1b 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -203,7 +203,7 @@ public class XPCExtensionService { extension XPCExtensionService: XPCServiceDelegate { public func connectionDidInterrupt() async { - // do nothing + service = nil } public func connectionDidInvalidate() async { 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() } From ccfa37665c947edbe0ab849bb88093a67cae61e3 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 May 2024 04:45:27 +0800 Subject: [PATCH 68/90] Fix GitHub Copilot suggestion generation --- .../LanguageServer/GitHubCopilotRequest.swift | 10 +- .../LanguageServer/GitHubCopilotService.swift | 106 ++++++++++-------- 2 files changed, 65 insertions(+), 51 deletions(-) diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 86f303d9..2b1c107d 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -264,11 +264,12 @@ enum GitHubCopilotRequest { } } - var doc: GitHubCopilotDoc + var doc: Input struct Input: Codable { var textDocument: _TextDocument; struct _TextDocument: Codable { var uri: String + var version: Int } var position: Position @@ -284,12 +285,7 @@ enum GitHubCopilotRequest { } var request: ClientRequest { - let data = (try? JSONEncoder().encode(Input( - textDocument: .init(uri: doc.uri), - position: doc.position, - formattingOptions: .init(tabSize: doc.tabSize, insertSpaces: doc.insertSpaces), - context: .init(triggerKind: .invoked) - ))) ?? Data() + let data = (try? JSONEncoder().encode(doc)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) return .custom("textDocument/inlineCompletion", dict) } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 86062535..7b7fca08 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -28,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 @@ -364,54 +364,21 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, 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( - source: content, - tabSize: tabSize, - indentSize: indentSize, - insertSpaces: !usesTabsForIndentation, - path: fileURL.path, - uri: fileURL.path, - relativePath: relativePath, - languageId: languageId, - position: cursorPosition + textDocument: .init(uri: fileURL.path, version: 1), + position: cursorPosition, + formattingOptions: .init( + tabSize: tabSize, + insertSpaces: !usesTabsForIndentation + ), + context: .init(triggerKind: .invoked) ))) .items .compactMap { (item: _) -> CodeSuggestion? in @@ -427,11 +394,56 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, try Task.checkCancellation() return completions } catch let error as ServerError { + switch error { + case .serverError: + if maxTry <= 0 { break } + Logger.gitHubCopilot.error( + "Try getting suggestions again: \(GitHubCopilotError.languageServerError(error).localizedDescription)" + ) + try await Task.sleep(nanoseconds: 400_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() + } + throw error + } catch { + await recoverContent() + throw error + } + } ongoingTasks.insert(task) @@ -440,6 +452,8 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, @GitHubCopilotSuggestionActor public func cancelRequest() async { + ongoingTasks.forEach { $0.cancel() } + ongoingTasks.removeAll() await localProcessServer?.cancelOngoingTasks() } @@ -480,14 +494,18 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, } @GitHubCopilotSuggestionActor - public func notifyChangeTextDocument(fileURL: URL, content: String) async throws { + 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, From 4b6e846bd8daeb155977f5e43b289778c45b65cf Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 May 2024 04:45:42 +0800 Subject: [PATCH 69/90] Fix that the change text notification is not called --- Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index d948d22c..6542bd51 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -93,7 +93,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { do { guard let content else { return } guard let service = await serviceLocator.getService(from: workspace) else { return } - try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) + try await service.notifyChangeTextDocument(fileURL: documentURL, content: content, version: 0) } catch { Logger.gitHubCopilot.error(error.localizedDescription) } From 6c3d8d76a7195c06e25ca9ef32aa0a463ddb1cdc Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 May 2024 04:45:48 +0800 Subject: [PATCH 70/90] Add version --- Tool/Sources/Workspace/Filespace.swift | 8 ++++++++ Tool/Sources/Workspace/Workspace.swift | 1 + 2 files changed, 9 insertions(+) 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) From 4dd291ea89b74ead4f9bd287fd42ae9f9a7b5b60 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 May 2024 04:46:01 +0800 Subject: [PATCH 71/90] Adjust file url for special tabs --- .../Sources/XcodeInspector/XcodeWindowInspector.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 + } } From 86c076b131c8c8b0a8aa6bc664bbcbd8d65a4a43 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 May 2024 04:50:34 +0800 Subject: [PATCH 72/90] Update retry interval --- .../LanguageServer/GitHubCopilotService.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 7b7fca08..04bb977d 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -396,11 +396,14 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, } 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)" ) - try await Task.sleep(nanoseconds: 400_000_000) + try await Task.sleep(nanoseconds: 200_000_000) return try await sendRequest(maxTry: maxTry - 1) default: break From 373e818f33e42403daa101f82e000cce6433130d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 May 2024 16:31:54 +0800 Subject: [PATCH 73/90] Reopen document if it's not found in the language server --- .../GitHubCopilotExtension.swift | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index 6542bd51..a9083fec 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -1,6 +1,7 @@ import BuiltinExtension import CopilotForXcodeKit import Foundation +import LanguageServerProtocol import Logger import Preferences import Workspace @@ -9,7 +10,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .gitHubCopilot } public let suggestionService: GitHubCopilotSuggestionService? - + private var extensionUsage = ExtensionUsage( isSuggestionServiceInUse: false, isChatServiceInUse: false @@ -90,10 +91,23 @@ public final class GitHubCopilotExtension: BuiltinExtension { { return } Task { + guard let content else { return } + guard let service = await serviceLocator.getService(from: workspace) else { return } do { - guard let content else { return } - guard let service = await serviceLocator.getService(from: workspace) else { return } - try await service.notifyChangeTextDocument(fileURL: documentURL, content: content, version: 0) + 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) } From 028ed92490657d15e3acf7ac6cdfd109fa655923 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 May 2024 16:32:06 +0800 Subject: [PATCH 74/90] Fix incorrect method call --- Tool/Sources/CodeiumService/CodeiumExtension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/CodeiumService/CodeiumExtension.swift b/Tool/Sources/CodeiumService/CodeiumExtension.swift index d2c113ef..ee3f18f6 100644 --- a/Tool/Sources/CodeiumService/CodeiumExtension.swift +++ b/Tool/Sources/CodeiumService/CodeiumExtension.swift @@ -85,7 +85,7 @@ public final class CodeiumExtension: BuiltinExtension { do { guard let content else { return } guard let service = await serviceLocator.getService(from: workspace) else { return } - try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) + try await service.notifyChangeTextDocument(fileURL: documentURL, content: content) } catch { Logger.gitHubCopilot.error(error.localizedDescription) } From cbb02218b90c9237721e7d58e549cc54b35ce6ce Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 25 May 2024 02:05:55 +0800 Subject: [PATCH 75/90] Add evaluated content changed event to SourceEditor --- .../Sources/XcodeInspector/SourceEditor.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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) + } + } } } From 3257f5f4485b60d103d783b902359864791f43ef Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 25 May 2024 02:07:00 +0800 Subject: [PATCH 76/90] Put syntax highlighting code into a enum --- .../ChatGPTChatTab/CodeBlockHighlighter.swift | 2 +- Pro | 2 +- .../Experiment/NewCodeBlock.swift | 2 +- .../SyntaxHighlighting.swift | 270 +++++++++--------- 4 files changed, 139 insertions(+), 137 deletions(-) diff --git a/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift b/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift index 714a5eb2..cfbde1c2 100644 --- a/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift +++ b/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift @@ -45,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", diff --git a/Pro b/Pro index 9fc4c31e..b915eb29 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 9fc4c31e3380f33e30d192f0deab512ba66e63b5 +Subproject commit b915eb299fd44668902a3125c257af3e8f302d72 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) } From 3bc2c86acfb06af6e5f7a446d39b717601935593 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 25 May 2024 02:07:47 +0800 Subject: [PATCH 77/90] Add CursorPositionTracker --- .../CursorPositionTracker.swift | 53 +++++++++++++++++++ .../WidgetWindowsController.swift | 5 +- 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 Core/Sources/SuggestionWidget/CursorPositionTracker.swift 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/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index b150e387..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. @@ -670,7 +671,7 @@ public final class WidgetWindows { state: \.sharedPanelState, action: \.sharedPanel ) - ) + ).environment(cursorPositionTracker) ) it.setIsVisible(true) it.canBecomeKeyChecker = { [store] in @@ -704,7 +705,7 @@ public final class WidgetWindows { state: \.suggestionPanelState, action: \.suggestionPanel ) - ) + ).environment(cursorPositionTracker) ) it.canBecomeKeyChecker = { false } it.setIsVisible(true) From 2a4c5c60be79036154ed164489c26778db33135b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 25 May 2024 02:08:10 +0800 Subject: [PATCH 78/90] Update CodeSuggestionProvider to be a Perceptible object --- .../Providers/CodeSuggestionProvider.swift | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) 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() } + + } From ffaa1832e6b905aaf32cbf4a0c1d564ee33289b1 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 25 May 2024 02:08:35 +0800 Subject: [PATCH 79/90] Update suggestion panel to highlight async and dim typed content --- .../CodeBlockSuggestionPanel.swift | 252 ++++++++++-------- Tool/Package.swift | 6 +- .../SharedUIComponents/AsyncCodeBlock.swift | 242 +++++++++++++++++ 3 files changed, 381 insertions(+), 119 deletions(-) create mode 100644 Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift 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/Tool/Package.swift b/Tool/Package.swift index 553b8439..36ecdb15 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -207,7 +207,7 @@ let package = Package( dependencies: [ "SuggestionModel", "Workspace", - .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit") + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] ), @@ -217,7 +217,9 @@ let package = Package( "Highlightr", "Preferences", "SuggestionModel", + "DebounceFunction", .product(name: "STTextView", package: "STTextView"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), .testTarget(name: "SharedUIComponentsTests", dependencies: ["SharedUIComponents"]), @@ -248,7 +250,7 @@ let package = Package( "Workspace", "SuggestionProvider", "XPCShared", - "BuiltinExtension" + "BuiltinExtension", ] ), 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 + ) + } +} + From 06cb36172cec60ee1d23d570f8ca5e0090f3df3c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 25 May 2024 02:14:40 +0800 Subject: [PATCH 80/90] Reference the function from CodeHighlighting --- Tool/Sources/SharedUIComponents/CodeBlock.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 9e495912ffe61b96ce6d41095c5d9942c3ed0825 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 25 May 2024 02:40:57 +0800 Subject: [PATCH 81/90] Update to generate non-stream response from stream reponse --- .../APIs/OpenAIChatCompletionsService.swift | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index 6a8ffe59..00b79eab 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -278,34 +278,38 @@ 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") - - 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 { - 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 + 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) { From 91621545e921d72e86041ffee93881f2329728b1 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 25 May 2024 03:12:41 +0800 Subject: [PATCH 82/90] Support enforcing message order --- .../ChatModelManagement/ChatModelEdit.swift | 9 +- .../ChatModelEditView.swift | 4 + Tool/Sources/AIModel/ChatModel.swift | 19 ++- .../APIs/OpenAIChatCompletionsService.swift | 118 +++++++++++++----- 4 files changed, 118 insertions(+), 32 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index 8aeed02f..7450105e 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -28,6 +28,7 @@ struct ChatModelEdit { var suggestedMaxTokens: Int? var apiKeySelection: APIKeySelection.State = .init() var baseURLSelection: BaseURLSelection.State = .init() + var enforceMessageOrder: Bool = false } enum Action: Equatable, BindableAction { @@ -197,11 +198,12 @@ 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, @@ -216,7 +218,8 @@ extension ChatModel { apiKeyName: info.apiKeyName, apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName]) ), - baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL) + 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 fecb5c9f..1eee9725 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -308,6 +308,10 @@ struct ChatModelEditView: View { MaxTokensTextField(store: store) SupportsFunctionCallingToggle(store: store) + + Toggle(isOn: $store.enforceMessageOrder) { + Text("Enforce message order to be user/assistant alternated") + } } } } 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/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index 00b79eab..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 } @@ -468,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 From a0954150c160dbef26f139cf924b92ab957848d4 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 25 May 2024 23:32:20 +0800 Subject: [PATCH 83/90] Disable transparent background of chat panel --- Core/Sources/ChatGPTChatTab/ChatPanel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index c842504e..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) } } } From cb33d2a05fa346174c15c9e5bd21f5f00c1d4e4e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 26 May 2024 00:14:34 +0800 Subject: [PATCH 84/90] Adjust actor conformance to avoid warnings --- .../XPCShared/XPCCommunicationBridge.swift | 6 +++--- .../XPCShared/XPCExtensionService.swift | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift index 6e3b7eb2..df57f853 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) @@ -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 64aacb1b..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 } @@ -203,24 +205,30 @@ public class XPCExtensionService { extension XPCExtensionService: XPCServiceDelegate { public func connectionDidInterrupt() async { - service = nil + 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) From 35199a3263ad2beb74c74c67113327c7f187e4d8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 26 May 2024 00:14:44 +0800 Subject: [PATCH 85/90] Terminate the app in the correct way --- ExtensionService/AppDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 2190bbb2..0c41c772 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -53,7 +53,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { @objc func quit() { Task { @MainActor in await service.prepareForExit() - exit(0) + NSApp.terminate(self) } } From b282d955639b1b9b8d35add247cbda560d043102 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 26 May 2024 14:48:13 +0800 Subject: [PATCH 86/90] Fix that can't bring the extension service app back to live when it quits --- CommunicationBridge/ServiceDelegate.swift | 21 +++++++++++++++++-- Core/Sources/HostApp/General.swift | 4 +++- ExtensionService/AppDelegate.swift | 1 + ExtensionService/XPCController.swift | 8 ++++++- .../XPCShared/XPCCommunicationBridge.swift | 2 +- 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift index b56f8645..8a064aef 100644 --- a/CommunicationBridge/ServiceDelegate.swift +++ b/CommunicationBridge/ServiceDelegate.swift @@ -117,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 } @@ -126,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/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index 1404de3b..b50cd876 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -24,6 +24,8 @@ struct General { } @Dependency(\.toast) var toast + + struct ReloadStatusCancellableId: Hashable {} var body: some ReducerOf { Reduce { state, action in @@ -91,7 +93,7 @@ struct General { toast(error.localizedDescription, .error) await send(.failedReloading) } - } + }.cancellable(id: ReloadStatusCancellableId(), cancelInFlight: true) case let .finishReloading(version, granted): state.xpcServiceVersion = version diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 0c41c772..3f5a8c3f 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -53,6 +53,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { @objc func quit() { Task { @MainActor in await service.prepareForExit() + 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/Tool/Sources/XPCShared/XPCCommunicationBridge.swift b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift index df57f853..d0a20b32 100644 --- a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift +++ b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift @@ -34,7 +34,7 @@ public class XPCCommunicationBridge { self.logger = logger } - public func setDelegate(_ delegate: XPCServiceDelegate) { + public func setDelegate(_ delegate: XPCServiceDelegate?) { service.delegate = delegate } From fed9f3790c954aa62b7527e849079c468f9bf286 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 May 2024 22:02:29 +0800 Subject: [PATCH 87/90] Bump build number --- Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index aad26f66..5a72de69 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ APP_VERSION = 0.33.1 -APP_BUILD = 378 +APP_BUILD = 382 From b1517b9faf01692270b99e576c19f021b8b27366 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 27 May 2024 15:33:02 +0800 Subject: [PATCH 88/90] Update appcast.xml --- appcast.xml | 50 +++++++------------------------------------------- 1 file changed, 7 insertions(+), 43 deletions(-) diff --git a/appcast.xml b/appcast.xml index 592407e0..ed01aa93 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 15:19:54 +0800 + https://github.com/intitni/CopilotForXcode/releases/tag/0.33.1 + 382 0.33.1 12.0 - + 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 From 66c07cf15410d833f3c1065f3feedadbfb8a46f8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 27 May 2024 16:36:35 +0800 Subject: [PATCH 89/90] Fix make file --- Makefile | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 6def12b9..b16f8417 100644 --- a/Makefile +++ b/Makefile @@ -8,13 +8,18 @@ setup: appcast: $(eval TMPDIR := ~/Library/Caches/CopilotForXcodeRelease/$(shell uuidgen)) $(eval BUNDLENAME := $(shell basename "$(app)")) - $(eval ZIPNAME := $(ZIPNAME_BASE).$(if $(channel),$(channel).)$(if $(release),$(release),1).zip) + $(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 "$(app)" && cd .. && zip -r "$(ZIPNAME)" "$(BUNDLENAME)" - cd "$(app)" && cd .. && cp "$(ZIPNAME)" $(TMPDIR)/ - -Core/.build/artifacts/sparkle/bin/generate_appcast $(TMPDIR) --download-url-prefix "$(GITHUB_URL)releases/download/$(tag)/" --full-release-notes-url "$(GITHUB_URL)releases/tag/$(tag)" $(if $(channel),--channel "$(channel)") + 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 appcast \ No newline at end of file From 93640be877634435406f0d62d93d7bc5ffaf9cff Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 27 May 2024 16:37:01 +0800 Subject: [PATCH 90/90] Update appcast.xml --- appcast.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/appcast.xml b/appcast.xml index ed01aa93..9aa6f8f5 100644 --- a/appcast.xml +++ b/appcast.xml @@ -4,12 +4,12 @@ Copilot for Xcode 0.33.1 - Mon, 27 May 2024 15:19:54 +0800 - https://github.com/intitni/CopilotForXcode/releases/tag/0.33.1 + 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