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