diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 13ab8477..ad49af50 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -43,6 +43,7 @@ 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 */; }; + C8DD9CB12BC673F80036641C /* CloseIdleTabsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */; }; C8F1032B2A7A39D700D28F4F /* launchAgent.plist in Copy Launch Agent */ = {isa = PBXBuildFile; fileRef = C8F103292A7A365000D28F4F /* launchAgent.plist */; }; /* End PBXBuildFile section */ @@ -197,6 +198,7 @@ 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 = ""; }; + 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 */ @@ -264,6 +266,7 @@ C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */, C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */, C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */, + C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */, C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */, C81458972939EFDC00135263 /* Info.plist */, C81458982939EFDC00135263 /* EditorExtension.entitlements */, @@ -448,7 +451,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1420; + LastSwiftUpdateCheck = 1520; LastUpgradeCheck = 1410; TargetAttributes = { C814588B2939EFDC00135263 = { @@ -525,6 +528,7 @@ files = ( C8DCF00029CE11D500FDDDD7 /* ChatWithSelection.swift in Sources */, C81458942939EFDC00135263 /* SourceEditorExtension.swift in Sources */, + C8DD9CB12BC673F80036641C /* CloseIdleTabsCommand.swift in Sources */, C8758E7029F04BFF00D29C1C /* CustomCommand.swift in Sources */, C8758E7229F04CF100D29C1C /* SeparatorCommand.swift in Sources */, C861A6A329E5503F005C41A3 /* PromptToCodeCommand.swift in Sources */, diff --git a/Core/Package.swift b/Core/Package.swift index 02e7d479..1e1a1fcd 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -89,7 +89,7 @@ let package = Package( ], dependencies: [ .package(path: "../Tool"), - .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"), + .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.1.0"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), @@ -113,7 +113,9 @@ let package = Package( .product(name: "SuggestionModel", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "Preferences", package: "Tool"), - ] + ].pro([ + "ProClient", + ]) ), .target( name: "Service", diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index d66c0d25..db080dac 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -533,19 +533,22 @@ struct ChatPanel_EmptyChat_Preview: PreviewProvider { struct ChatCodeSyntaxHighlighter: CodeSyntaxHighlighter { let brightMode: Bool - let fontSize: Double + let font: NSFont + let colorChange: Color? - init(brightMode: Bool, fontSize: Double) { + init(brightMode: Bool, font: NSFont, colorChange: Color?) { self.brightMode = brightMode - self.fontSize = fontSize + self.font = font + self.colorChange = colorChange } func highlightCode(_ content: String, language: String?) -> Text { let content = highlightedCodeBlock( code: content, language: language ?? "", + scenario: "chat", brightMode: brightMode, - fontSize: fontSize + font: font ) return Text(AttributedString(content)) } diff --git a/Core/Sources/ChatGPTChatTab/Styles.swift b/Core/Sources/ChatGPTChatTab/Styles.swift index 3f8d40c4..e654b72e 100644 --- a/Core/Sources/ChatGPTChatTab/Styles.swift +++ b/Core/Sources/ChatGPTChatTab/Styles.swift @@ -46,13 +46,17 @@ extension View { .padding(.top, 14) } - func codeBlockStyle(_ configuration: CodeBlockConfiguration) -> some View { - background(Color(nsColor: .textBackgroundColor).opacity(0.7)) + func codeBlockStyle( + _ configuration: CodeBlockConfiguration, + backgroundColor: Color, + labelColor: Color + ) -> some View { + background(backgroundColor) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay(alignment: .top) { HStack(alignment: .center) { Text(configuration.language ?? "code") - .foregroundStyle(.tertiary) + .foregroundStyle(labelColor) .font(.callout) .padding(.leading, 8) .lineLimit(1) @@ -63,12 +67,76 @@ extension View { } } } + .overlay { + RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.05), lineWidth: 1) + } .markdownMargin(top: 4, bottom: 16) } } +struct ThemedMarkdownText: View { + @AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme + @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight + @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight + @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark + @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark + @AppStorage(\.chatFontSize) var chatFontSize + @AppStorage(\.chatCodeFont) var chatCodeFont + @Environment(\.colorScheme) var colorScheme + + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + Markdown(text) + .textSelection(.enabled) + .markdownTheme(.custom( + fontSize: chatFontSize, + codeBlockBackgroundColor: { + if syncCodeHighlightTheme { + if colorScheme == .light, let color = codeBackgroundColorLight.value { + return color.swiftUIColor + } else if let color = codeBackgroundColorDark.value { + return color.swiftUIColor + } + } + + return Color(nsColor: .textBackgroundColor).opacity(0.7) + }(), + codeBlockLabelColor: { + if syncCodeHighlightTheme { + if colorScheme == .light, + let color = codeForegroundColorLight.value + { + return color.swiftUIColor.opacity(0.5) + } else if let color = codeForegroundColorDark.value { + return color.swiftUIColor.opacity(0.5) + } + } + return Color.secondary.opacity(0.7) + }() + )) + .markdownCodeSyntaxHighlighter( + ChatCodeSyntaxHighlighter( + brightMode: colorScheme != .dark, + font: chatCodeFont.value.nsFont, + colorChange: colorScheme == .dark + ? codeForegroundColorDark.value?.swiftUIColor + : codeForegroundColorLight.value?.swiftUIColor + ) + ) + } +} + extension MarkdownUI.Theme { - static func custom(fontSize: Double) -> MarkdownUI.Theme { + static func custom( + fontSize: Double, + codeBlockBackgroundColor: Color, + codeBlockLabelColor: Color + ) -> MarkdownUI.Theme { .gitHub.text { ForegroundColor(.primary) BackgroundColor(Color.clear) @@ -80,14 +148,22 @@ extension MarkdownUI.Theme { if wrapCode { configuration.label .codeBlockLabelStyle() - .codeBlockStyle(configuration) + .codeBlockStyle( + configuration, + backgroundColor: codeBlockBackgroundColor, + labelColor: codeBlockLabelColor + ) } else { ScrollView(.horizontal) { configuration.label .codeBlockLabelStyle() } .workaroundForVerticalScrollingBugInMacOS() - .codeBlockStyle(configuration) + .codeBlockStyle( + configuration, + backgroundColor: codeBlockBackgroundColor, + labelColor: codeBlockLabelColor + ) } } } @@ -109,14 +185,22 @@ extension MarkdownUI.Theme { if wrapCode { configuration.label .codeBlockLabelStyle() - .codeBlockStyle(configuration) + .codeBlockStyle( + configuration, + backgroundColor: Color(nsColor: .textBackgroundColor).opacity(0.7), + labelColor: Color.secondary.opacity(0.7) + ) } else { ScrollView(.horizontal) { configuration.label .codeBlockLabelStyle() } .workaroundForVerticalScrollingBugInMacOS() - .codeBlockStyle(configuration) + .codeBlockStyle( + configuration, + backgroundColor: Color(nsColor: .textBackgroundColor).opacity(0.7), + labelColor: Color.secondary.opacity(0.7) + ) } } .table { configuration in diff --git a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift index 67457bf1..5d202678 100644 --- a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift +++ b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift @@ -11,8 +11,6 @@ struct BotMessage: View { let references: [DisplayedChatMessage.Reference] let chat: StoreOf @Environment(\.colorScheme) var colorScheme - @AppStorage(\.chatFontSize) var chatFontSize - @AppStorage(\.chatCodeFontSize) var chatCodeFontSize @State var isReferencesPresented = false @State var isReferencesHovered = false @@ -45,15 +43,7 @@ struct BotMessage: View { } } - Markdown(text) - .textSelection(.enabled) - .markdownTheme(.custom(fontSize: chatFontSize)) - .markdownCodeSyntaxHighlighter( - ChatCodeSyntaxHighlighter( - brightMode: colorScheme != .dark, - fontSize: chatCodeFontSize - ) - ) + ThemedMarkdownText(text) } .frame(alignment: .trailing) .padding() diff --git a/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift b/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift index c470cc4e..f27e3ed4 100644 --- a/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift +++ b/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift @@ -9,19 +9,9 @@ struct UserMessage: View { let text: String let chat: StoreOf @Environment(\.colorScheme) var colorScheme - @AppStorage(\.chatFontSize) var chatFontSize - @AppStorage(\.chatCodeFontSize) var chatCodeFontSize var body: some View { - Markdown(text) - .textSelection(.enabled) - .markdownTheme(.custom(fontSize: chatFontSize)) - .markdownCodeSyntaxHighlighter( - ChatCodeSyntaxHighlighter( - brightMode: colorScheme != .dark, - fontSize: chatCodeFontSize - ) - ) + ThemedMarkdownText(text) .frame(alignment: .leading) .padding() .background { diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index 52964392..62eec368 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -1,11 +1,11 @@ import AIModel -import Toast import ComposableArchitecture import Dependencies import Keychain import OpenAIService import Preferences import SwiftUI +import Toast struct ChatModelEdit: ReducerProtocol { struct State: Equatable, Identifiable { @@ -16,6 +16,7 @@ struct ChatModelEdit: ReducerProtocol { @BindingState var supportsFunctionCalling: Bool = true @BindingState var modelName: String = "" @BindingState var ollamaKeepAlive: String = "" + @BindingState var apiVersion: String = "" var apiKeyName: String { apiKeySelection.apiKeyName } var baseURL: String { baseURLSelection.baseURL } var isFullURL: Bool { baseURLSelection.isFullURL } @@ -47,6 +48,7 @@ struct ChatModelEdit: ReducerProtocol { toast($0, $1, "ChatModelEdit") } } + @Dependency(\.apiKeyKeychain) var keychain var body: some ReducerProtocol { @@ -77,19 +79,7 @@ struct ChatModelEdit: ReducerProtocol { case .testButtonClicked: guard !state.isTesting else { return .none } state.isTesting = true - let model = ChatModel( - id: state.id, - name: state.name, - format: state.format, - info: .init( - apiKeyName: state.apiKeyName, - baseURL: state.baseURL, - isFullURL: state.isFullURL, - maxTokens: state.maxTokens, - supportsFunctionCalling: state.supportsFunctionCalling, - modelName: state.modelName - ) - ) + let model = ChatModel(state: state) return .run { send in do { let service = ChatGPTService( @@ -194,6 +184,7 @@ extension ChatModelEdit.State { 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]) @@ -223,7 +214,8 @@ extension ChatModel { } }(), modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines), - ollamaInfo: .init(keepAlive: state.ollamaKeepAlive) + ollamaInfo: .init(keepAlive: state.ollamaKeepAlive), + googleGenerativeAIInfo: .init(apiVersion: state.apiVersion) ) ) } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index fe03c453..2fe21715 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -328,6 +328,10 @@ struct ChatModelEditView: View { @ViewBuilder var googleAI: some View { + baseURLTextField(prompt: Text("https://generativelanguage.googleapis.com")) { + Text("/v1") + } + apiKeyNamePicker WithViewStore( @@ -353,6 +357,10 @@ struct ChatModelEditView: View { } maxTokensTextField + + WithViewStore(store, removeDuplicates: { $0.apiVersion == $1.apiVersion }) { viewStore in + TextField("API Version", text: viewStore.$apiVersion, prompt: Text("v1")) + } } @ViewBuilder @@ -392,7 +400,7 @@ struct ChatModelEditView: View { baseURLTextField(prompt: Text("https://api.anthropic.com")) { Text("/v1/messages") } - + apiKeyNamePicker WithViewStore( @@ -421,7 +429,7 @@ struct ChatModelEditView: View { .frame(width: 20) } } - + maxTokensTextField VStack(alignment: .leading, spacing: 8) { diff --git a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift index aaeb7af4..7683ebf2 100644 --- a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift @@ -13,7 +13,7 @@ struct ChatSettingsView: View { @AppStorage(\.chatGPTTemperature) var chatGPTTemperature @AppStorage(\.chatGPTMaxMessageCount) var chatGPTMaxMessageCount @AppStorage(\.chatFontSize) var chatFontSize - @AppStorage(\.chatCodeFontSize) var chatCodeFontSize + @AppStorage(\.chatCodeFont) var chatCodeFont @AppStorage(\.defaultChatFeatureChatModelId) var defaultChatFeatureChatModelId @AppStorage(\.defaultChatSystemPrompt) var defaultChatSystemPrompt @@ -150,22 +150,19 @@ struct ChatSettingsView: View { Text("pt") } - HStack { - TextField(text: .init(get: { - "\(Int(settings.chatCodeFontSize))" - }, set: { - settings.chatCodeFontSize = Double(Int($0) ?? 0) - })) { - Text("Font size of code block") - } - .textFieldStyle(.roundedBorder) - - Text("pt") + FontPicker(font: $settings.chatCodeFont) { + Text("Font of code") } Toggle(isOn: $settings.wrapCodeInCodeBlock) { Text("Wrap code in code block") } + + #if canImport(ProHostApp) + + CodeHighlightThemePicker(scenario: .chat) + + #endif } } diff --git a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift index 6f33d599..630a9bba 100644 --- a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift @@ -10,8 +10,8 @@ struct PromptToCodeSettingsView: View { final class Settings: ObservableObject { @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) var hideCommonPrecedingSpaces - @AppStorage(\.promptToCodeCodeFontSize) - var fontSize + @AppStorage(\.promptToCodeCodeFont) + var font @AppStorage(\.promptToCodeGenerateDescription) var promptToCodeGenerateDescription @AppStorage(\.promptToCodeGenerateDescriptionInUserPreferredLanguage) @@ -91,17 +91,14 @@ struct PromptToCodeSettingsView: View { Text("Hide Common Preceding Spaces") } - HStack { - TextField(text: .init(get: { - "\(Int(settings.fontSize))" - }, set: { - settings.fontSize = Double(Int($0) ?? 0) - })) { - Text("Font size of suggestion code") - } - .textFieldStyle(.roundedBorder) + #if canImport(ProHostApp) - Text("pt") + CodeHighlightThemePicker(scenario: .promptToCode) + + #endif + + FontPicker(font: $settings.font) { + Text("Font") } } diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift index 17761adb..02c31e54 100644 --- a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift @@ -46,8 +46,8 @@ struct SuggestionSettingsView: View { var suggestionFeatureEnabledProjectList @AppStorage(\.hideCommonPrecedingSpacesInSuggestion) var hideCommonPrecedingSpacesInSuggestion - @AppStorage(\.suggestionCodeFontSize) - var suggestionCodeFontSize + @AppStorage(\.suggestionCodeFont) + var font @AppStorage(\.suggestionFeatureProvider) var suggestionFeatureProvider @AppStorage(\.suggestionDisplayCompactMode) @@ -187,7 +187,7 @@ struct SuggestionSettingsView: View { Text("Accept Suggestion with Tab") } } - + Toggle(isOn: $settings.dismissSuggestionWithEsc) { Text("Dismiss Suggestion with ESC") } @@ -247,17 +247,14 @@ struct SuggestionSettingsView: View { Text("Hide Common Preceding Spaces") } - HStack { - TextField(text: .init(get: { - "\(Int(settings.suggestionCodeFontSize))" - }, set: { - settings.suggestionCodeFontSize = Double(Int($0) ?? 0) - })) { - Text("Font size of suggestion code") - } - .textFieldStyle(.roundedBorder) + #if canImport(ProHostApp) + + CodeHighlightThemePicker(scenario: .suggestion) + + #endif - Text("pt") + FontPicker(font: $settings.font) { + Text("Font") } } } diff --git a/Core/Sources/HostApp/FeatureSettings/TerminalSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/TerminalSettingsView.swift new file mode 100644 index 00000000..43a8a539 --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettings/TerminalSettingsView.swift @@ -0,0 +1,27 @@ +import Preferences +import SharedUIComponents +import SwiftUI + +#if canImport(ProHostApp) +import ProHostApp +#endif + +struct TerminalSettingsView: View { + class Settings: ObservableObject { + @AppStorage(\.terminalFont) var terminalFont + init() {} + } + + @StateObject var settings = Settings() + + var body: some View { + ScrollView { + Form { + FontPicker(font: $settings.terminalFont) { + Text("Font of code") + } + } + } + + } +} diff --git a/Core/Sources/HostApp/FeatureSettings/XcodeSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/XcodeSettingsView.swift new file mode 100644 index 00000000..198aae19 --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettings/XcodeSettingsView.swift @@ -0,0 +1,20 @@ +import Foundation +import SharedUIComponents +import SwiftUI + +#if canImport(ProHostApp) +import ProHostApp +#endif + +struct XcodeSettingsView: View { + var body: some View { + VStack { + #if canImport(ProHostApp) + CloseXcodeIdleTabsSettingsView() + #endif + + EmptyView() + } + } +} + diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift index cc8616c4..cd5683f7 100644 --- a/Core/Sources/HostApp/FeatureSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettingsView.swift @@ -34,6 +34,28 @@ struct FeatureSettingsView: View { subtitle: "Write code with natural language", image: "paintbrush" ) + + ScrollView { + XcodeSettingsView().padding() + } + .sidebarItem( + tag: 3, + title: "Xcode", + subtitle: "Xcode related features", + image: "app" + ) + +// #if canImport(ProHostApp) +// ScrollView { +// TerminalSettingsView().padding() +// } +// .sidebarItem( +// tag: 3, +// title: "Terminal", +// subtitle: "Terminal chat tab", +// image: "terminal" +// ) +// #endif } } } diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 4d07e62d..27965c7a 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -43,14 +43,6 @@ public actor RealtimeSuggestionController { } private func handleFocusElementChange(_ sourceEditor: SourceEditor) { - Task { // Notify suggestion service for open file. - try await Task.sleep(nanoseconds: 500_000_000) - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL - else { return } - _ = try await Service.shared.workspacePool - .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) - } - self.sourceEditor = sourceEditor let notificationsFromEditor = sourceEditor.axNotifications @@ -81,7 +73,7 @@ public actor RealtimeSuggestionController { } if #available(macOS 13.0, *) { - for await _ in valueChange.throttle(for: .milliseconds(200)) { + for await _ in valueChange._throttle(for: .milliseconds(200)) { if Task.isCancelled { return } await handler() } @@ -103,7 +95,7 @@ public actor RealtimeSuggestionController { } if #available(macOS 13.0, *) { - for await _ in selectedTextChanged.throttle(for: .milliseconds(200)) { + for await _ in selectedTextChanged._throttle(for: .milliseconds(200)) { if Task.isCancelled { return } await handler() } @@ -145,7 +137,7 @@ public actor RealtimeSuggestionController { func triggerPrefetchDebounced(force: Bool = false) { inflightPrefetchTask = Task(priority: .utility) { @WorkspaceActor in try? await Task.sleep(nanoseconds: UInt64( - max(UserDefaults.shared.value(for: \.realtimeSuggestionDebounce), 0.25) + max(UserDefaults.shared.value(for: \.realtimeSuggestionDebounce), 0.15) * 1_000_000_000 )) diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 08d86cae..d02d8ef3 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -1,3 +1,4 @@ +import Combine import Dependencies import Foundation import SuggestionService @@ -33,6 +34,7 @@ public final class Service { #endif @Dependency(\.toast) var toast + var cancellable = Set() private init() { @Dependency(\.workspacePool) var workspacePool @@ -70,6 +72,19 @@ public final class Service { #endif DependencyUpdater().update() globalShortcutManager.start() + + Task { + await XcodeInspector.shared.safe.$activeDocumentURL + .removeDuplicates() + .filter { $0 != .init(fileURLWithPath: "/") } + .compactMap { $0 } + .sink { [weak self] fileURL in + Task { + try await self?.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + } + }.store(in: &cancellable) + } } } diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index fbfb4d1d..d20b3f1b 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -11,6 +11,10 @@ import ProExtension public protocol SuggestionServiceType: SuggestionServiceProvider {} public actor SuggestionService: SuggestionServiceType { + public var configuration: SuggestionServiceConfiguration { + get async { await suggestionProvider.configuration } + } + var middlewares: [SuggestionServiceMiddleware] { SuggestionServiceMiddlewareContainer.middlewares } @@ -50,7 +54,7 @@ public actor SuggestionService: SuggestionServiceType { return provider } #endif - + switch serviceType { case .builtIn(.codeium): return CodeiumSuggestionProvider( @@ -75,10 +79,15 @@ public extension SuggestionService { _ request: SuggestionRequest ) async throws -> [SuggestionModel.CodeSuggestion] { var getSuggestion = suggestionProvider.getSuggestions + let configuration = await configuration for middleware in middlewares.reversed() { getSuggestion = { [getSuggestion] request in - try await middleware.getSuggestion(request, next: getSuggestion) + try await middleware.getSuggestion( + request, + configuration: configuration, + next: getSuggestion + ) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index 8ba5d57a..1b7e715f 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -4,10 +4,15 @@ import SwiftUI struct CodeBlockSuggestionPanel: View { @ObservedObject var suggestion: CodeSuggestionProvider @Environment(\.colorScheme) var colorScheme - @AppStorage(\.suggestionCodeFontSize) var fontSize + @AppStorage(\.suggestionCodeFont) var codeFont @AppStorage(\.suggestionDisplayCompactMode) var suggestionDisplayCompactMode @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode @AppStorage(\.hideCommonPrecedingSpacesInSuggestion) var hideCommonPrecedingSpaces + @AppStorage(\.syncSuggestionHighlightTheme) var syncHighlightTheme + @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight + @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark + @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight + @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark struct ToolBar: View { @ObservedObject var suggestion: CodeSuggestionProvider @@ -38,7 +43,7 @@ struct CodeBlockSuggestionPanel: View { }) { Text("Dismiss").foregroundStyle(.tertiary).padding(.trailing, 4) }.buttonStyle(.plain) - + Button(action: { suggestion.rejectSuggestion() }) { @@ -101,13 +106,37 @@ struct CodeBlockSuggestionPanel: View { code: suggestion.code, language: suggestion.language, startLineIndex: suggestion.startLineIndex, + scenario: "suggestion", colorScheme: colorScheme, - fontSize: fontSize, - droppingLeadingSpaces: hideCommonPrecedingSpaces + font: codeFont.value.nsFont, + droppingLeadingSpaces: hideCommonPrecedingSpaces, + proposedForegroundColor: { + if syncHighlightTheme { + if colorScheme == .light, + let color = codeForegroundColorLight.value?.swiftUIColor + { + return color + } else if let color = codeForegroundColorDark.value?.swiftUIColor { + return color + } + } + return nil + }() ) .frame(maxWidth: .infinity) + .background({ () -> Color in + if syncHighlightTheme { + if colorScheme == .light, + let color = codeBackgroundColorLight.value?.swiftUIColor + { + return color + } else if let color = codeBackgroundColorDark.value?.swiftUIColor { + return color + } + } + return Color.contentBackground + }()) } - .background(Color.contentBackground) if suggestionDisplayCompactMode { CompactToolBar(suggestion: suggestion) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 2414803e..65da23cf 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -202,9 +202,14 @@ extension PromptToCodePanel { struct Content: View { let store: StoreOf @Environment(\.colorScheme) var colorScheme - @AppStorage(\.promptToCodeCodeFontSize) var fontSize + @AppStorage(\.promptToCodeCodeFont) var codeFont @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) var hideCommonPrecedingSpaces - + @AppStorage(\.syncPromptToCodeHighlightTheme) var syncHighlightTheme + @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight + @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark + @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight + @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark + struct CodeContent: Equatable { var code: String var language: String @@ -212,6 +217,32 @@ extension PromptToCodePanel { var firstLinePrecedingSpaceCount: Int var isResponding: Bool } + + var codeForegroundColor: Color? { + if syncHighlightTheme { + if colorScheme == .light, + let color = codeForegroundColorLight.value?.swiftUIColor + { + return color + } else if let color = codeForegroundColorDark.value?.swiftUIColor { + return color + } + } + return nil + } + + var codeBackgroundColor: Color { + if syncHighlightTheme { + if colorScheme == .light, + let color = codeBackgroundColorLight.value?.swiftUIColor + { + return color + } else if let color = codeBackgroundColorDark.value?.swiftUIColor { + return color + } + } + return Color.clear + } var body: some View { CustomScrollView { @@ -243,6 +274,7 @@ extension PromptToCodePanel { .textSelection(.enabled) .markdownTheme(.gitHub.text { BackgroundColor(Color.clear) + ForegroundColor(codeForegroundColor) }) .padding() .frame(maxWidth: .infinity) @@ -266,7 +298,7 @@ extension PromptToCodePanel { ? "Thinking..." : "Enter your requirement to generate code." ) - .foregroundColor(.secondary) + .foregroundColor(codeForegroundColor?.opacity(0.7) ?? .secondary) .padding() .multilineTextAlignment(.center) .frame(maxWidth: .infinity) @@ -275,18 +307,21 @@ extension PromptToCodePanel { CodeBlock( code: viewStore.state.code, language: viewStore.state.language, - startLineIndex: viewStore.state.startLineIndex, + startLineIndex: viewStore.state.startLineIndex, + scenario: "promptToCode", colorScheme: colorScheme, firstLinePrecedingSpaceCount: viewStore.state .firstLinePrecedingSpaceCount, - fontSize: fontSize, - droppingLeadingSpaces: hideCommonPrecedingSpaces + font: codeFont.value.nsFont, + droppingLeadingSpaces: hideCommonPrecedingSpaces, + proposedForegroundColor:codeForegroundColor ) .frame(maxWidth: .infinity) .scaleEffect(x: 1, y: -1, anchor: .center) } } } + .background(codeBackgroundColor) } .scaleEffect(x: 1, y: -1, anchor: .center) } diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 60ff0b18..4ab5c142 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -338,6 +338,8 @@ private extension WidgetWindowsController { case .created, .uiElementDestroyed, .xcodeCompletionPanelChanged, .applicationDeactivated: continue + case .titleChanged: + continue } } } diff --git a/EditorExtension/CloseIdleTabsCommand.swift b/EditorExtension/CloseIdleTabsCommand.swift new file mode 100644 index 00000000..b2dde69a --- /dev/null +++ b/EditorExtension/CloseIdleTabsCommand.swift @@ -0,0 +1,20 @@ +import Client +import Foundation +import SuggestionModel +import XcodeKit + +class CloseIdleTabsCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Close Idle Tabs" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.postNotification(name: "CloseIdleTabsOfXcodeWindow") + } + } +} + diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift index 396a8d00..4b3882e2 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -3,6 +3,10 @@ import Foundation import Preferences import XcodeKit +#if canImport(PreferencesPlus) +import PreferencesPlus +#endif + class SourceEditorExtension: NSObject, XCSourceEditorExtension { var builtin: [[XCSourceEditorCommandDefinitionKey: Any]] { [ @@ -17,6 +21,18 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { ].map(makeCommandDefinition) } + var optional: [[XCSourceEditorCommandDefinitionKey: Any]] { + var all = [[XCSourceEditorCommandDefinitionKey: Any]]() + + #if canImport(PreferencesPlus) + if UserDefaults.shared.value(for: \.enableCloseIdleTabCommandInXcodeMenu) { + all.append(CloseIdleTabsCommand().makeCommandDefinition()) + } + #endif + + return all + } + var internalUse: [[XCSourceEditorCommandDefinitionKey: Any]] { [ SeparatorCommand().named("------"), @@ -34,7 +50,7 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { } var commandDefinitions: [[XCSourceEditorCommandDefinitionKey: Any]] { - return builtin + custom + internalUse + return builtin + optional + custom + internalUse } func extensionDidFinishLaunching() { diff --git a/Pro b/Pro index 43e54161..66867275 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 43e54161edb5893f0e89f2d9aaa4a8588f5ffe72 +Subproject commit 668672752669e512aa5f675d113a3ed9049e85c2 diff --git a/Tool/Package.swift b/Tool/Package.swift index bfd91c4b..4fd93b6a 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -52,12 +52,12 @@ let package = Package( // TODO: Update LanguageClient some day. .package(url: "https://github.com/ChimeHQ/LanguageClient", exact: "0.3.1"), .package(url: "https://github.com/ChimeHQ/LanguageServerProtocol", exact: "0.8.0"), - .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"), + .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), .package(url: "https://github.com/ChimeHQ/JSONRPC", exact: "0.6.0"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), .package(url: "https://github.com/unum-cloud/usearch", from: "0.19.1"), - .package(url: "https://github.com/intitni/Highlightr", branch: "bump-highlight-js-version"), + .package(url: "https://github.com/intitni/Highlightr", branch: "master"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.55.0" @@ -65,7 +65,12 @@ let package = Package( .package(url: "https://github.com/apple/swift-syntax.git", exact: "509.0.2"), .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), .package(url: "https://github.com/krzyzanowskim/STTextView", from: "0.8.21"), - .package(url: "https://github.com/google/generative-ai-swift", from: "0.4.4"), + // A fork of https://github.com/google/generative-ai-swift to support setting base url. + .package( + url: "https://github.com/intitni/generative-ai-swift", + branch: "support-setting-base-url" + ), + .package(url: "https://github.com/intitni/CopilotForXcodeKit", from: "0.4.0"), // TreeSitter .package(url: "https://github.com/intitni/SwiftTreeSitter.git", branch: "main"), @@ -89,7 +94,7 @@ let package = Package( .target( name: "CustomAsyncAlgorithms", dependencies: [ - .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] ), @@ -279,6 +284,7 @@ let package = Package( "GitHubCopilotService", "CodeiumService", "UserDefaultsObserver", + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ]), // MARK: - GitHub Copilot diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift index 00e9fc01..06d0a022 100644 --- a/Tool/Sources/AIModel/ChatModel.swift +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -43,6 +43,15 @@ public struct ChatModel: Codable, Equatable, Identifiable { self.organizationID = organizationID } } + + public struct GoogleGenerativeAIInfo: Codable, Equatable { + @FallbackDecoding + public var apiVersion: String + + public init(apiVersion: String = "") { + self.apiVersion = apiVersion + } + } @FallbackDecoding public var apiKeyName: String @@ -61,6 +70,8 @@ public struct ChatModel: Codable, Equatable, Identifiable { public var openAIInfo: OpenAIInfo @FallbackDecoding public var ollamaInfo: OllamaInfo + @FallbackDecoding + public var googleGenerativeAIInfo: GoogleGenerativeAIInfo public init( apiKeyName: String = "", @@ -70,7 +81,8 @@ public struct ChatModel: Codable, Equatable, Identifiable { supportsFunctionCalling: Bool = true, modelName: String = "", openAIInfo: OpenAIInfo = OpenAIInfo(), - ollamaInfo: OllamaInfo = OllamaInfo() + ollamaInfo: OllamaInfo = OllamaInfo(), + googleGenerativeAIInfo: GoogleGenerativeAIInfo = GoogleGenerativeAIInfo() ) { self.apiKeyName = apiKeyName self.baseURL = baseURL @@ -80,6 +92,7 @@ public struct ChatModel: Codable, Equatable, Identifiable { self.modelName = modelName self.openAIInfo = openAIInfo self.ollamaInfo = ollamaInfo + self.googleGenerativeAIInfo = googleGenerativeAIInfo } } @@ -102,8 +115,8 @@ public struct ChatModel: Codable, Equatable, Identifiable { return "\(baseURL)/openai/deployments/\(deployment)/chat/completions?api-version=\(version)" case .googleAI: let baseURL = info.baseURL - if baseURL.isEmpty { return "https://generativelanguage.googleapis.com/v1" } - return "\(baseURL)/v1/chat/completions" + if baseURL.isEmpty { return "https://generativelanguage.googleapis.com" } + return "\(baseURL)" case .ollama: let baseURL = info.baseURL if baseURL.isEmpty { return "http://localhost:11434/api/chat" } @@ -132,3 +145,6 @@ public struct EmptyChatModelOpenAIInfo: FallbackValueProvider { public static var defaultValue: ChatModel.Info.OpenAIInfo { .init() } } +public struct EmptyChatModelGoogleGenerativeAIInfo: FallbackValueProvider { + public static var defaultValue: ChatModel.Info.GoogleGenerativeAIInfo { .init() } +} diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index 58b12d00..d0dc22d0 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -21,6 +21,10 @@ public extension AXUIElement { var value: String { (try? copyValue(key: kAXValueAttribute)) ?? "" } + + var intValue: Int? { + (try? copyValue(key: kAXValueAttribute)) + } var title: String { (try? copyValue(key: kAXTitleAttribute)) ?? "" diff --git a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift index 7936b494..df296cc8 100644 --- a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift +++ b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift @@ -43,6 +43,8 @@ private actor TimedDebounceFunction { public extension AsyncSequence { /// Debounce, but only if the value is received within a certain time frame. + /// + /// In the future when we drop macOS 12 support we should just use chunked from AsyncAlgorithms. func timedDebounce( for duration: TimeInterval ) -> AsyncThrowingStream { diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift index e18eb58a..7af99ff1 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift @@ -43,11 +43,43 @@ protocol GitHubCopilotLSP { enum GitHubCopilotError: Error, LocalizedError { case languageServerNotInstalled + case languageServerError(ServerError) var errorDescription: String? { switch self { case .languageServerNotInstalled: return "Language server is not installed." + case let .languageServerError(error): + switch error { + case let .handlerUnavailable(handler): + return "Language server error: Handler \(handler) unavailable" + case let .unhandledMethod(method): + return "Language server error: Unhandled method \(method)" + case let .notificationDispatchFailed(error): + return "Language server error: Notification dispatch failed: \(error)" + case let .requestDispatchFailed(error): + return "Language server error: Request dispatch failed: \(error)" + case let .clientDataUnavailable(error): + return "Language server error: Client data unavailable: \(error)" + case .serverUnavailable: + return "Language server error: Server unavailable, please make sure that:\n1. The path to node is correctly set.\n2. The node is not a shim executable.\n3. the node version is high enough." + case .missingExpectedParameter: + return "Language server error: Missing expected parameter" + case .missingExpectedResult: + return "Language server error: Missing expected result" + case let .unableToDecodeRequest(error): + return "Language server error: Unable to decode request: \(error)" + case let .unableToSendRequest(error): + return "Language server error: Unable to send request: \(error)" + case let .unableToSendNotification(error): + return "Language server error: Unable to send notification: \(error)" + case let .serverError(code: code, message: message, data: data): + return "Language server error: Server error: \(code) \(message) \(String(describing: data))" + case .invalidRequest: + return "Language server error: Invalid request" + case .timeout: + return "Language server error: Timeout, please try again later" + } } } } @@ -228,28 +260,58 @@ public final class GitHubCopilotAuthService: GitHubCopilotBaseService, } public func checkStatus() async throws -> GitHubCopilotAccountStatus { - try await server.sendRequest(GitHubCopilotRequest.CheckStatus()).status + do { + return try await server.sendRequest(GitHubCopilotRequest.CheckStatus()).status + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } } public func signInInitiate() async throws -> (verificationUri: String, userCode: String) { - let result = try await server.sendRequest(GitHubCopilotRequest.SignInInitiate()) - return (result.verificationUri, result.userCode) + do { + let result = try await server.sendRequest(GitHubCopilotRequest.SignInInitiate()) + return (result.verificationUri, result.userCode) + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } } public func signInConfirm(userCode: String) async throws -> (username: String, status: GitHubCopilotAccountStatus) { - let result = try await server - .sendRequest(GitHubCopilotRequest.SignInConfirm(userCode: userCode)) - return (result.user, result.status) + do { + let result = try await server + .sendRequest(GitHubCopilotRequest.SignInConfirm(userCode: userCode)) + return (result.user, result.status) + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } } public func signOut() async throws -> GitHubCopilotAccountStatus { - try await server.sendRequest(GitHubCopilotRequest.SignOut()).status + do { + return try await server.sendRequest(GitHubCopilotRequest.SignOut()).status + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } } public func version() async throws -> String { - try await server.sendRequest(GitHubCopilotRequest.GetVersion()).version + do { + return try await server.sendRequest(GitHubCopilotRequest.GetVersion()).version + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } } } diff --git a/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift index 80a5c9b8..2770b6e2 100644 --- a/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift @@ -8,17 +8,20 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA let model: ChatModel var requestBody: ChatCompletionsRequestBody let prompt: ChatGPTPrompt + let baseURL: String init( apiKey: String, model: ChatModel, requestBody: ChatCompletionsRequestBody, - prompt: ChatGPTPrompt + prompt: ChatGPTPrompt, + baseURL: String ) { self.apiKey = apiKey self.model = model self.requestBody = requestBody self.prompt = prompt + self.baseURL = baseURL } func callAsFunction() async throws -> ChatCompletionResponseBody { @@ -27,7 +30,11 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA apiKey: apiKey, generationConfig: .init(GenerationConfig( temperature: requestBody.temperature.map(Float.init) - )) + )), + baseURL: baseURL, + requestOptions: model.info.googleGenerativeAIInfo.apiVersion.isEmpty + ? .init() + : .init(apiVersion: model.info.googleGenerativeAIInfo.apiVersion) ) let history = prompt.googleAICompatible.history.map { message in ModelContent(message) @@ -53,6 +60,12 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA throw error case .responseStoppedEarly: throw error + case .promptImageContentError: + throw error + case .invalidAPIKey: + throw error + case .unsupportedUserLocation: + throw error } } catch { throw error @@ -67,7 +80,11 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA apiKey: apiKey, generationConfig: .init(GenerationConfig( temperature: requestBody.temperature.map(Float.init) - )) + )), + baseURL: baseURL, + requestOptions: model.info.googleGenerativeAIInfo.apiVersion.isEmpty + ? .init() + : .init(apiVersion: model.info.googleGenerativeAIInfo.apiVersion) ) let history = prompt.googleAICompatible.history.map { message in ModelContent(message) @@ -100,6 +117,12 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA continuation.finish(throwing: error) case .responseStoppedEarly: continuation.finish(throwing: error) + case .promptImageContentError: + continuation.finish(throwing: error) + case .invalidAPIKey: + continuation.finish(throwing: error) + case .unsupportedUserLocation: + continuation.finish(throwing: error) } } catch { continuation.finish(throwing: error) diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 17847e18..76b39322 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -95,7 +95,8 @@ public class ChatGPTService: ChatGPTServiceType { apiKey: apiKey, model: model, requestBody: requestBody, - prompt: prompt + prompt: prompt, + baseURL: endpoint.absoluteString ) case .openAI, .openAICompatible, .azureOpenAI: return OpenAIChatCompletionsService( @@ -129,7 +130,8 @@ public class ChatGPTService: ChatGPTServiceType { apiKey: apiKey, model: model, requestBody: requestBody, - prompt: prompt + prompt: prompt, + baseURL: endpoint.absoluteString ) case .openAI, .openAICompatible, .azureOpenAI: return OpenAIChatCompletionsService( diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 92daeb5f..780ec9cf 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -93,9 +93,9 @@ public struct UserDefaultPreferenceKeys { defaultValue: false, key: "ShowHideWidgetShortcutGlobally" ) - + // MARK: Update Channel - + public let installBetaBuilds = PreferenceKey( defaultValue: false, key: "InstallBetaBuilds" @@ -163,7 +163,7 @@ public extension UserDefaultPreferenceKeys { var gitHubCopilotProxyPort: PreferenceKey { .init(defaultValue: "", key: "GitHubCopilotProxyPort") } - + var gitHubCopilotEnterpriseURI: PreferenceKey { .init(defaultValue: "", key: "GitHubCopilotEnterpriseURI") } @@ -290,15 +290,15 @@ public extension UserDefaultPreferenceKeys { var promptToCodeEmbeddingModelId: PreferenceKey { .init(defaultValue: "", key: "PromptToCodeEmbeddingModelId") } - + var enableSenseScopeByDefaultInPromptToCode: PreferenceKey { .init(defaultValue: false, key: "EnableSenseScopeByDefaultInPromptToCode") } - + var promptToCodeCodeFontSize: PreferenceKey { .init(defaultValue: 13, key: "PromptToCodeCodeFontSize") } - + var hideCommonPrecedingSpacesInPromptToCode: PreferenceKey { .init(defaultValue: true, key: "HideCommonPrecedingSpacesInPromptToCode") } @@ -310,7 +310,7 @@ public extension UserDefaultPreferenceKeys { var oldSuggestionFeatureProvider: DeprecatedPreferenceKey { .init(defaultValue: .gitHubCopilot, key: "SuggestionFeatureProvider") } - + var suggestionFeatureProvider: PreferenceKey { .init(defaultValue: .builtIn(.gitHubCopilot), key: "NewSuggestionFeatureProvider") } @@ -354,11 +354,11 @@ public extension UserDefaultPreferenceKeys { var acceptSuggestionWithTab: PreferenceKey { .init(defaultValue: true, key: "AcceptSuggestionWithTab") } - + var dismissSuggestionWithEsc: PreferenceKey { .init(defaultValue: true, key: "DismissSuggestionWithEsc") } - + var isSuggestionSenseEnabled: PreferenceKey { .init(defaultValue: false, key: "IsSuggestionSenseEnabled") } @@ -449,20 +449,80 @@ public extension UserDefaultPreferenceKeys { var enableWebScopeByDefaultInChatContext: PreferenceKey { .init(defaultValue: false, key: "EnableWebScopeByDefaultInChatContext") } - + var preferredChatModelIdForSenseScope: PreferenceKey { .init(defaultValue: "", key: "PreferredChatModelIdForSenseScope") } - + var preferredChatModelIdForProjectScope: PreferenceKey { .init(defaultValue: "", key: "PreferredChatModelIdForProjectScope") } - + var preferredChatModelIdForWebScope: PreferenceKey { .init(defaultValue: "", key: "PreferredChatModelIdForWebScope") } } +// MARK: - Theme + +public extension UserDefaultPreferenceKeys { + var syncSuggestionHighlightTheme: PreferenceKey { + .init(defaultValue: false, key: "SyncSuggestionHighlightTheme") + } + + var syncPromptToCodeHighlightTheme: PreferenceKey { + .init(defaultValue: false, key: "SyncPromptToCodeHighlightTheme") + } + + var syncChatCodeHighlightTheme: PreferenceKey { + .init(defaultValue: false, key: "SyncChatCodeHighlightTheme") + } + + var codeForegroundColorLight: PreferenceKey> { + .init(defaultValue: .init(nil), key: "CodeForegroundColorLight") + } + + var codeForegroundColorDark: PreferenceKey> { + .init(defaultValue: .init(nil), key: "CodeForegroundColorDark") + } + + var codeBackgroundColorLight: PreferenceKey> { + .init(defaultValue: .init(nil), key: "CodeBackgroundColorLight") + } + + var codeBackgroundColorDark: PreferenceKey> { + .init(defaultValue: .init(nil), key: "CodeBackgroundColorDark") + } + + var suggestionCodeFont: PreferenceKey> { + .init( + defaultValue: .init(.init(nsFont: .monospacedSystemFont(ofSize: 12, weight: .regular))), + key: "SuggestionCodeFont" + ) + } + + var promptToCodeCodeFont: PreferenceKey> { + .init( + defaultValue: .init(.init(nsFont: .monospacedSystemFont(ofSize: 12, weight: .regular))), + key: "PromptToCodeCodeFont" + ) + } + + var chatCodeFont: PreferenceKey> { + .init( + defaultValue: .init(.init(nsFont: .monospacedSystemFont(ofSize: 12, weight: .regular))), + key: "ChatCodeFont" + ) + } + + var terminalFont: PreferenceKey> { + .init( + defaultValue: .init(.init(nsFont: .monospacedSystemFont(ofSize: 12, weight: .regular))), + key: "TerminalCodeFont" + ) + } +} + // MARK: - Bing Search public extension UserDefaultPreferenceKeys { @@ -576,11 +636,11 @@ public extension UserDefaultPreferenceKeys { key: "FeatureFlag-DisableGitHubCopilotSettingsAutoRefreshOnAppear" ) } - + var disableGitIgnoreCheck: FeatureFlag { .init(defaultValue: false, key: "FeatureFlag-DisableGitIgnoreCheck") } - + var disableFileContentManipulationByCheatsheet: FeatureFlag { .init(defaultValue: true, key: "FeatureFlag-DisableFileContentManipulationByCheatsheet") } @@ -591,32 +651,32 @@ public extension UserDefaultPreferenceKeys { key: "FeatureFlag-DisableEnhancedWorkspace" ) } - + var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning: FeatureFlag { .init( defaultValue: false, key: "FeatureFlag-RestartXcodeInspectorIfAccessibilityAPIIsMalfunctioning" ) } - + var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer: FeatureFlag { .init( defaultValue: true, key: "FeatureFlag-RestartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer" ) } - + var toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted: FeatureFlag { .init( defaultValue: false, key: "FeatureFlag-ToastForTheReasonWhyXcodeInspectorNeedsToBeRestarted" ) } - + var observeToAXNotificationWithDefaultMode: FeatureFlag { .init(defaultValue: false, key: "FeatureFlag-observeToAXNotificationWithDefaultMode") } - + var useCloudflareDomainNameForLicenseCheck: FeatureFlag { .init(defaultValue: false, key: "FeatureFlag-UseCloudflareDomainNameForLicenseCheck") } diff --git a/Tool/Sources/Preferences/Types/ChatGPTModel.swift b/Tool/Sources/Preferences/Types/ChatGPTModel.swift index 1541f61c..d56ac4a1 100644 --- a/Tool/Sources/Preferences/Types/ChatGPTModel.swift +++ b/Tool/Sources/Preferences/Types/ChatGPTModel.swift @@ -5,11 +5,13 @@ public enum ChatGPTModel: String { case gpt35Turbo16k = "gpt-3.5-turbo-16k" case gpt4 = "gpt-4" case gpt432k = "gpt-4-32k" - case gpt4TurboPreview = "gpt-4-turbo-preview" + case gpt4Turbo = "gpt-4-turbo" case gpt40314 = "gpt-4-0314" case gpt40613 = "gpt-4-0613" case gpt41106Preview = "gpt-4-1106-preview" case gpt4VisionPreview = "gpt-4-vision-preview" + case gpt4TurboPreview = "gpt-4-turbo-preview" + case gpt4Turbo20240409 = "gpt-4-turbo-2024-04-09" case gpt35Turbo0301 = "gpt-3.5-turbo-0301" case gpt35Turbo0613 = "gpt-3.5-turbo-0613" case gpt35Turbo1106 = "gpt-3.5-turbo-1106" @@ -57,12 +59,16 @@ public extension ChatGPTModel { return 128000 case .gpt40125: return 128000 + case .gpt4Turbo: + return 128000 + case .gpt4Turbo20240409: + return 128000 } } var supportsImages: Bool { switch self { - case .gpt4VisionPreview: + case .gpt4VisionPreview, .gpt4Turbo, .gpt4Turbo20240409: return true default: return false diff --git a/Tool/Sources/Preferences/Types/StorableColors.swift b/Tool/Sources/Preferences/Types/StorableColors.swift new file mode 100644 index 00000000..2d7e4f83 --- /dev/null +++ b/Tool/Sources/Preferences/Types/StorableColors.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct StorableColor: Codable { + public var red: Double + public var green: Double + public var blue: Double + public var alpha: Double + + public init(red: Double, green: Double, blue: Double, alpha: Double) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } +} + +#if canImport(SwiftUI) +import SwiftUI +public extension StorableColor { + var swiftUIColor: SwiftUI.Color { + SwiftUI.Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha) + } +} +#endif + +#if canImport(AppKit) +import AppKit +public extension StorableColor { + var nsColor: NSColor { + NSColor( + srgbRed: CGFloat(red), + green: CGFloat(green), + blue: CGFloat(blue), + alpha: CGFloat(alpha) + ) + } +} +#endif + diff --git a/Tool/Sources/Preferences/Types/StorableFont.swift b/Tool/Sources/Preferences/Types/StorableFont.swift new file mode 100644 index 00000000..f6d72dd3 --- /dev/null +++ b/Tool/Sources/Preferences/Types/StorableFont.swift @@ -0,0 +1,40 @@ +import AppKit +import Foundation + +public struct StorableFont: Codable { + public var nsFont: NSFont + + public init(nsFont: NSFont) { + self.nsFont = nsFont + } + + public enum CodingKeys: String, CodingKey { + case nsFont + } + + public init(from decoder: Decoder) throws { + var container = try decoder.container(keyedBy: CodingKeys.self) + let fontData = try container.decode(Data.self, forKey: .nsFont) + guard let nsFont = try NSKeyedUnarchiver.unarchivedObject( + ofClass: NSFont.self, + from: fontData + ) else { + throw DecodingError.dataCorruptedError( + forKey: .nsFont, + in: container, + debugDescription: "Failed to decode NSFont" + ) + } + self.nsFont = nsFont + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + let fontData = try NSKeyedArchiver.archivedData( + withRootObject: nsFont, + requiringSecureCoding: false + ) + try container.encode(fontData, forKey: .nsFont) + } +} + diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift index 1cf3b0b4..5052e1a8 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -1,4 +1,5 @@ import AIModel +import AppKit import Configs import Foundation @@ -29,6 +30,27 @@ public extension UserDefaults { for: \.promptToCodeCodeFontSize, defaultValue: shared.value(for: \.suggestionCodeFontSize) ) + shared.setupDefaultValue( + for: \.suggestionCodeFont, + defaultValue: .init(.init(nsFont: .monospacedSystemFont( + ofSize: shared.value(for: \.suggestionCodeFontSize), + weight: .regular + ))) + ) + shared.setupDefaultValue( + for: \.promptToCodeCodeFont, + defaultValue: .init(.init(nsFont: .monospacedSystemFont( + ofSize: shared.value(for: \.promptToCodeCodeFontSize), + weight: .regular + ))) + ) + shared.setupDefaultValue( + for: \.chatCodeFont, + defaultValue: .init(.init(nsFont: .monospacedSystemFont( + ofSize: shared.value(for: \.chatCodeFontSize), + weight: .regular + ))) + ) } } @@ -63,6 +85,32 @@ extension Array: RawRepresentable where Element: Codable { } } +public struct UserDefaultsStorageBox: RawRepresentable { + public let value: Element + + public init(_ value: Element) { + self.value = value + } + + public init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode(Element.self, from: data) + else { + return nil + } + value = result + } + + public var rawValue: String { + guard let data = try? JSONEncoder().encode(value), + let result = String(data: data, encoding: .utf8) + else { + return "" + } + return result + } +} + public extension UserDefaultsType { // MARK: Normal Types @@ -122,6 +170,16 @@ public extension UserDefaultsType { return K.Value(rawValue: rawValue) ?? key.defaultValue } + func value( + for keyPath: KeyPath + ) -> V where K.Value == UserDefaultsStorageBox { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? String else { + return key.defaultValue.value + } + return (K.Value(rawValue: rawValue) ?? key.defaultValue).value + } + func set( _ value: K.Value, for keyPath: KeyPath @@ -138,6 +196,14 @@ public extension UserDefaultsType { set(value.rawValue, forKey: key.key) } + func set( + _ value: V, + for keyPath: KeyPath + ) where K.Value == UserDefaultsStorageBox { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + set(UserDefaultsStorageBox(value).rawValue, forKey: key.key) + } + func setupDefaultValue( for keyPath: KeyPath, defaultValue: K.Value? = nil diff --git a/Tool/Sources/SharedUIComponents/CodeBlock.swift b/Tool/Sources/SharedUIComponents/CodeBlock.swift index c66a816a..5d9884ca 100644 --- a/Tool/Sources/SharedUIComponents/CodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/CodeBlock.swift @@ -1,45 +1,57 @@ +import Preferences import SwiftUI public struct CodeBlock: View { public let code: String public let language: String public let startLineIndex: Int + public let scenario: String public let colorScheme: ColorScheme public let commonPrecedingSpaceCount: Int public let highlightedCode: [NSAttributedString] public let firstLinePrecedingSpaceCount: Int - public let fontSize: Double + public let font: NSFont public let droppingLeadingSpaces: Bool + public let proposedForegroundColor: Color? public init( code: String, language: String, startLineIndex: Int, + scenario: String, colorScheme: ColorScheme, firstLinePrecedingSpaceCount: Int = 0, - fontSize: Double, - droppingLeadingSpaces: Bool + font: NSFont, + droppingLeadingSpaces: Bool, + proposedForegroundColor: Color? ) { self.code = code self.language = language self.startLineIndex = startLineIndex + self.scenario = scenario self.colorScheme = colorScheme self.droppingLeadingSpaces = droppingLeadingSpaces self.firstLinePrecedingSpaceCount = firstLinePrecedingSpaceCount - self.fontSize = fontSize + self.font = font + self.proposedForegroundColor = proposedForegroundColor let padding = firstLinePrecedingSpaceCount > 0 ? String(repeating: " ", count: firstLinePrecedingSpaceCount) : "" let result = Self.highlight( code: padding + code, language: language, + scenario: scenario, colorScheme: colorScheme, - fontSize: fontSize, + font: font, droppingLeadingSpaces: droppingLeadingSpaces ) commonPrecedingSpaceCount = result.commonLeadingSpaceCount highlightedCode = result.code } + + var foregroundColor: Color { + proposedForegroundColor ?? (colorScheme == .dark ? .white : .black) + } public var body: some View { VStack(spacing: 2) { @@ -47,10 +59,10 @@ public struct CodeBlock: View { HStack(alignment: .firstTextBaseline, spacing: 4) { Text("\(index + startLineIndex + 1)") .multilineTextAlignment(.trailing) - .foregroundColor(.secondary) + .foregroundColor(foregroundColor.opacity(0.5)) .frame(minWidth: 40) Text(AttributedString(highlightedCode[index])) - .foregroundColor(.white.opacity(0.1)) + .foregroundColor(foregroundColor.opacity(0.3)) .frame(maxWidth: .infinity, alignment: .leading) .multilineTextAlignment(.leading) .lineSpacing(4) @@ -59,7 +71,7 @@ public struct CodeBlock: View { Text("\(commonPrecedingSpaceCount + 1)") .padding(.top, -12) .font(.footnote) - .foregroundStyle(colorScheme == .dark ? .white : .black) + .foregroundStyle(foregroundColor) .opacity(0.3) } } @@ -67,7 +79,7 @@ public struct CodeBlock: View { } } .foregroundColor(.white) - .font(.system(size: fontSize, design: .monospaced)) + .font(.init(font)) .padding(.leading, 4) .padding([.trailing, .top, .bottom]) } @@ -75,16 +87,18 @@ public struct CodeBlock: View { static func highlight( code: String, language: String, + scenario: String, colorScheme: ColorScheme, - fontSize: Double, + font: NSFont, droppingLeadingSpaces: Bool ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { return highlighted( code: code, language: language, + scenario: scenario, brightMode: colorScheme != .dark, droppingLeadingSpaces: droppingLeadingSpaces, - fontSize: fontSize + font: font ) } } @@ -100,10 +114,12 @@ struct CodeBlock_Previews: PreviewProvider { """, language: "swift", startLineIndex: 0, + scenario: "", colorScheme: .dark, firstLinePrecedingSpaceCount: 0, - fontSize: 12, - droppingLeadingSpaces: true + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: nil ) } } diff --git a/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift b/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift index 21c53d0b..b455b9a0 100644 --- a/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift @@ -8,11 +8,12 @@ private let insetTop = 12 as Double struct _CodeBlock: View { @Binding private var selection: NSRange? @State private var contentHeight: Double = 500 - let fontSize: Double + let font: NSFont let commonPrecedingSpaceCount: Int let highlightedCode: AttributedString let colorScheme: ColorScheme let droppingLeadingSpaces: Bool + let scenario: String /// Create a text edit view with a certain text that uses a certain options. /// - Parameters: @@ -23,15 +24,17 @@ struct _CodeBlock: View { code: String, language: String, firstLinePrecedingSpaceCount: Int, + scenario: String, colorScheme: ColorScheme, - fontSize: Double, + font: NSFont, droppingLeadingSpaces: Bool, selection: Binding = .constant(nil) ) { _selection = selection - self.fontSize = fontSize + self.font = font self.colorScheme = colorScheme self.droppingLeadingSpaces = droppingLeadingSpaces + self.scenario = scenario let padding = firstLinePrecedingSpaceCount > 0 ? String(repeating: " ", count: firstLinePrecedingSpaceCount) @@ -39,8 +42,9 @@ struct _CodeBlock: View { let result = Self.highlight( code: padding + code, language: language, + scenario: scenario, colorScheme: colorScheme, - fontSize: fontSize, + font: font, droppingLeadingSpaces: droppingLeadingSpaces ) commonPrecedingSpaceCount = result.commonLeadingSpaceCount @@ -51,7 +55,7 @@ struct _CodeBlock: View { _CodeBlockRepresentable( text: highlightedCode, selection: $selection, - fontSize: fontSize, + font: font, onHeightChange: { height in print("Q", height) contentHeight = height @@ -68,16 +72,18 @@ struct _CodeBlock: View { static func highlight( code: String, language: String, + scenario: String, colorScheme: ColorScheme, - fontSize: Double, + font: NSFont, droppingLeadingSpaces: Bool ) -> (code: AttributedString, commonLeadingSpaceCount: Int) { let (lines, commonLeadingSpaceCount) = highlighted( code: code, - language: language, + language: language, + scenario: scenario, brightMode: colorScheme != .dark, droppingLeadingSpaces: droppingLeadingSpaces, - fontSize: fontSize, + font: font, replaceSpacesWithMiddleDots: false ) @@ -99,18 +105,18 @@ private struct _CodeBlockRepresentable: NSViewRepresentable { @Binding private var selection: NSRange? let text: AttributedString - let fontSize: Double + let font: NSFont let onHeightChange: (Double) -> Void init( text: AttributedString, selection: Binding, - fontSize: Double, + font: NSFont, onHeightChange: @escaping (Double) -> Void ) { self.text = text _selection = selection - self.fontSize = fontSize + self.font = font self.onHeightChange = onHeightChange } @@ -182,7 +188,6 @@ private struct _CodeBlockRepresentable: NSViewRepresentable { textView.heightTracksTextView = true } - let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) if textView.font != font { textView.font = font } diff --git a/Tool/Sources/SharedUIComponents/FontPicker.swift b/Tool/Sources/SharedUIComponents/FontPicker.swift new file mode 100644 index 00000000..2f91c9d0 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/FontPicker.swift @@ -0,0 +1,89 @@ +import AppKit +import Foundation +import Preferences +import SwiftUI + +public struct FontPicker: View { + @State var fontManagerDelegate: FontManagerDelegate? + @Binding var font: NSFont + let label: Label + + public init(font: Binding, @ViewBuilder label: () -> Label) { + _font = font + self.label = label() + } + + public var body: some View { + if #available(macOS 13.0, *) { + LabeledContent { + button + } label: { + label + } + } else { + HStack { + label + button + } + } + } + + var button: some View { + Button { + if NSFontPanel.shared.isVisible { + NSFontPanel.shared.orderOut(nil) + } + + self.fontManagerDelegate = FontManagerDelegate(font: font) { + self.font = $0 + } + NSFontManager.shared.target = self.fontManagerDelegate + NSFontPanel.shared.setPanelFont(self.font, isMultiple: false) + NSFontPanel.shared.orderBack(nil) + } label: { + HStack { + Text(font.fontName) + + Text(" - ") + + Text(font.pointSize, format: .number.precision(.fractionLength(1))) + + Text("pt") + + Spacer().frame(width: 30) + + Image(systemName: "textformat") + .frame(width: 13) + .scaledToFit() + } + } + } + + final class FontManagerDelegate: NSObject { + let font: NSFont + let onSelection: (NSFont) -> Void + init(font: NSFont, onSelection: @escaping (NSFont) -> Void) { + self.font = font + self.onSelection = onSelection + } + + @objc func changeFont(_ sender: NSFontManager) { + onSelection(sender.convert(font)) + } + } +} + +public extension FontPicker { + init(font: Binding>, @ViewBuilder label: () -> Label) { + _font = Binding( + get: { font.wrappedValue.value.nsFont }, + set: { font.wrappedValue = .init(StorableFont(nsFont: $0)) } + ) + self.label = label() + } +} + +#Preview { + FontPicker(font: .constant(.systemFont(ofSize: 15))) { + Text("Font") + } + .padding() +} + diff --git a/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift index 844a243f..b6dd0c02 100644 --- a/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift +++ b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift @@ -7,8 +7,9 @@ import SwiftUI public func highlightedCodeBlock( code: String, language: String, + scenario: String, brightMode: Bool, - fontSize: Double + font: NSFont ) -> NSAttributedString { var language = language // Workaround: Highlightr uses a different identifier for Objective-C. @@ -20,15 +21,21 @@ public func highlightedCodeBlock( string: code, attributes: [ .foregroundColor: brightMode ? NSColor.black : NSColor.white, - .font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular), + .font: font, ] ) } guard let highlighter = Highlightr() else { return unhighlightedCode() } - highlighter.setTheme(to: brightMode ? "xcode" : "atom-one-dark") - highlighter.theme.setCodeFont(.monospacedSystemFont(ofSize: fontSize, weight: .regular)) + 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() } @@ -41,16 +48,18 @@ public func highlightedCodeBlock( public func highlighted( code: String, language: String, + scenario: String, brightMode: Bool, droppingLeadingSpaces: Bool, - fontSize: Double, + font: NSFont, replaceSpacesWithMiddleDots: Bool = true ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { let formatted = highlightedCodeBlock( code: code, - language: language, + language: language, + scenario: scenario, brightMode: brightMode, - fontSize: fontSize + font: font ) let middleDotColor = brightMode ? NSColor.black.withAlphaComponent(0.1) diff --git a/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift b/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift index e2f7d164..fac572b9 100644 --- a/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift +++ b/Tool/Sources/SuggestionProvider/CodeiumSuggestionProvider.swift @@ -4,6 +4,14 @@ 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? diff --git a/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift b/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift index 98f4ba46..ca330a2f 100644 --- a/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift +++ b/Tool/Sources/SuggestionProvider/GitHubCopilotSuggestionProvider.swift @@ -4,11 +4,22 @@ 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) { + public init( + projectRootURL: URL, + onServiceLaunched: @escaping (SuggestionServiceProvider) -> Void + ) { self.projectRootURL = projectRootURL self.onServiceLaunched = onServiceLaunched } diff --git a/Tool/Sources/SuggestionProvider/SuggestionProvider.swift b/Tool/Sources/SuggestionProvider/SuggestionProvider.swift index f1af1acd..e59c4a64 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionProvider.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionProvider.swift @@ -1,4 +1,5 @@ -import AppKit +import AppKit +import struct CopilotForXcodeKit.SuggestionServiceConfiguration import Foundation import Preferences import SuggestionModel @@ -13,7 +14,8 @@ public struct SuggestionRequest { public var tabSize: Int public var indentSize: Int public var usesTabsForIndentation: Bool - public var ignoreSpaceOnlySuggestions: Bool + public var ignoreSpaceOnlySuggestions: Bool + public var relevantCodeSnippets: [RelevantCodeSnippet] public init( fileURL: URL, @@ -24,7 +26,8 @@ public struct SuggestionRequest { tabSize: Int, indentSize: Int, usesTabsForIndentation: Bool, - ignoreSpaceOnlySuggestions: Bool + ignoreSpaceOnlySuggestions: Bool, + relevantCodeSnippets: [RelevantCodeSnippet] ) { self.fileURL = fileURL self.relativePath = relativePath @@ -35,12 +38,24 @@ public struct SuggestionRequest { self.indentSize = indentSize self.usesTabsForIndentation = usesTabsForIndentation self.ignoreSpaceOnlySuggestions = ignoreSpaceOnlySuggestions + self.relevantCodeSnippets = relevantCodeSnippets + } +} + +public struct RelevantCodeSnippet: Codable { + public var content: String + public var priority: Int + public var filePath: String + + public init(content: String, priority: Int, filePath: String) { + self.content = content + self.priority = priority + self.filePath = filePath } } 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 @@ -49,5 +64,8 @@ public protocol SuggestionServiceProvider { func notifySaveTextDocument(fileURL: URL) async throws func cancelRequest() async func terminate() async + + var configuration: SuggestionServiceConfiguration { get async } } +public typealias SuggestionServiceConfiguration = CopilotForXcodeKit.SuggestionServiceConfiguration diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift index f7ba1742..c447131f 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift @@ -5,7 +5,11 @@ import SuggestionModel public protocol SuggestionServiceMiddleware { typealias Next = (SuggestionRequest) async throws -> [CodeSuggestion] - func getSuggestion(_ request: SuggestionRequest, next: Next) async throws -> [CodeSuggestion] + func getSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] } public enum SuggestionServiceMiddlewareContainer { @@ -29,6 +33,7 @@ public struct DisabledLanguageSuggestionServiceMiddleware: SuggestionServiceMidd public func getSuggestion( _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, next: Next ) async throws -> [CodeSuggestion] { let language = languageIdentifierFromFileURL(request.fileURL) @@ -50,6 +55,7 @@ public struct DebugSuggestionServiceMiddleware: SuggestionServiceMiddleware { public func getSuggestion( _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, next: Next ) async throws -> [CodeSuggestion] { Logger.service.info(""" diff --git a/Tool/Sources/Workspace/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift index e6228803..264a3dae 100644 --- a/Tool/Sources/Workspace/Filespace.swift +++ b/Tool/Sources/Workspace/Filespace.swift @@ -76,6 +76,9 @@ public final class Filespace { public let fileURL: URL public private(set) lazy var language: CodeLanguage = languageIdentifierFromFileURL(fileURL) public var codeMetadata: FilespaceCodeMetadata = .init() + public var isTextReadable: Bool { + fileURL.pathExtension != "mlmodel" + } // MARK: Suggestions @@ -92,10 +95,10 @@ public final class Filespace { // MARK: Life Cycle public var isExpired: Bool { - Environment.now().timeIntervalSince(lastSuggestionUpdateTime) > 60 * 3 + Environment.now().timeIntervalSince(lastUpdateTime) > 60 * 3 } - private(set) var lastSuggestionUpdateTime: Date = Environment.now() + public private(set) var lastUpdateTime: Date = Environment.now() private var additionalProperties = FilespacePropertyValues() let fileSaveWatcher: FileSaveWatcher let onClose: (URL) -> Void @@ -154,7 +157,7 @@ public final class Filespace { } public func refreshUpdateTime() { - lastSuggestionUpdateTime = Environment.now() + lastUpdateTime = Environment.now() } @WorkspaceActor diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index 9facada9..40c87261 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -72,9 +72,9 @@ public final class Workspace { public let workspaceURL: URL public let projectRootURL: URL public let openedFileRecoverableStorage: OpenedFileRecoverableStorage - public private(set) var lastSuggestionUpdateTime = Environment.now() + public private(set) var lastLastUpdateTime = Environment.now() public var isExpired: Bool { - Environment.now().timeIntervalSince(lastSuggestionUpdateTime) > 60 * 60 * 1 + Environment.now().timeIntervalSince(lastLastUpdateTime) > 60 * 60 * 1 } public private(set) var filespaces = [URL: Filespace]() @@ -113,7 +113,7 @@ public final class Workspace { } public func refreshUpdateTime() { - lastSuggestionUpdateTime = Environment.now() + lastLastUpdateTime = Environment.now() } @WorkspaceActor @@ -123,24 +123,18 @@ public final class Workspace { fileURL: fileURL, onSave: { [weak self] filespace in guard let self else { return } - for plugin in self.plugins.values { - plugin.didSaveFilespace(filespace) - } + self.didSaveFilespace(filespace) }, onClose: { [weak self] url in guard let self else { return } - for plugin in self.plugins.values { - plugin.didCloseFilespace(url) - } + self.didCloseFilespace(url) } ) if filespaces[fileURL] == nil { filespaces[fileURL] = filespace } if existedFilespace == nil { - for plugin in plugins.values { - plugin.didOpenFilespace(filespace) - } + didOpenFilespace(filespace) } else { filespace.refreshUpdateTime() } @@ -154,10 +148,37 @@ public final class Workspace { @WorkspaceActor public func didUpdateFilespace(fileURL: URL, content: String) { + refreshUpdateTime() guard let filespace = filespaces[fileURL] else { return } + filespace.refreshUpdateTime() for plugin in plugins.values { plugin.didUpdateFilespace(filespace, content: content) } } + + @WorkspaceActor + func didOpenFilespace(_ filespace: Filespace) { + refreshUpdateTime() + openedFileRecoverableStorage.openFile(fileURL: filespace.fileURL) + for plugin in plugins.values { + plugin.didOpenFilespace(filespace) + } + } + + @WorkspaceActor + func didCloseFilespace(_ fileURL: URL) { + for plugin in self.plugins.values { + plugin.didCloseFilespace(fileURL) + } + } + + @WorkspaceActor + func didSaveFilespace(_ filespace: Filespace) { + refreshUpdateTime() + filespace.refreshUpdateTime() + for plugin in plugins.values { + plugin.didSaveFilespace(filespace) + } + } } diff --git a/Tool/Sources/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift index 7798ad6e..2b9a0737 100644 --- a/Tool/Sources/Workspace/WorkspacePool.swift +++ b/Tool/Sources/Workspace/WorkspacePool.swift @@ -88,11 +88,6 @@ public class WorkspacePool { public func fetchOrCreateWorkspaceAndFilespace(fileURL: URL) async throws -> (workspace: Workspace, filespace: Filespace) { - let ignoreFileExtensions = ["mlmodel"] - if ignoreFileExtensions.contains(fileURL.pathExtension) { - throw Workspace.UnsupportedFileError(extensionName: fileURL.pathExtension) - } - // If we can get the workspace URL directly. if let currentWorkspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL { if let existed = workspaces[currentWorkspaceURL] { diff --git a/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift b/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift index 40b85ac6..3e999628 100644 --- a/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift +++ b/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift @@ -95,9 +95,8 @@ public final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { } public func notifyOpenFile(filespace: Filespace) { - workspace?.refreshUpdateTime() - workspace?.openedFileRecoverableStorage.openFile(fileURL: filespace.fileURL) 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 @@ -114,9 +113,8 @@ public final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { } public func notifyUpdateFile(filespace: Filespace, content: String) { - filespace.refreshUpdateTime() - workspace?.refreshUpdateTime() Task { + guard filespace.isTextReadable else { return } guard !(await filespace.isGitIgnored) else { return } try await suggestionService?.notifyChangeTextDocument( fileURL: filespace.fileURL, @@ -126,9 +124,8 @@ public final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { } public func notifySaveFile(filespace: Filespace) { - filespace.refreshUpdateTime() - workspace?.refreshUpdateTime() Task { + guard filespace.isTextReadable else { return } guard !(await filespace.isGitIgnored) else { return } try await suggestionService?.notifySaveTextDocument(fileURL: filespace.fileURL) } diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift index 3dd91dda..850eb405 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift @@ -64,7 +64,8 @@ public extension Workspace { tabSize: editor.tabSize, indentSize: editor.indentSize, usesTabsForIndentation: editor.usesTabsForIndentation, - ignoreSpaceOnlySuggestions: true + ignoreSpaceOnlySuggestions: true, + relevantCodeSnippets: [] ) ) diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index c16b09a0..852a4de7 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -12,6 +12,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } public enum AXNotificationKind { + case titleChanged case applicationActivated case applicationDeactivated case moved @@ -29,6 +30,8 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { public init?(rawValue: String) { switch rawValue { + case kAXTitleChangedNotification: + self = .titleChanged case kAXApplicationActivatedNotification: self = .applicationActivated case kAXApplicationDeactivatedNotification: @@ -211,6 +214,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { let axNotificationStream = AXNotificationStream( app: runningApplication, notificationNames: + kAXTitleChangedNotification, kAXApplicationActivatedNotification, kAXApplicationDeactivatedNotification, kAXMovedNotification, @@ -233,7 +237,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { guard let self else { return } try Task.checkCancellation() await Task.yield() - + guard let event = AXNotificationKind(rawValue: notification.name) else { continue } diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index 5878a27a..d3178781 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -16,9 +16,9 @@ public class XcodeWindowInspector: ObservableObject { public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { let app: NSRunningApplication - @Published var documentURL: URL = .init(fileURLWithPath: "/") - @Published var workspaceURL: URL = .init(fileURLWithPath: "/") - @Published var projectRootURL: URL = .init(fileURLWithPath: "/") + @Published public internal(set) var documentURL: URL = .init(fileURLWithPath: "/") + @Published public internal(set) var workspaceURL: URL = .init(fileURLWithPath: "/") + @Published public internal(set) var projectRootURL: URL = .init(fileURLWithPath: "/") private var focusedElementChangedTask: Task? public func refresh() { @@ -47,7 +47,9 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { group.addTask { [weak self] in for await notification in await axNotifications.notifications() { - guard notification.kind == .focusedUIElementChanged else { continue } + guard notification.kind == .focusedUIElementChanged + || notification.kind == .titleChanged + else { continue } guard let self else { return } try Task.checkCancellation() await Task.yield() diff --git a/Version.xcconfig b/Version.xcconfig index b9735f6d..3aa40d04 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.31.3 -APP_BUILD = 343 +APP_VERSION = 0.32.0 +APP_BUILD = 360 diff --git a/appcast.xml b/appcast.xml index 791b3150..65216067 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,6 +3,18 @@ Copilot for Xcode + + 0.32.0 + Mon, 15 Apr 2024 23:48:22 +0800 + 360 + 0.32.0 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.32.0 + + + + 0.32.0 Thu, 11 Apr 2024 11:31:29 +0800