diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 71d9e18..14ce6d1 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -261,6 +261,15 @@ "version" : "509.0.2" } }, + { + "identity" : "swiftui-flow-layout", + "kind" : "remoteSourceControl", + "location" : "https://github.com/globulus/swiftui-flow-layout", + "state" : { + "revision" : "de7da3440c3b87ba94adfa98c698828d7746a76d", + "version" : "1.0.5" + } + }, { "identity" : "swiftui-navigation", "kind" : "remoteSourceControl", diff --git a/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json new file mode 100644 index 0000000..8ad86a7 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "CopilotIssue.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/CopilotIssue.svg b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/CopilotIssue.svg new file mode 100644 index 0000000..af4e890 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/CopilotIssue.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Copilot for Xcode/Assets.xcassets/DescriptionForegroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/DescriptionForegroundColor.colorset/Contents.json new file mode 100644 index 0000000..bdcbb88 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/DescriptionForegroundColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x9D", + "green" : "0x9D", + "red" : "0x9D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/Contents.json new file mode 100644 index 0000000..1c65bf6 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "file_type_swift.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/file_type_swift.svg b/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/file_type_swift.svg new file mode 100644 index 0000000..c232d1f --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/file_type_swift.svg @@ -0,0 +1 @@ +file_type_swift \ No newline at end of file diff --git a/Copilot for Xcode/Assets.xcassets/TextLinkForegroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/TextLinkForegroundColor.colorset/Contents.json new file mode 100644 index 0000000..d892da1 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/TextLinkForegroundColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x94", + "red" : "0x37" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Credits.rtf b/Copilot for Xcode/Credits.rtf index 5655b19..c2bc880 100644 --- a/Copilot for Xcode/Credits.rtf +++ b/Copilot for Xcode/Credits.rtf @@ -3216,4 +3216,30 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ SOFTWARE.\ \ \ +Dependency: github.com/globulus/swiftui-flow-layout\ +Version: 1.0.5\ +License Content:\ +MIT License\ +\ +Copyright (c) 2021 Gordan Glavaš\ +\ +Permission is hereby granted, free of charge, to any person obtaining a copy\ +of this software and associated documentation files (the "Software"), to deal\ +in the Software without restriction, including without limitation the rights\ +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ +copies of the Software, and to permit persons to whom the Software is\ +furnished to do so, subject to the following conditions:\ +\ +The above copyright notice and this permission notice shall be included in all\ +copies or substantial portions of the Software.\ +\ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\ +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\ +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\ +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\ +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\ +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ +SOFTWARE.\ +\ +\ } \ No newline at end of file diff --git a/Copilot-for-Xcode-Info.plist b/Copilot-for-Xcode-Info.plist index b45f6d1..12d852d 100644 --- a/Copilot-for-Xcode-Info.plist +++ b/Copilot-for-Xcode-Info.plist @@ -1,32 +1,34 @@ - - APP_ID_PREFIX - $(AppIdentifierPrefix) - APPLICATION_SUPPORT_FOLDER - $(APPLICATION_SUPPORT_FOLDER) - BUNDLE_IDENTIFIER_BASE - $(BUNDLE_IDENTIFIER_BASE) - EXTENSION_BUNDLE_NAME - $(EXTENSION_BUNDLE_NAME) - HOST_APP_NAME - $(HOST_APP_NAME) - LANGUAGE_SERVER_PATH - $(LANGUAGE_SERVER_PATH) - NODE_PATH - $(NODE_PATH) - SUEnableAutomaticChecks - YES - SUScheduledCheckInterval - 3600 - SUEnableJavaScript - NO - SUFeedURL - $(SPARKLE_FEED_URL) - SUPublicEDKey - $(SPARKLE_PUBLIC_KEY) - TEAM_ID_PREFIX - $(TeamIdentifierPrefix) - - + + APP_ID_PREFIX + $(AppIdentifierPrefix) + APPLICATION_SUPPORT_FOLDER + $(APPLICATION_SUPPORT_FOLDER) + BUNDLE_IDENTIFIER_BASE + $(BUNDLE_IDENTIFIER_BASE) + EXTENSION_BUNDLE_NAME + $(EXTENSION_BUNDLE_NAME) + HOST_APP_NAME + $(HOST_APP_NAME) + LANGUAGE_SERVER_PATH + $(LANGUAGE_SERVER_PATH) + NODE_PATH + $(NODE_PATH) + SUEnableAutomaticChecks + YES + SUScheduledCheckInterval + 3600 + SUEnableJavaScript + NO + SUFeedURL + $(SPARKLE_FEED_URL) + SUPublicEDKey + $(SPARKLE_PUBLIC_KEY) + TEAM_ID_PREFIX + $(TeamIdentifierPrefix) + STANDARD_TELEMETRY_CHANNEL_KEY + $(STANDARD_TELEMETRY_CHANNEL_KEY) + + \ No newline at end of file diff --git a/Core/Package.swift b/Core/Package.swift index 385746c..6cf84fb 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -33,6 +33,7 @@ let package = Package( "Client", "LaunchAgentManager", "UpdateChecker", + "GitHubCopilotViewModel", ] ), ], @@ -52,6 +53,8 @@ let package = Package( .package(url: "https://github.com/devm33/KeyboardShortcuts", branch: "main"), .package(url: "https://github.com/devm33/CGEventOverride", branch: "devm33/fix-stale-AXIsProcessTrusted"), .package(url: "https://github.com/devm33/Highlightr", branch: "master"), + .package(url: "https://github.com/globulus/swiftui-flow-layout", + from: "1.0.5") ], targets: [ // MARK: - Main @@ -76,6 +79,7 @@ let package = Package( "ConversationTab", "KeyBindingManager", "XcodeThemeController", + .product(name: "TelemetryService", package: "Tool"), .product(name: "XPCShared", package: "Tool"), .product(name: "SuggestionProvider", package: "Tool"), .product(name: "ConversationServiceProvider", package: "Tool"), @@ -84,10 +88,12 @@ let package = Package( .product(name: "AppMonitoring", package: "Tool"), .product(name: "SuggestionBasic", package: "Tool"), .product(name: "Status", package: "Tool"), + .product(name: "StatusBarItemView", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "ChatAPIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), + .product(name: "AXHelper", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Dependencies", package: "swift-dependencies"), @@ -114,6 +120,7 @@ let package = Package( dependencies: [ "Client", "LaunchAgentManager", + "GitHubCopilotViewModel", .product(name: "SuggestionProvider", package: "Tool"), .product(name: "Toast", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), @@ -167,6 +174,7 @@ let package = Package( .product(name: "Parsing", package: "swift-parsing"), .product(name: "ChatAPIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), + .product(name: "AXHelper", package: "Tool"), .product(name: "ConversationServiceProvider", package: "Tool"), .product(name: "GitHubCopilotService", package: "Tool"), ]), @@ -180,8 +188,10 @@ let package = Package( .product(name: "Logger", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "Terminal", package: "Tool"), + .product(name: "Cache", package: "Tool"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout") ] ), @@ -192,6 +202,7 @@ let package = Package( dependencies: [ "PromptToCodeService", "ConversationTab", + "GitHubCopilotViewModel", .product(name: "GitHubCopilotService", package: "Tool"), .product(name: "Toast", package: "Tool"), .product(name: "UserDefaultsObserver", package: "Tool"), @@ -209,7 +220,7 @@ let package = Package( // MARK: - Helpers - .target(name: "FileChangeChecker"), + .target(name: "FileChangeChecker"), .target( name: "LaunchAgentManager", dependencies: [ @@ -224,6 +235,14 @@ let package = Package( .product(name: "Logger", package: "Tool"), ] ), + .target( + name: "GitHubCopilotViewModel", + dependencies: [ + .product(name: "GitHubCopilotService", package: "Tool"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "Status", package: "Tool"), + ] + ), // MARK: Key Binding diff --git a/Core/Sources/ChatService/ChatInjector.swift b/Core/Sources/ChatService/ChatInjector.swift new file mode 100644 index 0000000..81a6024 --- /dev/null +++ b/Core/Sources/ChatService/ChatInjector.swift @@ -0,0 +1,97 @@ +import SuggestionBasic +import AppKit +import XcodeInspector +import AXHelper +import ApplicationServices +import AppActivator + + +public struct ChatInjector { + public init() {} + + public func insertCodeBlock(codeBlock: String) { + do { + guard let editorContent = XcodeInspector.shared.focusedEditor?.getContent(), + let focusElement = XcodeInspector.shared.focusedElement, + focusElement.description == "Source Editor" + else { return } + + var cursorPosition = editorContent.cursorPosition + guard cursorPosition.line >= 0, cursorPosition.character >= 0 else { return } + + var lines = editorContent.content.splitByNewLine( + omittingEmptySubsequences: false + ).map { String($0) } + // Ensure the line number is within the bounds of the file + guard cursorPosition.line <= lines.count else { return } + + var modifications: [Modification] = [] + + // remove selection + // make sure there is selection exist and valid + if let selection = editorContent.selections.first, + selection.isValid, + selection.start.line < lines.endIndex { + let selectionEndLine = min(selection.end.line, lines.count - 1) + let deletedSelection = CursorRange( + start: selection.start, + end: .init(line: selectionEndLine, character: selection.end.character) + ) + modifications.append(.deletedSelection(deletedSelection)) + lines = lines.applying([.deletedSelection(deletedSelection)]) + + // update cursorPosition to the start of selection + cursorPosition = selection.start + } + + let targetLine = lines[cursorPosition.line] + + // Determine the indention level of the target line + let leadingWhitespace = cursorPosition.character > 0 ? targetLine.prefix { $0.isWhitespace } : "" + let indentation = String(leadingWhitespace) + + // Insert codeblock at the specified position + let index = targetLine.index(targetLine.startIndex, offsetBy: min(cursorPosition.character, targetLine.count)) + let before = targetLine[.. String in + return index == 0 ? String(element) : indentation + String(element) + } + + var toBeInsertedLines = [String]() + toBeInsertedLines.append(String(before) + codeBlockLines.first!) + toBeInsertedLines.append(contentsOf: codeBlockLines.dropFirst().dropLast()) + toBeInsertedLines.append(codeBlockLines.last! + String(after)) + + lines.replaceSubrange((cursorPosition.line)...(cursorPosition.line), with: toBeInsertedLines) + + // Join the lines + let newContent = String(lines.joined(separator: "\n")) + + // Inject updated content + let newCursorPosition = CursorPosition( + line: cursorPosition.line + codeBlockLines.count - 1, + character: codeBlockLines.last?.count ?? 0 + ) + modifications.append(.inserted(cursorPosition.line, toBeInsertedLines)) + try AXHelper().injectUpdatedCodeWithAccessibilityAPI( + .init( + content: newContent, + newSelection: .cursor(newCursorPosition), + modifications: modifications + ), + focusElement: focusElement, + onSuccess: { + NSWorkspace.activatePreviousActiveXcode() + } + + ) + + } catch { + print("Failed to insert code block: \(error)") + } + } +} diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 06f8c4d..99ea272 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -5,10 +5,12 @@ import GitHubCopilotService import Preferences import ConversationServiceProvider import BuiltinExtension +import JSONRPC +import Status public protocol ChatServiceType { var memory: ContextAwareAutoManagedChatMemory { get set } - func send(_ id: String, content: String) async throws + func send(_ id: String, content: String, skillSet: [ConversationSkill], references: [FileReference]) async throws func stopReceivingMessage() async func upvote(_ id: String, _ rating: ConversationRating) async func downvote(_ id: String, _ rating: ConversationRating) async @@ -20,13 +22,16 @@ public final class ChatService: ChatServiceType, ObservableObject { public var memory: ContextAwareAutoManagedChatMemory @Published public internal(set) var chatHistory: [ChatMessage] = [] @Published public internal(set) var isReceivingMessage = false + public var chatTemplates: [ChatTemplate]? = nil + public static var shared: ChatService = ChatService.service() private let conversationProvider: ConversationServiceProvider? private let conversationProgressHandler: ConversationProgressHandler + private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared private var cancellables = Set() private var activeRequestId: String? private var conversationId: String? - + private var skillSet: [ConversationSkill] = [] init(provider: any ConversationServiceProvider, memory: ContextAwareAutoManagedChatMemory = ContextAwareAutoManagedChatMemory(), conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared) { @@ -36,6 +41,7 @@ public final class ChatService: ChatServiceType, ObservableObject { memory.chatService = self subscribeToNotifications() + subscribeToConversationContextRequest() } private func subscribeToNotifications() { @@ -59,6 +65,16 @@ public final class ChatService: ChatServiceType, ObservableObject { }.store(in: &cancellables) } + private func subscribeToConversationContextRequest() { + self.conversationContextHandler.onConversationContext.sink(receiveValue: { [weak self] (request, completion) in + guard let skills = self?.skillSet, !skills.isEmpty, request.params!.conversationId == self?.conversationId else { return } + skills.forEach { skill in + if (skill.applies(params: request.params!)) { + skill.resolveSkill(request: request, completion: completion) + } + } + }).store(in: &cancellables) + } public static func service() -> ChatService { let provider = BuiltinExtensionConversationServiceProvider( extension: GitHubCopilotExtension.self @@ -66,20 +82,29 @@ public final class ChatService: ChatServiceType, ObservableObject { return ChatService(provider: provider) } - public func send(_ id: String, content: String) async throws { + public func send(_ id: String, content: String, skillSet: Array, references: Array) async throws { guard activeRequestId == nil else { return } let workDoneToken = UUID().uuidString activeRequestId = workDoneToken - await memory.appendMessage(ChatMessage(id: id, role: .user, content: content, summary: nil, references: [])) - + await memory.appendMessage(ChatMessage(id: id, role: .user, content: content, references: [])) + let skillCapabilities: [String] = [ CurrentEditorSkill.ID, ProblemsInActiveDocumentSkill.ID ] + let supportedSkills: [String] = skillSet.map { $0.id } + let ignoredSkills: [String] = skillCapabilities.filter { + !supportedSkills.contains($0) + } let request = ConversationRequest(workDoneToken: workDoneToken, - content: content, workspaceFolder: "", skills: []) + content: content, + workspaceFolder: "", + skills: skillCapabilities, + ignoredSkills: ignoredSkills, + references: references) + self.skillSet = skillSet try await send(request) } public func sendAndWait(_ id: String, content: String) async throws -> String { - try await send(id, content: content) + try await send(id, content: content, skillSet: [], references: []) if let reply = await memory.history.last(where: { $0.role == .assistant })?.content { return reply } @@ -117,7 +142,7 @@ public final class ChatService: ChatServiceType, ObservableObject { if let message = (await memory.history).first(where: { $0.id == id }) { do { - try await send(id, content: message.content) + try await send(id, content: message.content, skillSet: [], references: []) } catch { print("Failed to resend message") } @@ -185,7 +210,7 @@ public final class ChatService: ChatServiceType, ObservableObject { if let sendingMessageImmediately = info.sendingMessageImmediately, !sendingMessageImmediately.isEmpty { - try await send(UUID().uuidString, content: templateProcessor.process(sendingMessageImmediately)) + try await send(UUID().uuidString, content: templateProcessor.process(sendingMessageImmediately), skillSet: [], references: []) } } @@ -201,6 +226,21 @@ public final class ChatService: ChatServiceType, ObservableObject { // TODO: pass copy code info to Copilot server } + public func loadChatTemplates() async -> [ChatTemplate]? { + guard self.chatTemplates == nil else { return self.chatTemplates } + + do { + if let templates = (try await conversationProvider?.templates()) { + self.chatTemplates = templates + return templates + } + } catch { + // handle error if desired + } + + return nil + } + public func handleSingleRoundDialogCommand( systemPrompt: String?, overwriteSystemPrompt: Bool, @@ -210,7 +250,7 @@ public final class ChatService: ChatServiceType, ObservableObject { return try await sendAndWait(UUID().uuidString, content: templateProcessor.process(prompt)) } - private func handleProgressBegin(token: String, progress: ConversationProgress) { + private func handleProgressBegin(token: String, progress: ConversationProgressBegin) { guard let workDoneToken = activeRequestId, workDoneToken == token else { return } conversationId = progress.conversationId @@ -221,17 +261,82 @@ public final class ChatService: ChatServiceType, ObservableObject { } } - private func handleProgressReport(token: String, progress: ConversationProgress) { - guard let workDoneToken = activeRequestId, workDoneToken == token, let reply = progress.reply else { return } + private func handleProgressReport(token: String, progress: ConversationProgressReport) { + guard let workDownToken = activeRequestId, workDownToken == token else { + return + } + + let id = progress.turnId + var content = "" + var references: [ConversationReference] = [] + + if let reply = progress.reply { + content = reply + } + if let progressReferences = progress.references, !progressReferences.isEmpty { + progressReferences.forEach { item in + let reference = ConversationReference( + uri: item.uri, + status: .included, + kind: .other + ) + references.append(reference) + } + } + + if content.isEmpty && references.isEmpty { + return + } + + // create immutable copies + let messageContent = content + let messageReferences = references + Task { - let message = ChatMessage(id: progress.turnId, role: .assistant, content: reply) + let message = ChatMessage(id: id, role: .assistant, content: messageContent, references: messageReferences) await memory.appendMessage(message) } } - private func handleProgressEnd(token: String, progress: ConversationProgress) { + private func handleProgressEnd(token: String, progress: ConversationProgressEnd) { guard let workDoneToken = activeRequestId, workDoneToken == token else { return } + let followUp = progress.followUp + + if let CLSError = progress.error { + // CLS Error Code 402: reached monthly chat messages limit + if CLSError.code == 402 { + Task { + await Status.shared + .updateCLSStatus(.error, message: CLSError.message) + let errorMessage = ChatMessage( + id: progress.turnId, + role: .system, + content: CLSError.message + ) + await memory.removeMessage(progress.turnId) + await memory.appendMessage(errorMessage) + } + } else { + Task { + let errorMessage = ChatMessage( + id: progress.turnId, + role: .assistant, + content: "", + errorMessage: CLSError.message + ) + await memory.appendMessage(errorMessage) + } + } + resetOngoingRequest() + return + } + + Task { + let message = ChatMessage(id: progress.turnId, role: .assistant, content: "", followUp: followUp, suggestedTitle: progress.suggestedTitle) + await memory.appendMessage(message) + } + resetOngoingRequest() } diff --git a/Core/Sources/ChatService/ConversationSkill.swift b/Core/Sources/ChatService/ConversationSkill.swift new file mode 100644 index 0000000..df2735e --- /dev/null +++ b/Core/Sources/ChatService/ConversationSkill.swift @@ -0,0 +1,8 @@ +import JSONRPC +import GitHubCopilotService + +public protocol ConversationSkill { + var id: String { get } + func applies(params: ConversationContextParams) -> Bool + func resolveSkill(request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) +} diff --git a/Core/Sources/ChatService/CurrentEditorSkill.swift b/Core/Sources/ChatService/CurrentEditorSkill.swift new file mode 100644 index 0000000..28ab47b --- /dev/null +++ b/Core/Sources/ChatService/CurrentEditorSkill.swift @@ -0,0 +1,33 @@ +import ConversationServiceProvider +import Foundation +import GitHubCopilotService +import JSONRPC + +public class CurrentEditorSkill: ConversationSkill { + public static let ID = "current-editor" + private var currentFile: FileReference + public var id: String { + return CurrentEditorSkill.ID + } + + public init( + currentFile: FileReference + ) { + self.currentFile = currentFile + } + + public func applies(params: ConversationContextParams) -> Bool { + return params.skillId == self.id + } + + public func resolveSkill(request: ConversationContextRequest, completion: (AnyJSONRPCResponse) -> Void){ + let uri: String? = self.currentFile.url.absoluteString + completion( + AnyJSONRPCResponse(id: request.id, + result: JSONValue.array([ + JSONValue.hash(["uri" : .string(uri ?? "")]), + JSONValue.null + ])) + ) + } +} diff --git a/Core/Sources/ChatService/ProblemsInActiveDocumentSkill.swift b/Core/Sources/ChatService/ProblemsInActiveDocumentSkill.swift new file mode 100644 index 0000000..22f6d3d --- /dev/null +++ b/Core/Sources/ChatService/ProblemsInActiveDocumentSkill.swift @@ -0,0 +1,52 @@ +import ConversationServiceProvider +import Foundation +import GitHubCopilotService +import JSONRPC +import XcodeInspector + +public class ProblemsInActiveDocumentSkill: ConversationSkill { + public static let ID = "problems-in-active-document" + public var id: String { + return ProblemsInActiveDocumentSkill.ID + } + + public init() { + } + + public func applies(params: ConversationContextParams) -> Bool { + return params.skillId == self.id + } + + public func resolveSkill(request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) { + Task { + let editor = await XcodeInspector.shared.getFocusedEditorContent() + let result: JSONValue = JSONValue.hash([ + "uri": JSONValue.string(editor?.documentURL.absoluteString ?? ""), + "problems": JSONValue.array(editor?.editorContent?.lineAnnotations.map { annotation in + JSONValue.hash([ + "message": JSONValue.string(annotation.message), + "range": JSONValue.hash([ + "start": JSONValue.hash([ + "line": JSONValue.number(Double(annotation.line)), + "character": JSONValue.number(0) + ]), + "end": JSONValue.hash([ + "line": JSONValue.number(Double(annotation.line)), + "character": JSONValue.number(0) + ]) + ]) + ]) + } ?? []) + ]) + + completion( + AnyJSONRPCResponse(id: request.id, + result: JSONValue.array([ + result, + JSONValue.null + ])) + ) + } + } +} + diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index c7f2649..eaee3a5 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -10,44 +10,26 @@ public struct DisplayedChatMessage: Equatable { public enum Role: Equatable { case user case assistant - case tool + case system case ignored } - public struct Reference: Equatable { - public typealias Kind = ChatMessage.Reference.Kind - - public var title: String - public var subtitle: String - public var uri: String - public var startLine: Int? - public var kind: Kind - - public init( - title: String, - subtitle: String, - uri: String, - startLine: Int?, - kind: Kind - ) { - self.title = title - self.subtitle = subtitle - self.uri = uri - self.startLine = startLine - self.kind = kind - } - } - public var id: String public var role: Role public var text: String - public var references: [Reference] = [] + public var references: [ConversationReference] = [] + public var followUp: ConversationFollowUp? = nil + public var suggestedTitle: String? = nil + public var errorMessage: String? = nil - public init(id: String, role: Role, text: String, references: [Reference]) { + public init(id: String, role: Role, text: String, references: [ConversationReference] = [], followUp: ConversationFollowUp? = nil, suggestedTitle: String? = nil, errorMessage: String? = nil) { self.id = id self.role = role self.text = text self.references = references + self.followUp = followUp + self.suggestedTitle = suggestedTitle + self.errorMessage = errorMessage } } @@ -61,12 +43,16 @@ struct Chat { @ObservableState struct State: Equatable { - var title: String = "Chat" + var title: String = "New Chat" + var isTitleSet: Bool = false + var typedMessage = "" var history: [DisplayedChatMessage] = [] var isReceivingMessage = false var chatMenu = ChatMenu.State() var focusedField: Field? + var currentEditor: FileReference? = nil + var selectedFiles: [FileReference] = [] enum Field: String, Hashable { case textField @@ -86,10 +72,11 @@ struct Chat { case resendMessageButtonTapped(MessageID) case setAsExtraPromptButtonTapped(MessageID) case focusOnTextField - case referenceClicked(DisplayedChatMessage.Reference) + case referenceClicked(ConversationReference) case upvote(MessageID, ConversationRating) case downvote(MessageID, ConversationRating) case copyCode(MessageID) + case insertCode(String) case observeChatService case observeHistoryChange @@ -99,6 +86,15 @@ struct Chat { case isReceivingMessageChanged case chatMenu(ChatMenu.Action) + + // context + case addSelectedFile(FileReference) + case removeSelectedFile(FileReference) + case resetCurrentEditor + case setCurrentEditor(FileReference) + + case followUpButtonClicked(String, String) + case setTitle(DisplayedChatMessage) } let service: ChatService @@ -137,11 +133,25 @@ struct Chat { } case let .sendButtonTapped(id): - guard !state.typedMessage.isEmpty else { return .none } + guard !state.typedMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return .none } let message = state.typedMessage + let skillSet = state.buildSkillSet() state.typedMessage = "" + + let selectedFiles = state.selectedFiles + return .run { _ in - try await service.send(id, content: message) + try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles) + }.cancellable(id: CancelID.sendMessage(self.id)) + + case let .followUpButtonClicked(id, message): + guard !message.isEmpty else { return .none } + let skillSet = state.buildSkillSet() + + let selectedFiles = state.selectedFiles + + return .run { _ in + try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles) }.cancellable(id: CancelID.sendMessage(self.id)) case .returnButtonTapped: @@ -177,7 +187,9 @@ struct Chat { } case let .referenceClicked(reference): - let fileURL = URL(fileURLWithPath: reference.uri) + guard let fileURL = reference.url else { + return .none + } return .run { _ in if FileManager.default.fileExists(atPath: fileURL.path) { let terminal = Terminal() @@ -186,7 +198,7 @@ struct Chat { "/bin/bash", arguments: [ "-c", - "xed -l \(reference.startLine ?? 0) \"\(reference.uri)\"", + "xed -l 0 \"\(reference.filePath)\"", ], environment: [:] ) @@ -253,42 +265,41 @@ struct Chat { id: message.id, role: { switch message.role { - case .system: return .ignored + case .system: return .system case .user: return .user case .assistant: return .assistant } }(), - text: message.summary ?? message.content, + text: message.content, references: message.references.map { .init( - title: $0.title, - subtitle: $0.subTitle, uri: $0.uri, - startLine: $0.startLine, + status: $0.status, kind: $0.kind ) - } + }, + followUp: message.followUp, + suggestedTitle: message.suggestedTitle, + errorMessage: message.errorMessage )) return all } - - state.title = { - let defaultTitle = "Chat" - guard let lastMessageText = state.history - .filter({ $0.role == .assistant || $0.role == .user }) - .last? - .text else { return defaultTitle } - if lastMessageText.isEmpty { return defaultTitle } - let trimmed = lastMessageText - .trimmingCharacters(in: .punctuationCharacters) - .trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.starts(with: "```") { - return "Code Block" - } else { - return trimmed - } - }() + + guard let lastChatMessage = state.history.last else { return .none } + return .run { send in + await send(.setTitle(lastChatMessage)) + } + + case let .setTitle(message): + guard state.isTitleSet == false, + message.role == .assistant, + let suggestedTitle = message.suggestedTitle + else { return .none } + + state.title = suggestedTitle + state.isTitleSet = true + return .none case .isReceivingMessageChanged: @@ -312,6 +323,25 @@ struct Chat { return .run { _ in await service.copyCode(id) } + + case let .insertCode(code): + ChatInjector().insertCodeBlock(codeBlock: code) + return .none + + case let .addSelectedFile(fileReference): + guard !state.selectedFiles.contains(fileReference) else { return .none } + state.selectedFiles.append(fileReference) + return .none + case let .removeSelectedFile(fileReference): + guard let index = state.selectedFiles.firstIndex(of: fileReference) else { return .none } + state.selectedFiles.remove(at: index) + return .none + case .resetCurrentEditor: + state.currentEditor = nil + return .none + case let .setCurrentEditor(fileReference): + state.currentEditor = fileReference + return .none } } } diff --git a/Core/Sources/ConversationTab/ChatContextMenu.swift b/Core/Sources/ConversationTab/ChatContextMenu.swift index c47f3c4..3e1ac09 100644 --- a/Core/Sources/ConversationTab/ChatContextMenu.swift +++ b/Core/Sources/ConversationTab/ChatContextMenu.swift @@ -14,6 +14,17 @@ struct ChatTabItemView: View { } } +struct ChatConversationItemView: View { + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + Text(chat.title) + .frame(alignment: .leading) + } + } +} + struct ChatContextMenu: View { let store: StoreOf @AppStorage(\.customCommands) var customCommands diff --git a/Core/Sources/ConversationTab/ChatExtension.swift b/Core/Sources/ConversationTab/ChatExtension.swift new file mode 100644 index 0000000..0e3537b --- /dev/null +++ b/Core/Sources/ConversationTab/ChatExtension.swift @@ -0,0 +1,17 @@ +import ChatService +import ConversationServiceProvider + +extension Chat.State { + func buildSkillSet() -> [ConversationSkill] { + guard let currentFile = self.currentEditor else { + return [] + } + let fileReference = FileReference( + url: currentFile.url, + relativePath: currentFile.relativePath, + fileName: currentFile.fileName, + isCurrentEditor: currentFile.isCurrentEditor + ) + return [CurrentEditorSkill(currentFile: fileReference), ProblemsInActiveDocumentSkill()] + } +} diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index e53730e..ff231d6 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -1,11 +1,14 @@ import AppKit import Combine import ComposableArchitecture +import ConversationServiceProvider import MarkdownUI import ChatAPIService import SharedUIComponents import SwiftUI import ChatService +import SwiftUIFlowLayout +import XcodeInspector private let r: Double = 8 @@ -15,10 +18,33 @@ public struct ChatPanel: View { public var body: some View { VStack(spacing: 0) { - ChatPanelMessages(chat: chat) - Divider() + + if chat.history.isEmpty { + VStack { + Spacer() + Instruction() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(.leading, -16) + } else { + ChatPanelMessages(chat: chat) + + if chat.history.last?.role == .system { + ChatCLSError(chat: chat).padding(.trailing, 16) + } else { + ChatFollowUp(chat: chat) + .padding(.trailing, 16) + .padding(.vertical, 8) + + } + } + ChatPanelInputArea(chat: chat) + .padding(.trailing, 16) } + .padding(.leading, 16) + .padding(.bottom, 16) .background(Color(nsColor: .windowBackgroundColor)) .onAppear { chat.send(.appear) } } @@ -60,10 +86,6 @@ struct ChatPanelMessages: View { GeometryReader { listGeo in List { Group { - Spacer(minLength: 12) - .id(topID) - - Instruction(chat: chat) ChatHistory(chat: chat) .listItemTint(.clear) @@ -94,11 +116,11 @@ struct ChatPanelMessages: View { if #available(macOS 13.0, *) { view .listRowSeparator(.hidden) - .listSectionSeparator(.hidden) } else { view } } + .padding(.leading, -8) } .listStyle(.plain) .listRowBackground(EmptyView()) @@ -190,8 +212,8 @@ struct ChatPanelMessages: View { proxy.scrollTo(bottomID, anchor: .bottom) } }) { - Image(systemName: "arrow.down") - .padding(4) + Image(systemName: "chevron.down") + .padding(8) .background { Circle() .fill(.thickMaterial) @@ -201,11 +223,12 @@ struct ChatPanelMessages: View { Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1) } .foregroundStyle(.secondary) - .padding(4) } + .buttonStyle(HoverButtonStyle(padding: 0)) + .padding(4) .keyboardShortcut(.downArrow, modifiers: [.command]) .opacity(isScrollToBottomButtonDisplayed ? 1 : 0) - .buttonStyle(.plain) + .help("Scroll Down") } struct ExtraSpacingInResponding: View { @@ -240,6 +263,8 @@ struct ChatPanelMessages: View { scrollToBottom() } } + } else { + Task { pinnedToBottom = false } } } .onChange(of: chat.history.last) { _ in @@ -271,9 +296,18 @@ struct ChatHistory: View { var body: some View { WithPerceptionTracking { - ForEach(chat.history, id: \.id) { message in - WithPerceptionTracking { - ChatHistoryItem(chat: chat, message: message).id(message.id) + ForEach(Array(chat.history.enumerated()), id: \.element.id) { index, message in + VStack(spacing: 0) { + WithPerceptionTracking { + ChatHistoryItem(chat: chat, message: message) + .id(message.id) + .padding(.top, 4) + .padding(.bottom, 12) + } + + // add divider between messages + if message.role != .ignored && index < chat.history.count - 1 { + Divider() } } } } @@ -290,29 +324,17 @@ struct ChatHistoryItem: View { switch message.role { case .user: UserMessage(id: message.id, text: text, chat: chat) - .listRowInsets(EdgeInsets( - top: 0, - leading: -8, - bottom: 0, - trailing: -8 - )) - .padding(.vertical, 4) case .assistant: BotMessage( id: message.id, text: text, references: message.references, + followUp: message.followUp, + errorMessage: message.errorMessage, chat: chat ) - .listRowInsets(EdgeInsets( - top: 0, - leading: -8, - bottom: 0, - trailing: -8 - )) - .padding(.vertical, 4) - case .tool: - FunctionMessage(id: message.id, text: text) + case .system: + FunctionMessage(chat: chat, id: message.id, text: text) case .ignored: EmptyView() } @@ -357,17 +379,79 @@ private struct StopRespondingButton: View { } } +struct ChatFollowUp: View { + let chat: StoreOf + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + WithPerceptionTracking { + HStack { + if let followUp = chat.history.last?.followUp { + Button(action: { + chat.send(.followUpButtonClicked(UUID().uuidString, followUp.message)) + }) { + HStack(spacing: 4) { + Image(systemName: "sparkles") + .foregroundColor(.blue) + + Text(followUp.message) + .font(.system(size: chatFontSize)) + .foregroundColor(.blue) + } + } + .buttonStyle(.plain) + .onHover { isHovered in + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +struct ChatCLSError: View { + let chat: StoreOf + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + WithPerceptionTracking { + HStack(alignment: .top) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.blue) + .padding(.leading, 8) + + Text("Monthly chat limit reached. [Upgrade now](https://github.com/github-copilot/signup/copilot_individual) or wait until your usage resets.") + .font(.system(size: chatFontSize)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + .background( + RoundedCorners(tl: r, tr: r, bl: 0, br: 0) + .fill(.ultraThickMaterial) + ) + .overlay( + RoundedCorners(tl: r, tr: r, bl: 0, br: 0) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .padding(.top, 4) + } + } +} + struct ChatPanelInputArea: View { let chat: StoreOf @FocusState var focusedField: Chat.State.Field? var body: some View { HStack { - clearButton InputAreaTextEditor(chat: chat, focusedField: $focusedField) } - .padding(8) - .background(.ultraThickMaterial) + .background(Color.clear) } @MainActor @@ -396,36 +480,103 @@ struct ChatPanelInputArea: View { struct InputAreaTextEditor: View { @Perception.Bindable var chat: StoreOf var focusedField: FocusState.Binding + @State var cancellable = Set() + @State private var isFilePickerPresented = false + @State private var allFiles: [FileReference] = [] + @State private var searchText = "" + @State private var selectedFiles: [FileReference] = [] + @State private var filteredTemplates: [ChatTemplate] = [] + @State private var showingTemplates = false var body: some View { WithPerceptionTracking { - HStack(spacing: 0) { - AutoresizingCustomTextEditor( - text: $chat.typedMessage, - font: .systemFont(ofSize: 14), - isEditable: true, - maxHeight: 400, - onSubmit: { - chat.send(.sendButtonTapped(UUID().uuidString)) - }, - completions: chatAutoCompletion - ) - .focused(focusedField, equals: .textField) - .bind($chat.focusedField, to: focusedField) - .padding(8) - .fixedSize(horizontal: false, vertical: true) + VStack(spacing: 0) { + ZStack(alignment: .topLeading) { + if chat.typedMessage.isEmpty { + Text("Ask Copilot") + .font(.system(size: 14)) + .foregroundColor(Color(nsColor: .placeholderTextColor)) + .padding(8) + .padding(.horizontal, 4) + } - Button(action: { - chat.send(.sendButtonTapped(UUID().uuidString)) - }) { - Image(systemName: "paperplane.fill") + HStack(spacing: 0) { + AutoresizingCustomTextEditor( + text: $chat.typedMessage, + font: .systemFont(ofSize: 14), + isEditable: true, + maxHeight: 400, + onSubmit: { + if (!showingTemplates) { + submitChatMessage() + } + showingTemplates = false + }, + completions: chatAutoCompletion + ) + .focused(focusedField, equals: .textField) + .bind($chat.focusedField, to: focusedField) .padding(8) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: chat.typedMessage) { newValue in + Task { + filteredTemplates = await chatTemplateCompletion(text: newValue) + showingTemplates = !filteredTemplates.isEmpty + } + } + } + .frame(maxWidth: .infinity) } - .buttonStyle(.plain) - .disabled(chat.isReceivingMessage) - .keyboardShortcut(KeyEquivalent.return, modifiers: []) + .padding(.top, 4) + + attachedFilesView + + if isFilePickerPresented { + filePickerView + .transition(.move(edge: .bottom)) + .onAppear() { + allFiles = ContextUtils.getFilesInActiveWorkspace() + } + } + + HStack(spacing: 0) { + Button(action: { + withAnimation { + isFilePickerPresented.toggle() + } + }) { + Image(systemName: "paperclip") + .padding(4) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .help("Attach Context") + + Spacer() + + Button(action: { + submitChatMessage() + }) { + Image(systemName: "paperplane.fill") + .padding(4) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .disabled(chat.isReceivingMessage) + .keyboardShortcut(KeyEquivalent.return, modifiers: []) + .help("Send") + } + .padding(8) + .padding(.top, -4) + } + .overlay(alignment: .top) { + if showingTemplates { + ChatTemplateDropdownView(templates: $filteredTemplates) { template in + chat.typedMessage = "/" + template.id + " " + } + } + } + .onAppear() { + subscribeToActiveDocumentChangeEvent() } - .frame(maxWidth: .infinity) .background { RoundedRectangle(cornerRadius: 6) .fill(Color(nsColor: .controlBackgroundColor)) @@ -452,11 +603,146 @@ struct ChatPanelInputArea: View { } } + private var attachedFilesView: some View { + FlowLayout(mode: .scrollable, items: [chat.state.currentEditor] + chat.state.selectedFiles, itemSpacing: 4) { file in + if let select = file { + HStack(spacing: 4) { + drawFileIcon(select.url) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .foregroundColor(.secondary) + + Text(select.url.lastPathComponent) + .lineLimit(1) + .truncationMode(.middle) + .help(select.getPathRelativeToHome()) + + Button(action: { + if select.isCurrentEditor { + chat.send(.resetCurrentEditor) + } else { + chat.send(.removeSelectedFile(select)) + } + }) { + Image(systemName: "xmark") + .resizable() + .frame(width: 8, height: 8) + .foregroundColor(.secondary) + } + .buttonStyle(HoverButtonStyle()) + .help("Remove from Context") + } + .padding(4) + .cornerRadius(6) + .shadow(radius: 2) +// .background( +// RoundedRectangle(cornerRadius: r) +// .fill(.ultraThickMaterial) +// ) + .overlay( + RoundedRectangle(cornerRadius: r) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + } + .padding(.horizontal, 8) + } + + private var filePickerView: some View { + VStack(spacing: 8) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField("Search files...", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor)) + + Button(action: { + withAnimation { + isFilePickerPresented = false + } + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(HoverButtonStyle()) + .help("Close") + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.1)) + ) + .cornerRadius(6) + .padding(.horizontal, 4) + .padding(.top, 4) + + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(filteredFiles, id: \.self) { doc in + FileRowView(doc: doc) + .contentShape(Rectangle()) + .onTapGesture { + chat.send(.addSelectedFile(doc)) + } + } + + if filteredFiles.isEmpty { + Text("No results found") + .foregroundColor(.secondary) + .padding(.leading, 4) + .padding(.vertical, 4) + } + } + } + .frame(maxHeight: 200) + .padding(.horizontal, 4) + .padding(.bottom, 4) + } + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(6) + .shadow(radius: 2) +// .background( +// RoundedRectangle(cornerRadius: r) +// .fill(.ultraThickMaterial) +// ) + .overlay( + RoundedRectangle(cornerRadius: r) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .padding(.horizontal, 12) + } + + private var filteredFiles: [FileReference] { + if searchText.isEmpty { + return allFiles + } + + return allFiles.filter { doc in + (doc.fileName ?? doc.url.lastPathComponent) .localizedCaseInsensitiveContains(searchText) + } + } + + func chatTemplateCompletion(text: String) async -> [ChatTemplate] { + guard text.count >= 1 && text.first == "/" else { return [] } + let prefix = text.dropFirst() + let templates = await ChatService.shared.loadChatTemplates() ?? [] + guard !templates.isEmpty else { + return [] + } + + let skippedTemplates = [ "feedback", "help" ] + return templates.filter { $0.scopes.contains(.chatPanel) && + $0.id.hasPrefix(prefix) && !skippedTemplates.contains($0.id)} + } + func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] { guard text.count == 1 else { return [] } let plugins = [String]() // chat.pluginIdentifiers.map { "/\($0)" } let availableFeatures = plugins + [ - "/exit", +// "/exit", "@code", "@sense", "@project", @@ -475,6 +761,51 @@ struct ChatPanelInputArea: View { } return result } + func subscribeToActiveDocumentChangeEvent() { + XcodeInspector.shared.$activeDocumentURL.receive(on: DispatchQueue.main) + .sink { newDocURL in + if supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") { + let currentEditor = FileReference(url: newDocURL!, isCurrentEditor: true) + chat.send(.setCurrentEditor(currentEditor)) + } + } + .store(in: &cancellable) + } + + func submitChatMessage() { + chat.send(.sendButtonTapped(UUID().uuidString)) + } + } + + struct FileRowView: View { + @State private var isHovered = false + let doc: FileReference + + var body: some View { + HStack { + drawFileIcon(doc.url) + .resizable() + .frame(width: 16, height: 16) + .foregroundColor(.secondary) + .padding(.leading, 4) + + VStack(alignment: .leading) { + Text(doc.fileName ?? doc.url.lastPathComponent) + .font(.body) + .hoverPrimaryForeground(isHovered: isHovered) + Text(doc.relativePath ?? doc.url.path) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.vertical, 4) + .hoverRadiusBackground(isHovered: isHovered, cornerRadius: 6) + .onHover(perform: { hovering in + isHovered = hovering + }) + } } } @@ -499,10 +830,8 @@ struct ChatPanel_Preview: PreviewProvider { """, references: [ .init( - title: "Hello Hello Hello Hello", - subtitle: "Hi Hi Hi Hi", - uri: "https://google.com", - startLine: nil, + uri: "Hi Hi Hi Hi", + status: .included, kind: .class ), ] @@ -513,18 +842,6 @@ struct ChatPanel_Preview: PreviewProvider { text: "Ignored", references: [] ), - .init( - id: "6", - role: .tool, - text: """ - Searching for something... - - abc - - [def](https://1.com) - > hello - > hi - """, - references: [] - ), .init( id: "5", role: .assistant, @@ -554,7 +871,8 @@ struct ChatPanel_Preview: PreviewProvider { - (void)bar {} ``` """#, - references: [] + references: [], + followUp: .init(message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.", id: "3", type: "type") ), ] diff --git a/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift b/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift new file mode 100644 index 0000000..f99167a --- /dev/null +++ b/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift @@ -0,0 +1,105 @@ +import ConversationServiceProvider +import AppKit +import SwiftUI + +public struct ChatTemplateDropdownView: View { + @Binding var templates: [ChatTemplate] + let onSelect: (ChatTemplate) -> Void + @State private var selectedIndex = 0 + @State private var frameHeight: CGFloat = 0 + @State private var localMonitor: Any? = nil + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(templates.enumerated()), id: \.element.id) { index, template in + HStack { + Text("/" + template.id) + .hoverPrimaryForeground(isHovered: selectedIndex == index) + Spacer() + Text(template.shortDescription) + .hoverSecondaryForeground(isHovered: selectedIndex == index) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .onTapGesture { + onSelect(template) + } + .hoverBackground(isHovered: selectedIndex == index) + .onHover { isHovered in + if isHovered { + selectedIndex = index + } + } + } + } + .background( + GeometryReader { geometry in + Color.clear + .onAppear { frameHeight = geometry.size.height } + .onChange(of: geometry.size.height) { newHeight in + frameHeight = newHeight + } + } + ) + .background(.ultraThickMaterial) + .cornerRadius(6) + .shadow(radius: 2) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .frame(maxWidth: .infinity) + .offset(y: -1 * frameHeight) + .onChange(of: templates) { _ in + selectedIndex = 0 + } + .onAppear { + selectedIndex = 0 + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + switch event.keyCode { + case 126: // Up arrow + moveSelection(up: true) + case 125: // Down arrow + moveSelection(up: false) + case 36: // Return key + handleEnter() + case 48: // Tab key + handleTab() + return nil // not forwarding the Tab Event which will replace the typed message to "\t" + default: + break + } + return event + } + } + .onDisappear { + if let monitor = localMonitor { + NSEvent.removeMonitor(monitor) + localMonitor = nil + } + } + } + + private func moveSelection(up: Bool) { + guard !templates.isEmpty else { return } + let lowerBound = 0 + let upperBound = templates.count - 1 + let newIndex = selectedIndex + (up ? -1 : 1) + selectedIndex = newIndex < lowerBound ? upperBound : (newIndex > upperBound ? lowerBound : newIndex) + } + + private func handleEnter() { + handleTemplateSelection() + } + + private func handleTab() { + handleTemplateSelection() + } + + private func handleTemplateSelection() { + if templates.count > 0 && selectedIndex < templates.count { + onSelect(templates[selectedIndex]) + } + } +} diff --git a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift index cfbde1c..553f597 100644 --- a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift +++ b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift @@ -86,10 +86,13 @@ struct AsyncCodeBlockView: View { Group { if let highlighted = storage.highlighted { Text(highlighted) + .frame(maxWidth: .infinity, alignment: .leading) } else { Text(content).font(.init(font)) + .frame(maxWidth: .infinity, alignment: .leading) } } + .frame(maxWidth: .infinity) .onAppear { storage.highlight(debounce: false, for: self) } diff --git a/Core/Sources/ConversationTab/ContextUtils.swift b/Core/Sources/ConversationTab/ContextUtils.swift new file mode 100644 index 0000000..71277a6 --- /dev/null +++ b/Core/Sources/ConversationTab/ContextUtils.swift @@ -0,0 +1,77 @@ +import ConversationServiceProvider +import XcodeInspector +import Foundation +import Logger + +public let supportedFileExtensions: Set = ["swift", "m", "mm", "h", "cpp", "c", "js", "py", "rb", "java", "applescript", "scpt", "plist", "entitlements"] +private let skipPatterns: [String] = [ + ".git", + ".svn", + ".hg", + "CVS", + ".DS_Store", + "Thumbs.db", + "node_modules", + "bower_components" +] + +public struct ContextUtils { + static func matchesPatterns(_ url: URL, patterns: [String]) -> Bool { + let fileName = url.lastPathComponent + for pattern in patterns { + if fnmatch(pattern, fileName, 0) == 0 { + return true + } + } + return false + } + + public static func getFilesInActiveWorkspace() -> [FileReference] { + guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL, + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL else { + return [] + } + + do { + let fileManager = FileManager.default + let enumerator = fileManager.enumerator( + at: projectURL, + includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) + + var files: [FileReference] = [] + while let fileURL = enumerator?.nextObject() as? URL { + // Skip items matching the specified pattern + if matchesPatterns(fileURL, patterns: skipPatterns) { + enumerator?.skipDescendants() + continue + } + + let resourceValues = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) + // Handle directories if needed + if resourceValues.isDirectory == true { + continue + } + + guard resourceValues.isRegularFile == true else { continue } + if supportedFileExtensions.contains(fileURL.pathExtension.lowercased()) == false { + continue + } + + let relativePath = fileURL.path.replacingOccurrences(of: projectURL.path, with: "") + let fileName = fileURL.lastPathComponent + + let file = FileReference(url: fileURL, + relativePath: relativePath, + fileName: fileName) + files.append(file) + } + + return files + } catch { + Logger.client.error("Failed to get files in workspace: \(error)") + return [] + } + } +} diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift index 440c91f..0aa6026 100644 --- a/Core/Sources/ConversationTab/ConversationTab.swift +++ b/Core/Sources/ConversationTab/ConversationTab.swift @@ -11,6 +11,7 @@ import SwiftUI /// A chat tab that provides a context aware chat bot, powered by Chat. public class ConversationTab: ChatTab { + public static var name: String { "Chat" } public let service: ChatService @@ -18,6 +19,13 @@ public class ConversationTab: ChatTab { private var cancellable = Set() private var observer = NSObject() private let updateContentDebounce = DebounceRunner(duration: 0.5) + + // Get chat tab title. As the tab title is always "Chat" and won't be modified. + // Use the chat title as the tab title. + // TODO: modify tab title dynamicly + public func getChatTabTitle() -> String { + return chat.title + } struct RestorableState: Codable { var history: [ChatAPIService.ChatMessage] @@ -45,6 +53,10 @@ public class ConversationTab: ChatTab { public func buildTabItem() -> any View { ChatTabItemView(chat: chat) } + + public func buildChatConversationItem() -> any View { + ChatConversationItemView(chat: chat) + } public func buildIcon() -> any View { WithPerceptionTracking { diff --git a/Core/Sources/ConversationTab/Styles.swift b/Core/Sources/ConversationTab/Styles.swift index 6c117c9..a4b5ddf 100644 --- a/Core/Sources/ConversationTab/Styles.swift +++ b/Core/Sources/ConversationTab/Styles.swift @@ -42,14 +42,16 @@ extension View { FontFamilyVariant(.monospaced) FontSize(.em(0.85)) } - .padding(16) - .padding(.top, 14) + .padding(.leading, 8) + .padding(.top, 24) + .padding(.bottom, 8) } func codeBlockStyle( _ configuration: CodeBlockConfiguration, backgroundColor: Color, - labelColor: Color + labelColor: Color, + insertAction: (() -> Void)? = nil ) -> some View { background(backgroundColor) .clipShape(RoundedRectangle(cornerRadius: 6)) @@ -61,16 +63,27 @@ extension View { .padding(.leading, 8) .lineLimit(1) Spacer() - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(configuration.content, forType: .string) + + HStack(spacing: 4) { + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(configuration.content, forType: .string) + } + + InsertButton { + if let insertAction = insertAction { + insertAction() + } + } } } + .padding(.trailing, 8) } .overlay { RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.05), lineWidth: 1) } .markdownMargin(top: 4, bottom: 16) + .frame(maxWidth: .infinity) } } @@ -165,3 +178,11 @@ struct RoundedCorners: Shape { } } +// Chat Message Styles +extension View { + func chatMessageHeaderTextStyle() -> some View { + // semibold -> 600 + font(.system(size: 13, weight: .semibold)) + } +} + diff --git a/Core/Sources/ConversationTab/ViewExtension.swift b/Core/Sources/ConversationTab/ViewExtension.swift new file mode 100644 index 0000000..6a4a0f8 --- /dev/null +++ b/Core/Sources/ConversationTab/ViewExtension.swift @@ -0,0 +1,59 @@ +import SwiftUI + +let BLUE_IN_LIGHT_THEME = Color(red: 98/255, green: 154/255, blue: 248/255) +let BLUE_IN_DARK_THEME = Color(red: 55/255, green: 108/255, blue: 194/255) + +struct HoverBackgroundModifier: ViewModifier { + @Environment(\.colorScheme) var colorScheme + var isHovered: Bool + + func body(content: Content) -> some View { + content + .background(isHovered ? (colorScheme == .dark ? BLUE_IN_DARK_THEME : BLUE_IN_LIGHT_THEME) : Color.clear) + } +} + +struct HoverRadiusBackgroundModifier: ViewModifier { + @Environment(\.colorScheme) var colorScheme + var isHovered: Bool + var cornerRadius: CGFloat = 0 + + func body(content: Content) -> some View { + content.background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(isHovered ? (colorScheme == .dark ? BLUE_IN_DARK_THEME : BLUE_IN_LIGHT_THEME) : Color.clear) + ) + } +} + +struct HoverForegroundModifier: ViewModifier { + @Environment(\.colorScheme) var colorScheme + var isHovered: Bool + var defaultColor: Color + + func body(content: Content) -> some View { + content.foregroundColor(isHovered ? Color.white : defaultColor) + } +} + +extension View { + public func hoverBackground(isHovered: Bool) -> some View { + self.modifier(HoverBackgroundModifier(isHovered: isHovered)) + } + + public func hoverRadiusBackground(isHovered: Bool, cornerRadius: CGFloat) -> some View { + self.modifier(HoverRadiusBackgroundModifier(isHovered: isHovered, cornerRadius: cornerRadius)) + } + + public func hoverForeground(isHovered: Bool, defaultColor: Color) -> some View { + self.modifier(HoverForegroundModifier(isHovered: isHovered, defaultColor: defaultColor)) + } + + public func hoverPrimaryForeground(isHovered: Bool) -> some View { + self.hoverForeground(isHovered: isHovered, defaultColor: .primary) + } + + public func hoverSecondaryForeground(isHovered: Bool) -> some View { + self.hoverForeground(isHovered: isHovered, defaultColor: .secondary) + } +} diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index d71fc31..511d603 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -4,76 +4,128 @@ import Foundation import MarkdownUI import SharedUIComponents import SwiftUI +import ConversationServiceProvider + struct BotMessage: View { var r: Double { messageBubbleCornerRadius } let id: String let text: String - let references: [DisplayedChatMessage.Reference] + let references: [ConversationReference] + let followUp: ConversationFollowUp? + let errorMessage: String? let chat: StoreOf @Environment(\.colorScheme) var colorScheme + @AppStorage(\.chatFontSize) var chatFontSize @State var isReferencesPresented = false - @State var isReferencesHovered = false - - var body: some View { - HStack(alignment: .bottom) { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 0) { - Spacer() // Pushes the buttons to the right - UpvoteButton { rating in - chat.send(.upvote(id, rating)) - } - - DownvoteButton { rating in - chat.send(.downvote(id, rating)) + + struct ResponseToolBar: View { + let id: String + let chat: StoreOf + let text: String + + var body: some View { + HStack(spacing: 4) { + + UpvoteButton { rating in + chat.send(.upvote(id, rating)) + } + + DownvoteButton { rating in + chat.send(.downvote(id, rating)) + } + + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + chat.send(.copyCode(id)) + } + + Spacer() // Pushes the buttons to the left + } + } + } + + struct ReferenceButton: View { + var r: Double { messageBubbleCornerRadius } + let references: [ConversationReference] + let chat: StoreOf + + @Binding var isReferencesPresented: Bool + + @State var isReferencesHovered = false + + @AppStorage(\.chatFontSize) var chatFontSize + + func MakeReferenceTitle(references: [ConversationReference]) -> String { + guard !references.isEmpty else { + return "" + } + + let count = references.count + let title = count > 1 ? "Used \(count) references" : "Used \(count) reference" + return title + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Button(action: { + isReferencesPresented.toggle() + }, label: { + HStack(spacing: 4) { + Image(systemName: isReferencesPresented ? "chevron.down" : "chevron.right") + + Text(MakeReferenceTitle(references: references)) + .font(.system(size: chatFontSize)) } - - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - chat.send(.copyCode(id)) + .background { + RoundedRectangle(cornerRadius: r - 4) + .fill(isReferencesHovered ? Color.gray.opacity(0.1) : Color.clear) } + .foregroundStyle(.secondary) + }) + .buttonStyle(HoverButtonStyle()) + + if isReferencesPresented { + ReferenceList(references: references, chat: chat) + .background( + RoundedRectangle(cornerRadius: 5) + .stroke(Color.gray, lineWidth: 0.2) + ) } + } + } + } + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 8) { + CopilotMessageHeader() + .padding(.leading, 6) if !references.isEmpty { - Button(action: { - isReferencesPresented.toggle() - }, label: { - HStack(spacing: 4) { - Image(systemName: "plus.circle") - Text("Used \(references.count) references") - } - .padding(8) - .background { - RoundedRectangle(cornerRadius: r - 4) - .foregroundStyle(Color(isReferencesHovered ? .black : .clear)) - } - .overlay { - RoundedRectangle(cornerRadius: r - 4) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - .foregroundStyle(.secondary) - }) - .buttonStyle(.plain) - .popover(isPresented: $isReferencesPresented, arrowEdge: .trailing) { - ReferenceList(references: references, chat: chat) + WithPerceptionTracking { + ReferenceButton( + references: references, + chat: chat, + isReferencesPresented: $isReferencesPresented + ) } } - ThemedMarkdownText(text) - } - .frame(alignment: .trailing) - .padding() - .background { - RoundedCorners(tl: r, tr: r, bl: 0, br: r) - .fill(Color.contentBackground) - } - .overlay { - RoundedCorners(tl: r, tr: r, bl: 0, br: r) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ThemedMarkdownText(text: text, chat: chat) + + if errorMessage != nil { + HStack(spacing: 4) { + Image(systemName: "info.circle") + Text(errorMessage!) + .font(.system(size: chatFontSize)) + } + } + + ResponseToolBar(id: id, chat: chat, text: text) } - .padding(.leading, 8) .shadow(color: .black.opacity(0.05), radius: 6) .contextMenu { Button("Copy") { @@ -92,132 +144,76 @@ struct BotMessage: View { } } } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.trailing, 2) } } struct ReferenceList: View { - let references: [DisplayedChatMessage.Reference] + let references: [ConversationReference] let chat: StoreOf - var body: some View { - WithPerceptionTracking { - ScrollView { - VStack(alignment: .leading, spacing: 8) { - ForEach(0.. + @AppStorage(\.chatFontSize) var chatFontSize + @Binding var itemHeight: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(0.. let id: String let text: String @AppStorage(\.chatFontSize) var chatFontSize + @Environment(\.openURL) private var openURL + + private let displayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .short + return formatter + }() + + private func extractDate(from text: String) -> Date? { + guard let match = (try? NSRegularExpression(pattern: "until (.*?) for"))? + .firstMatch(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)), + let dateRange = Range(match.range(at: 1), in: text) else { + return nil + } + + let dateString = String(text[dateRange]) + let formatter = DateFormatter() + formatter.dateFormat = "M/d/yyyy, h:mm:ss a" + return formatter.date(from: dateString) + } var body: some View { - Markdown(text) - .textSelection(.enabled) - .markdownTheme(.functionCall(fontSize: chatFontSize)) - .padding(.vertical, 2) - .padding(.trailing, 2) + VStack(alignment: .leading, spacing: 8) { + HStack { + Image("CopilotLogo") + .resizable() + .renderingMode(.template) + .scaledToFill() + .frame(width: 12, height: 12) + .overlay( + Circle() + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + .frame(width: 24, height: 24) + ) + .padding(.leading, 8) + + Text("GitHub Copilot") + .font(.system(size: 13)) + .fontWeight(.semibold) + .padding(4) + + Spacer() + } + + VStack(alignment: .leading, spacing: 16) { + Text("You've reached your monthly chat limit for GitHub Copilot Free.") + .font(.system(size: 13)) + .fontWeight(.medium) + + if let date = extractDate(from: text) { + Text("Upgrade to Copilot Pro with a 30-day free trial or wait until \(displayFormatter.string(from: date)) for your limit to reset.") + .font(.system(size: 13)) + } + + Button("Update to Copilot Pro") { + if let url = URL(string: "https://github.com/github-copilot/signup/copilot_individual") { + openURL(url) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + +// HStack { +// Button(action: { +// // Add your refresh action here +// }) { +// Image(systemName: "arrow.clockwise") +// .resizable() +// .aspectRatio(contentMode: .fit) +// .frame(width: 14, height: 14) +// .frame(width: 20, height: 20, alignment: .center) +// .foregroundColor(.secondary) +// .background( +// .regularMaterial, +// in: RoundedRectangle(cornerRadius: 4, style: .circular) +// ) +// .padding(4) +// } +// .buttonStyle(.borderless) +// +// DownvoteButton { rating in +// chat.send(.downvote(id, rating)) +// } +// +// Button(action: { +// // Add your more options action here +// }) { +// Image(systemName: "ellipsis") +// .resizable() +// .aspectRatio(contentMode: .fit) +// .frame(width: 14, height: 14) +// .frame(width: 20, height: 20, alignment: .center) +// .foregroundColor(.secondary) +// .background( +// .regularMaterial, +// in: RoundedRectangle(cornerRadius: 4, style: .circular) +// ) +// .padding(4) +// } +// .buttonStyle(.borderless) +// } + } + .padding(.vertical, 12) } } #Preview { - FunctionMessage(id: "1", text: """ - Searching for something... - - abc - - [def](https://1.com) - > hello - > hi - """) + FunctionMessage( + chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service()) }), + id: "1", + text: "You've reached your monthly chat limit. Upgrade to Copilot Pro (30-day free trial) or wait until 1/17/2025, 8:00:00 AM for your limit to reset." + ) .padding() .fixedSize() } diff --git a/Core/Sources/ConversationTab/Views/Instructions.swift b/Core/Sources/ConversationTab/Views/Instructions.swift deleted file mode 100644 index 8ee892c..0000000 --- a/Core/Sources/ConversationTab/Views/Instructions.swift +++ /dev/null @@ -1,39 +0,0 @@ -import ComposableArchitecture -import Foundation -import MarkdownUI -import SwiftUI - -struct Instruction: View { - let chat: StoreOf - - var body: some View { - WithPerceptionTracking { - Group { - Markdown( - """ - Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. - """ - ) - .modifier(InstructionModifier()) - } - } - } - - struct InstructionModifier: ViewModifier { - @AppStorage(\.chatFontSize) var chatFontSize - - func body(content: Content) -> some View { - content - .textSelection(.enabled) - .markdownTheme(.instruction(fontSize: chatFontSize)) - .opacity(0.8) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - } - } -} - diff --git a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift index eca57a2..fa133e6 100644 --- a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift +++ b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift @@ -1,6 +1,9 @@ import Foundation import MarkdownUI import SwiftUI +import ChatService +import ComposableArchitecture +import SuggestionBasic struct ThemedMarkdownText: View { @AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme @@ -13,9 +16,11 @@ struct ThemedMarkdownText: View { @Environment(\.colorScheme) var colorScheme let text: String + let chat: StoreOf - init(_ text: String) { + init(text: String, chat: StoreOf) { self.text = text + self.chat = chat } var body: some View { @@ -46,7 +51,8 @@ struct ThemedMarkdownText: View { } } return Color.secondary.opacity(0.7) - }() + }(), + chat: chat )) } } @@ -58,7 +64,8 @@ extension MarkdownUI.Theme { fontSize: Double, codeFont: NSFont, codeBlockBackgroundColor: Color, - codeBlockLabelColor: Color + codeBlockLabelColor: Color, + chat: StoreOf ) -> MarkdownUI.Theme { .gitHub.text { ForegroundColor(.primary) @@ -66,37 +73,73 @@ extension MarkdownUI.Theme { FontSize(fontSize) } .codeBlock { configuration in - let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock) + MarkdownCodeBlockView( + codeBlockConfiguration: configuration, + codeFont: codeFont, + codeBlockBackgroundColor: codeBlockBackgroundColor, + codeBlockLabelColor: codeBlockLabelColor, + chat: chat + ) + } + } +} + +struct MarkdownCodeBlockView: View { + let codeBlockConfiguration: CodeBlockConfiguration + let codeFont: NSFont + let codeBlockBackgroundColor: Color + let codeBlockLabelColor: Color + let chat: StoreOf + + func insertCode() { + chat.send(.insertCode(codeBlockConfiguration.content)) + } + + var body: some View { + let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock) - if wrapCode { + if wrapCode { + AsyncCodeBlockView( + fenceInfo: codeBlockConfiguration.language, + content: codeBlockConfiguration.content, + font: codeFont + ) + .codeBlockLabelStyle() + .codeBlockStyle( + codeBlockConfiguration, + backgroundColor: codeBlockBackgroundColor, + labelColor: codeBlockLabelColor, + insertAction: insertCode + ) + } else { + ScrollView(.horizontal) { AsyncCodeBlockView( - fenceInfo: configuration.language, - content: configuration.content, + fenceInfo: codeBlockConfiguration.language, + content: codeBlockConfiguration.content, font: codeFont ) .codeBlockLabelStyle() - .codeBlockStyle( - configuration, - backgroundColor: codeBlockBackgroundColor, - labelColor: codeBlockLabelColor - ) - } else { - ScrollView(.horizontal) { - AsyncCodeBlockView( - fenceInfo: configuration.language, - content: configuration.content, - font: codeFont - ) - .codeBlockLabelStyle() - } - .workaroundForVerticalScrollingBugInMacOS() - .codeBlockStyle( - configuration, - backgroundColor: codeBlockBackgroundColor, - labelColor: codeBlockLabelColor - ) } + .workaroundForVerticalScrollingBugInMacOS() + .codeBlockStyle( + codeBlockConfiguration, + backgroundColor: codeBlockBackgroundColor, + labelColor: codeBlockLabelColor, + insertAction: insertCode + ) } } } +#Preview("Themed Markdown Text") { + ThemedMarkdownText( + text:""" +```swift +let sumClosure: (Int, Int) -> Int = { (a: Int, b: Int) in + return a + b +} +``` +""", + chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service()) })) +} + diff --git a/Core/Sources/ConversationTab/Views/UserMessage.swift b/Core/Sources/ConversationTab/Views/UserMessage.swift index e6917bb..0342bda 100644 --- a/Core/Sources/ConversationTab/Views/UserMessage.swift +++ b/Core/Sources/ConversationTab/Views/UserMessage.swift @@ -4,6 +4,8 @@ import Foundation import MarkdownUI import SharedUIComponents import SwiftUI +import Status +import Cache struct UserMessage: View { var r: Double { messageBubbleCornerRadius } @@ -11,27 +13,44 @@ struct UserMessage: View { let text: String let chat: StoreOf @Environment(\.colorScheme) var colorScheme + @ObservedObject private var statusObserver = StatusObserver.shared + + struct AvatarView: View { + @ObservedObject private var avatarViewModel = AvatarViewModel.shared + + var body: some View { + if let avatarImage = avatarViewModel.avatarImage { + avatarImage + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 24, height: 24) + .clipShape(Circle()) + } else { + Image(systemName: "person.circle") + .resizable() + .frame(width: 24, height: 24) + } + } + } var body: some View { - HStack() { - Spacer() - VStack(alignment: .trailing) { - ThemedMarkdownText(text) - .frame(alignment: .leading) - .padding() - } - .background { - RoundedCorners(tl: r, tr: r, bl: r, br: 0) - .fill(Color.userChatContentBackground) - } - .overlay { - RoundedCorners(tl: r, tr: r, bl: r, br: 0) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + HStack { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + AvatarView() + + Text(statusObserver.authStatus.username ?? "") + .chatMessageHeaderTextStyle() + .padding(2) + + Spacer() + } + + ThemedMarkdownText(text: text, chat: chat) + .frame(maxWidth: .infinity, alignment: .leading) } - .shadow(color: .black.opacity(0.05), radius: 6) } - .padding(.leading, 8) - .padding(.trailing, 8) + .shadow(color: .black.opacity(0.05), radius: 6) } } @@ -58,5 +77,6 @@ struct UserMessage: View { ) .padding() .fixedSize(horizontal: true, vertical: true) + .background(Color.yellow) } diff --git a/Core/Sources/HostApp/GitHubCopilotViewModel.swift b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift similarity index 54% rename from Core/Sources/HostApp/GitHubCopilotViewModel.swift rename to Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift index bbd01f2..7c369ed 100644 --- a/Core/Sources/HostApp/GitHubCopilotViewModel.swift +++ b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift @@ -3,48 +3,79 @@ import GitHubCopilotService import ComposableArchitecture import Status import SwiftUI +import Cache -struct SignInResponse { - let userCode: String - let verificationURL: URL +public struct SignInResponse { + public let status: SignInInitiateStatus + public let userCode: String + public let verificationURL: URL } @MainActor -class GitHubCopilotViewModel: ObservableObject { +public class GitHubCopilotViewModel: ObservableObject { + // Add static shared instance + public static let shared = GitHubCopilotViewModel() + @Dependency(\.toast) var toast @Dependency(\.openURL) var openURL @AppStorage("username") var username: String = "" - @Published var isRunningAction: Bool = false - @Published var status: GitHubCopilotAccountStatus? - @Published var version: String? - @Published var userCode: String? - @Published var isSignInAlertPresented = false - @Published var signInResponse: SignInResponse? - @Published var waitingForSignIn = false + @Published public var isRunningAction: Bool = false + @Published public var status: GitHubCopilotAccountStatus? + @Published public var version: String? + @Published public var userCode: String? + @Published public var isSignInAlertPresented = false + @Published public var signInResponse: SignInResponse? + @Published public var waitingForSignIn = false + + static var copilotAuthService: GitHubCopilotService? - static var copilotAuthService: GitHubCopilotAuthServiceType? + // Make init private to enforce singleton pattern + private init() {} - func getGitHubCopilotAuthService() throws -> GitHubCopilotAuthServiceType { + public func getGitHubCopilotAuthService() throws -> GitHubCopilotService { if let service = Self.copilotAuthService { return service } let service = try GitHubCopilotService() Self.copilotAuthService = service return service } - func signIn() { + public func preSignIn() async throws -> SignInResponse? { + let service = try getGitHubCopilotAuthService() + let result = try await service.signInInitiate() + + if result.status == .alreadySignedIn { + guard let user = result.user else { + toast("Missing user info.", .error) + throw NSError(domain: "Missing user info.", code: 0, userInfo: nil) + } + await Status.shared.updateAuthStatus(.loggedIn, username: user) + self.username = user + broadcastStatusChange() + return nil + } + + guard let uri = result.verificationUri, + let userCode = result.userCode, + let url = URL(string: uri) else { + toast("Verification URI is incorrect.", .error) + throw NSError(domain: "Verification URI is incorrect.", code: 0, userInfo: nil) + } + return SignInResponse( + status: SignInInitiateStatus.promptUserDeviceFlow, + userCode: userCode, + verificationURL: url + ) + } + + public func signIn() { Task { isRunningAction = true defer { isRunningAction = false } do { - let service = try getGitHubCopilotAuthService() - let (uri, userCode) = try await service.signInInitiate() - guard let url = URL(string: uri) else { - toast("Verification URI is incorrect.", .error) - return - } - self.signInResponse = .init(userCode: userCode, verificationURL: url) + guard let result = try await preSignIn() else { return } + self.signInResponse = result self.isSignInAlertPresented = true } catch { toast(error.localizedDescription, .error) @@ -52,7 +83,7 @@ class GitHubCopilotViewModel: ObservableObject { } } - func checkStatus() { + public func checkStatus() { Task { isRunningAction = true defer { isRunningAction = false } @@ -67,25 +98,35 @@ class GitHubCopilotViewModel: ObservableObject { } } - func signOut() { + public func signOut() { Task { isRunningAction = true defer { isRunningAction = false } do { let service = try getGitHubCopilotAuthService() status = try await service.signOut() + await Status.shared.updateAuthStatus(.notLoggedIn) + await Status.shared.updateCLSStatus(.unknown, message: "") + username = "" broadcastStatusChange() } catch { toast(error.localizedDescription, .error) } + + // Sign out all other CLS instances + do { + try await GitHubCopilotService.signOutAll() + } catch { + // ignore + } } } - func cancelWaiting() { + public func cancelWaiting() { waitingForSignIn = false } - func copyAndOpen() { + public func copyAndOpen() { waitingForSignIn = true guard let signInResponse else { toast("Missing sign in details.", .error) @@ -101,7 +142,7 @@ class GitHubCopilotViewModel: ObservableObject { } } - func waitForSignIn() { + public func waitForSignIn() { Task { do { guard waitingForSignIn else { return } @@ -114,6 +155,7 @@ class GitHubCopilotViewModel: ObservableObject { waitingForSignIn = false self.username = username self.status = status + await Status.shared.updateAuthStatus(.loggedIn, username: username) broadcastStatusChange() } catch let error as GitHubCopilotError { if case .languageServerError(.timeout) = error { @@ -129,7 +171,7 @@ class GitHubCopilotViewModel: ObservableObject { } } - func broadcastStatusChange() { + public func broadcastStatusChange() { DistributedNotificationCenter.default().post( name: .authStatusDidChange, object: nil diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index 5bcfe18..80bfcf5 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -67,6 +67,7 @@ struct General { _ = try await service .send(requestBody: ExtensionServiceRequests.OpenExtensionManager()) } catch { + Logger.ui.error("Failed to open extension manager. \(error.localizedDescription)") toast(error.localizedDescription, .error) await send(.failedReloading) } diff --git a/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift b/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift index 8fd3a5e..837f304 100644 --- a/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift +++ b/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import GitHubCopilotService +import GitHubCopilotViewModel import SwiftUI struct AppInfoView: View { @@ -68,7 +69,7 @@ struct AppInfoView: View { #Preview { AppInfoView( - viewModel: .init(), + viewModel: GitHubCopilotViewModel.shared, store: .init(initialState: .init(), reducer: { General() }) ) } diff --git a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift index 0b62b86..aeb8bd7 100644 --- a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift +++ b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift @@ -1,10 +1,11 @@ import ComposableArchitecture +import GitHubCopilotViewModel import SwiftUI struct CopilotConnectionView: View { @AppStorage("username") var username: String = "" @Environment(\.toast) var toast - @StateObject var viewModel = GitHubCopilotViewModel() + @StateObject var viewModel: GitHubCopilotViewModel let store: StoreOf @@ -99,16 +100,16 @@ struct CopilotConnectionView: View { #Preview { CopilotConnectionView( - viewModel: .init(), + viewModel: GitHubCopilotViewModel.shared, store: .init(initialState: .init(), reducer: { General() }) ) } #Preview("Running") { - let runningModel = GitHubCopilotViewModel() + let runningModel = GitHubCopilotViewModel.shared runningModel.isRunningAction = true return CopilotConnectionView( - viewModel: runningModel, + viewModel: GitHubCopilotViewModel.shared, store: .init(initialState: .init(), reducer: { General() }) ) } diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index b4f5d8a..7ba6283 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -1,9 +1,10 @@ import ComposableArchitecture +import GitHubCopilotViewModel import SwiftUI struct GeneralView: View { let store: StoreOf - @StateObject private var viewModel = GitHubCopilotViewModel() + @StateObject private var viewModel = GitHubCopilotViewModel.shared var body: some View { ScrollView { diff --git a/Core/Sources/HostApp/SharedComponents/SettingsSection.swift b/Core/Sources/HostApp/SharedComponents/SettingsSection.swift index e52d9ad..1526e80 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsSection.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsSection.swift @@ -21,7 +21,7 @@ struct SettingsSection: View { .padding(.horizontal, 10) if showWarning { HStack{ - Text("GitHub Copilot features are disabled. Please check your subscription to access them.") + Text("GitHub Copilot features are disabled. Please [check your subscription](https://github.com/settings/copilot) to access them.") .foregroundColor(Color("WarningForegroundColor")) .padding(4) Spacer() diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index f21e2da..7d7ac08 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -19,9 +19,9 @@ struct GUI { struct State: Equatable { var suggestionWidgetState = WidgetFeature.State() - var chatTabGroup: ChatPanelFeature.ChatTabGroup { - get { suggestionWidgetState.chatPanelState.chatTabGroup } - set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue } + var chatHistory: ChatHistory { + get { suggestionWidgetState.chatPanelState.chatHistory } + set { suggestionWidgetState.chatPanelState.chatHistory = newValue } } var promptToCodeGroup: PromptToCodeGroup.State { @@ -34,11 +34,13 @@ struct GUI { case start case openChatPanel(forceDetach: Bool) case createAndSwitchToChatTabIfNeeded - case createAndSwitchToBrowserTabIfNeeded(url: URL) +// case createAndSwitchToBrowserTabIfNeeded(url: URL) case sendCustomCommandToActiveChat(CustomCommand) case toggleWidgetsHotkeyPressed case suggestionWidget(WidgetFeature.Action) + case switchWorkspace(path: String, name: String) + case initWorkspaceChatTabIfNeeded(path: String) static func promptToCodeGroup(_ action: PromptToCodeGroup.Action) -> Self { .suggestionWidget(.panel(.sharedPanel(.promptToCodeGroup(action)))) @@ -63,22 +65,27 @@ struct GUI { } Scope( - state: \.chatTabGroup, + state: \.chatHistory, action: \.suggestionWidget.chatPanel ) { Reduce { _, action in switch action { case let .createNewTapButtonClicked(kind): +// return .run { send in +// if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) { +// await send(.createNewTab(chatTabInfo)) +// } +// } return .run { send in if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) { await send(.appendAndSelectTab(chatTabInfo)) } } - case let .closeTabButtonClicked(id): - return .run { _ in - chatTabPool.removeTab(of: id) - } +// case let .closeTabButtonClicked(id): +// return .run { _ in +// chatTabPool.removeTab(of: id) +// } case let .chatTab(_, .openNewTab(builder)): return .run { send in @@ -125,14 +132,15 @@ struct GUI { } case .createAndSwitchToChatTabIfNeeded: - if let selectedTabInfo = state.chatTabGroup.selectedTabInfo, + + if let currentChatWorkspace = state.chatHistory.currentChatWorkspace, let selectedTabInfo = currentChatWorkspace.selectedTabInfo, chatTabPool.getTab(of: selectedTabInfo.id) is ConversationTab { // Already in Chat tab return .none } - if let firstChatTabInfo = state.chatTabGroup.tabInfo.first(where: { + if let firstChatTabInfo = state.chatHistory.currentChatWorkspace?.tabInfo.first(where: { chatTabPool.getTab(of: $0.id) is ConversationTab }) { return .run { send in @@ -149,52 +157,69 @@ struct GUI { } } - case let .createAndSwitchToBrowserTabIfNeeded(url): - #if canImport(BrowserChatTab) - func match(_ tabURL: URL?) -> Bool { - guard let tabURL else { return false } - return tabURL == url - || tabURL.absoluteString.hasPrefix(url.absoluteString) - } - - if let selectedTabInfo = state.chatTabGroup.selectedTabInfo, - let tab = chatTabPool.getTab(of: selectedTabInfo.id) as? BrowserChatTab, - match(tab.url) - { - // Already in the target Browser tab - return .none - } - - if let firstChatTabInfo = state.chatTabGroup.tabInfo.first(where: { - guard let tab = chatTabPool.getTab(of: $0.id) as? BrowserChatTab, - match(tab.url) - else { return false } - return true - }) { - return .run { send in - await send(.suggestionWidget(.chatPanel(.tabClicked( - id: firstChatTabInfo.id - )))) - } + case let .switchWorkspace(path, name): + return .run { send in + await send( + .suggestionWidget(.chatPanel(.switchWorkspace(path, name))) + ) + await send(.initWorkspaceChatTabIfNeeded(path: path)) } - + case let .initWorkspaceChatTabIfNeeded(path): + guard let chatWorkspace = state.chatHistory.workspaces[id: path], chatWorkspace.tabInfo.isEmpty + else { return .none } return .run { send in - if let (_, chatTabInfo) = await chatTabPool.createTab( - for: .init(BrowserChatTab.urlChatBuilder( - url: url, - externalDependency: ChatTabFactory - .externalDependenciesForBrowserChatTab() - )) - ) { + if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil) { await send( - .suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo))) - ) + .suggestionWidget(.chatPanel(.appendTabToWorkspace(chatTabInfo, chatWorkspace))) + ) } } - - #else - return .none - #endif +// case let .createAndSwitchToBrowserTabIfNeeded(url): +// #if canImport(BrowserChatTab) +// func match(_ tabURL: URL?) -> Bool { +// guard let tabURL else { return false } +// return tabURL == url +// || tabURL.absoluteString.hasPrefix(url.absoluteString) +// } +// +// if let selectedTabInfo = state.chatTabGroup.selectedTabInfo, +// let tab = chatTabPool.getTab(of: selectedTabInfo.id) as? BrowserChatTab, +// match(tab.url) +// { +// // Already in the target Browser tab +// return .none +// } +// +// if let firstChatTabInfo = state.chatTabGroup.tabInfo.first(where: { +// guard let tab = chatTabPool.getTab(of: $0.id) as? BrowserChatTab, +// match(tab.url) +// else { return false } +// return true +// }) { +// return .run { send in +// await send(.suggestionWidget(.chatPanel(.tabClicked( +// id: firstChatTabInfo.id +// )))) +// } +// } +// +// return .run { send in +// if let (_, chatTabInfo) = await chatTabPool.createTab( +// for: .init(BrowserChatTab.urlChatBuilder( +// url: url, +// externalDependency: ChatTabFactory +// .externalDependenciesForBrowserChatTab() +// )) +// ) { +// await send( +// .suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo))) +// ) +// } +// } +// +// #else +// return .none +// #endif case let .sendCustomCommandToActiveChat(command): @Sendable func stopAndHandleCommand(_ tab: ConversationTab) async { @@ -204,7 +229,7 @@ struct GUI { try? await tab.service.handleCustomCommand(command) } - if let info = state.chatTabGroup.selectedTabInfo, + if let info = state.chatHistory.currentChatWorkspace?.selectedTabInfo, let activeTab = chatTabPool.getTab(of: info.id) as? ConversationTab { return .run { send in @@ -213,13 +238,15 @@ struct GUI { } } - if let info = state.chatTabGroup.tabInfo.first(where: { + if var chatWorkspace = state.chatHistory.currentChatWorkspace, let info = chatWorkspace.tabInfo.first(where: { chatTabPool.getTab(of: $0.id) is ConversationTab }), let chatTab = chatTabPool.getTab(of: info.id) as? ConversationTab { - state.chatTabGroup.selectedTabId = chatTab.id + chatWorkspace.selectedTabId = chatTab.id + let updatedChatWorkspace = chatWorkspace return .run { send in + await send(.suggestionWidget(.chatPanel(.updateChatHistory(updatedChatWorkspace)))) await send(.openChatPanel(forceDetach: false)) await stopAndHandleCommand(chatTab) } @@ -252,15 +279,15 @@ struct GUI { return .none #endif - case let .suggestionWidget(.chatPanel(.closeTabButtonClicked(id))): - #if canImport(ChatTabPersistent) - // when a tab is closed, remove it from persistence. - return .run { send in - await send(.persistent(.chatTabClosed(id: id))) - } - #else - return .none - #endif +// case let .suggestionWidget(.chatPanel(.closeTabButtonClicked(id))): +// #if canImport(ChatTabPersistent) +// // when a tab is closed, remove it from persistence. +// return .run { send in +// await send(.persistent(.chatTabClosed(id: id))) +// } +// #else +// return .none +// #endif case .suggestionWidget: return .none @@ -271,20 +298,21 @@ struct GUI { #endif } } - }.onChange(of: \.chatTabGroup.tabInfo) { old, new in - Reduce { _, _ in - guard old.map(\.id) != new.map(\.id) else { - return .none - } - #if canImport(ChatTabPersistent) - return .run { send in - await send(.persistent(.chatOrderChanged)) - }.debounce(id: Debounce.updateChatTabOrder, for: 1, scheduler: DispatchQueue.main) - #else - return .none - #endif - } } +// .onChange(of: \.chatCollection.selectedChatGroup?.tabInfo) { old, new in +// Reduce { _, _ in +// guard old.map(\.id) != new.map(\.id) else { +// return .none +// } +// #if canImport(ChatTabPersistent) +// return .run { send in +// await send(.persistent(.chatOrderChanged)) +// }.debounce(id: Debounce.updateChatTabOrder, for: 1, scheduler: DispatchQueue.main) +// #else +// return .none +// #endif +// } +// } } } @@ -340,7 +368,7 @@ public final class GraphicalUserInterfaceController { chatTabPool.createStore = { id in store.scope( state: { state in - state.chatTabGroup.tabInfo[id: id] ?? .init(id: id, title: "") + state.chatHistory.currentChatWorkspace?.tabInfo[id: id] ?? .init(id: id, title: "") }, action: { childAction in .suggestionWidget(.chatPanel(.chatTab(id: id, action: childAction))) diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 3fb9afa..463f7e9 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -77,6 +77,7 @@ public final class Service { let scheduledCleaner = ScheduledCleaner() scheduledCleaner.service = self + Logger.telemetryLogger = TelemetryLogger() } @MainActor @@ -95,10 +96,23 @@ public final class Service { .compactMap { $0 } .sink { [weak self] fileURL in Task { - try await self?.workspacePool - .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + do { + try await self?.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + } catch { + Logger.workspacePool.error(error) + } } }.store(in: &cancellable) + + await XcodeInspector.shared.safe.$activeWorkspaceURL.receive(on: DispatchQueue.main) + .sink { newURL in + if let path = newURL?.path, self.guiController.store.chatHistory.selectedWorkspacePath != path { + let name = self.getDisplayNameOfXcodeWorkspace(url: newURL!) + self.guiController.store.send(.switchWorkspace(path: path, name: name)) + } + + }.store(in: &cancellable) } } @@ -108,6 +122,18 @@ public final class Service { keyBindingManager.stopForExit() await scheduledCleaner.closeAllChildProcesses() } + + private func getDisplayNameOfXcodeWorkspace(url: URL) -> String { + var name = url.lastPathComponent + let suffixes = [".xcworkspace", ".xcodeproj"] + for suffix in suffixes { + if name.hasSuffix(suffix) { + name = String(name.dropLast(suffix.count)) + break + } + } + return name + } } public extension Service { diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index ac0b46e..f919ae7 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -9,6 +9,7 @@ import Workspace import WorkspaceSuggestionService import XcodeInspector import XPCShared +import AXHelper /// It's used to run some commands without really triggering the menu bar item. /// @@ -89,6 +90,13 @@ struct PseudoCommandHandler { cursorPosition: editorContent.cursorPosition ) } + if !filespace.errorMessage.isEmpty { + presenter + .presentWarningMessage( + filespace.errorMessage, + url: "https://github.com/github-copilot/signup/copilot_individual" + ) + } if filespace.presentingSuggestion != nil { presenter.presentSuggestion(fileURL: fileURL) workspace.notifySuggestionShown(fileFileAt: fileURL) @@ -324,56 +332,14 @@ extension PseudoCommandHandler { _ result: UpdatedContent, focusElement: AXUIElement ) throws { - let oldPosition = focusElement.selectedTextRange - let oldScrollPosition = focusElement.parent?.verticalScrollBar?.doubleValue - - let error = AXUIElementSetAttributeValue( - focusElement, - kAXValueAttribute as CFString, - result.content as CFTypeRef - ) - - if error != AXError.success { - PresentInWindowSuggestionPresenter() - .presentErrorMessage("Fail to set editor content.") - } - - // recover selection range - - if let selection = result.newSelection { - var range = SourceEditor.convertCursorRangeToRange(selection, in: result.content) - if let value = AXValueCreate(.cfRange, &range) { - AXUIElementSetAttributeValue( - focusElement, - kAXSelectedTextRangeAttribute as CFString, - value - ) - } - } else if let oldPosition { - var range = CFRange( - location: oldPosition.lowerBound, - length: 0 - ) - if let value = AXValueCreate(.cfRange, &range) { - AXUIElementSetAttributeValue( - focusElement, - kAXSelectedTextRangeAttribute as CFString, - value - ) + try AXHelper().injectUpdatedCodeWithAccessibilityAPI( + result, + focusElement: focusElement, + onError: { + PresentInWindowSuggestionPresenter() + .presentErrorMessage("Fail to set editor content.") } - } - - // recover scroll position - - if let oldScrollPosition, - let scrollBar = focusElement.parent?.verticalScrollBar - { - AXUIElementSetAttributeValue( - scrollBar, - kAXValueAttribute as CFString, - oldScrollPosition as CFTypeRef - ) - } + ) } func getFileContent(sourceEditor: AXUIElement?) async diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift index 29780d4..4007a06 100644 --- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -49,6 +49,20 @@ struct PresentInWindowSuggestionPresenter { } } + func presentWarningMessage(_ message: String, url: String?) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.presentWarning(message: message, url: url) + } + } + + func dismissWarning() { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.dismissWarning() + } + } + func closeChatRoom(fileURL: URL) { Task { @MainActor in let controller = Service.shared.guiController.widgetController diff --git a/Core/Sources/Service/TelemetryLogger.swift b/Core/Sources/Service/TelemetryLogger.swift new file mode 100644 index 0000000..1bdf318 --- /dev/null +++ b/Core/Sources/Service/TelemetryLogger.swift @@ -0,0 +1,42 @@ +import Logger +import Foundation +import TelemetryService + +public class TelemetryLogger: TelemetryLoggerProvider { + public func sendError( + error: any Error, + category: String, + file: StaticString, + line: UInt, + function: StaticString, + callStackSymbols: [String] + ) { + TelemetryService.shared.sendError( + error, + category: category, + file: file, + line: line, + function: function, + from: callStackSymbols + ) + } + + public func sendError( + message: String, + category: String, + file: StaticString, + line: UInt, + function: StaticString, + callStackSymbols: [String] + ) { + TelemetryService.shared + .sendError( + message, + category: category, + file: file, + line: line, + function: function, + from: callStackSymbols + ) + } +} diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index a2ef2ca..49cab1f 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -148,7 +148,7 @@ public class XPCService: NSObject, XPCServiceProtocol { withReply reply: @escaping (Data?, Error?) -> Void ) { let handler = PseudoCommandHandler() - handler.openChat(forceDetach: false) + handler.openChat(forceDetach: true) reply(nil, nil) } diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index 3f1b772..6282b21 100644 --- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -20,7 +20,7 @@ final class ChatPanelWindow: NSWindow { self.minimizeWindow = minimizeWindow super.init( contentRect: .zero, - styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView], + styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView, .closable], backing: .buffered, defer: false ) @@ -103,5 +103,8 @@ final class ChatPanelWindow: NSWindow { override func miniaturize(_: Any?) { minimizeWindow() } -} + override func close() { + minimizeWindow() + } +} diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift new file mode 100644 index 0000000..29357d8 --- /dev/null +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift @@ -0,0 +1,233 @@ +import ActiveApplicationMonitor +import ConversationTab +import AppKit +import ComposableArchitecture +import SwiftUI +import ChatTab +import SharedUIComponents + + +struct ChatHistoryView: View { + let store: StoreOf + @Environment(\.chatTabPool) var chatTabPool + @Binding var isChatHistoryVisible: Bool + @State private var searchText = "" + + var body: some View { + WithPerceptionTracking { + let _ = store.currentChatWorkspace?.tabInfo + + VStack(alignment: .center, spacing: 0) { + Header(isChatHistoryVisible: $isChatHistoryVisible) + .frame(height: 32) + .padding(.leading, 16) + .padding(.trailing, 12) + + Divider() + + ChatHistorySearchBarView(searchText: $searchText) + .padding(.horizontal, 16) + .padding(.vertical, 4) + + ItemView(store: store, searchText: $searchText, isChatHistoryVisible: $isChatHistoryVisible) + .padding(.horizontal, 16) + } + } + } + + struct Header: View { + @Binding var isChatHistoryVisible: Bool + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + HStack { + Text("Chat History") + .font(.system(size: 13, weight: .bold)) + .lineLimit(nil) + + Spacer() + + Button(action: { + isChatHistoryVisible = false + }) { + Image(systemName: "xmark") + } + .buttonStyle(HoverButtonStyle()) + .help("Close") + } + } + } + + struct ItemView: View { + let store: StoreOf + @Binding var searchText: String + @Binding var isChatHistoryVisible: Bool + + @Environment(\.chatTabPool) var chatTabPool + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(filteredTabInfo, id: \.id) { info in + if let tab = chatTabPool.getTab(of: info.id){ + ChatHistoryItemView( + store: store, + info: info, + content: { tab.chatConversationItem }, + isChatHistoryVisible: $isChatHistoryVisible + ) + .id(info.id) + .frame(height: 49) + } + else { + EmptyView() + } + } + } + } + } + + var filteredTabInfo: IdentifiedArray { + guard let tabInfo = store.currentChatWorkspace?.tabInfo else { + return [] + } + + guard !searchText.isEmpty else { return tabInfo } + let result = tabInfo.filter { info in + if let tab = chatTabPool.getTab(of: info.id), + let conversationTab = tab as? ConversationTab { + return conversationTab.getChatTabTitle().localizedCaseInsensitiveContains(searchText) + } + + return false + } + return result + } + } +} + + +struct ChatHistorySearchBarView: View { + @Binding var searchText: String + @FocusState private var isSearchBarFocused: Bool + + var body: some View { + HStack(spacing: 5) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField("Search", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .focused($isSearchBarFocused) + .foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor)) + } + .cornerRadius(10) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.1)) + ) + .onAppear { + isSearchBarFocused = true + } + } +} + +struct ChatHistoryItemView: View { + let store: StoreOf + let info: ChatTabInfo + let content: () -> Content + @Binding var isChatHistoryVisible: Bool + @State private var isHovered = false + + func isTabSelected() -> Bool { + return store.state.currentChatWorkspace?.selectedTabId == info.id + } + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .center, spacing: 0) { + HStack(spacing: 8) { + content() + .font(.system(size: 14, weight: .regular)) + .lineLimit(1) + .hoverPrimaryForeground(isHovered: isHovered) + + if isTabSelected() { + Text("Current") + .foregroundStyle(.secondary) + } + } + + Spacer() + + if !isTabSelected() { + if isHovered { + Button(action: { + store.send(.chatHisotryDeleteButtonClicked(id: info.id)) + }) { + Image(systemName: "trash") + } + .buttonStyle(HoverButtonStyle()) + .help("Delete") + } + } + } + .padding(.horizontal, 12) + } + .frame(maxHeight: .infinity) + .onHover(perform: { + isHovered = $0 + }) + .hoverRadiusBackground(isHovered: isHovered, cornerRadius: 4) + .onTapGesture { + store.send(.chatHistoryItemClicked(id: info.id)) + isChatHistoryVisible = false + } + } +} + +struct ChatHistoryView_Previews: PreviewProvider { + static let pool = ChatTabPool([ + "2": EmptyChatTab(id: "2"), + "3": EmptyChatTab(id: "3"), + "4": EmptyChatTab(id: "4"), + "5": EmptyChatTab(id: "5"), + "6": EmptyChatTab(id: "6") + ]) + + static func createStore() -> StoreOf { + StoreOf( + initialState: .init( + chatHistory: .init( + workspaces: [.init( + id: "activeWorkspacePath", + tabInfo: [ + .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") + ] as IdentifiedArray, + selectedTabId: "2" + )] as IdentifiedArray, + selectedWorkspacePath: "activeWorkspacePath", + selectedWorkspaceName: "activeWorkspacePath" + ), + isPanelDisplayed: true + ), + reducer: { ChatPanelFeature() } + ) + } + + static var previews: some View { + ChatHistoryView( + store: createStore(), + isChatHistoryVisible: .constant(true) + ) + .xcodeStyleFrame() + .padding() + .environment(\.chatTabPool, pool) + } +} diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift new file mode 100644 index 0000000..d017d78 --- /dev/null +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift @@ -0,0 +1,82 @@ +import SwiftUI +import Perception +import GitHubCopilotViewModel +import SharedUIComponents + +struct ChatLoginView: View { + @StateObject var viewModel: GitHubCopilotViewModel + @Environment(\.openURL) private var openURL + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0){ + VStack(spacing: 20) { + Spacer() + Image("CopilotLogo") + .resizable() + .renderingMode(.template) + .scaledToFill() + .frame(width: 60.0, height: 60.0) + .foregroundColor(.secondary) + + Text("Welcome to Copilot") + .font(.system(size: 24)) + + Text("Your AI-powered coding assistant\nI use the power of AI to help you:") + .font(.system(size: 12)) + + Button("Sign Up for Copilot Free") { + if let url = URL(string: "https://github.com/features/copilot/plans") { + openURL(url) + } + } + .buttonStyle(.borderedProminent) + + HStack{ + Text("Already have an account?") + Button("Sign In") { viewModel.signIn() } + .buttonStyle(.borderless) + .foregroundColor(Color("TextLinkForegroundColor")) + + if viewModel.isRunningAction || viewModel.waitingForSignIn { + ProgressView() + .controlSize(.small) + } + } + + Spacer() + Text("Copilot Free and Copilot Pro may show [public code](https://aka.ms/github-copilot-match-public-code) suggestions and collect telemetry. You can change these [GitHub settings](https://aka.ms/github-copilot-settings) at any time. By continuing, you agree to our [terms](https://github.com/customer-terms/github-copilot-product-specific-terms) and [privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).") + .font(.system(size: 12)) + } + .padding() + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + } + .xcodeStyleFrame(cornerRadius: 10) + .ignoresSafeArea(edges: .top) + .alert( + viewModel.signInResponse?.userCode ?? "", + isPresented: $viewModel.isSignInAlertPresented, + presenting: viewModel.signInResponse + ) { _ in + Button("Cancel", role: .cancel, action: {}) + Button("Copy Code and Open", action: viewModel.copyAndOpen) + } message: { response in + Text(""" + Please enter the above code in the GitHub website \ + to authorize your GitHub account with Copilot for Xcode. + + \(response?.verificationURL.absoluteString ?? "") + """) + } + } + } +} + +struct ChatLoginView_Previews: PreviewProvider { + static var previews: some View { + ChatLoginView(viewModel: GitHubCopilotViewModel.shared) + } +} diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift new file mode 100644 index 0000000..5c05241 --- /dev/null +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift @@ -0,0 +1,68 @@ +import SwiftUI +import Perception +import GitHubCopilotViewModel +import SharedUIComponents + +struct ChatNoSubscriptionView: View { + @StateObject var viewModel: GitHubCopilotViewModel + @Environment(\.openURL) private var openURL + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + VStack(alignment: .center, spacing: 20) { + Spacer() + Image("CopilotIssue") + .resizable() + .renderingMode(.template) + .scaledToFill() + .frame(width: 60.0, height: 60.0) + .foregroundColor(.primary) + + Text("No Copilot Subscription Found") + .font(.system(size: 24)) + .multilineTextAlignment(.center) + + Text("Request a license from your organization manager \nor start a 30-day [free trial](https://github.com/github-copilot/signup/copilot_individual) to explore Copilot") + .font(.system(size: 12)) + .multilineTextAlignment(.center) + + HStack{ + Button("Check Subscription Plans") { + if let url = URL(string: "https://github.com/settings/copilot") { + openURL(url) + } + } + .buttonStyle(.borderedProminent) + + Button("Retry") { viewModel.checkStatus() } + .buttonStyle(.bordered) + + if viewModel.isRunningAction || viewModel.waitingForSignIn { + ProgressView() + .controlSize(.small) + } + } + + Spacer() + + Text("Copilot Free and Copilot Pro may show [public code](https://aka.ms/github-copilot-match-public-code) suggestions and collect telemetry. You can change these [GitHub settings](https://aka.ms/github-copilot-settings) at any time. By continuing, you agree to our [terms](https://github.com/customer-terms/github-copilot-product-specific-terms) and [privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).") + .font(.system(size: 12)) + } + .padding() + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + } + .xcodeStyleFrame(cornerRadius: 10) + .ignoresSafeArea(edges: .top) + } + } +} + +struct ChatNoSubcription_Previews: PreviewProvider { + static var previews: some View { + ChatNoSubscriptionView(viewModel: GitHubCopilotViewModel.shared) + } +} diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index f9f68a4..d12e889 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -1,38 +1,120 @@ import ActiveApplicationMonitor +import ConversationTab import AppKit import ChatTab import ComposableArchitecture import SwiftUI +import SharedUIComponents +import GitHubCopilotViewModel +import Status private let r: Double = 8 struct ChatWindowView: View { let store: StoreOf let toggleVisibility: (Bool) -> Void + @State private var isChatHistoryVisible: Bool = false + @ObservedObject private var statusObserver = StatusObserver.shared var body: some View { WithPerceptionTracking { - let _ = store.chatTabGroup.selectedTabId // force re-evaluation - VStack(spacing: 0) { - Rectangle().fill(.regularMaterial).frame(height: 28) + let _ = store.currentChatWorkspace?.selectedTabId // force re-evaluation + ZStack { + switch statusObserver.authStatus.status { + case .loggedIn: + ChatView(store: store, isChatHistoryVisible: $isChatHistoryVisible) + case .notLoggedIn: + ChatLoginView(viewModel: GitHubCopilotViewModel.shared) + case .notAuthorized: + ChatNoSubscriptionView(viewModel: GitHubCopilotViewModel.shared) + default: + ChatLoadingView() + } + } + .onChange(of: store.isPanelDisplayed) { isDisplayed in + toggleVisibility(isDisplayed) + } + .preferredColorScheme(store.colorScheme) + } + } +} - Divider() +struct ChatView: View { + let store: StoreOf + @Binding var isChatHistoryVisible: Bool + + var body: some View { + VStack(spacing: 0) { + Rectangle().fill(.regularMaterial).frame(height: 28) - ChatTabBar(store: store) - .frame(height: 26) + Divider() - Divider() + ZStack { + VStack(spacing: 0) { + ChatBar(store: store, isChatHistoryVisible: $isChatHistoryVisible) + .frame(height: 32) + .background(Color(nsColor: .windowBackgroundColor)) + + Divider() + + ChatTabContainer(store: store) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + .xcodeStyleFrame(cornerRadius: 10) + .ignoresSafeArea(edges: .top) + + if isChatHistoryVisible { + VStack(spacing: 0) { + Rectangle().fill(.regularMaterial).frame(height: 28) - ChatTabContainer(store: store) - .frame(maxWidth: .infinity, maxHeight: .infinity) + Divider() + + ChatHistoryView( + store: store, + isChatHistoryVisible: $isChatHistoryVisible + ) + .background(Color(nsColor: .windowBackgroundColor)) + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) } .xcodeStyleFrame(cornerRadius: 10) .ignoresSafeArea(edges: .top) - .onChange(of: store.isPanelDisplayed) { isDisplayed in - toggleVisibility(isDisplayed) - } .preferredColorScheme(store.colorScheme) + .focusable() + .onExitCommand(perform: { + isChatHistoryVisible = false + }) + } + } +} + +struct ChatLoadingView: View { + var body: some View { + VStack(alignment: .center) { + + Spacer() + + VStack(spacing: 24) { + Instruction() + + ProgressView("Loading...") + + } + .frame(maxWidth: .infinity, alignment: .center) + // keep same as chat view + .padding(.top, 20) // chat bar + + Spacer() + } + .xcodeStyleFrame(cornerRadius: 10) + .ignoresSafeArea(edges: .top) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) } } @@ -134,8 +216,9 @@ private extension View { } } -struct ChatTabBar: View { +struct ChatBar: View { let store: StoreOf + @Binding var isChatHistoryVisible: Bool struct TabBarState: Equatable { var tabInfo: IdentifiedArray @@ -143,36 +226,36 @@ struct ChatTabBar: View { } var body: some View { - HStack(spacing: 0) { - Divider() - Tabs(store: store) - CreateButton(store: store) - } - .background { - Button(action: { store.send(.switchToNextTab) }) { EmptyView() } - .opacity(0) - .keyboardShortcut("]", modifiers: [.command, .shift]) - Button(action: { store.send(.switchToPreviousTab) }) { EmptyView() } - .opacity(0) - .keyboardShortcut("[", modifiers: [.command, .shift]) + WithPerceptionTracking { + HStack(spacing: 0) { + if let name = store.chatHistory.selectedWorkspaceName { + ChatWindowHeader(store: store) + } + + Spacer() + + CreateButton(store: store) + + ChatHistoryButton(store: store, isChatHistoryVisible: $isChatHistoryVisible) + } + .padding(.horizontal, 12) } } struct Tabs: View { let store: StoreOf - @State var draggingTabId: String? @Environment(\.chatTabPool) var chatTabPool var body: some View { WithPerceptionTracking { - let tabInfo = store.chatTabGroup.tabInfo - let selectedTabId = store.chatTabGroup.selectedTabId - ?? store.chatTabGroup.tabInfo.first?.id + let tabInfo = store.currentChatWorkspace?.tabInfo + let selectedTabId = store.currentChatWorkspace?.selectedTabId + ?? store.currentChatWorkspace?.tabInfo.first?.id ?? "" ScrollViewReader { proxy in ScrollView(.horizontal) { HStack(spacing: 0) { - ForEach(tabInfo, id: \.id) { info in + ForEach(tabInfo!, id: \.id) { info in if let tab = chatTabPool.getTab(of: info.id) { ChatTabBarButton( store: store, @@ -185,20 +268,6 @@ struct ChatTabBar: View { tab.menu } .id(info.id) - .onDrag { - draggingTabId = info.id - return NSItemProvider(object: info.id as NSString) - } - .onDrop( - of: [.text], - delegate: ChatTabBarDropDelegate( - store: store, - tabs: tabInfo, - itemId: info.id, - draggingTabId: $draggingTabId - ) - ) - } else { EmptyView() } @@ -216,77 +285,61 @@ struct ChatTabBar: View { } } - struct CreateButton: View { + struct ChatWindowHeader: View { let store: StoreOf var body: some View { WithPerceptionTracking { - let collection = store.chatTabGroup.tabCollection - Menu { - ForEach(0.. - let tabs: IdentifiedArray - let itemId: String - @Binding var draggingTabId: String? - - func dropUpdated(info: DropInfo) -> DropProposal? { - return DropProposal(operation: .move) - } + struct CreateButton: View { + let store: StoreOf - func performDrop(info: DropInfo) -> Bool { - draggingTabId = nil - return true + var body: some View { + WithPerceptionTracking { + Button(action: { + store.send(.createNewTapButtonClicked(kind: nil)) + }) { + Image(systemName: "plus") + } + .buttonStyle(HoverButtonStyle()) + .padding(.horizontal, 4) + .help("New Chat") + } + } } - - func dropEntered(info: DropInfo) { - guard itemId != draggingTabId else { return } - let from = tabs.firstIndex { $0.id == draggingTabId } - let to = tabs.firstIndex { $0.id == itemId } - guard let from, let to, from != to else { return } - store.send(.moveChatTab(from: from, to: to)) + + struct ChatHistoryButton: View { + let store: StoreOf + @Binding var isChatHistoryVisible: Bool + + var body: some View { + WithPerceptionTracking { + Button(action: { + isChatHistoryVisible = true + }) { + Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + .buttonStyle(HoverButtonStyle()) + .help("Show Chats...") + } + } } } @@ -299,39 +352,17 @@ struct ChatTabBarButton: View { @State var isHovered: Bool = false var body: some View { - HStack(spacing: 0) { - HStack(spacing: 4) { - icon().foregroundColor(.secondary) - content() - } - .font(.callout) - .lineLimit(1) - .frame(maxWidth: 120) - .padding(.horizontal, 28) - .contentShape(Rectangle()) - .onTapGesture { - store.send(.tabClicked(id: info.id)) - } - .overlay(alignment: .leading) { - Button(action: { - store.send(.closeTabButtonClicked(id: info.id)) - }) { - Image(systemName: "xmark") - .foregroundColor(.secondary) + if self.isSelected { + HStack(spacing: 0) { + HStack(spacing: 0) { + icon() + .buttonStyle(.plain) } - .buttonStyle(.plain) - .padding(2) - .padding(.leading, 8) - .opacity(isHovered ? 1 : 0) + .font(.callout) + .lineLimit(1) } - .onHover { isHovered = $0 } - .animation(.linear(duration: 0.1), value: isHovered) - .animation(.linear(duration: 0.1), value: isSelected) - - Divider().padding(.vertical, 6) + .frame(maxHeight: .infinity) } - .background(isSelected ? Color(nsColor: .selectedControlColor) : Color.clear) - .frame(maxHeight: .infinity) } } @@ -341,16 +372,16 @@ struct ChatTabContainer: View { var body: some View { WithPerceptionTracking { - let tabInfo = store.chatTabGroup.tabInfo - let selectedTabId = store.chatTabGroup.selectedTabId - ?? store.chatTabGroup.tabInfo.first?.id + let tabInfo = store.currentChatWorkspace?.tabInfo + let selectedTabId = store.currentChatWorkspace?.selectedTabId + ?? store.currentChatWorkspace?.tabInfo.first?.id ?? "" ZStack { - if tabInfo.isEmpty { + if tabInfo == nil || tabInfo!.isEmpty { Text("Empty") } else { - ForEach(tabInfo) { tabInfo in + ForEach(tabInfo!) { tabInfo in if let tab = chatTabPool.getTab(of: tabInfo.id) { let isActive = tab.id == selectedTabId tab.body @@ -398,16 +429,23 @@ struct ChatWindowView_Previews: PreviewProvider { static func createStore() -> StoreOf { StoreOf( initialState: .init( - chatTabGroup: .init( - tabInfo: [ - .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"), - ] as IdentifiedArray, - selectedTabId: "2" + chatHistory: .init( + workspaces: [ + .init( + id: "activeWorkspacePath", + tabInfo: [ + .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"), + ] as IdentifiedArray, + selectedTabId: "2" + ) + ] as IdentifiedArray, + selectedWorkspacePath: "activeWorkspacePath", + selectedWorkspaceName: "activeWorkspacePath" ), isPanelDisplayed: true ), @@ -423,3 +461,8 @@ struct ChatWindowView_Previews: PreviewProvider { } } +struct ChatLoadingView_Previews: PreviewProvider { + static var previews: some View { + ChatLoadingView() + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index 990b557..e27112f 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -23,32 +23,66 @@ public struct ChatTabKind: Equatable { } } -@Reducer -public struct ChatPanelFeature { - public struct ChatTabGroup: Equatable { - 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] - } +@ObservableState +public struct ChatHistory: Equatable { + public var workspaces: IdentifiedArray + public var selectedWorkspacePath: String? + public var selectedWorkspaceName: String? + + public var currentChatWorkspace: ChatWorkspace? { + guard let id = selectedWorkspacePath else { return workspaces.first } + return workspaces[id: id] + } + + init(workspaces: IdentifiedArray = [], + selectedWorkspacePath: String? = nil, + selectedWorkspaceName: String? = nil) { + self.workspaces = workspaces + self.selectedWorkspacePath = selectedWorkspacePath + self.selectedWorkspaceName = selectedWorkspaceName + } - init( - tabInfo: IdentifiedArray = [], - tabCollection: [ChatTabBuilderCollection] = [], - selectedTabId: String? = nil - ) { - self.tabInfo = tabInfo - self.tabCollection = tabCollection - self.selectedTabId = selectedTabId + mutating func updateHistory(_ workspace: ChatWorkspace) { + if let index = workspaces.firstIndex(where: { $0.id == workspace.id }) { + workspaces[index] = workspace } } +} + +@ObservableState +public struct ChatWorkspace: Identifiable, Equatable { + public var id: String + public var tabInfo: IdentifiedArray + public var tabCollection: [ChatTabBuilderCollection] + public var selectedTabId: String? + + public var selectedTabInfo: ChatTabInfo? { + guard let tabId = selectedTabId else { return tabInfo.first } + return tabInfo[id: tabId] + } + public init( + id: String = UUID().uuidString, + tabInfo: IdentifiedArray = [], + tabCollection: [ChatTabBuilderCollection] = [], + selectedTabId: String? = nil + ) { + self.id = id + self.tabInfo = tabInfo + self.tabCollection = tabCollection + self.selectedTabId = selectedTabId + } +} + +@Reducer +public struct ChatPanelFeature { @ObservableState public struct State: Equatable { - public var chatTabGroup = ChatTabGroup() + public var chatHistory = ChatHistory() + public var currentChatWorkspace: ChatWorkspace? { + return chatHistory.currentChatWorkspace + } + var colorScheme: ColorScheme = .light public internal(set) var isPanelDisplayed = false var isDetached = false @@ -65,18 +99,25 @@ public struct ChatPanelFeature { case enterFullScreen case exitFullScreen case presentChatPanel(forceDetach: Bool) + case switchWorkspace(String, String) // Tabs - case updateChatTabInfo(IdentifiedArray) - case createNewTapButtonHovered + case updateChatHistory(ChatWorkspace) +// case updateChatTabInfo(IdentifiedArray) +// case createNewTapButtonHovered case closeTabButtonClicked(id: String) case createNewTapButtonClicked(kind: ChatTabKind?) case tabClicked(id: String) case appendAndSelectTab(ChatTabInfo) - case switchToNextTab - case switchToPreviousTab - case moveChatTab(from: Int, to: Int) + case appendTabToWorkspace(ChatTabInfo, ChatWorkspace) +// case switchToNextTab +// case switchToPreviousTab +// case moveChatTab(from: Int, to: Int) case focusActiveChatTab + + // Chat History + case chatHistoryItemClicked(id: String) + case chatHisotryDeleteButtonClicked(id: String) case chatTab(id: String, action: ChatTabItem.Action) } @@ -111,7 +152,7 @@ public struct ChatPanelFeature { } case .closeActiveTabClicked: - if let id = state.chatTabGroup.selectedTabId { + if let id = state.currentChatWorkspace?.selectedTabId { return .run { send in await send(.closeTabButtonClicked(id: id)) } @@ -165,133 +206,177 @@ public struct ChatPanelFeature { activateExtensionService() await send(.focusActiveChatTab) } - - case let .updateChatTabInfo(chatTabInfo): - let previousSelectedIndex = state.chatTabGroup.tabInfo - .firstIndex(where: { $0.id == state.chatTabGroup.selectedTabId }) - state.chatTabGroup.tabInfo = chatTabInfo - if !chatTabInfo.contains(where: { $0.id == state.chatTabGroup.selectedTabId }) { - if let previousSelectedIndex { - let proposedSelectedIndex = previousSelectedIndex - 1 - if proposedSelectedIndex >= 0, - proposedSelectedIndex < chatTabInfo.endIndex - { - state.chatTabGroup.selectedTabId = chatTabInfo[proposedSelectedIndex].id - } else { - state.chatTabGroup.selectedTabId = chatTabInfo.first?.id - } - } else { - state.chatTabGroup.selectedTabId = nil - } + case let .switchWorkspace(path, name): + state.chatHistory.selectedWorkspacePath = path + state.chatHistory.selectedWorkspaceName = name + if state.chatHistory.currentChatWorkspace == nil { + state.chatHistory.workspaces[id: path] = ChatWorkspace(id: path) } return .none + case let .updateChatHistory(chatWorkspace): + state.chatHistory.updateHistory(chatWorkspace) + return .none +// case let .updateChatTabInfo(chatTabInfo): +// let previousSelectedIndex = state.chatTabGroup.tabInfo +// .firstIndex(where: { $0.id == state.chatTabGroup.selectedTabId }) +// state.chatTabGroup.tabInfo = chatTabInfo +// if !chatTabInfo.contains(where: { $0.id == state.chatTabGroup.selectedTabId }) { +// if let previousSelectedIndex { +// let proposedSelectedIndex = previousSelectedIndex - 1 +// if proposedSelectedIndex >= 0, +// proposedSelectedIndex < chatTabInfo.endIndex +// { +// state.chatTabGroup.selectedTabId = chatTabInfo[proposedSelectedIndex].id +// } else { +// state.chatTabGroup.selectedTabId = chatTabInfo.first?.id +// } +// } else { +// state.chatTabGroup.selectedTabId = nil +// } +// } +// return .none case let .closeTabButtonClicked(id): - let firstIndex = state.chatTabGroup.tabInfo.firstIndex { $0.id == id } + guard var currentChatWorkspace = state.currentChatWorkspace else { + return .none + } + let firstIndex = currentChatWorkspace.tabInfo.firstIndex { $0.id == id } let nextIndex = { guard let firstIndex else { return 0 } let nextIndex = firstIndex - 1 return max(nextIndex, 0) }() - state.chatTabGroup.tabInfo.removeAll { $0.id == id } - if state.chatTabGroup.tabInfo.isEmpty { + currentChatWorkspace.tabInfo.removeAll { $0.id == id } + if currentChatWorkspace.tabInfo.isEmpty { state.isPanelDisplayed = false } - if nextIndex < state.chatTabGroup.tabInfo.count { - state.chatTabGroup.selectedTabId = state.chatTabGroup.tabInfo[nextIndex].id + if nextIndex < currentChatWorkspace.tabInfo.count { + currentChatWorkspace.selectedTabId = currentChatWorkspace.tabInfo[nextIndex].id } else { - state.chatTabGroup.selectedTabId = nil + currentChatWorkspace.selectedTabId = nil } + state.chatHistory.updateHistory(currentChatWorkspace) return .none - - case .createNewTapButtonHovered: - state.chatTabGroup.tabCollection = chatTabBuilderCollection() + + case let .chatHisotryDeleteButtonClicked(id): + // the current chat should not be deleted + guard var currentChatWorkspace = state.currentChatWorkspace, id != currentChatWorkspace.selectedTabId else { + return .none + } + currentChatWorkspace.tabInfo.removeAll { $0.id == id } + state.chatHistory.updateHistory(currentChatWorkspace) return .none +// case .createNewTapButtonHovered: +// state.chatTabGroup.tabCollection = chatTabBuilderCollection() +// return .none + case .createNewTapButtonClicked: return .none // handled elsewhere case let .tabClicked(id): - guard state.chatTabGroup.tabInfo.contains(where: { $0.id == id }) else { - state.chatTabGroup.selectedTabId = nil + guard var currentChatWorkspace = state.currentChatWorkspace, currentChatWorkspace.tabInfo.contains(where: { $0.id == id }) else { +// chatTabGroup.selectedTabId = nil return .none } - state.chatTabGroup.selectedTabId = id + currentChatWorkspace.selectedTabId = id + state.chatHistory.updateHistory(currentChatWorkspace) return .run { send in await send(.focusActiveChatTab) } - - case let .appendAndSelectTab(tab): - guard !state.chatTabGroup.tabInfo.contains(where: { $0.id == tab.id }) - else { return .none } - state.chatTabGroup.tabInfo.append(tab) - state.chatTabGroup.selectedTabId = tab.id - return .run { send in - await send(.focusActiveChatTab) - } - - case .switchToNextTab: - 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.chatTabGroup.tabInfo.endIndex { + + case let .chatHistoryItemClicked(id): + guard var chatWorkspace = state.currentChatWorkspace, chatWorkspace.tabInfo.contains(where: { $0.id == id }) else { +// state.chatGroupCollection.selectedChatGroup?.selectedTabId = nil return .none } - let targetId = state.chatTabGroup.tabInfo[nextIndex].id - state.chatTabGroup.selectedTabId = targetId + chatWorkspace.selectedTabId = id + state.chatHistory.updateHistory(chatWorkspace) return .run { send in await send(.focusActiveChatTab) } - case .switchToPreviousTab: - let selectedId = state.chatTabGroup.selectedTabId - guard let index = state.chatTabGroup.tabInfo - .firstIndex(where: { $0.id == selectedId }) + case let .appendAndSelectTab(tab): + guard var chatWorkspace = state.currentChatWorkspace, !chatWorkspace.tabInfo.contains(where: { $0.id == tab.id }) else { return .none } - let previousIndex = index - 1 - if previousIndex < 0 || previousIndex >= state.chatTabGroup.tabInfo.endIndex { - return .none - } - let targetId = state.chatTabGroup.tabInfo[previousIndex].id - state.chatTabGroup.selectedTabId = targetId + chatWorkspace.tabInfo.append(tab) + chatWorkspace.selectedTabId = tab.id + state.chatHistory.updateHistory(chatWorkspace) return .run { send in await send(.focusActiveChatTab) } - - case let .moveChatTab(from, to): - guard from >= 0, from < state.chatTabGroup.tabInfo.endIndex, to >= 0, - to <= state.chatTabGroup.tabInfo.endIndex - else { - return .none - } - let tab = state.chatTabGroup.tabInfo[from] - state.chatTabGroup.tabInfo.remove(at: from) - state.chatTabGroup.tabInfo.insert(tab, at: to) + case let .appendTabToWorkspace(tab, chatWorkspace): + guard !chatWorkspace.tabInfo.contains(where: { $0.id == tab.id }) + else { return .none } + var targetWorkspace = chatWorkspace + targetWorkspace.tabInfo.append(tab) + targetWorkspace.selectedTabId = tab.id + state.chatHistory.updateHistory(targetWorkspace) return .none +// case .switchToNextTab: +// 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.chatTabGroup.tabInfo.endIndex { +// return .none +// } +// let targetId = state.chatTabGroup.tabInfo[nextIndex].id +// state.chatTabGroup.selectedTabId = targetId +// return .run { send in +// await send(.focusActiveChatTab) +// } + +// case .switchToPreviousTab: +// 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.chatTabGroup.tabInfo.endIndex { +// return .none +// } +// let targetId = state.chatTabGroup.tabInfo[previousIndex].id +// state.chatTabGroup.selectedTabId = targetId +// return .run { send in +// await send(.focusActiveChatTab) +// } + +// case let .moveChatTab(from, to): +// guard from >= 0, from < state.chatTabGroup.tabInfo.endIndex, to >= 0, +// to <= state.chatTabGroup.tabInfo.endIndex +// else { +// return .none +// } +// let tab = state.chatTabGroup.tabInfo[from] +// state.chatTabGroup.tabInfo.remove(at: from) +// state.chatTabGroup.tabInfo.insert(tab, at: to) +// return .none + case .focusActiveChatTab: guard FeatureFlagNotifierImpl.shared.featureFlags.chat else { return .none } - let id = state.chatTabGroup.selectedTabInfo?.id + let id = state.currentChatWorkspace?.selectedTabInfo?.id guard let id else { return .none } return .run { send in await send(.chatTab(id: id, action: .focus)) } - case let .chatTab(id, .close): - return .run { send in - await send(.closeTabButtonClicked(id: id)) - } +// case let .chatTab(id, .close): +// return .run { send in +// await send(.closeTabButtonClicked(id: id)) +// } case .chatTab: return .none } - }.forEach(\.chatTabGroup.tabInfo, action: /Action.chatTab) { - ChatTabItem() } +// .forEach(\.chatGroupCollection.selectedChatGroup?.tabInfo, action: /Action.chatTab) { +// ChatTabItem() +// } } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift index 1e3f3dc..e76afbc 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -21,6 +21,9 @@ public struct PanelFeature { // MARK: SuggestionPanel var suggestionPanelState = SuggestionPanelFeature.State() + + var warningMessage: String? + var warningURL: String? } public enum Action: Equatable { @@ -38,6 +41,9 @@ public struct PanelFeature { case sharedPanel(SharedPanelFeature.Action) case suggestionPanel(SuggestionPanelFeature.Action) + + case presentWarning(message: String, url: String?) + case dismissWarning } @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @@ -142,6 +148,20 @@ public struct PanelFeature { case .suggestionPanel: return .none + + case .presentWarning(let message, let url): + state.warningMessage = message + state.warningURL = url + state.suggestionPanelState.warningMessage = message + state.suggestionPanelState.warningURL = url + return .none + + case .dismissWarning: + state.warningMessage = nil + state.warningURL = nil + state.suggestionPanelState.warningMessage = nil + state.suggestionPanelState.warningURL = nil + return .none } } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift index 82010df..028ae77 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift @@ -14,6 +14,8 @@ public struct SuggestionPanelFeature { var lineHeight: Double = 17 var isPanelDisplayed: Bool = false var isPanelOutOfFrame: Bool = false + var warningMessage: String? + var warningURL: String? var opacity: Double { guard isPanelDisplayed else { return 0 } if isPanelOutOfFrame { return 0 } @@ -24,9 +26,19 @@ public struct SuggestionPanelFeature { public enum Action: Equatable { case noAction + case dismissWarning } public var body: some ReducerOf { - Reduce { _, _ in .none } + Reduce { state, action in + switch action { + case .dismissWarning: + state.warningMessage = nil + state.warningURL = nil + return .none + default: + return .none + } + } } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index be6c2e6..e0af56c 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -66,8 +66,9 @@ public struct WidgetFeature { } return false }(), - isContentEmpty: chatPanelState.chatTabGroup.tabInfo.isEmpty - && panelState.sharedPanelState.isEmpty, + isContentEmpty: chatPanelState.currentChatWorkspace == nil + || (chatPanelState.currentChatWorkspace!.tabInfo.isEmpty + && panelState.sharedPanelState.isEmpty), isChatPanelDetached: chatPanelState.isDetached, isChatOpen: chatPanelState.isPanelDisplayed ) @@ -162,7 +163,7 @@ public struct WidgetFeature { } let isDisplayingContent = state._internalCircularWidgetState.isDisplayingContent - let hasChat = state.chatPanelState.chatTabGroup.selectedTabInfo != nil + let hasChat = state.chatPanelState.currentChatWorkspace?.selectedTabInfo != nil let hasPromptToCode = state.panelState.sharedPanelState.content .promptToCodeGroup.activePromptToCode != nil diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index d8be66d..c272077 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -5,7 +5,7 @@ import SwiftUI enum Style { static let panelHeight: Double = 560 - static let panelWidth: Double = 454 + static let panelWidth: Double = 504 static let inlineSuggestionMaxHeight: Double = 400 static let inlineSuggestionPadding: Double = 25 static let widgetHeight: Double = 20 diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift new file mode 100644 index 0000000..f6c429c --- /dev/null +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift @@ -0,0 +1,78 @@ +import SwiftUI +import SharedUIComponents +import XcodeInspector + +struct WarningPanel: View { + let message: String + let url: String? + let firstLineIndent: Double + let onDismiss: () -> Void + + @Environment(\.colorScheme) var colorScheme + @Environment(CursorPositionTracker.self) var cursorPositionTracker + @AppStorage(\.clsWarningDismissedUntilRelaunch) var isDismissedUntilRelaunch + + var foregroundColor: Color { + return colorScheme == .light ? .black.opacity(0.85) : .white.opacity(0.85) + } + + var body: some View { + if !isDismissedUntilRelaunch { + HStack(spacing: 12) { + HStack(spacing: 8) { + Image("CopilotLogo") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(.primary) + .frame(width: 14, height: 14) + + Text("Monthly completion limit reached.") + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineLimit(1) + } + .padding(.horizontal, 9) + .background( + Capsule() + .fill(foregroundColor.opacity(0.1)) + .frame(height: 17) + ) + .fixedSize() + + HStack(spacing: 8) { + if let url = url { + Button("Upgrade Now") { + NSWorkspace.shared.open(URL(string: url)!) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color(nsColor: .controlAccentColor)) + .foregroundColor(Color(nsColor: .white)) + .cornerRadius(6) + .font(.system(size: 12)) + .fixedSize() + } + + Button("Dismiss") { + isDismissedUntilRelaunch = true + onDismiss() + } + .buttonStyle(.bordered) + .font(.system(size: 12)) + .keyboardShortcut(.escape, modifiers: []) + .fixedSize() + } + } + .padding(.top, 24) + .padding( + .leading, + firstLineIndent + 20 + CGFloat( + cursorPositionTracker.cursorPosition.character + ) + ) + .background(.clear) + } + } +} diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift index 2f2306d..a046985 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift @@ -7,30 +7,42 @@ struct SuggestionPanelView: View { var body: some View { WithPerceptionTracking { - VStack(spacing: 0) { - Content(store: store) - .allowsHitTesting( - store.isPanelDisplayed && !store.isPanelOutOfFrame + Group { + if let message = store.warningMessage { + WarningPanel( + message: message, + url: store.warningURL, + firstLineIndent: store.firstLineIndent + ) { + store.send(.dismissWarning) + } + } else { + VStack(spacing: 0) { + Content(store: store) + .allowsHitTesting( + store.isPanelDisplayed && !store.isPanelOutOfFrame + ) + .frame(maxWidth: .infinity) + } + .preferredColorScheme(store.colorScheme) + .opacity(store.opacity) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: store.isPanelDisplayed ) - .frame(maxWidth: .infinity) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: store.isPanelOutOfFrame + ) + .frame( + maxWidth: .infinity, + maxHeight: Style.inlineSuggestionMaxHeight, + alignment: .top + ) + } } - .preferredColorScheme(store.colorScheme) - .opacity(store.opacity) - .animation( - featureFlag: \.animationBCrashSuggestion, - .easeInOut(duration: 0.2), - value: store.isPanelDisplayed - ) - .animation( - featureFlag: \.animationBCrashSuggestion, - .easeInOut(duration: 0.2), - value: store.isPanelOutOfFrame - ) - .frame( - maxWidth: .infinity, - maxHeight: Style.inlineSuggestionMaxHeight, - alignment: .top - ) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 1d80452..06adce2 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -92,3 +92,13 @@ public extension SuggestionWidgetController { } } +extension SuggestionWidgetController { + public func presentWarning(message: String, url: String?) { + store.send(.panel(.presentWarning(message: message, url: url))) + } + + public func dismissWarning() { + store.send(.panel(.dismissWarning)) + } +} + diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 3cb6298..a7dcae3 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -135,16 +135,10 @@ enum UpdateLocationStrategy { }() let alignPanelTopToAnchor = fixedAlignment ?? (y > activeScreen.frame.midY) + let chatPanelFrame = getChatPanelFrame(mainScreen) + if putPanelToTheRight { let anchorFrame = proposedAnchorFrameOnTheRightSide - let panelFrame = CGRect( - x: proposedPanelX, - y: alignPanelTopToAnchor - ? anchorFrame.maxY - Style.panelHeight - : anchorFrame.minY - editorFrameExpendedSize.height, - width: Style.panelWidth, - height: Style.panelHeight - ) let tabFrame = CGRect( x: anchorFrame.origin.x, y: alignPanelTopToAnchor @@ -158,7 +152,7 @@ enum UpdateLocationStrategy { widgetFrame: widgetFrameOnTheRightSide, tabFrame: tabFrame, defaultPanelLocation: .init( - frame: panelFrame, + frame: chatPanelFrame, alignPanelTop: alignPanelTopToAnchor ), suggestionPanelLocation: nil @@ -197,14 +191,6 @@ enum UpdateLocationStrategy { if putAnchorToTheLeft { let anchorFrame = proposedAnchorFrameOnTheLeftSide - let panelFrame = CGRect( - x: proposedPanelX, - y: alignPanelTopToAnchor - ? anchorFrame.maxY - Style.panelHeight - : anchorFrame.minY - editorFrameExpendedSize.height, - width: Style.panelWidth, - height: Style.panelHeight - ) let tabFrame = CGRect( x: anchorFrame.origin.x, y: alignPanelTopToAnchor @@ -217,23 +203,13 @@ enum UpdateLocationStrategy { widgetFrame: widgetFrameOnTheLeftSide, tabFrame: tabFrame, defaultPanelLocation: .init( - frame: panelFrame, + frame: chatPanelFrame, alignPanelTop: alignPanelTopToAnchor ), suggestionPanelLocation: nil ) } else { let anchorFrame = proposedAnchorFrameOnTheRightSide - let panelFrame = CGRect( - x: anchorFrame.maxX - Style.panelWidth, - y: alignPanelTopToAnchor - ? anchorFrame.maxY - Style.panelHeight - Style.widgetHeight - - Style.widgetPadding - : anchorFrame.maxY + Style.widgetPadding - - editorFrameExpendedSize.height, - width: Style.panelWidth, - height: Style.panelHeight - ) let tabFrame = CGRect( x: anchorFrame.minX - Style.widgetPadding - Style.widgetWidth, y: anchorFrame.origin.y, @@ -244,7 +220,7 @@ enum UpdateLocationStrategy { widgetFrame: widgetFrameOnTheRightSide, tabFrame: tabFrame, defaultPanelLocation: .init( - frame: panelFrame, + frame: chatPanelFrame, alignPanelTop: alignPanelTopToAnchor ), suggestionPanelLocation: nil @@ -342,5 +318,15 @@ enum UpdateLocationStrategy { return selectionFrame } + + static func getChatPanelFrame(_ screen: NSScreen) -> CGRect { + let visibleScreenFrame = screen.visibleFrame + // avoid too wide + let width = min(Style.panelWidth, visibleScreenFrame.width * 0.3) + let height = visibleScreenFrame.height + let x = visibleScreenFrame.width - width + + return CGRect(x: x, y: visibleScreenFrame.height, width: width, height: height) + } } diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 72ad057..6328530 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -360,7 +360,8 @@ extension WidgetWindowsController { await MainActor.run { let state = store.withState { $0 } let isChatPanelDetached = state.chatPanelState.isDetached - let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty + let hasChat = state.chatPanelState.currentChatWorkspace != nil + && !state.chatPanelState.currentChatWorkspace!.tabInfo.isEmpty if let activeApp, activeApp.isXcode { let application = activeApp.appElement diff --git a/Docs/chat_dark.gif b/Docs/chat_dark.gif new file mode 100644 index 0000000..abd5cc2 Binary files /dev/null and b/Docs/chat_dark.gif differ diff --git a/Docs/copilot-menu_dark.png b/Docs/copilot-menu_dark.png new file mode 100644 index 0000000..3c8b4b4 Binary files /dev/null and b/Docs/copilot-menu_dark.png differ diff --git a/Docs/xcode-menu.png b/Docs/xcode-menu.png index a3dc21a..c30e539 100644 Binary files a/Docs/xcode-menu.png and b/Docs/xcode-menu.png differ diff --git a/Docs/xcode-menu_dark.png b/Docs/xcode-menu_dark.png new file mode 100644 index 0000000..28b957b Binary files /dev/null and b/Docs/xcode-menu_dark.png differ diff --git a/EditorExtension/Info.plist b/EditorExtension/Info.plist index 12cf1cc..13a9bdb 100644 --- a/EditorExtension/Info.plist +++ b/EditorExtension/Info.plist @@ -42,5 +42,7 @@ TEAM_ID_PREFIX $(TeamIdentifierPrefix) + STANDARD_TELEMETRY_CHANNEL_KEY + $(STANDARD_TELEMETRY_CHANNEL_KEY) diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 9453d31..959f914 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -5,6 +5,7 @@ import Status import SuggestionBasic import XcodeInspector import Logger +import StatusBarItemView extension AppDelegate { fileprivate var statusBarMenuIdentifier: NSUserInterfaceItemIdentifier { @@ -19,14 +20,6 @@ extension AppDelegate { .init("sourceEditorDebugMenu") } - fileprivate var toggleCompletionsMenuItemIdentifier: NSUserInterfaceItemIdentifier { - .init("toggleCompletionsMenuItem") - } - - fileprivate var toggleIgnoreLanguageMenuItemIdentifier: NSUserInterfaceItemIdentifier { - .init("toggleIgnoreLanguageMenuItem") - } - @MainActor @objc func buildStatusBarMenu() { let statusBar = NSStatusBar.system @@ -34,14 +27,22 @@ extension AppDelegate { withLength: NSStatusItem.squareLength ) statusBarItem.button?.image = NSImage(named: "MenuBarIcon") + statusBarItem.button?.image?.isTemplate = false let statusBarMenu = NSMenu(title: "Status Bar Menu") statusBarMenu.identifier = statusBarMenuIdentifier statusBarItem.menu = statusBarMenu - let hostAppName = Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String - ?? "GitHub Copilot for Xcode" - + let boldTitle = NSAttributedString( + string: "Github Copilot", + attributes: [ + .font: NSFont.boldSystemFont(ofSize: NSFont.systemFontSize), + .foregroundColor: NSColor(.primary) + ] + ) + let attributedTitle = NSMenuItem() + attributedTitle.attributedTitle = boldTitle + let checkForUpdate = NSMenuItem( title: "Check for Updates", action: #selector(checkForUpdate), @@ -49,7 +50,7 @@ extension AppDelegate { ) let openCopilotForXcodeItem = NSMenuItem( - title: "Open \(hostAppName) Settings", + title: "Settings", action: #selector(openCopilotForXcode), keyEquivalent: "" ) @@ -65,12 +66,12 @@ extension AppDelegate { xcodeInspectorDebug.submenu = xcodeInspectorDebugMenu xcodeInspectorDebug.isHidden = false - statusMenuItem = NSMenuItem( + extensionStatusItem = NSMenuItem( title: "", - action: #selector(openStatusLink), + action: #selector(openExtensionStatusLink), keyEquivalent: "" ) - statusMenuItem.isHidden = true + extensionStatusItem.isHidden = true let quitItem = NSMenuItem( title: "Quit", @@ -79,50 +80,82 @@ extension AppDelegate { ) quitItem.target = self - let toggleCompletions = NSMenuItem( + toggleCompletions = NSMenuItem( title: "Enable/Disable Completions", action: #selector(toggleCompletionsEnabled), keyEquivalent: "" ) - toggleCompletions.identifier = toggleCompletionsMenuItemIdentifier; - - let toggleIgnoreLanguage = NSMenuItem( + + toggleIgnoreLanguage = NSMenuItem( title: "No Active Document", action: nil, keyEquivalent: "" ) - toggleIgnoreLanguage.identifier = toggleIgnoreLanguageMenuItemIdentifier; - authMenuItem = NSMenuItem( - title: "Copilot Connection: Checking...", - action: #selector(openCopilotForXcode), + // Auth menu item with custom view + accountItem = NSMenuItem() + accountItem.view = AccountItemView( + target: self, + action: #selector(signIntoGitHub) + ) + + authStatusItem = NSMenuItem( + title: "", + action: nil, + keyEquivalent: "" + ) + extensionStatusItem.isHidden = true + + upSellItem = NSMenuItem( + title: "", + action: #selector(openUpSellLink), keyEquivalent: "" ) + extensionStatusItem.isHidden = true let openDocs = NSMenuItem( - title: "View Copilot Documentation...", + title: "View Documentation", action: #selector(openCopilotDocs), keyEquivalent: "" ) let openForum = NSMenuItem( - title: "View Copilot Feedback Forum...", + title: "Feedback Forum", action: #selector(openCopilotForum), keyEquivalent: "" ) + openChat = NSMenuItem( + title: "Open Chat", + action: #selector(openGlobalChat), + keyEquivalent: "" + ) + + signOutItem = NSMenuItem( + title: "Sign Out", + action: #selector(signOutGitHub), + keyEquivalent: "" + ) + + statusBarMenu.addItem(attributedTitle) + statusBarMenu.addItem(accountItem) + statusBarMenu.addItem(authStatusItem) + statusBarMenu.addItem(upSellItem) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(extensionStatusItem) + statusBarMenu.addItem(.separator()) statusBarMenu.addItem(openCopilotForXcodeItem) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(checkForUpdate) statusBarMenu.addItem(toggleCompletions) statusBarMenu.addItem(toggleIgnoreLanguage) - statusBarMenu.addItem(.separator()) - statusBarMenu.addItem(authMenuItem) - statusBarMenu.addItem(statusMenuItem) + statusBarMenu.addItem(openChat) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(openDocs) statusBarMenu.addItem(openForum) statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(signOutItem) + statusBarMenu.addItem(.separator()) statusBarMenu.addItem(xcodeInspectorDebug) statusBarMenu.addItem(quitItem) @@ -142,21 +175,19 @@ extension AppDelegate: NSMenuDelegate { .value(for: \.enableXcodeInspectorDebugMenu) } - if let toggleCompletions = menu.items.first(where: { item in - item.identifier == toggleCompletionsMenuItemIdentifier - }) { + if toggleCompletions != nil { toggleCompletions.title = "\(UserDefaults.shared.value(for: \.realtimeSuggestionToggle) ? "Disable" : "Enable") Completions" } - - if let toggleLanguage = menu.items.first(where: { item in - item.identifier == toggleIgnoreLanguageMenuItemIdentifier - }) { + + if toggleIgnoreLanguage != nil { if let lang = DisabledLanguageList.shared.activeDocumentLanguage { - toggleLanguage.title = "\(DisabledLanguageList.shared.isEnabled(lang) ? "Disable" : "Enable") Completions for \(lang.rawValue)" - toggleLanguage.action = #selector(toggleIgnoreLanguage) + toggleIgnoreLanguage.title = "\(DisabledLanguageList.shared.isEnabled(lang) ? "Disable" : "Enable") Completions for \(lang.rawValue)" + toggleIgnoreLanguage.action = #selector( + toggleIgnoreLanguageEnabled + ) } else { - toggleLanguage.title = "No Active Document" - toggleLanguage.action = nil + toggleIgnoreLanguage.title = "No Active Document" + toggleIgnoreLanguage.action = nil } } @@ -271,8 +302,8 @@ private extension AppDelegate { } } } - - @objc func toggleIgnoreLanguage() { + + @objc func toggleIgnoreLanguageEnabled() { guard let lang = DisabledLanguageList.shared.activeDocumentLanguage else { return } if DisabledLanguageList.shared.isEnabled(lang) { @@ -298,7 +329,7 @@ private extension AppDelegate { } } - @objc func openStatusLink() { + @objc func openExtensionStatusLink() { Task { let status = await Status.shared.getStatus() if let s = status.url, let url = URL(string: s) { @@ -306,6 +337,21 @@ private extension AppDelegate { } } } + + @objc func openUpSellLink() { + Task { + let status = await Status.shared.getStatus() + if status.authStatus == AuthStatus.Status.notAuthorized { + if let url = URL(string: "https://github.com/features/copilot/plans") { + NSWorkspace.shared.open(url) + } + } else { + if let url = URL(string: "https://github.com/github-copilot/signup/copilot_individual") { + NSWorkspace.shared.open(url) + } + } + } + } } private extension NSMenuItem { @@ -319,4 +365,3 @@ private extension NSMenuItem { return item } } - diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index dd4e888..f10a358 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -13,6 +13,8 @@ import UserDefaultsObserver import UserNotifications import XcodeInspector import XPCShared +import GitHubCopilotViewModel +import StatusBarItemView let bundleIdentifierBase = Bundle.main .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String @@ -31,8 +33,14 @@ class ExtensionUpdateCheckerDelegate: UpdateCheckerDelegate { class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let service = Service.shared var statusBarItem: NSStatusItem! - var statusMenuItem: NSMenuItem! - var authMenuItem: NSMenuItem! + var extensionStatusItem: NSMenuItem! + var accountItem: NSMenuItem! + var authStatusItem: NSMenuItem! + var upSellItem: NSMenuItem! + var toggleCompletions: NSMenuItem! + var toggleIgnoreLanguage: NSMenuItem! + var openChat: NSMenuItem! + var signOutItem: NSMenuItem! var xpcController: XPCController? let updateChecker = UpdateChecker( @@ -60,6 +68,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { watchAXStatus() watchAuthStatus() setInitialStatusBarStatus() + UserDefaults.shared.set(false, for: \.clsWarningDismissedUntilRelaunch) } @objc func quit() { @@ -78,6 +87,44 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { task.launch() task.waitUntilExit() } + + @objc func signIntoGitHub() { + Task { @MainActor in + let viewModel = GitHubCopilotViewModel.shared + // Don't trigger the shared viewModel's alert + do { + guard let signInResponse = try await viewModel.preSignIn() else { + return + } + + NSApp.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.messageText = signInResponse.userCode + alert.informativeText = """ + Please enter the above code in the GitHub website to authorize your \ + GitHub account with Copilot for Xcode. + \(signInResponse.verificationURL.absoluteString) + """ + alert.addButton(withTitle: "Copy Code and Open") + alert.addButton(withTitle: "Cancel") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + viewModel.signInResponse = signInResponse + viewModel.copyAndOpen() + } + } catch { + Logger.service.error("GitHub copilot view model Sign in fails: \(error)") + } + } + } + + @objc func signOutGitHub() { + Task { @MainActor in + let viewModel = GitHubCopilotViewModel.shared + viewModel.signOut() + } + } @objc func openGlobalChat() { Task { @MainActor in @@ -210,20 +257,121 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { Logger.service.error("Failed to read auth status: \(error)") } } + + private func configureNotLoggedIn() { + self.accountItem.view = AccountItemView( + target: self, + action: #selector(signIntoGitHub) + ) + self.authStatusItem.isHidden = true + self.upSellItem.isHidden = true + self.toggleCompletions.isHidden = true + self.toggleIgnoreLanguage.isHidden = true + self.openChat.isHidden = true + self.signOutItem.isHidden = true + } + + private func configureLoggedIn(status: StatusResponse) { + self.accountItem.view = AccountItemView( + target: self, + action: nil, + userName: status.userName ?? "" + ) + if !status.clsMessage.isEmpty { + self.authStatusItem.isHidden = false + let CLSMessageSummary = getCLSMessageSummary(status.clsMessage) + self.authStatusItem.title = CLSMessageSummary.summary + + let submenu = NSMenu() + let attributedCLSErrorItem = NSMenuItem() + attributedCLSErrorItem.view = ErrorMessageView( + errorMessage: CLSMessageSummary.detail + ) + submenu.addItem(attributedCLSErrorItem) + submenu.addItem(.separator()) + submenu.addItem( + NSMenuItem( + title: "View Details on GitHub", + action: #selector(openGitHubDetailsLink), + keyEquivalent: "" + ) + ) + + self.authStatusItem.submenu = submenu + self.authStatusItem.isEnabled = true + + self.upSellItem.title = "Upgrade Now" + self.upSellItem.isHidden = false + self.upSellItem.isEnabled = true + } else { + self.authStatusItem.isHidden = true + self.upSellItem.isHidden = true + } + self.toggleCompletions.isHidden = false + self.toggleIgnoreLanguage.isHidden = false + self.openChat.isHidden = false + self.signOutItem.isHidden = false + } + + private func configureNotAuthorized(status: StatusResponse) { + self.accountItem.view = AccountItemView( + target: self, + action: nil, + userName: status.userName ?? "" + ) + self.authStatusItem.isHidden = false + self.authStatusItem.title = "No Subscription" + + let submenu = NSMenu() + let attributedNotAuthorizedItem = NSMenuItem() + attributedNotAuthorizedItem.view = ErrorMessageView( + errorMessage: "GitHub Copilot features are disabled. Check your subscription to enable them." + ) + attributedNotAuthorizedItem.isEnabled = true + submenu.addItem(attributedNotAuthorizedItem) + + self.authStatusItem.submenu = submenu + self.authStatusItem.isEnabled = true + + self.upSellItem.title = "Check Subscription Plans" + self.upSellItem.isHidden = false + self.upSellItem.isEnabled = true + self.toggleCompletions.isHidden = true + self.toggleIgnoreLanguage.isHidden = true + self.openChat.isHidden = true + self.signOutItem.isHidden = false + } + + private func configureUnknown() { + self.accountItem.view = AccountItemView( + target: self, + action: nil, + userName: "Unknown User" + ) + self.authStatusItem.isHidden = true + self.upSellItem.isHidden = true + self.toggleCompletions.isHidden = false + self.toggleIgnoreLanguage.isHidden = false + self.openChat.isHidden = false + self.signOutItem.isHidden = false + } func updateStatusBarItem() { Task { @MainActor in let status = await Status.shared.getStatus() - let image = status.icon.nsImage - self.statusBarItem.button?.image = image - self.authMenuItem.title = status.authMessage + self.statusBarItem.button?.image = status.icon.nsImage + switch status.authStatus { + case .notLoggedIn: configureNotLoggedIn() + case .loggedIn: configureLoggedIn(status: status) + case .notAuthorized: configureNotAuthorized(status: status) + case .unknown: configureUnknown() + } if let message = status.message { - // TODO switch to attributedTitle to enable line breaks and color. - self.statusMenuItem.title = message - self.statusMenuItem.isHidden = false - self.statusMenuItem.isEnabled = status.url != nil + self.extensionStatusItem.title = message + self.extensionStatusItem.isHidden = false + self.extensionStatusItem.isEnabled = status.url != nil } else { - self.statusMenuItem.isHidden = true + self.extensionStatusItem.isHidden = true } self.markAsProcessing(status.inProgress) } @@ -250,6 +398,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { statusBarItem.button?.image = nil progressView = progress } + + @objc func openGitHubDetailsLink() { + Task { + if let url = URL(string: "https://github.com/copilot") { + NSWorkspace.shared.open(url) + } + } + } } extension NSRunningApplication { @@ -275,3 +431,35 @@ func locateHostBundleURL(url: URL) -> URL { return devAppURL } +struct CLSMessage { + let summary: String + let detail: String +} + +func extractDateFromCLSMessage(_ message: String) -> String? { + let pattern = #"until (\d{1,2}/\d{1,2}/\d{4}, \d{1,2}:\d{2}:\d{2} [AP]M)"# + if let range = message.range(of: pattern, options: .regularExpression) { + return String(message[range].dropFirst(6)) + } + return nil +} + +func getCLSMessageSummary(_ message: String) -> CLSMessage { + let summary: String + if message.contains("You've reached your monthly chat messages limit") { + summary = "Monthly Chat Limit Reached" + } else if message.contains("You've reached your monthly code completion limit") { + summary = "Monthly Completion Limit Reached" + } else { + summary = "CLS Error" + } + + let detail: String + if let date = extractDateFromCLSMessage(message) { + detail = "Visit GitHub to check your usage and upgrade to Copilot Pro or wait until \(date) for your limit to reset." + } else { + detail = message + } + + return CLSMessage(summary: summary, detail: detail) +} diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/Contents.json b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/Contents.json new file mode 100644 index 0000000..57a72d4 --- /dev/null +++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "insertButton.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "insertButton 1.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "insertButton 2.svg", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 1.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 1.svg new file mode 100644 index 0000000..b0e60fb --- /dev/null +++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 2.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 2.svg new file mode 100644 index 0000000..b0e60fb --- /dev/null +++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton.svg new file mode 100644 index 0000000..b0e60fb --- /dev/null +++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/Contents.json b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/Contents.json new file mode 100644 index 0000000..7f79e25 --- /dev/null +++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "insert1.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "insert1 1.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "insert1 2.svg", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 1.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 1.svg new file mode 100644 index 0000000..1f52da3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 2.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 2.svg new file mode 100644 index 0000000..1f52da3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1.svg new file mode 100644 index 0000000..1f52da3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json index 60c5e84..fe26a6c 100644 --- a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "copilot-16.png", + "filename" : "active-16.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "copilot-32.png", + "filename" : "active-32.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "copilot-48.png", + "filename" : "active-48.png", "idiom" : "universal", "scale" : "3x" } diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-16.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-16.png new file mode 100644 index 0000000..e53ee85 Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-16.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-32.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-32.png new file mode 100644 index 0000000..dfab434 Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-32.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-48.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-48.png new file mode 100644 index 0000000..43dafb5 Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-48.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png deleted file mode 100644 index 6add79d..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png deleted file mode 100644 index f6cf654..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png deleted file mode 100644 index 9c76a88..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json index 60c5e84..5528911 100644 --- a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "copilot-16.png", + "filename" : "inactive-16.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "copilot-32.png", + "filename" : "inactive-32.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "copilot-48.png", + "filename" : "inactive-48.png", "idiom" : "universal", "scale" : "3x" } diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-16.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-16.png deleted file mode 100644 index 0884ac6..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-16.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-32.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-32.png deleted file mode 100644 index 74aa2b4..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-32.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-48.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-48.png deleted file mode 100644 index f7e21ee..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-48.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-16.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-16.png new file mode 100644 index 0000000..e737a2b Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-16.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-32.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-32.png new file mode 100644 index 0000000..57798c9 Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-32.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-48.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-48.png new file mode 100644 index 0000000..c4d086e Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-48.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json index 60c5e84..7e671d9 100644 --- a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "copilot-16.png", + "filename" : "error-16.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "copilot-32.png", + "filename" : "error-32.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "copilot-48.png", + "filename" : "error-48.png", "idiom" : "universal", "scale" : "3x" } diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-16.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-16.png deleted file mode 100644 index 6497d37..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-16.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-32.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-32.png deleted file mode 100644 index c0073a7..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-32.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-48.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-48.png deleted file mode 100644 index 0daf8ca..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-48.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-16.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-16.png new file mode 100644 index 0000000..7166cc2 Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-16.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-32.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-32.png new file mode 100644 index 0000000..2f6ae68 Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-32.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-48.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-48.png new file mode 100644 index 0000000..08ed245 Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-48.png differ diff --git a/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Contents.json new file mode 100644 index 0000000..4e408c3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "filename" : "Xcode_16x16.svg", + "idiom" : "universal", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "Xcode_32x32.svg", + "idiom" : "universal", + "scale" : "2x", + "size" : "32x32" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_16x16.svg b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_16x16.svg new file mode 100644 index 0000000..0e118ea --- /dev/null +++ b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_16x16.svg @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_32x32.svg b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_32x32.svg new file mode 100644 index 0000000..af5de9b --- /dev/null +++ b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_32x32.svg @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ExtensionService/Info.plist b/ExtensionService/Info.plist index 2ad095c..19f114f 100644 --- a/ExtensionService/Info.plist +++ b/ExtensionService/Info.plist @@ -27,5 +27,7 @@ $(COPILOT_DOCS_URL) COPILOT_FORUM_URL $(COPILOT_FORUM_URL) + STANDARD_TELEMETRY_CHANNEL_KEY + $(STANDARD_TELEMETRY_CHANNEL_KEY) diff --git a/README.md b/README.md index 8eabfdb..791a922 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,23 @@ # GitHub Copilot for Xcode -Demo of GitHub Copilot for Xcode - [GitHub Copilot](https://github.com/features/copilot) is an AI pair programmer -tool that helps you write code faster and smarter. Copilot for Xcode is an Xcode -extension that provides inline coding suggestions as you type. +tool that helps you write code faster and smarter. Copilot for Xcode is an Xcode extension that provides inline coding suggestions as you type and a chat assistant to answer your coding questions. -## Beta Preview Policy +## Chat [Preview] -Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Terms](https://docs.github.com/en/site-policy/github-terms/github-pre-release-license-terms). We want to remind you that: +GitHub Copilot Chat provides suggestions to your specific coding tasks via chat. +Chat of GitHub Copilot for Xcode -> Beta Previews may not be supported or may change at any time. You may receive confidential information through those programs that must remain confidential while the program is private. We'd love your feedback to make our Beta Previews better. +## Code Completion +You can receive auto-complete type suggestions from GitHub Copilot either by starting to write the code you want to use, or by writing a natural language comment describing what you want the code to do. +Code Completion of GitHub Copilot for Xcode + +## Preview Policy + +Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Terms](https://docs.github.com/en/site-policy/github-terms/github-pre-release-license-terms). We want to remind you that: + +> Previews may not be supported or may change at any time. You may receive confidential information through those programs that must remain confidential while the program is private. We'd love your feedback to make our Previews better. ## Requirements @@ -39,7 +45,7 @@ Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Te 1. Open the `GitHub Copilot for Xcode` application (from the `Applications` folder). Accept the security warning.

- Screenshot of MacOS download permission request + Screenshot of MacOS download permission request

@@ -104,6 +110,25 @@ Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Te Screenshot of welcome screen

+## How to use Chat [Preview] + + Open Copilot Chat in GitHub Copilot. + - Open via the Xcode menu `Xcode -> Editor -> GitHub Copilot -> Open Chat`. +

+ Screenshot of Xcode Editor GitHub Copilot menu item +

+ + - Open via GitHub Copilot app menu `Open Chat`. + +

+ Screenshot of GitHub Copilot menu item +

+ +## How to use Code Completion + + Press `tab` to accept the first line of a suggestion, hold `option` to view + the full suggestion, and press `option` + `tab` to accept the full suggestion. + ## License This project is licensed under the terms of the MIT open source license. Please @@ -128,4 +153,4 @@ forum](https://github.com/orgs/community/discussions/categories/copilot). Thank you to @intitni for creating the original project that this is based on. Attributions can be found under About when running the app or in -[Credits.rtf](./Copilot%20for%20Xcode/Credits.rtf). +[Credits.rtf](./Copilot%20for%20Xcode/Credits.rtf). \ No newline at end of file diff --git a/Server/package-lock.json b/Server/package-lock.json index b3eefd7..6602031 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,13 +8,13 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.263.0" + "@github/copilot-language-server": "^1.265.0" } }, "node_modules/@github/copilot-language-server": { - "version": "1.263.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.263.0.tgz", - "integrity": "sha512-kf8M5kN1gYp+8yjk+yaH6iR9c8pSamWVpD7M6S2ZRkd3E4NqFGJe90Jgk6nub19tv5/qC7h7fHgeWxuhMRAGLQ==", + "version": "1.265.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.265.0.tgz", + "integrity": "sha512-p74KG3jphQ8CPfzd+AvpNGrOV4EAvv/U1AXxI1iODjSp1m4kJRiDjI5nQAZVi6FWgoHb5wtNedCf3ZxKHwal5Q==", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" }, diff --git a/Server/package.json b/Server/package.json index b82186e..60ed22f 100644 --- a/Server/package.json +++ b/Server/package.json @@ -4,6 +4,6 @@ "description": "Package for downloading @github/copilot-language-server", "private": true, "dependencies": { - "@github/copilot-language-server": "^1.263.0" + "@github/copilot-language-server": "^1.265.0" } } diff --git a/Tool/Package.swift b/Tool/Package.swift index 7b97c58..b66e7b0 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -11,6 +11,7 @@ let package = Package( .library(name: "Terminal", targets: ["Terminal"]), .library(name: "Preferences", targets: ["Preferences", "Configs"]), .library(name: "Logger", targets: ["Logger"]), + .library(name: "SystemUtils", targets: ["SystemUtils"]), .library(name: "ChatAPIService", targets: ["ChatAPIService"]), .library(name: "ChatTab", targets: ["ChatTab"]), .library(name: "FileSystem", targets: ["FileSystem"]), @@ -28,6 +29,14 @@ let package = Package( name: "ConversationServiceProvider", targets: ["ConversationServiceProvider"] ), + .library( + name: "TelemetryServiceProvider", + targets: ["TelemetryServiceProvider"] + ), + .library( + name: "TelemetryService", + targets: ["TelemetryService"] + ), .library( name: "GitHubCopilotService", targets: ["GitHubCopilotService"] @@ -49,6 +58,9 @@ let package = Package( .library(name: "DebounceFunction", targets: ["DebounceFunction"]), .library(name: "AsyncPassthroughSubject", targets: ["AsyncPassthroughSubject"]), .library(name: "CustomAsyncAlgorithms", targets: ["CustomAsyncAlgorithms"]), + .library(name: "AXHelper", targets: ["AXHelper"]), + .library(name: "Cache", targets: ["Cache"]), + .library(name: "StatusBarItemView", targets: ["StatusBarItemView"]) ], dependencies: [ // TODO: Update LanguageClient some day. @@ -198,6 +210,20 @@ let package = Package( "GitHubCopilotService", ] ), + + .target( + name: "AXHelper", + dependencies: [ + "XPCShared", + "XcodeInspector" + ] + ), + + .target(name: "StatusBarItemView", dependencies: ["Cache"]), + + .target( + name: "Cache" + ), .testTarget( name: "WorkspaceSuggestionServiceTests", @@ -209,7 +235,10 @@ let package = Package( // MARK: - Services - .target(name: "Status"), + .target( + name: "Status", + dependencies: ["Cache"] + ), .target(name: "SuggestionProvider", dependencies: [ "SuggestionBasic", @@ -223,6 +252,19 @@ let package = Package( .target(name: "ConversationServiceProvider", dependencies: [ .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ]), + + .target(name: "TelemetryServiceProvider", dependencies: [ + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + ]), + + .target( + name: "TelemetryService", + dependencies: [ + "TelemetryServiceProvider", + "GitHubCopilotService", + "BuiltinExtension", + "SystemUtils", + ]), // MARK: - GitHub Copilot @@ -237,7 +279,9 @@ let package = Package( "Terminal", "BuiltinExtension", "ConversationServiceProvider", + "TelemetryServiceProvider", "Status", + "SystemUtils", .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] @@ -255,6 +299,7 @@ let package = Package( dependencies: [ "Logger", "Preferences", + "GitHubCopilotService", .product(name: "JSONRPC", package: "JSONRPC"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product( @@ -273,6 +318,10 @@ let package = Package( package: "swift-composable-architecture" )] ), + + // MARK: - SystemUtils + + .target(name: "SystemUtils"), ] ) diff --git a/Tool/Sources/AXHelper/AXHelper.swift b/Tool/Sources/AXHelper/AXHelper.swift new file mode 100644 index 0000000..c6e7405 --- /dev/null +++ b/Tool/Sources/AXHelper/AXHelper.swift @@ -0,0 +1,70 @@ +import XPCShared +import XcodeInspector +import AppKit + +public struct AXHelper { + public init() {} + + /// When Xcode commands are not available, we can fallback to directly + /// set the value of the editor with Accessibility API. + public func injectUpdatedCodeWithAccessibilityAPI( + _ result: UpdatedContent, + focusElement: AXUIElement, + onSuccess: (() -> Void)? = nil, + onError: (() -> Void)? = nil + ) throws { + let oldPosition = focusElement.selectedTextRange + let oldScrollPosition = focusElement.parent?.verticalScrollBar?.doubleValue + + let error = AXUIElementSetAttributeValue( + focusElement, + kAXValueAttribute as CFString, + result.content as CFTypeRef + ) + + if error != AXError.success { + if let onError = onError { + onError() + } + } + + // recover selection range + if let selection = result.newSelection { + var range = SourceEditor.convertCursorRangeToRange(selection, in: result.content) + if let value = AXValueCreate(.cfRange, &range) { + AXUIElementSetAttributeValue( + focusElement, + kAXSelectedTextRangeAttribute as CFString, + value + ) + } + } else if let oldPosition { + var range = CFRange( + location: oldPosition.lowerBound, + length: 0 + ) + if let value = AXValueCreate(.cfRange, &range) { + AXUIElementSetAttributeValue( + focusElement, + kAXSelectedTextRangeAttribute as CFString, + value + ) + } + } + + // recover scroll position + if let oldScrollPosition, + let scrollBar = focusElement.parent?.verticalScrollBar + { + AXUIElementSetAttributeValue( + scrollBar, + kAXValueAttribute as CFString, + oldScrollPosition as CFTypeRef + ) + } + + if let onSuccess = onSuccess { + onSuccess() + } + } +} diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift index 9e30dd4..525b5c5 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift @@ -2,14 +2,18 @@ import CopilotForXcodeKit import Foundation import Preferences import ConversationServiceProvider +import TelemetryServiceProvider -public typealias CopilotForXcodeCapability = CopilotForXcodeExtensionCapability & CopilotForXcodeChatCapability +public typealias CopilotForXcodeCapability = CopilotForXcodeExtensionCapability & CopilotForXcodeChatCapability & CopilotForXcodeTelemetryCapability public protocol CopilotForXcodeChatCapability { - /// Not implemented yet. var conversationService: ConversationServiceType? { get } } +public protocol CopilotForXcodeTelemetryCapability { + var telemetryService: TelemetryServiceType? { get } +} + public protocol BuiltinExtension: CopilotForXcodeCapability { /// An id that let the extension manager determine whether the extension is in use. var suggestionServiceId: BuiltInSuggestionFeatureProvider { get } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift index b7ab3e3..eb1cf7a 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -97,4 +97,17 @@ public final class BuiltinExtensionConversationServiceProvider< } try? await conversationService.copyCode(request: request, workspace: workspaceInfo) } + + public func templates() async throws -> [ChatTemplate]? { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return nil + } + guard let workspaceInfo = await activeWorkspace() else { + Logger.service.error("Could not get active workspace info") + return nil + } + + return (try? await conversationService.templates(workspace: workspaceInfo)) + } } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionTelemetryServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionTelemetryServiceProvider.swift new file mode 100644 index 0000000..c78899e --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionTelemetryServiceProvider.swift @@ -0,0 +1,59 @@ +import TelemetryServiceProvider +import CopilotForXcodeKit +import Foundation +import Logger +import XcodeInspector + +public final class BuiltinExtensionTelemetryServiceProvider< + T: BuiltinExtension +>: TelemetryServiceProvider { + + private let extensionManager: BuiltinExtensionManager + + public init( + extension: T.Type, + extensionManager: BuiltinExtensionManager = .shared + ) { + self.extensionManager = extensionManager + } + + var telemetryService: TelemetryServiceType? { + extensionManager.extensions.first { $0 is T }?.telemetryService + } + + private func activeWorkspace() async -> WorkspaceInfo? { + guard let workspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL, + let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL + else { return nil } + + return WorkspaceInfo(workspaceURL: workspaceURL, projectURL: projectURL) + } + + struct BuiltinExtensionTelemetryServiceNotFoundError: Error, LocalizedError { + var errorDescription: String? { + "Builtin telemetry service not found." + } + } + + struct BuiltinExtensionActiveWorkspaceInfoNotFoundError: Error, LocalizedError { + var errorDescription: String? { + "Builtin active workspace info not found." + } + } + + public func sendError(_ request: TelemetryExceptionRequest) async throws { + guard let telemetryService else { + Logger.service.error("Builtin telemetry service not found.") + throw BuiltinExtensionTelemetryServiceNotFoundError() + } + guard let workspaceInfo = await activeWorkspace() else { + Logger.service.error("Builtin active workspace info not found.") + throw BuiltinExtensionActiveWorkspaceInfoNotFoundError() + } + + try await telemetryService.sendError( + request, + workspace: workspaceInfo + ) + } +} diff --git a/Tool/Sources/Cache/AvatarCache.swift b/Tool/Sources/Cache/AvatarCache.swift new file mode 100644 index 0000000..a0a91c4 --- /dev/null +++ b/Tool/Sources/Cache/AvatarCache.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftUI +import AppKit + +public final class AvatarCache { + public static let shared = AvatarCache() + private let cache = NSCache() + + private init () {} + + public func set(forUser username: String) async -> Void { + guard let data = await fetchAvatarData(forUser: username) else { return } + cache.setObject(data as NSData, forKey: username as NSString) + } + + public func get(forUser username: String) -> Data? { + return cache.object(forKey: username as NSString) as Data? + } + + public func remove(forUser username: String) { + cache.removeObject(forKey: username as NSString) + } +} + +extension AvatarCache { + // Directly get the avatar from URL like https://avatars.githubusercontent.com/ + // TODO: when the `agent` feature added, the avatarUrl could be obtained from the response of GitHub LSP + func fetchAvatarData(forUser username: String) async -> Data? { + let avatarUrl = "https://avatars.githubusercontent.com/\(username)" + guard let avatarUrl = URL(string: avatarUrl) else { return nil } + + do { + let (data, _) = try await URLSession.shared.data(from: avatarUrl) + return data + } catch { + return nil + } + } + + public func getAvatarImage(forUser username: String) -> Image? { + guard let data = get(forUser: username), + let nsImage = NSImage(data: data) + else { + return nil + } + + return Image(nsImage: nsImage) + } +} diff --git a/Tool/Sources/Cache/AvatarViewModel.swift b/Tool/Sources/Cache/AvatarViewModel.swift new file mode 100644 index 0000000..53dcafc --- /dev/null +++ b/Tool/Sources/Cache/AvatarViewModel.swift @@ -0,0 +1,23 @@ +import SwiftUI + +@MainActor +public class AvatarViewModel: ObservableObject { + @Published private(set) public var avatarImage: Image? + public static let shared = AvatarViewModel() + + public init() { } + + public func loadAvatar(forUser userName: String?) { + guard let userName = userName, !userName.isEmpty + else { + avatarImage = nil + return + } + + // Fetch if not in cache + Task { + await AvatarCache.shared.set(forUser: userName) + self.avatarImage = AvatarCache.shared.getAvatarImage(forUser: userName) + } + } +} diff --git a/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift index ca9f06c..556e008 100644 --- a/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift @@ -1,6 +1,7 @@ import Foundation import Logger import Preferences +import ConversationServiceProvider @globalActor public enum AutoManagedChatMemoryActor: GlobalActor { @@ -32,7 +33,7 @@ public actor AutoManagedChatMemory: ChatMemory { public var systemPrompt: String public var contextSystemPrompt: String - public var retrievedContent: [ChatMessage.Reference] = [] + public var retrievedContent: [ConversationReference] = [] var onHistoryChange: () -> Void = {} @@ -71,7 +72,7 @@ public actor AutoManagedChatMemory: ChatMemory { contextSystemPrompt = newPrompt } - public func mutateRetrievedContent(_ newContent: [ChatMessage.Reference]) { + public func mutateRetrievedContent(_ newContent: [ConversationReference]) { retrievedContent = newContent } diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift index 19c6d3b..7ba7b9c 100644 --- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift @@ -13,6 +13,12 @@ public extension ChatMemory { await mutateHistory { history in if let index = history.firstIndex(where: { $0.id == message.id }) { history[index].content = history[index].content + message.content + history[index].references.append(contentsOf: message.references) + history[index].followUp = message.followUp + history[index].suggestedTitle = message.suggestedTitle + if let errorMessage = message.errorMessage { + history[index].errorMessage = (history[index].errorMessage ?? "") + errorMessage + } } else { history.append(message) } diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index 80fbc39..83b9408 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -1,6 +1,7 @@ import CodableWrappers import Foundation import ConversationServiceProvider +import GitHubCopilotService public struct ChatMessage: Equatable, Codable { public typealias ID = String @@ -10,53 +11,7 @@ public struct ChatMessage: Equatable, Codable { case user case assistant } - - public struct Reference: Codable, Equatable { - public enum Kind: String, Codable { - case `class` - case `struct` - case `enum` - case `actor` - case `protocol` - case `extension` - case `case` - case property - case `typealias` - case function - case method - case text - case webpage - case other - } - - public var title: String - public var subTitle: String - public var uri: String - public var content: String - public var startLine: Int? - public var endLine: Int? - @FallbackDecoding - public var kind: Kind - - public init( - title: String, - subTitle: String, - content: String, - uri: String, - startLine: Int?, - endLine: Int?, - kind: Kind - ) { - self.title = title - self.subTitle = subTitle - self.content = content - self.uri = uri - self.startLine = startLine - self.endLine = endLine - self.kind = kind - } - } - + /// The role of a message. @FallbackDecoding public var role: Role @@ -64,9 +19,6 @@ public struct ChatMessage: Equatable, Codable { /// The content of the message, either the chat message, or a result of a function call. public var content: String - /// The summary of a message that is used for display. - public var summary: String? - /// The id of the message. public var id: ID @@ -77,30 +29,38 @@ public struct ChatMessage: Equatable, Codable { public var rating: ConversationRating = .unrated /// The references of this message. - @FallbackDecoding> - public var references: [Reference] + @FallbackDecoding> + public var references: [ConversationReference] + + /// The followUp question of this message + public var followUp: ConversationFollowUp? + + public var suggestedTitle: String? + + /// The error occurred during responding chat in server + public var errorMessage: String? public init( id: String = UUID().uuidString, role: Role, turnId: String? = nil, content: String, - summary: String? = nil, - references: [Reference] = [] + references: [ConversationReference] = [], + followUp: ConversationFollowUp? = nil, + suggestedTitle: String? = nil, + errorMessage: String? = nil ) { self.role = role self.content = content - self.summary = summary self.id = id self.turnId = turnId self.references = references + self.followUp = followUp + self.suggestedTitle = suggestedTitle + self.errorMessage = errorMessage } } -public struct ReferenceKindFallback: FallbackValueProvider { - public static var defaultValue: ChatMessage.Reference.Kind { .other } -} - public struct ChatMessageRoleFallback: FallbackValueProvider { public static var defaultValue: ChatMessage.Role { .user } } diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 9396373..d559852 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -28,6 +28,9 @@ public protocol ChatTabType { /// Build the tabItem for this chat tab. @ViewBuilder func buildTabItem() -> any View + /// Build the chatConversationItem + @ViewBuilder + func buildChatConversationItem() -> any View /// Build the icon for this chat tab. @ViewBuilder func buildIcon() -> any View @@ -108,6 +111,16 @@ open class BaseChatTab { } } + @ViewBuilder + public var chatConversationItem: some View { + let id = "ChatTabTab\(id)" + if let tab = self as? (any ChatTabType) { + ContentView(buildView: tab.buildChatConversationItem).id(id) + } else { + EmptyView().id(id) + } + } + /// The icon for this chat tab. @ViewBuilder public var icon: some View { @@ -203,6 +216,10 @@ public class EmptyChatTab: ChatTab { Text("Empty-\(id)") } + public func buildChatConversationItem() -> any View { + Text("Empty-\(id)") + } + public func buildIcon() -> any View { Image(systemName: "square") } diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index 22e987f..e0bd0d5 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -1,4 +1,6 @@ import CopilotForXcodeKit +import Foundation +import CodableWrappers public protocol ConversationServiceType { func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws @@ -6,6 +8,7 @@ public protocol ConversationServiceType { func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws func rateConversation(turnId: String, rating: ConversationRating, workspace: WorkspaceInfo) async throws func copyCode(request: CopyCodeRequest, workspace: WorkspaceInfo) async throws + func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]? } public protocol ConversationServiceProvider { @@ -14,6 +17,51 @@ public protocol ConversationServiceProvider { func stopReceivingMessage(_ workDoneToken: String) async throws func rateConversation(turnId: String, rating: ConversationRating) async throws func copyCode(_ request: CopyCodeRequest) async throws + func templates() async throws -> [ChatTemplate]? +} + +public struct FileReference: Hashable { + public let url: URL + public let relativePath: String? + public let fileName: String? + public var isCurrentEditor: Bool = false + + public init(url: URL, relativePath: String?, fileName: String?, isCurrentEditor: Bool = false) { + self.url = url + self.relativePath = relativePath + self.fileName = fileName + self.isCurrentEditor = isCurrentEditor + } + + public init(url: URL, isCurrentEditor: Bool = false) { + self.url = url + self.relativePath = nil + self.fileName = nil + self.isCurrentEditor = isCurrentEditor + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(url) + hasher.combine(isCurrentEditor) + } + + public static func == (lhs: FileReference, rhs: FileReference) -> Bool { + return lhs.url == rhs.url && lhs.isCurrentEditor == rhs.isCurrentEditor + } +} + +extension FileReference { + public func getPathRelativeToHome() -> String { + let filePath = url.path + guard !filePath.isEmpty else { return "" } + + let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path + if !homeDirectory.isEmpty { + return filePath.replacingOccurrences(of: homeDirectory, with: "~") + } + + return filePath + } } public struct ConversationRequest { @@ -21,17 +69,23 @@ public struct ConversationRequest { public var content: String public var workspaceFolder: String public var skills: [String] + public var ignoredSkills: [String]? + public var references: [FileReference]? public init( workDoneToken: String, content: String, workspaceFolder: String, - skills: [String] + skills: [String], + ignoredSkills: [String]? = nil, + references: [FileReference]? = nil ) { self.workDoneToken = workDoneToken self.content = content self.workspaceFolder = workspaceFolder self.skills = skills + self.ignoredSkills = ignoredSkills + self.references = references } } @@ -63,3 +117,114 @@ public enum CopyKind: Int, Codable { case keyboard = 1 case toolbar = 2 } + +public struct ConversationReference: Codable, Equatable { + public enum Kind: String, Codable { + case `class` + case `struct` + case `enum` + case `actor` + case `protocol` + case `extension` + case `case` + case property + case `typealias` + case function + case method + case text + case webpage + case other + } + + public enum Status: String, Codable { + case included, blocked, notfound, empty + } + + public var uri: String + public var status: Status? + @FallbackDecoding + public var kind: Kind + + public var ext: String { + return url?.pathExtension ?? "" + } + + public var fileName: String { + return url?.lastPathComponent ?? "" + } + + public var filePath: String { + return url?.path ?? "" + } + + public var url: URL? { + return URL(string: uri) + } + + public init( + uri: String, + status: Status?, + kind: Kind + ) { + self.uri = uri + self.status = status + self.kind = kind + + } +} + +extension ConversationReference { + public func getPathRelativeToHome() -> String { + guard !filePath.isEmpty else { return filePath} + + let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path + if !homeDirectory.isEmpty{ + return filePath.replacingOccurrences(of: homeDirectory, with: "~") + } + + return filePath + } +} + +public struct ReferenceKindFallback: FallbackValueProvider { + public static var defaultValue: ConversationReference.Kind { .other } +} + +public struct ConversationFollowUp: Codable, Equatable { + public var message: String + public var id: String + public var type: String + + public init(message: String, id: String, type: String) { + self.message = message + self.id = id + self.type = type + } +} + +public struct ChatTemplate: Codable, Equatable { + public var id: String + public var description: String + public var shortDescription: String + public var scopes: [ChatPromptTemplateScope] + + public init(id: String, description: String, shortDescription: String, scopes: [ChatPromptTemplateScope]=[]) { + self.id = id + self.description = description + self.shortDescription = shortDescription + self.scopes = scopes + } +} + +public enum ChatPromptTemplateScope: String, Codable, Equatable { + case chatPanel = "chat-panel" + case editor = "editor" + case inline = "inline" +} + +public struct CopilotLanguageServerError: Codable { + public var code: Int? + public var message: String + public var responseIsIncomplete: Bool? + public var responseIsFiltered: Bool? +} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/ConversationContextHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ConversationContextHandler.swift new file mode 100644 index 0000000..bd8ad82 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Conversation/ConversationContextHandler.swift @@ -0,0 +1,17 @@ +import JSONRPC +import Combine + +public protocol ConversationContextHandler { + var onConversationContext: PassthroughSubject<(ConversationContextRequest, (AnyJSONRPCResponse) -> Void), Never> { get } + func handleConversationContext(_ request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) +} + +public final class ConversationContextHandlerImpl: ConversationContextHandler { + public static let shared = ConversationContextHandlerImpl() + + public var onConversationContext = PassthroughSubject<(ConversationContextRequest, (AnyJSONRPCResponse) -> Void), Never>() + + public func handleConversationContext(_ request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) { + onConversationContext.send((request, completion)) + } +} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift index f88d3a7..4a7c559 100644 --- a/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift +++ b/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift @@ -2,46 +2,49 @@ import Combine import Foundation import JSONRPC import LanguageServerProtocol +import Logger public enum ProgressKind: String { case begin, report, end } public protocol ConversationProgressHandler { - var onBegin: PassthroughSubject<(String, ConversationProgress), Never> { get } - var onProgress: PassthroughSubject<(String, ConversationProgress), Never> { get } - var onEnd: PassthroughSubject<(String, ConversationProgress), Never> { get } + var onBegin: PassthroughSubject<(String, ConversationProgressBegin), Never> { get } + var onProgress: PassthroughSubject<(String, ConversationProgressReport), Never> { get } + var onEnd: PassthroughSubject<(String, ConversationProgressEnd), Never> { get } func handleConversationProgress(_ progressParams: ProgressParams) } public final class ConversationProgressHandlerImpl: ConversationProgressHandler { public static let shared = ConversationProgressHandlerImpl() - public var onBegin = PassthroughSubject<(String, ConversationProgress), Never>() - public var onProgress = PassthroughSubject<(String, ConversationProgress), Never>() - public var onEnd = PassthroughSubject<(String, ConversationProgress), Never>() + public var onBegin = PassthroughSubject<(String, ConversationProgressBegin), Never>() + public var onProgress = PassthroughSubject<(String, ConversationProgressReport), Never>() + public var onEnd = PassthroughSubject<(String, ConversationProgressEnd), Never>() private var cancellables = Set() public func handleConversationProgress(_ progressParams: ProgressParams) { guard let token = getValueAsString(from: progressParams.token), - let data = try? JSONEncoder().encode(progressParams.value), - let progress = try? JSONDecoder().decode(ConversationProgress.self, from: data) else { + let data = try? JSONEncoder().encode(progressParams.value) else { print("Error encountered while parsing conversation progress params") + Logger.gitHubCopilot.error("Error encountered while parsing conversation progress params") return } - if let kind = ProgressKind(rawValue: progress.kind) { - switch kind { - case .begin: - onBegin.send((token, progress)) - case .report: - onProgress.send((token, progress)) - case .end: - onEnd.send((token, progress)) - } + let progress = try? JSONDecoder().decode(ConversationProgressContainer.self, from: data) + switch progress { + case .begin(let begin): + onBegin.send((token, begin)) + case .report(let report): + onProgress.send((token, report)) + case .end(let end): + onEnd.send((token, end)) + default: + print("Invalid progress kind") + return } - } +} private func getValueAsString(from token: ProgressToken) -> String? { switch token { diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index b99cf7d..407bdeb 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -1,6 +1,7 @@ import BuiltinExtension import CopilotForXcodeKit import ConversationServiceProvider +import TelemetryServiceProvider import Foundation import LanguageServerProtocol import Logger @@ -11,8 +12,8 @@ public final class GitHubCopilotExtension: BuiltinExtension { public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .gitHubCopilot } public let suggestionService: GitHubCopilotSuggestionService? - public let conversationService: ConversationServiceType? + public let telemetryService: TelemetryServiceType? private var extensionUsage = ExtensionUsage( isSuggestionServiceInUse: false, @@ -33,6 +34,8 @@ public final class GitHubCopilotExtension: BuiltinExtension { self.suggestionService = suggestionService let conversationService = GitHubCopilotConversationService.init(serviceLocator: serviceLocator) self.conversationService = conversationService + let telemetryService = GitHubCopilotTelemetryService.init(serviceLocator: serviceLocator) + self.telemetryService = telemetryService } public func workspaceDidOpen(_: WorkspaceInfo) {} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index f97aec1..f0ee356 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -11,6 +11,7 @@ import Status /// We need it because the original one does not allow us to handle custom notifications. class CopilotLocalProcessServer { public var notificationPublisher: PassthroughSubject = PassthroughSubject() + public var serverRequestPublisher: PassthroughSubject<(AnyJSONRPCRequest, (AnyJSONRPCResponse) -> Void), Never> = PassthroughSubject<(AnyJSONRPCRequest, (AnyJSONRPCResponse) -> Void), Never>() private let transport: StdioDataTransport private let customTransport: CustomDataTransport @@ -64,6 +65,7 @@ class CopilotLocalProcessServer { } catch { // Handle decoding error print("Error decoding ConversationCreateParams: \(error)") + Logger.gitHubCopilot.error("Error decoding ConversationCreateParams: \(error)") } } } @@ -76,6 +78,7 @@ class CopilotLocalProcessServer { } catch { // Handle decoding error print("Error decoding TurnCreateParams: \(error)") + Logger.gitHubCopilot.error("Error decoding TurnCreateParams: \(error)") } } } @@ -85,6 +88,10 @@ class CopilotLocalProcessServer { wrappedServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in self?.notificationPublisher.send(notification) }).store(in: &cancellables) + + wrappedServer?.serverRequestPublisher.sink(receiveValue: { [weak self] (request, callback) in + self?.serverRequestPublisher.send((request, callback)) + }).store(in: &cancellables) process.standardInput = transport.stdinPipe process.standardOutput = transport.stdoutPipe @@ -198,6 +205,7 @@ final class CustomJSONRPCLanguageServer: Server { public var requestHandler: RequestHandler? public var notificationHandler: NotificationHandler? public var notificationPublisher: PassthroughSubject = PassthroughSubject() + public var serverRequestPublisher: PassthroughSubject<(AnyJSONRPCRequest, (AnyJSONRPCResponse) -> Void), Never> = PassthroughSubject<(AnyJSONRPCRequest, (AnyJSONRPCResponse) -> Void), Never>() private var outOfBandError: Error? @@ -313,6 +321,7 @@ extension CustomJSONRPCLanguageServer { data: Data, callback: @escaping (AnyJSONRPCResponse) -> Void ) -> Bool { + serverRequestPublisher.send((request: request, callback: callback)) return false } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift index e918ac6..77d54f2 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift @@ -3,6 +3,8 @@ import Foundation import LanguageServerProtocol import SuggestionBasic import ConversationServiceProvider +import JSONRPC +import Logger enum ConversationSource: String, Codable { case panel, inline @@ -13,13 +15,14 @@ public struct Doc: Codable { var uri: String } -struct Reference: Codable { - let uri: String - let position: Position? - let visibleRange: SuggestionBasic.CursorRange? - let selection: SuggestionBasic.CursorRange? - let openedAt: String? - let activeAt: String? +public struct Reference: Codable { + public var type: String = "file" + public let uri: String + public let position: Position? + public let visibleRange: SuggestionBasic.CursorRange? + public let selection: SuggestionBasic.CursorRange? + public let openedAt: String? + public let activeAt: String? } struct ConversationCreateParams: Codable { @@ -31,6 +34,7 @@ struct ConversationCreateParams: Codable { var computeSuggestions: Bool? var source: ConversationSource? var workspaceFolder: String? + var ignoredSkills: [String]? struct Capabilities: Codable { var skills: [String] @@ -38,27 +42,73 @@ struct ConversationCreateParams: Codable { } } -public struct ConversationProgress: Codable { - public struct FollowUp: Codable { - public var message: String - public var id: String - public var type: String - } +// MARK: Conversation Progress + +public enum ConversationProgressKind: String, Codable { + case begin, report, end +} + +protocol BaseConversationProgress: Codable { + var kind: ConversationProgressKind { get } + var conversationId: String { get } + var turnId: String { get } +} - public let kind: String +public struct ConversationProgressBegin: BaseConversationProgress { + public let kind: ConversationProgressKind + public let conversationId: String + public let turnId: String +} + +public struct ConversationProgressReport: BaseConversationProgress { + + public let kind: ConversationProgressKind public let conversationId: String public let turnId: String public let reply: String? + public let references: [Reference]? +} + +public struct ConversationProgressEnd: BaseConversationProgress { + public let kind: ConversationProgressKind + public let conversationId: String + public let turnId: String + public let error: CopilotLanguageServerError? + public let followUp: ConversationFollowUp? public let suggestedTitle: String? +} - init(kind: String, conversationId: String, turnId: String, reply: String = "", suggestedTitle: String? = nil) { - self.kind = kind - self.conversationId = conversationId - self.turnId = turnId - self.reply = reply - self.suggestedTitle = suggestedTitle +enum ConversationProgressContainer: Decodable { + case begin(ConversationProgressBegin) + case report(ConversationProgressReport) + case end(end: ConversationProgressEnd) + + enum CodingKeys: String, CodingKey { + case kind } -} + + init(from decoder: Decoder) throws { + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(ConversationProgressKind.self, forKey: .kind) + + switch kind { + case .begin: + let begin = try ConversationProgressBegin(from: decoder) + self = .begin(begin) + case .report: + let report = try ConversationProgressReport(from: decoder) + self = .report(report) + case .end: + let end = try ConversationProgressEnd(from: decoder) + self = .end(end: end) + } + } catch { + Logger.gitHubCopilot.error("Error decoding ConversationProgressContainer: \(error)") + throw error + } + } + } // MARK: Conversation rating @@ -82,6 +132,8 @@ struct TurnCreateParams: Codable { var conversationId: String var message: String var doc: Doc? + var ignoredSkills: [String]? + var references: [Reference]? } // MARK: Copy @@ -96,3 +148,35 @@ struct CopyCodeParams: Codable { var doc: Doc? var source: ConversationSource? } + +// MARK: Conversation context + +public struct ConversationContextParams: Codable { + public var conversationId: String + public var turnId: String + public var skillId: String +} + +public typealias ConversationContextRequest = JSONRPCRequest + +// MARK: Conversation template + +public struct Template: Codable { + public var id: String + public var description: String + public var shortDescription: String + public var scopes: [PromptTemplateScope] + + public init(id: String, description: String, shortDescription: String, scopes: [PromptTemplateScope]) { + self.id = id + self.description = description + self.shortDescription = shortDescription + self.scopes = scopes + } +} + +public enum PromptTemplateScope: String, Codable { + case chatPanel = "chat-panel" + case editor = "editor" + case inline = "inline" +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Telemetry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Telemetry.swift new file mode 100644 index 0000000..8d1b580 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Telemetry.swift @@ -0,0 +1,32 @@ +import Foundation +import TelemetryServiceProvider + +struct TelemetryExceptionParams: Codable { + public let transaction: String? + public let stacktrace: String? + public let properties: [String: String]? + public let platform: String? + public let exceptionDetail: [ExceptionDetail]? + + public init( + transaction: String? = nil, + stacktrace: String? = nil, + properties: [String: String]? = nil, + platform: String? = nil, + exceptionDetail: [ExceptionDetail]? = nil + ) { + self.transaction = transaction + self.stacktrace = stacktrace + self.properties = properties + self.platform = platform + self.exceptionDetail = exceptionDetail + } + + enum CodingKeys: String, CodingKey { + case transaction + case stacktrace + case properties + case platform + case exceptionDetail = "exception_detail" + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 9b5bd1f..c13c3e8 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -88,6 +88,11 @@ public func editorConfiguration() -> JSONValue { return .hash(d) } +public enum SignInInitiateStatus: String, Codable { + case promptUserDeviceFlow = "PromptUserDeviceFlow" + case alreadySignedIn = "AlreadySignedIn" +} + enum GitHubCopilotRequest { struct GetVersion: GitHubCopilotRequestType { struct Response: Codable { @@ -112,11 +117,12 @@ enum GitHubCopilotRequest { struct SignInInitiate: GitHubCopilotRequestType { struct Response: Codable { - var verificationUri: String - var status: String - var userCode: String - var expiresIn: Int - var interval: Int + var status: SignInInitiateStatus + var userCode: String? + var verificationUri: String? + var expiresIn: Int? + var interval: Int? + var user: String? } var request: ClientRequest { @@ -331,6 +337,16 @@ enum GitHubCopilotRequest { return .custom("conversation/rating", dict) } } + + // MARK: Conversation templates + + struct GetTemplates: GitHubCopilotRequestType { + typealias Response = Array