From 77b2c029e9f57f6972382db6dac82cb278faa128 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 16 May 2025 05:48:03 +0000 Subject: [PATCH 01/26] Pre-release 0.34.118 --- Copilot for Xcode/App.swift | 19 ++- Core/Sources/ChatService/ChatService.swift | 21 ++- Core/Sources/ConversationTab/Chat.swift | 2 +- Core/Sources/ConversationTab/ChatPanel.swift | 2 +- .../ConversationTab/ContextUtils.swift | 6 +- .../ConversationTab/ConversationTab.swift | 4 +- Core/Sources/HostApp/MCPConfigView.swift | 37 ++-- .../HostApp/MCPSettings/MCPIntroView.swift | 7 +- .../MCPSettings/MCPServerToolsSection.swift | 160 ++++++++++++------ .../HostApp/MCPSettings/MCPToolRowView.swift | 3 +- Server/package-lock.json | 8 +- Server/package.json | 2 +- TestPlan.xctestplan | 7 + Tool/Package.swift | 9 +- ...ExtensionConversationServiceProvider.swift | 29 ++-- .../ConversationServiceProvider.swift | 10 +- .../CopilotMCPToolManager.swift | 16 -- .../LanguageServer/GitHubCopilotService.swift | 135 +++++++++------ Tool/Sources/Preferences/Keys.swift | 4 + Tool/Sources/SystemUtils/SystemUtils.swift | 52 ++++++ Tool/Sources/Terminal/TerminalSession.swift | 34 +--- Tool/Sources/Workspace/WorkspaceFile.swift | 10 ++ .../SystemUtilsTests/SystemUtilsTests.swift | 55 +++++- 23 files changed, 422 insertions(+), 210 deletions(-) diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift index 6a5c9516..d8ed3cdd 100644 --- a/Copilot for Xcode/App.swift +++ b/Copilot for Xcode/App.swift @@ -6,6 +6,7 @@ import SharedUIComponents import UpdateChecker import XPCShared import HostAppActivator +import ComposableArchitecture struct VisualEffect: NSViewRepresentable { func makeNSView(context: Self.Context) -> NSView { return NSVisualEffectView() } @@ -192,14 +193,16 @@ struct CopilotForXcodeApp: App { } var body: some Scene { - Settings { - TabContainer() - .frame(minWidth: 800, minHeight: 600) - .background(VisualEffect().ignoresSafeArea()) - .environment(\.updateChecker, UpdateChecker( - hostBundle: Bundle.main, - checkerDelegate: AppUpdateCheckerDelegate() - )) + WithPerceptionTracking { + Settings { + TabContainer() + .frame(minWidth: 800, minHeight: 600) + .background(VisualEffect().ignoresSafeArea()) + .environment(\.updateChecker, UpdateChecker( + hostBundle: Bundle.main, + checkerDelegate: AppUpdateCheckerDelegate() + )) + } } } } diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 5d483a75..8cad9e5b 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -379,7 +379,7 @@ public final class ChatService: ChatServiceType, ObservableObject { public func stopReceivingMessage() async { if let activeRequestId = activeRequestId { do { - try await conversationProvider?.stopReceivingMessage(activeRequestId) + try await conversationProvider?.stopReceivingMessage(activeRequestId, workspaceURL: getWorkspaceURL()) } catch { print("Failed to cancel ongoing request with WDT: \(activeRequestId)") } @@ -393,7 +393,7 @@ public final class ChatService: ChatServiceType, ObservableObject { await memory.clearHistory() if let activeRequestId = activeRequestId { do { - try await conversationProvider?.stopReceivingMessage(activeRequestId) + try await conversationProvider?.stopReceivingMessage(activeRequestId, workspaceURL: getWorkspaceURL()) } catch { print("Failed to cancel ongoing request with WDT: \(activeRequestId)") } @@ -491,13 +491,20 @@ public final class ChatService: ChatServiceType, ObservableObject { try await send(UUID().uuidString, content: templateProcessor.process(sendingMessageImmediately), skillSet: [], references: []) } } - + + public func getWorkspaceURL() -> URL? { + guard !chatTabInfo.workspacePath.isEmpty else { + return nil + } + return URL(fileURLWithPath: chatTabInfo.workspacePath) + } + public func upvote(_ id: String, _ rating: ConversationRating) async { - try? await conversationProvider?.rateConversation(turnId: id, rating: rating) + try? await conversationProvider?.rateConversation(turnId: id, rating: rating, workspaceURL: getWorkspaceURL()) } public func downvote(_ id: String, _ rating: ConversationRating) async { - try? await conversationProvider?.rateConversation(turnId: id, rating: rating) + try? await conversationProvider?.rateConversation(turnId: id, rating: rating, workspaceURL: getWorkspaceURL()) } public func copyCode(_ id: String) async { @@ -725,7 +732,7 @@ public final class ChatService: ChatServiceType, ObservableObject { do { if let conversationId = conversationId { - try await conversationProvider?.createTurn(with: conversationId, request: request) + try await conversationProvider?.createTurn(with: conversationId, request: request, workspaceURL: getWorkspaceURL()) } else { var requestWithTurns = request @@ -738,7 +745,7 @@ public final class ChatService: ChatServiceType, ObservableObject { requestWithTurns.turns = turns } - try await conversationProvider?.createConversation(requestWithTurns) + try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL()) } } catch { resetOngoingRequest() diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index 2ca29c96..51ee178d 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -66,6 +66,7 @@ struct Chat { var fileEditMap: OrderedDictionary = [:] var diffViewerController: DiffViewWindowController? = nil var isAgentMode: Bool = AppState.shared.isAgentModeEnabled() + var workspaceURL: URL? = nil enum Field: String, Hashable { case textField case fileSearchBar @@ -564,4 +565,3 @@ private actor TimedDebounceFunction { await block() } } - diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index f164d760..1ce5ecde 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -488,7 +488,7 @@ struct ChatPanelInputArea: View { } ) .onAppear() { - allFiles = ContextUtils.getFilesInActiveWorkspace() + allFiles = ContextUtils.getFilesInActiveWorkspace(workspaceURL: chat.workspaceURL) } } diff --git a/Core/Sources/ConversationTab/ContextUtils.swift b/Core/Sources/ConversationTab/ContextUtils.swift index 84517df2..51cab9bf 100644 --- a/Core/Sources/ConversationTab/ContextUtils.swift +++ b/Core/Sources/ConversationTab/ContextUtils.swift @@ -6,7 +6,11 @@ import Workspace public struct ContextUtils { - public static func getFilesInActiveWorkspace() -> [FileReference] { + public static func getFilesInActiveWorkspace(workspaceURL: URL?) -> [FileReference] { + if let workspaceURL = workspaceURL, let info = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) { + return WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: info.workspaceURL, workspaceRootURL: info.projectURL) + } + guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL, let workspaceRootURL = XcodeInspector.shared.realtimeActiveProjectURL else { return [] diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift index 5d6d5014..54a5b621 100644 --- a/Core/Sources/ConversationTab/ConversationTab.swift +++ b/Core/Sources/ConversationTab/ConversationTab.swift @@ -114,7 +114,7 @@ public class ConversationTab: ChatTab { let service = ChatService.service(for: info) self.service = service - chat = .init(initialState: .init(), reducer: { Chat(service: service) }) + chat = .init(initialState: .init(workspaceURL: service.getWorkspaceURL()), reducer: { Chat(service: service) }) super.init(store: store) // Start to observe changes of Chat Message @@ -128,7 +128,7 @@ public class ConversationTab: ChatTab { @MainActor public init(service: ChatService, store: StoreOf, with chatTabInfo: ChatTabInfo) { self.service = service - chat = .init(initialState: .init(), reducer: { Chat(service: service) }) + chat = .init(initialState: .init(workspaceURL: service.getWorkspaceURL()), reducer: { Chat(service: service) }) super.init(store: store) } diff --git a/Core/Sources/HostApp/MCPConfigView.swift b/Core/Sources/HostApp/MCPConfigView.swift index b151a9c6..3f72daf7 100644 --- a/Core/Sources/HostApp/MCPConfigView.swift +++ b/Core/Sources/HostApp/MCPConfigView.swift @@ -6,6 +6,7 @@ import SwiftUI import Toast import ConversationServiceProvider import GitHubCopilotService +import ComposableArchitecture struct MCPConfigView: View { @State private var mcpConfig: String = "" @@ -16,20 +17,24 @@ struct MCPConfigView: View { @State private var fileMonitorTask: Task? = nil @Environment(\.colorScheme) var colorScheme + private static var lastSyncTimestamp: Date? = nil + var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 8) { - MCPIntroView() - MCPToolsListView() - } - .padding(20) - .onAppear { - setupConfigFilePath() - startMonitoringConfigFile() - refreshConfiguration(()) - } - .onDisappear { - stopMonitoringConfigFile() + WithPerceptionTracking { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + MCPIntroView() + MCPToolsListView() + } + .padding(20) + .onAppear { + setupConfigFilePath() + startMonitoringConfigFile() + refreshConfiguration(()) + } + .onDisappear { + stopMonitoringConfigFile() + } } } } @@ -145,6 +150,12 @@ struct MCPConfigView: View { } func refreshConfiguration(_: Any) { + if MCPConfigView.lastSyncTimestamp == lastModificationDate { + return + } + + MCPConfigView.lastSyncTimestamp = lastModificationDate + let fileURL = URL(fileURLWithPath: configFilePath) if let jsonString = readAndValidateJSON(from: fileURL) { UserDefaults.shared.set(jsonString, for: \.gitHubCopilotMCPConfig) diff --git a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift b/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift index 3a0c6cab..98327a96 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift +++ b/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift @@ -13,7 +13,10 @@ struct MCPIntroView: View { "my-mcp-server": { "type": "stdio", "command": "my-command", - "args": [] + "args": [], + "env": { + "TOKEN": "my_token" + } } } } @@ -75,7 +78,7 @@ struct MCPIntroView: View { .overlay( RoundedRectangle(cornerRadius: 4) .inset(by: 0.5) - .stroke(Color(red: 0.9, green: 0.9, blue: 0.9), lineWidth: 1) + .stroke(Color("GroupBoxStrokeColor"), lineWidth: 1) ) } diff --git a/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift b/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift index 04c591da..5464a6f3 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift +++ b/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift @@ -1,6 +1,8 @@ import SwiftUI import Persist import GitHubCopilotService +import Client +import Logger /// Section for a single server's tools struct MCPServerToolsSection: View { @@ -10,6 +12,54 @@ struct MCPServerToolsSection: View { @State private var toolEnabledStates: [String: Bool] = [:] @State private var isExpanded: Bool = true private var originalServerName: String { serverTools.name } + + private var serverToggleLabel: some View { + HStack(spacing: 8) { + Text("MCP Server: \(serverTools.name)").fontWeight(.medium) + if serverTools.status == .error { + if hasUnsupportedServerType() { + Badge(text: getUnsupportedServerTypeMessage(), level: .danger, icon: "xmark.circle.fill") + } else { + let message = extractErrorMessage(serverTools.error?.description ?? "") + Badge(text: message, level: .danger, icon: "xmark.circle.fill") + } + } + Spacer() + } + } + + private var serverToggle: some View { + Toggle(isOn: Binding( + get: { isServerEnabled }, + set: { updateAllToolsStatus(enabled: $0) } + )) { + serverToggleLabel + } + .toggleStyle(.checkbox) + .padding(.leading, 4) + .disabled(serverTools.status == .error) + } + + private var divider: some View { + Divider() + .padding(.leading, 36) + .padding(.top, 2) + .padding(.bottom, 4) + } + + private var toolsList: some View { + VStack(spacing: 0) { + divider + ForEach(serverTools.tools, id: \.name) { tool in + MCPToolRow( + tool: tool, + isServerEnabled: isServerEnabled, + isToolEnabled: toolBindingFor(tool), + onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) } + ) + } + } + } // Function to check if the MCP config contains unsupported server types private func hasUnsupportedServerType() -> Bool { @@ -37,61 +87,36 @@ struct MCPServerToolsSection: View { } var body: some View { - VStack(spacing: 0) { - DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 0) { + // Conditional view rendering based on error state + if serverTools.status == .error { + // No disclosure group for error state VStack(spacing: 0) { - Divider() - .padding(.leading, 32) - .padding(.top, 2) - .padding(.bottom, 4) - ForEach(serverTools.tools, id: \.name) { tool in - MCPToolRow( - tool: tool, - isServerEnabled: isServerEnabled, - isToolEnabled: toolBindingFor(tool), - onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) } - ) - } + serverToggle.padding(.leading, 12) + divider.padding(.top, 4) } - } label: { - // Server name with checkbox - Toggle(isOn: Binding( - get: { isServerEnabled }, - set: { updateAllToolsStatus(enabled: $0) } - )) { - HStack(spacing: 8) { - Text("MCP Server: \(serverTools.name)").fontWeight(.medium) - if serverTools.status == .error { - if hasUnsupportedServerType() { - Badge(text: getUnsupportedServerTypeMessage(), level: .danger, icon: "xmark.circle.fill") - } else { - let message = extractErrorMessage(serverTools.error?.description ?? "") - Badge(text: message, level: .danger, icon: "xmark.circle.fill") - } - } - } + } else { + // Regular DisclosureGroup for non-error state + DisclosureGroup(isExpanded: $isExpanded) { + toolsList + } label: { + serverToggle } - .toggleStyle(.checkbox) - .padding(.leading, 4) - .disabled(serverTools.status == .error) - } - .onAppear { - initializeToolStates() - if forceExpand { - isExpanded = true + .onAppear { + initializeToolStates() + if forceExpand { + isExpanded = true + } } - } - .onChange(of: forceExpand) { newForceExpand in - if newForceExpand { - isExpanded = true + .onChange(of: forceExpand) { newForceExpand in + if newForceExpand { + isExpanded = true + } } - } - if !isExpanded { - Divider() - .padding(.leading, 32) - .padding(.top, 2) - .padding(.bottom, 4) + if !isExpanded { + divider + } } } } @@ -158,8 +183,7 @@ struct MCPServerToolsSection: View { tools: [UpdatedMCPToolsStatus(name: tool.name, status: isEnabled ? .enabled : .disabled)] ) - AppState.shared.updateMCPToolsStatus([serverUpdate]) - CopilotMCPToolManager.updateMCPToolsStatus([serverUpdate]) + updateMCPStatus([serverUpdate]) } private func updateAllToolsStatus(enabled: Bool) { @@ -182,7 +206,37 @@ struct MCPServerToolsSection: View { } ) - AppState.shared.updateMCPToolsStatus([serverUpdate]) - CopilotMCPToolManager.updateMCPToolsStatus([serverUpdate]) + updateMCPStatus([serverUpdate]) + } + + private func updateMCPStatus(_ serverUpdates: [UpdateMCPToolsStatusServerCollection]) { + // Update status in AppState and CopilotMCPToolManager + AppState.shared.updateMCPToolsStatus(serverUpdates) + + // Encode and save status to UserDefaults + let encoder = JSONEncoder() + if let jsonData = try? encoder.encode(serverUpdates), + let jsonString = String(data: jsonData, encoding: .utf8) + { + UserDefaults.shared.set(jsonString, for: \.gitHubCopilotMCPUpdatedStatus) + } + + // In-process update + NotificationCenter.default.post( + name: .gitHubCopilotShouldUpdateMCPToolsStatus, + object: nil + ) + + Task { + do { + let service = try getService() + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldUpdateMCPToolsStatus.rawValue + ) + } catch { + Logger.client.error("Failed to post MCP status update notification: \(error.localizedDescription)") + } + } } } diff --git a/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift b/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift index 21dd6b87..f6a8e20f 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift +++ b/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift @@ -23,6 +23,7 @@ struct MCPToolRow: View { .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) + .help(description) } } @@ -30,7 +31,7 @@ struct MCPToolRow: View { } } } - .padding(.leading, 32) + .padding(.leading, 36) .padding(.vertical, 0) .onChange(of: tool._status) { isToolEnabled = $0 == .enabled } .onChange(of: isServerEnabled) { if !$0 { isToolEnabled = false } } diff --git a/Server/package-lock.json b/Server/package-lock.json index f83bc5af..d2271df6 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.319.0", + "@github/copilot-language-server": "^1.321.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,9 +36,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.319.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.319.0.tgz", - "integrity": "sha512-SicoidG61WNUs/EJRglJEry6j8ZaJrKKcx/ZznDMxorVAQp7fTeNoE+fbM2lH+qgieZIt/f+pVagYePFIxsMVg==", + "version": "1.321.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.321.0.tgz", + "integrity": "sha512-IblryaajOPfGOSaeVSpu+NUxiodXIInmWcV1YQgmvmKSdcclzt4FxAnu/szRHuh0yIaZlldQ6lBRPFIVeuXv+g==", "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" diff --git a/Server/package.json b/Server/package.json index 8eddbeab..245bad10 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.319.0", + "@github/copilot-language-server": "^1.321.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 091e7fe5..e60ea435 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -109,6 +109,13 @@ "identifier" : "ChatServiceTests", "name" : "ChatServiceTests" } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SystemUtilsTests", + "name" : "SystemUtilsTests" + } } ], "version" : 1 diff --git a/Tool/Package.swift b/Tool/Package.swift index 725385da..cfdc50b7 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -90,7 +90,7 @@ let package = Package( .target(name: "Preferences", dependencies: ["Configs"]), - .target(name: "Terminal", dependencies: ["Logger"]), + .target(name: "Terminal", dependencies: ["Logger", "SystemUtils"]), .target(name: "Logger"), @@ -307,6 +307,7 @@ let package = Package( "Status", "SystemUtils", "Workspace", + "Persist", .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] @@ -346,7 +347,11 @@ let package = Package( // MARK: - SystemUtils - .target(name: "SystemUtils") + .target( + name: "SystemUtils", + dependencies: ["Logger"] + ), + .testTarget(name: "SystemUtilsTests", dependencies: ["SystemUtils"]), ] ) diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift index aca37267..811f2b65 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -3,6 +3,7 @@ import CopilotForXcodeKit import Foundation import Logger import XcodeInspector +import Workspace public final class BuiltinExtensionConversationServiceProvider< T: BuiltinExtension @@ -21,7 +22,13 @@ public final class BuiltinExtensionConversationServiceProvider< extensionManager.extensions.first { $0 is T }?.conversationService } - private func activeWorkspace() async -> WorkspaceInfo? { + private func activeWorkspace(_ workspaceURL: URL? = nil) async -> WorkspaceInfo? { + if let workspaceURL = workspaceURL { + if let workspaceBinding = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) { + return workspaceBinding + } + } + guard let workspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL, let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL else { return nil } @@ -35,12 +42,12 @@ public final class BuiltinExtensionConversationServiceProvider< } } - public func createConversation(_ request: ConversationRequest) async throws { + public func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } @@ -48,12 +55,12 @@ public final class BuiltinExtensionConversationServiceProvider< try await conversationService.createConversation(request, workspace: workspaceInfo) } - public func createTurn(with conversationId: String, request: ConversationRequest) async throws { + public func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } @@ -61,12 +68,12 @@ public final class BuiltinExtensionConversationServiceProvider< try await conversationService.createTurn(with: conversationId, request: request, workspace: workspaceInfo) } - public func stopReceivingMessage(_ workDoneToken: String) async throws { + public func stopReceivingMessage(_ workDoneToken: String, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } @@ -74,24 +81,24 @@ public final class BuiltinExtensionConversationServiceProvider< try await conversationService.cancelProgress(workDoneToken, workspace: workspaceInfo) } - public func rateConversation(turnId: String, rating: ConversationRating) async throws { + public func rateConversation(turnId: String, rating: ConversationRating, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } try? await conversationService.rateConversation(turnId: turnId, rating: rating, workspace: workspaceInfo) } - public func copyCode(_ request: CopyCodeRequest) async throws { + public func copyCode(_ request: CopyCodeRequest, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index 3e67a6c9..bb53fbc9 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -16,11 +16,11 @@ public protocol ConversationServiceType { } public protocol ConversationServiceProvider { - func createConversation(_ request: ConversationRequest) async throws - func createTurn(with conversationId: String, request: ConversationRequest) async throws - func stopReceivingMessage(_ workDoneToken: String) async throws - func rateConversation(turnId: String, rating: ConversationRating) async throws - func copyCode(_ request: CopyCodeRequest) async throws + func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws + func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws + func stopReceivingMessage(_ workDoneToken: String, workspaceURL: URL?) async throws + func rateConversation(turnId: String, rating: ConversationRating, workspaceURL: URL?) async throws + func copyCode(_ request: CopyCodeRequest, workspaceURL: URL?) async throws func templates() async throws -> [ChatTemplate]? func models() async throws -> [CopilotModel]? func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift index 3718f849..f64c58e7 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift @@ -7,7 +7,6 @@ public extension Notification.Name { public class CopilotMCPToolManager { private static var availableMCPServerTools: [MCPServerToolsCollection] = [] - private static var updatedMCPToolsStatusParams: UpdateMCPToolsStatusParams = .init(servers: []) public static func updateMCPTools(_ serverToolsCollections: [MCPServerToolsCollection]) { let sortedMCPServerTools = serverToolsCollections.sorted(by: { $0.name.lowercased() < $1.name.lowercased() }) @@ -30,21 +29,6 @@ public class CopilotMCPToolManager { public static func hasMCPTools() -> Bool { return !availableMCPServerTools.isEmpty } - - public static func updateMCPToolsStatus(_ servers: [UpdateMCPToolsStatusServerCollection]) { - updatedMCPToolsStatusParams = .init(servers: servers) - DispatchQueue.main.async { - NotificationCenter.default - .post( - name: .gitHubCopilotShouldUpdateMCPToolsStatus, - object: nil - ) - } - } - - public static func getUpdatedMCPToolsStatusParams() -> UpdateMCPToolsStatusParams { - return updatedMCPToolsStatusParams - } public static func clearMCPTools() { availableMCPServerTools = [] diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 6992d42f..ae9ac1a9 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -11,6 +11,7 @@ import Preferences import Status import SuggestionBasic import SystemUtils +import Persist public protocol GitHubCopilotAuthServiceType { func checkStatus() async throws -> GitHubCopilotAccountStatus @@ -175,6 +176,8 @@ public class GitHubCopilotBaseService { } } + environment["PATH"] = SystemUtils.shared.appendCommonBinPaths(path: environment["PATH"] ?? "") + let versionNumber = JSONValue( stringLiteral: SystemUtils.editorPluginVersion ?? "" ) @@ -285,6 +288,8 @@ public class GitHubCopilotBaseService { let notifications = NotificationCenter.default .notifications(named: .gitHubCopilotShouldRefreshEditorInformation) + let mcpNotifications = NotificationCenter.default + .notifications(named: .gitHubCopilotShouldUpdateMCPToolsStatus) Task { [weak self] in if projectRootURL.path != "/" { try? await server.sendNotification( @@ -299,6 +304,9 @@ public class GitHubCopilotBaseService { .init(settings: editorConfiguration()) ) ) + if let copilotService = self as? GitHubCopilotService { + _ = await copilotService.initializeMCP() + } for await _ in notifications { guard self != nil else { return } _ = try? await server.sendNotification( @@ -307,6 +315,10 @@ public class GitHubCopilotBaseService { ) ) } + for await _ in mcpNotifications { + guard self != nil else { return } + _ = await GitHubCopilotService.updateAllMCP() + } } } @@ -383,41 +395,16 @@ func getTerminalEnvironmentVariables(_ variableNames: [String]) -> [String: Stri guard let shell = userShell else { return results } - - let process = Process() - let pipe = Pipe() - - process.executableURL = URL(fileURLWithPath: shell) - process.arguments = ["-l", "-c", "env"] - process.standardOutput = pipe - - do { - try process.run() - process.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let outputString = String(data: data, encoding: .utf8) else { - Logger.gitHubCopilot.info("Failed to decode shell output for variables: \(variableNames.joined(separator: ", "))") - return results - } - - // Process each line of env output - for line in outputString.split(separator: "\n") { - // Each env line is in the format NAME=VALUE - if let idx = line.firstIndex(of: "=") { - let key = String(line[.. { .init(defaultValue: "", key: "GitHubCopilotMCPConfig") } + + var gitHubCopilotMCPUpdatedStatus: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotMCPUpdatedStatus") + } var gitHubCopilotEnterpriseURI: PreferenceKey { .init(defaultValue: "", key: "GitHubCopilotEnterpriseURI") diff --git a/Tool/Sources/SystemUtils/SystemUtils.swift b/Tool/Sources/SystemUtils/SystemUtils.swift index c2db343a..e5b0c79a 100644 --- a/Tool/Sources/SystemUtils/SystemUtils.swift +++ b/Tool/Sources/SystemUtils/SystemUtils.swift @@ -1,4 +1,5 @@ import Foundation +import Logger import IOKit import CryptoKit @@ -172,4 +173,55 @@ public class SystemUtils { return false #endif } + + /// Returns the environment of a login shell (to get correct PATH and other variables) + public func getLoginShellEnvironment(shellPath: String = "/bin/zsh") -> [String: String]? { + let task = Process() + let pipe = Pipe() + task.executableURL = URL(fileURLWithPath: shellPath) + task.arguments = ["-i", "-l", "-c", "env"] + task.standardOutput = pipe + do { + try task.run() + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return nil } + var env: [String: String] = [:] + for line in output.split(separator: "\n") { + if let idx = line.firstIndex(of: "=") { + let key = String(line[.. String { + let homeDirectory = NSHomeDirectory() + let commonPaths = [ + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + homeDirectory + "/.local/bin", + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + ] + + let paths = path.split(separator: ":").map { String($0) } + var newPath = path + for commonPath in commonPaths { + if FileManager.default.fileExists(atPath: commonPath) && !paths.contains(commonPath) { + newPath += (newPath.isEmpty ? "" : ":") + commonPath + } + } + + return newPath + } } diff --git a/Tool/Sources/Terminal/TerminalSession.swift b/Tool/Sources/Terminal/TerminalSession.swift index 237d2a05..6db53ef6 100644 --- a/Tool/Sources/Terminal/TerminalSession.swift +++ b/Tool/Sources/Terminal/TerminalSession.swift @@ -1,4 +1,5 @@ import Foundation +import SystemUtils import Logger import Combine @@ -45,7 +46,7 @@ class ShellProcessManager { // Configure the process process?.executableURL = URL(fileURLWithPath: "/bin/zsh") - process?.arguments = ["-i"] + process?.arguments = ["-i", "-l"] // Create temporary file for shell integration let tempDir = FileManager.default.temporaryDirectory @@ -68,11 +69,13 @@ class ShellProcessManager { var environment = ProcessInfo.processInfo.environment // Fetch login shell environment to get correct PATH - if let shellEnv = ShellProcessManager.getLoginShellEnvironment() { + if let shellEnv = SystemUtils.shared.getLoginShellEnvironment(shellPath: "/bin/zsh") { for (key, value) in shellEnv { environment[key] = value } } + // Append common bin paths to PATH + environment["PATH"] = SystemUtils.shared.appendCommonBinPaths(path: environment["PATH"] ?? "") let userZdotdir = environment["ZDOTDIR"] ?? NSHomeDirectory() environment["ZDOTDIR"] = zshdir.path @@ -108,33 +111,6 @@ class ShellProcessManager { } } - /// Returns the environment of a login shell (to get correct PATH and other variables) - private static func getLoginShellEnvironment() -> [String: String]? { - let task = Process() - let pipe = Pipe() - task.executableURL = URL(fileURLWithPath: "/bin/zsh") - task.arguments = ["-l", "-c", "env"] - task.standardOutput = pipe - do { - try task.run() - task.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let output = String(data: data, encoding: .utf8) else { return nil } - var env: [String: String] = [:] - for line in output.split(separator: "\n") { - if let idx = line.firstIndex(of: "=") { - let key = String(line[.. = ["swift", "m", "mm", "h", "cpp", "c", "js", "ts", "py", "rb", "java", "applescript", "scpt", "plist", "entitlements", "md", "json", "xml", "txt", "yaml", "yml", "html", "css"] public let skipPatterns: [String] = [ @@ -98,6 +99,15 @@ public struct WorkspaceFile { return false } + public static func getWorkspaceInfo(workspaceURL: URL) -> WorkspaceInfo? { + guard let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(workspaceURL: workspaceURL, documentURL: nil) else { + return nil + } + + let workspaceInfo = WorkspaceInfo(workspaceURL: workspaceURL, projectURL: projectURL) + return workspaceInfo + } + public static func getProjects(workspace: WorkspaceInfo) -> [ProjectInfo] { var subprojects: [ProjectInfo] = [] if isXCWorkspace(workspace.workspaceURL) { diff --git a/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift b/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift index a01a5a31..95313c0d 100644 --- a/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift +++ b/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift @@ -1,8 +1,5 @@ -import CopilotForXcodeKit -import LanguageServerProtocol import XCTest -@testable import Workspace @testable import SystemUtils final class SystemUtilsTests: XCTestCase { @@ -17,4 +14,56 @@ final class SystemUtilsTests: XCTestCase { XCTAssertTrue(versionTest.evaluate(with: version), "The Xcode version should match the expected format.") XCTAssertFalse(version.isEmpty, "The Xcode version should not be an empty string.") } + + func test_getLoginShellEnvironment() throws { + // Test with a valid shell path + let validShellPath = "/bin/zsh" + let env = SystemUtils.shared.getLoginShellEnvironment(shellPath: validShellPath) + + XCTAssertNotNil(env, "Environment should not be nil for valid shell path") + XCTAssertFalse(env?.isEmpty ?? true, "Environment should contain variables") + + // Check for essential environment variables + XCTAssertNotNil(env?["PATH"], "PATH should be present in environment") + XCTAssertNotNil(env?["HOME"], "HOME should be present in environment") + XCTAssertNotNil(env?["USER"], "USER should be present in environment") + + // Test with an invalid shell path + let invalidShellPath = "/nonexistent/shell" + let invalidEnv = SystemUtils.shared.getLoginShellEnvironment(shellPath: invalidShellPath) + XCTAssertNil(invalidEnv, "Environment should be nil for invalid shell path") + } + + func test_appendCommonBinPaths() { + // Test with an empty path + let appendedEmptyPath = SystemUtils.shared.appendCommonBinPaths(path: "") + XCTAssertFalse(appendedEmptyPath.isEmpty, "Result should not be empty when starting with empty path") + XCTAssertTrue(appendedEmptyPath.contains("/usr/bin"), "Common path /usr/bin should be added") + XCTAssertFalse(appendedEmptyPath.hasPrefix(":"), "Result should not start with ':'") + + // Test with a custom path + let customPath = "/custom/bin:/another/custom/bin" + let appendedCustomPath = SystemUtils.shared.appendCommonBinPaths(path: customPath) + + // Verify original paths are preserved + XCTAssertTrue(appendedCustomPath.hasPrefix(customPath), "Original paths should be preserved") + + // Verify common paths are added + XCTAssertTrue(appendedCustomPath.contains(":/usr/local/bin"), "Should contain /usr/local/bin") + XCTAssertTrue(appendedCustomPath.contains(":/usr/bin"), "Should contain /usr/bin") + XCTAssertTrue(appendedCustomPath.contains(":/bin"), "Should contain /bin") + + // Test with a path that already includes some common paths + let existingCommonPath = "/usr/bin:/custom/bin" + let appendedExistingPath = SystemUtils.shared.appendCommonBinPaths(path: existingCommonPath) + + // Check that /usr/bin wasn't added again + let pathComponents = appendedExistingPath.split(separator: ":") + let usrBinCount = pathComponents.filter { $0 == "/usr/bin" }.count + XCTAssertEqual(usrBinCount, 1, "Common path should not be duplicated") + + // Make sure the result is a valid PATH string + // First component should be the initial path components + XCTAssertTrue(appendedExistingPath.hasPrefix(existingCommonPath), "Should preserve original path at the beginning") + } } From 82d3232bb488056a909b8cc187cd335697fc466a Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 19 May 2025 02:24:19 +0000 Subject: [PATCH 02/26] Release 0.35.0 --- CHANGELOG.md | 16 ++++++++++++++++ Docs/welcome.png | Bin 178515 -> 0 bytes README.md | 16 ++++++++++++---- ReleaseNotes.md | 15 ++++++++++----- 4 files changed, 38 insertions(+), 9 deletions(-) delete mode 100644 Docs/welcome.png diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ffedc4..e8692d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.35.0 - May 19, 2025 +### Added +- Launched Agent Mode. Copilot will automatically use multiple requests to edit files, run terminal commands, and fix errors. +- Introduced Model Context Protocol (MCP) support in Agent Mode, allowing you to configure MCP tools to extend capabilities. + +### Changed +- Added a button to enable/disable referencing current file in conversations +- Added an animated progress icon in the response section +- Refined onboarding experience with updated instruction screens and welcome views +- Improved conversation reliability with extended timeout limits for agent requests + +### Fixed +- Addressed critical error handling issues in core functionality +- Resolved UI inconsistencies with chat interface padding adjustments +- Implemented custom certificate handling using system environment variables `NODE_EXTRA_CA_CERTS` and `NODE_TLS_REJECT_UNAUTHORIZED`, fixing network access issues + ## 0.34.0 - April 29, 2025 ### Added - Added support for new models in Chat: OpenAI GPT-4.1, o3 and o4-mini, Gemini 2.5 Pro diff --git a/Docs/welcome.png b/Docs/welcome.png deleted file mode 100644 index de2da42b828e7f9027c0a1fe3f191ffeac4c0ce5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 178515 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

1xWv(EPAp4m#t*9uI71K;(l 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 %# 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%0A=U8n&=Myoo*aaLCFG~Hv5}8P3_4i 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

o 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 nxG;U&qv}W@-61437Im|G&`u@1 +## Agent Mode + +GitHub Copilot Agent Mode provides AI-powered assistance that can understand and modify your codebase directly. With Agent Mode, you can: +- Get intelligent code edits applied directly to your files +- Run terminal commands and view their output without leaving the interface +- Search through your codebase to find relevant files and code snippets +- Create new files and directories as needed for your project +- Get assistance with enhanced context awareness across multiple files and folders +- Run Model Context Protocol (MCP) tools you configured to extend the capabilities + +Agent Mode integrates with Xcode's environment, creating a seamless development experience where Copilot can help implement features, fix bugs, and refactor code with comprehensive understanding of your project. + ## 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. @@ -98,10 +110,6 @@ You can receive auto-complete type suggestions from GitHub Copilot either by sta 1. 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. -

- Screenshot of welcome screen -

- ## How to use Chat Open Copilot Chat in GitHub Copilot. diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 20b05455..38eb6c14 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,14 +1,19 @@ -### GitHub Copilot for Xcode 0.34.0 +### GitHub Copilot for Xcode 0.35.0 **🚀 Highlights** -* **New Models**: OpenAI GPT-4.1, o3 and o4-mini, Gemini 2.5 Pro are now available in the Copilot Chat model selector. +* **Agent Mode**: Copilot will automatically use multiple requests to edit files, run terminal commands, and fix errors. +* **Model Context Protocol (MCP)**: Integrated with Agent Mode, allowing you to configure MCP tools to extend capabilities. **💪 Improvements** -* Switched default model to GPT-4.1 for new installations -* Enhanced model selection interface +* Added a button to enable/disable referencing current file in conversations +* Added an animated progress icon in the response section +* Refined onboarding experience with updated instruction screens and welcome views +* Improved conversation reliability with extended timeout limits for agent requests **🛠️ Bug Fixes** -* Resolved critical error handling issues +* Addressed critical error handling issues in core functionality +* Resolved UI inconsistencies with chat interface padding adjustments +* Improved network access with automatic detection of system environment variables for custom certificates From f04ddbedf93a26fb4ae7347026b2c82354eb28aa Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 3 Jun 2025 06:38:53 +0000 Subject: [PATCH 03/26] Pre-release 0.35.120 --- Core/Sources/ChatService/ChatService.swift | 100 ++- .../ContextAwareAutoManagedChatMemory.swift | 2 + Core/Sources/ConversationTab/Chat.swift | 28 +- Core/Sources/ConversationTab/ChatPanel.swift | 41 +- .../Controller/DiffViewWindowController.swift | 20 +- .../ConversationTab/ConversationTab.swift | 17 + .../ModelPicker/ModelPicker.swift | 190 ++++-- .../TerminalViews/RunInTerminalToolView.swift | 13 +- .../ConversationTab/Views/BotMessage.swift | 14 +- .../Views/FunctionMessage.swift | 128 ++-- .../GitHubCopilotViewModel.swift | 1 + .../AdvancedSettings/AdvancedSettings.swift | 1 + .../AdvancedSettings/ChatSection.swift | 79 +++ .../Extensions/ChatMessage+Storage.swift | 19 +- .../Extensions/ChatTabInfo+Storage.swift | 11 + .../Stores/ChatTabInfoStore.swift | 34 +- .../GraphicalUserInterfaceController.swift | 61 +- .../ChatWindow/ChatHistoryView.swift | 76 +-- .../SuggestionWidget/ChatWindowView.swift | 2 +- .../FeatureReducers/ChatPanelFeature.swift | 146 ++++- ExtensionService/AppDelegate+Menu.swift | 47 +- ExtensionService/AppDelegate.swift | 122 ++-- .../MenuBarErrorIcon.imageset/Contents.json | 16 + .../Status=error, Mode=dark.svg | 0 .../MenuBarWarningIcon.imageset/Contents.json | 2 +- .../Status=warning, Mode=dark.svg | 11 + Server/package-lock.json | 8 +- Server/package.json | 2 +- ...ExtensionConversationServiceProvider.swift | 7 +- .../APIs/ChatCompletionsAPIDefinition.swift | 3 - .../Memory/AutoManagedChatMemory.swift | 7 + .../ChatAPIService/Memory/ChatMemory.swift | 3 +- Tool/Sources/ChatAPIService/Models.swift | 6 +- Tool/Sources/ChatTab/ChatTab.swift | 15 + Tool/Sources/ChatTab/ChatTabPool.swift | 2 + .../ConversationServiceProvider.swift | 8 +- .../LSPTypes.swift | 49 +- .../Conversation/WatchedFilesHandler.swift | 2 +- .../LanguageServer/CopilotModelManager.swift | 12 + .../GitHubCopilotRequest+Conversation.swift | 2 + .../LanguageServer/GitHubCopilotRequest.swift | 8 + .../LanguageServer/GitHubCopilotService.swift | 37 +- .../GitHubCopilotConversationService.swift | 4 +- .../ConversationStorage.swift | 34 +- .../Storage/ConversationStorage/Model.swift | 9 +- .../Storage/ConversationStorageService.swift | 14 + Tool/Sources/Preferences/Keys.swift | 4 + .../SharedUIComponents/CustomTextEditor.swift | 136 +++-- Tool/Sources/Status/Status.swift | 66 +- Tool/Sources/Status/Types/AuthStatus.swift | 6 + Tool/Sources/Status/Types/CLSStatus.swift | 10 + .../Status/Types/GitHubCopilotQuotaInfo.swift | 15 + .../Sources/Status/Types/StatusResponse.swift | 35 ++ .../StatusBarItemView/AccountItemView.swift | 2 +- .../StatusBarItemView/HoverButton.swift | 145 +++++ .../Sources/StatusBarItemView/QuotaView.swift | 578 ++++++++++++++++++ .../FileChangeWatcher/FileChangeWatcher.swift | 6 +- 57 files changed, 1985 insertions(+), 431 deletions(-) create mode 100644 Core/Sources/HostApp/AdvancedSettings/ChatSection.swift create mode 100644 ExtensionService/Assets.xcassets/MenuBarErrorIcon.imageset/Contents.json rename ExtensionService/Assets.xcassets/{MenuBarWarningIcon.imageset => MenuBarErrorIcon.imageset}/Status=error, Mode=dark.svg (100%) create mode 100644 ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Status=warning, Mode=dark.svg create mode 100644 Tool/Sources/Status/Types/AuthStatus.swift create mode 100644 Tool/Sources/Status/Types/CLSStatus.swift create mode 100644 Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift create mode 100644 Tool/Sources/Status/Types/StatusResponse.swift create mode 100644 Tool/Sources/StatusBarItemView/HoverButton.swift create mode 100644 Tool/Sources/StatusBarItemView/QuotaView.swift diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 8cad9e5b..0162dfa8 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -17,7 +17,7 @@ import OrderedCollections public protocol ChatServiceType { var memory: ContextAwareAutoManagedChatMemory { get set } - func send(_ id: String, content: String, skillSet: [ConversationSkill], references: [FileReference], model: String?, agentMode: Bool) async throws + func send(_ id: String, content: String, skillSet: [ConversationSkill], references: [FileReference], model: String?, agentMode: Bool, userLanguage: String?, turnId: String?) async throws func stopReceivingMessage() async func upvote(_ id: String, _ rating: ConversationRating) async func downvote(_ id: String, _ rating: ConversationRating) async @@ -79,6 +79,7 @@ public final class ChatService: ChatServiceType, ObservableObject { private var activeRequestId: String? private(set) public var conversationId: String? private var skillSet: [ConversationSkill] = [] + private var lastUserRequest: ConversationRequest? private var isRestored: Bool = false private var pendingToolCallRequests: [String: ToolCallRequest] = [:] init(provider: any ConversationServiceProvider, @@ -98,6 +99,18 @@ public final class ChatService: ChatServiceType, ObservableObject { subscribeToClientToolConfirmationEvent() } + deinit { + Task { [weak self] in + await self?.stopReceivingMessage() + } + + // Clear all subscriptions + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + + // Memory will be deallocated automatically + } + private func subscribeToNotifications() { memory.observeHistoryChange { [weak self] in Task { [weak self] in @@ -303,7 +316,16 @@ public final class ChatService: ChatServiceType, ObservableObject { } } - public func send(_ id: String, content: String, skillSet: Array, references: Array, model: String? = nil, agentMode: Bool = false) async throws { + public func send( + _ id: String, + content: String, + skillSet: Array, + references: Array, + model: String? = nil, + agentMode: Bool = false, + userLanguage: String? = nil, + turnId: String? = nil + ) async throws { guard activeRequestId == nil else { return } let workDoneToken = UUID().uuidString activeRequestId = workDoneToken @@ -315,11 +337,15 @@ public final class ChatService: ChatServiceType, ObservableObject { content: content, references: references.toConversationReferences() ) - await memory.appendMessage(chatMessage) + + // If turnId is provided, it is used to update the existing message, no need to append the user message + if turnId == nil { + await memory.appendMessage(chatMessage) + } // reset file edits self.resetFileEdits() - + // persist saveChatMessageToStorage(chatMessage) @@ -363,7 +389,11 @@ public final class ChatService: ChatServiceType, ObservableObject { ignoredSkills: ignoredSkills, references: references, model: model, - agentMode: agentMode) + agentMode: agentMode, + userLanguage: userLanguage, + turnId: turnId + ) + self.lastUserRequest = request self.skillSet = skillSet try await send(request) } @@ -408,12 +438,23 @@ public final class ChatService: ChatServiceType, ObservableObject { deleteChatMessageFromStorage(id) } - // Not used for now - public func resendMessage(id: String) async throws { - if let message = (await memory.history).first(where: { $0.id == id }) + public func resendMessage(id: String, model: String? = nil) async throws { + if let _ = (await memory.history).first(where: { $0.id == id }), + let lastUserRequest { + // TODO: clean up contents for resend message + activeRequestId = nil do { - try await send(id, content: message.content, skillSet: [], references: []) + try await send( + id, + content: lastUserRequest.content, + skillSet: skillSet, + references: lastUserRequest.references ?? [], + model: model != nil ? model : lastUserRequest.model, + agentMode: lastUserRequest.agentMode, + userLanguage: lastUserRequest.userLanguage, + turnId: id + ) } catch { print("Failed to resend message") } @@ -611,17 +652,34 @@ public final class ChatService: ChatServiceType, ObservableObject { if CLSError.code == 402 { Task { await Status.shared - .updateCLSStatus(.error, busy: false, message: CLSError.message) + .updateCLSStatus(.warning, busy: false, message: CLSError.message) let errorMessage = ChatMessage( id: progress.turnId, chatTabID: self.chatTabInfo.id, clsTurnID: progress.turnId, - role: .system, - content: CLSError.message + role: .assistant, + content: "", + panelMessages: [.init(type: .error, title: String(CLSError.code ?? 0), message: CLSError.message, location: .Panel)] ) // will persist in resetongoingRequest() - await memory.removeMessage(progress.turnId) await memory.appendMessage(errorMessage) + + if let lastUserRequest { + guard let fallbackModel = CopilotModelManager.getFallbackLLM( + scope: lastUserRequest.agentMode ? .agentPanel : .chatPanel + ) else { + resetOngoingRequest() + return + } + do { + CopilotModelManager.switchToFallbackModel() + try await resendMessage(id: progress.turnId, model: fallbackModel.id) + } catch { + Logger.gitHubCopilot.error(error) + resetOngoingRequest() + } + return + } } } else if CLSError.code == 400 && CLSError.message.contains("model is not supported") { Task { @@ -633,6 +691,8 @@ public final class ChatService: ChatServiceType, ObservableObject { errorMessage: "Oops, the model is not supported. Please enable it first in [GitHub Copilot settings](https://github.com/settings/copilot)." ) await memory.appendMessage(errorMessage) + resetOngoingRequest() + return } } else { Task { @@ -646,10 +706,10 @@ public final class ChatService: ChatServiceType, ObservableObject { ) // will persist in resetOngoingRequest() await memory.appendMessage(errorMessage) + resetOngoingRequest() + return } } - resetOngoingRequest() - return } Task { @@ -664,9 +724,8 @@ public final class ChatService: ChatServiceType, ObservableObject { ) // will persist in resetOngoingRequest() await memory.appendMessage(message) + resetOngoingRequest() } - - resetOngoingRequest() } private func resetOngoingRequest() { @@ -732,7 +791,12 @@ public final class ChatService: ChatServiceType, ObservableObject { do { if let conversationId = conversationId { - try await conversationProvider?.createTurn(with: conversationId, request: request, workspaceURL: getWorkspaceURL()) + try await conversationProvider? + .createTurn( + with: conversationId, + request: request, + workspaceURL: getWorkspaceURL() + ) } else { var requestWithTurns = request diff --git a/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift index e86ede8b..f185f9b1 100644 --- a/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift +++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift @@ -18,6 +18,8 @@ public final class ContextAwareAutoManagedChatMemory: ChatMemory { systemPrompt: "" ) } + + deinit { } public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) async { await memory.mutateHistory(update) diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index 51ee178d..abde0b74 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -15,7 +15,6 @@ public struct DisplayedChatMessage: Equatable { public enum Role: Equatable { case user case assistant - case system case ignored } @@ -28,8 +27,20 @@ public struct DisplayedChatMessage: Equatable { public var errorMessage: String? = nil public var steps: [ConversationProgressStep] = [] public var editAgentRounds: [AgentRound] = [] - - public init(id: String, role: Role, text: String, references: [ConversationReference] = [], followUp: ConversationFollowUp? = nil, suggestedTitle: String? = nil, errorMessage: String? = nil, steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = []) { + public var panelMessages: [CopilotShowMessageParams] = [] + + public init( + id: String, + role: Role, + text: String, + references: [ConversationReference] = [], + followUp: ConversationFollowUp? = nil, + suggestedTitle: String? = nil, + errorMessage: String? = nil, + steps: [ConversationProgressStep] = [], + editAgentRounds: [AgentRound] = [], + panelMessages: [CopilotShowMessageParams] = [] + ) { self.id = id self.role = role self.text = text @@ -39,6 +50,7 @@ public struct DisplayedChatMessage: Equatable { self.errorMessage = errorMessage self.steps = steps self.editAgentRounds = editAgentRounds + self.panelMessages = panelMessages } } @@ -137,6 +149,7 @@ struct Chat { @Dependency(\.openURL) var openURL @AppStorage(\.enableCurrentEditorContext) var enableCurrentEditorContext: Bool + @AppStorage(\.chatResponseLocale) var chatResponseLocale var body: some ReducerOf { BindingReducer() @@ -180,7 +193,7 @@ struct Chat { let selectedModelFamily = AppState.shared.getSelectedModelFamily() ?? CopilotModelManager.getDefaultChatModel(scope: AppState.shared.modelScope())?.modelFamily let agentMode = AppState.shared.isAgentModeEnabled() return .run { _ in - try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles, model: selectedModelFamily, agentMode: agentMode) + try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles, model: selectedModelFamily, agentMode: agentMode, userLanguage: chatResponseLocale) }.cancellable(id: CancelID.sendMessage(self.id)) case let .toolCallAccepted(toolCallId): @@ -209,7 +222,7 @@ struct Chat { let selectedModelFamily = AppState.shared.getSelectedModelFamily() ?? CopilotModelManager.getDefaultChatModel(scope: AppState.shared.modelScope())?.modelFamily return .run { _ in - try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles, model: selectedModelFamily) + try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles, model: selectedModelFamily, userLanguage: chatResponseLocale) }.cancellable(id: CancelID.sendMessage(self.id)) case .returnButtonTapped: @@ -343,9 +356,9 @@ struct Chat { id: message.id, role: { switch message.role { - case .system: return .system case .user: return .user case .assistant: return .assistant + case .system: return .ignored } }(), text: message.content, @@ -360,7 +373,8 @@ struct Chat { suggestedTitle: message.suggestedTitle, errorMessage: message.errorMessage, steps: message.steps, - editAgentRounds: message.editAgentRounds + editAgentRounds: message.editAgentRounds, + panelMessages: message.panelMessages )) return all diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index 1ce5ecde..63b99607 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -37,9 +37,7 @@ public struct ChatPanel: View { .accessibilityElement(children: .combine) .accessibilityLabel("Chat Messages Group") - if chat.history.last?.role == .system { - ChatCLSError(chat: chat).padding(.trailing, 16) - } else if (chat.history.last?.followUp) != nil { + if let _ = chat.history.last?.followUp { ChatFollowUp(chat: chat) .padding(.trailing, 16) .padding(.vertical, 8) @@ -344,10 +342,9 @@ struct ChatHistoryItem: View { errorMessage: message.errorMessage, chat: chat, steps: message.steps, - editAgentRounds: message.editAgentRounds + editAgentRounds: message.editAgentRounds, + panelMessages: message.panelMessages ) - case .system: - FunctionMessage(chat: chat, id: message.id, text: text) case .ignored: EmptyView() } @@ -516,8 +513,7 @@ struct ChatPanelInputArea: View { submitChatMessage() } dropDownShowingType = nil - }, - completions: chatAutoCompletion + } ) .focused(focusedField, equals: .textField) .bind($chat.focusedField, to: focusedField) @@ -800,11 +796,7 @@ struct ChatPanelInputArea: View { if !chat.isAgentMode { promptTemplates = await SharedChatService.shared.loadChatTemplates() ?? [] } - - guard !promptTemplates.isEmpty else { - return [releaseNotesTemplate] - } - + let templates = promptTemplates + [releaseNotesTemplate] let skippedTemplates = [ "feedback", "help" ] @@ -831,29 +823,6 @@ struct ChatPanelInputArea: View { return chatAgents.filter { $0.slug.hasPrefix(prefix) && includedAgents.contains($0.slug) } } - func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] { - guard text.count == 1 else { return [] } - let plugins = [String]() // chat.pluginIdentifiers.map { "/\($0)" } - let availableFeatures = plugins + [ -// "/exit", - "@code", - "@sense", - "@project", - "@web", - ] - - let result: [String] = availableFeatures - .filter { $0.hasPrefix(text) && $0 != text } - .compactMap { - guard let index = $0.index( - $0.startIndex, - offsetBy: range.location, - limitedBy: $0.endIndex - ) else { return nil } - return String($0[index...]) - } - return result - } func subscribeToActiveDocumentChangeEvent() { Publishers.CombineLatest( XcodeInspector.shared.$latestActiveXcode, diff --git a/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift b/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift index 26651abd..e4da4784 100644 --- a/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift +++ b/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift @@ -16,16 +16,34 @@ class DiffViewWindowController: NSObject, NSWindowDelegate { private var diffWindow: NSWindow? private var hostingView: NSHostingView? - private let chat: StoreOf + private weak var chat: StoreOf? public private(set) var currentFileEdit: FileEdit? = nil public private(set) var diffViewerState: DiffViewerState = .closed public init(chat: StoreOf) { self.chat = chat } + + deinit { + // Break the delegate cycle + diffWindow?.delegate = nil + + // Close and release the wi + diffWindow?.close() + diffWindow = nil + + // Clear hosting view + hostingView = nil + + // Reset state + currentFileEdit = nil + diffViewerState = .closed + } @MainActor func showDiffWindow(fileEdit: FileEdit) { + guard let chat else { return } + currentFileEdit = fileEdit // Create diff view let newDiffView = DiffView(chat: chat, fileEdit: fileEdit) diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift index 54a5b621..2c6f674f 100644 --- a/Core/Sources/ConversationTab/ConversationTab.swift +++ b/Core/Sources/ConversationTab/ConversationTab.swift @@ -132,6 +132,23 @@ public class ConversationTab: ChatTab { super.init(store: store) } + deinit { + // Cancel all Combine subscriptions + cancellable.forEach { $0.cancel() } + cancellable.removeAll() + + // Stop the debounce runner + Task { @MainActor [weak self] in + await self?.updateContentDebounce.cancel() + } + + // Clear observer + observer = NSObject() + + // The deallocation of ChatService will be called automatically + // The TCA Store (chat) handles its own cleanup automatically + } + @MainActor public static func restoreConversation(by chatTabInfo: ChatTabInfo, store: StoreOf) -> ConversationTab { let service = ChatService.service(for: chatTabInfo) diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift index dc71303d..221500e4 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift @@ -9,6 +9,10 @@ import ConversationServiceProvider public let SELECTED_LLM_KEY = "selectedLLM" public let SELECTED_CHATMODE_KEY = "selectedChatMode" +extension Notification.Name { + static let gitHubCopilotSelectedModelDidChange = Notification.Name("com.github.CopilotForXcode.SelectedModelDidChange") +} + extension AppState { func getSelectedModelFamily() -> String? { if let savedModel = get(key: SELECTED_LLM_KEY), @@ -28,6 +32,7 @@ extension AppState { func setSelectedModel(_ model: LLMModel) { update(key: SELECTED_LLM_KEY, value: model) + NotificationCenter.default.post(name: .gitHubCopilotSelectedModelDidChange, object: nil) } func modelScope() -> PromptTemplateScope { @@ -76,6 +81,7 @@ class CopilotModelManagerObservable: ObservableObject { defaultChatModel = CopilotModelManager.getDefaultChatModel(scope: .chatPanel) defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel) + // Setup notification to update when models change NotificationCenter.default.publisher(for: .gitHubCopilotModelsDidChange) .receive(on: DispatchQueue.main) @@ -86,6 +92,24 @@ class CopilotModelManagerObservable: ObservableObject { self?.defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel) } .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .gitHubCopilotShouldSwitchFallbackModel) + .receive(on: DispatchQueue.main) + .sink { _ in + if let fallbackModel = CopilotModelManager.getFallbackLLM( + scope: AppState.shared + .isAgentModeEnabled() ? .agentPanel : .chatPanel + ) { + AppState.shared.setSelectedModel( + .init( + modelName: fallbackModel.modelName, + modelFamily: fallbackModel.id, + billing: fallbackModel.billing + ) + ) + } + } + .store(in: &cancellables) } } @@ -95,7 +119,11 @@ extension CopilotModelManager { return LLMs.filter( { $0.scopes.contains(scope) } ).map { - LLMModel(modelName: $0.modelName, modelFamily: $0.modelFamily) + return LLMModel( + modelName: $0.modelName, + modelFamily: $0.isChatFallback ? $0.id : $0.modelFamily, + billing: $0.billing + ) } } @@ -105,18 +133,30 @@ extension CopilotModelManager { let defaultModel = LLMsInScope.first(where: { $0.isChatDefault }) // If a default model is found, return it if let defaultModel = defaultModel { - return LLMModel(modelName: defaultModel.modelName, modelFamily: defaultModel.modelFamily) + return LLMModel( + modelName: defaultModel.modelName, + modelFamily: defaultModel.modelFamily, + billing: defaultModel.billing + ) } // Fallback to gpt-4.1 if available let gpt4_1 = LLMsInScope.first(where: { $0.modelFamily == "gpt-4.1" }) if let gpt4_1 = gpt4_1 { - return LLMModel(modelName: gpt4_1.modelName, modelFamily: gpt4_1.modelFamily) + return LLMModel( + modelName: gpt4_1.modelName, + modelFamily: gpt4_1.modelFamily, + billing: gpt4_1.billing + ) } // If no default model is found, fallback to the first available model if let firstModel = LLMsInScope.first { - return LLMModel(modelName: firstModel.modelName, modelFamily: firstModel.modelFamily) + return LLMModel( + modelName: firstModel.modelName, + modelFamily: firstModel.modelFamily, + billing: firstModel.billing + ) } return nil @@ -126,6 +166,7 @@ extension CopilotModelManager { struct LLMModel: Codable, Hashable { let modelName: String let modelFamily: String + let billing: CopilotModelBilling? } struct ModelPicker: View { @@ -167,10 +208,8 @@ struct ModelPicker: View { if !newModeModels.isEmpty && !newModeModels.contains(where: { $0.modelName == currentModel }) { let defaultModel = CopilotModelManager.getDefaultChatModel(scope: scope) if let defaultModel = defaultModel { - selectedModel = defaultModel.modelName AppState.shared.setSelectedModel(defaultModel) } else { - selectedModel = newModeModels[0].modelName AppState.shared.setSelectedModel(newModeModels[0]) } } @@ -179,7 +218,58 @@ struct ModelPicker: View { // Force refresh models self.updateCurrentModel() } - + + // Model picker menu component + private var modelPickerMenu: some View { + Menu(selectedModel) { + // Group models by premium status + let premiumModels = models.filter { $0.billing?.isPremium == true } + let standardModels = models.filter { $0.billing?.isPremium == false || $0.billing == nil } + + // Display standard models section if available + modelSection(title: "Standard Models", models: standardModels) + + // Display premium models section if available + modelSection(title: "Premium Models", models: premiumModels) + } + .menuStyle(BorderlessButtonMenuStyle()) + .frame(maxWidth: labelWidth()) + .padding(4) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(isHovered ? Color.gray.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + isHovered = hovering + } + } + + // Helper function to create a section of model options + @ViewBuilder + private func modelSection(title: String, models: [LLMModel]) -> some View { + if !models.isEmpty { + Section(title) { + ForEach(models, id: \.self) { model in + modelButton(for: model) + } + } + } + } + + // Helper function to create a model selection button + private func modelButton(for model: LLMModel) -> some View { + Button { + AppState.shared.setSelectedModel(model) + } label: { + Text(createModelMenuItemAttributedString( + modelName: model.modelName, + isSelected: selectedModel == model.modelName, + billing: model.billing + )) + } + } + + // Main view body var body: some View { WithPerceptionTracking { HStack(spacing: 0) { @@ -189,34 +279,10 @@ struct ModelPicker: View { updateAgentPicker() } - Group{ - // Model Picker + // Model Picker + Group { if !models.isEmpty && !selectedModel.isEmpty { - - Menu(selectedModel) { - ForEach(models, id: \.self) { option in - Button { - selectedModel = option.modelName - AppState.shared.setSelectedModel(option) - } label: { - if selectedModel == option.modelName { - Text("✓ \(option.modelName)") - } else { - Text(" \(option.modelName)") - } - } - } - } - .menuStyle(BorderlessButtonMenuStyle()) - .frame(maxWidth: labelWidth()) - .padding(4) - .background( - RoundedRectangle(cornerRadius: 5) - .fill(isHovered ? Color.gray.opacity(0.1) : Color.clear) - ) - .onHover { hovering in - isHovered = hovering - } + modelPickerMenu } else { EmptyView() } @@ -237,6 +303,9 @@ struct ModelPicker: View { .onChange(of: chatMode) { _ in updateCurrentModel() } + .onReceive(NotificationCenter.default.publisher(for: .gitHubCopilotSelectedModelDidChange)) { _ in + updateCurrentModel() + } } } @@ -247,13 +316,6 @@ struct ModelPicker: View { return CGFloat(width + 20) } - func agentPickerLabelWidth() -> CGFloat { - let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) - let attributes = [NSAttributedString.Key.font: font] - let width = chatMode.size(withAttributes: attributes).width - return CGFloat(width + 20) - } - @MainActor func refreshModels() async { let now = Date() @@ -267,6 +329,52 @@ struct ModelPicker: View { CopilotModelManager.updateLLMs(copilotModels) } } + + private func createModelMenuItemAttributedString(modelName: String, isSelected: Bool, billing: CopilotModelBilling?) -> AttributedString { + let displayName = isSelected ? "✓ \(modelName)" : " \(modelName)" + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) + let attributes: [NSAttributedString.Key: Any] = [.font: font] + let spaceWidth = "\u{200A}".size(withAttributes: attributes).width + + let targetXPositionForMultiplier: CGFloat = 230 + + var fullString = displayName + var attributedString = AttributedString(fullString) + + if let billingInfo = billing { + let multiplier = billingInfo.multiplier + + let effectiveMultiplierText: String + if multiplier == 0 { + effectiveMultiplierText = "Included" + } else { + let numberPart = multiplier.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", multiplier) + : String(format: "%.2f", multiplier) + effectiveMultiplierText = "\(numberPart)x" + } + + let displayNameWidth = displayName.size(withAttributes: attributes).width + let multiplierTextWidth = effectiveMultiplierText.size(withAttributes: attributes).width + let neededPaddingWidth = targetXPositionForMultiplier - displayNameWidth - multiplierTextWidth + + if neededPaddingWidth > 0 { + let numberOfSpaces = Int(round(neededPaddingWidth / spaceWidth)) + let padding = String(repeating: "\u{200A}", count: max(0, numberOfSpaces)) + fullString = "\(displayName)\(padding)\(effectiveMultiplierText)" + } else { + fullString = "\(displayName) \(effectiveMultiplierText)" + } + + attributedString = AttributedString(fullString) + + if let range = attributedString.range(of: effectiveMultiplierText) { + attributedString[range].foregroundColor = .secondary + } + } + + return attributedString + } } struct ModelPicker_Previews: PreviewProvider { diff --git a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift index 8113d793..c75e864e 100644 --- a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift +++ b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift @@ -13,7 +13,9 @@ struct RunInTerminalToolView: View { private var title: String = "Run command in terminal" @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight + @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark + @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark @AppStorage(\.chatFontSize) var chatFontSize @AppStorage(\.chatCodeFont) var chatCodeFont @Environment(\.colorScheme) var colorScheme @@ -102,6 +104,15 @@ struct RunInTerminalToolView: View { return Color(nsColor: .textBackgroundColor).opacity(0.7) } + var codeForegroundColor: Color { + if colorScheme == .light, let color = codeForegroundColorLight.value { + return color.swiftUIColor + } else if let color = codeForegroundColorDark.value { + return color.swiftUIColor + } + return Color(nsColor: .textColor) + } + var toolView: some View { WithPerceptionTracking { VStack { @@ -115,7 +126,7 @@ struct RunInTerminalToolView: View { .font(.system(size: chatFontSize, design: .monospaced)) .padding(8) .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle(.primary) + .foregroundStyle(codeForegroundColor) .background(codeBackgroundColor) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay { diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index 91586d6b..a6c0d8f5 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -18,6 +18,7 @@ struct BotMessage: View { let chat: StoreOf let steps: [ConversationProgressStep] let editAgentRounds: [AgentRound] + let panelMessages: [CopilotShowMessageParams] @Environment(\.colorScheme) var colorScheme @AppStorage(\.chatFontSize) var chatFontSize @@ -137,6 +138,14 @@ struct BotMessage: View { ProgressStep(steps: steps) } + if !panelMessages.isEmpty { + WithPerceptionTracking { + ForEach(panelMessages.indices, id: \.self) { index in + FunctionMessage(text: panelMessages[index].message, chat: chat) + } + } + } + if editAgentRounds.count > 0 { ProgressAgentRound(rounds: editAgentRounds, chat: chat) } @@ -304,7 +313,7 @@ struct BotMessage_Previews: PreviewProvider { status: .running) ]) ] - + static var previews: some View { let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") BotMessage( @@ -324,7 +333,8 @@ struct BotMessage_Previews: PreviewProvider { errorMessage: "Sorry, an error occurred while generating a response.", chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }), steps: steps, - editAgentRounds: agentRounds + editAgentRounds: agentRounds, + panelMessages: [] ) .padding() .fixedSize(horizontal: true, vertical: true) diff --git a/Core/Sources/ConversationTab/Views/FunctionMessage.swift b/Core/Sources/ConversationTab/Views/FunctionMessage.swift index 578ed1d3..ae9cf2c9 100644 --- a/Core/Sources/ConversationTab/Views/FunctionMessage.swift +++ b/Core/Sources/ConversationTab/Views/FunctionMessage.swift @@ -4,75 +4,92 @@ import ChatService import SharedUIComponents import ComposableArchitecture import ChatTab +import GitHubCopilotService struct FunctionMessage: View { - let chat: StoreOf - let id: String let text: String + let chat: StoreOf @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 + private var isFreePlanUser: Bool { + text.contains("30-day free trial") + } + + private var isOrgUser: Bool { + text.contains("reach out to your organization's Copilot admin") + } + + private var switchToFallbackModelText: String { + if let fallbackModelName = CopilotModelManager.getFallbackLLM( + scope: chat.isAgentMode ? .agentPanel : .chatPanel + )?.modelName { + return "We have automatically switched you to \(fallbackModelName) which is included with your plan." + } else { + return "" } + } + + private var errorContent: Text { + switch (isFreePlanUser, isOrgUser) { + case (true, _): + return Text("Monthly message limit reached. Upgrade to Copilot Pro (30-day free trial) or wait for your limit to reset.") + + case (_, true): + let parts = [ + "You have exceeded your free request allowance.", + switchToFallbackModelText, + "To enable additional paid premium requests, contact your organization admin." + ].filter { !$0.isEmpty } + return Text(attributedString(from: parts)) - let dateString = String(text[dateRange]) - let formatter = DateFormatter() - formatter.dateFormat = "M/d/yyyy, h:mm:ss a" - return formatter.date(from: dateString) + default: + let parts = [ + "You have exceeded your premium request allowance.", + switchToFallbackModelText, + "[Enable additional paid premium requests](https://aka.ms/github-copilot-manage-overage) to continue using premium models." + ].filter { !$0.isEmpty } + return Text(attributedString(from: parts)) + } + } + + private func attributedString(from parts: [String]) -> AttributedString { + do { + return try AttributedString(markdown: parts.joined(separator: " ")) + } catch { + return AttributedString(parts.joined(separator: " ")) + } } var body: some View { 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) + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .font(Font.system(size: 12)) + .foregroundColor(.orange) + + VStack(alignment: .leading, spacing: 8) { + errorContent - 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) + if isFreePlanUser { + Button("Update to Copilot Pro") { + if let url = URL(string: "https://aka.ms/github-copilot-upgrade-plan") { + openURL(url) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .onHover { isHovering in + if isHovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } } } - .buttonStyle(.borderedProminent) - .controlSize(.large) } + .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.vertical, 10) .padding(.horizontal, 12) .overlay( @@ -88,9 +105,8 @@ struct FunctionMessage_Previews: PreviewProvider { static var previews: some View { let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") FunctionMessage( - chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }), - 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." + 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.", + chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }) ) .padding() .fixedSize() diff --git a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift index 7acff3cd..1c6818d2 100644 --- a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift +++ b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift @@ -107,6 +107,7 @@ public class GitHubCopilotViewModel: ObservableObject { status = try await service.signOut() await Status.shared.updateAuthStatus(.notLoggedIn) await Status.shared.updateCLSStatus(.unknown, busy: false, message: "") + await Status.shared.updateQuotaInfo(nil) username = "" broadcastStatusChange() } catch { diff --git a/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift b/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift index 384daad4..f0cfbaca 100644 --- a/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift +++ b/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift @@ -5,6 +5,7 @@ struct AdvancedSettings: View { ScrollView { VStack(alignment: .leading, spacing: 30) { SuggestionSection() + ChatSection() EnterpriseSection() ProxySection() LoggingSection() diff --git a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift new file mode 100644 index 00000000..0ce7a006 --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift @@ -0,0 +1,79 @@ +import SwiftUI +import ComposableArchitecture + +struct ChatSection: View { + var body: some View { + SettingsSection(title: "Chat Settings") { + VStack(spacing: 10) { + // Response language picker + ResponseLanguageSetting() + } + .padding(10) + } + } +} + +struct ResponseLanguageSetting: View { + @AppStorage(\.chatResponseLocale) var chatResponseLocale + + // Locale codes mapped to language display names + // reference: https://code.visualstudio.com/docs/configure/locales#_available-locales + private let localeLanguageMap: [String: String] = [ + "en": "English", + "zh-cn": "Chinese, Simplified", + "zh-tw": "Chinese, Traditional", + "fr": "French", + "de": "German", + "it": "Italian", + "es": "Spanish", + "ja": "Japanese", + "ko": "Korean", + "ru": "Russian", + "pt-br": "Portuguese (Brazil)", + "tr": "Turkish", + "pl": "Polish", + "cs": "Czech", + "hu": "Hungarian" + ] + + var selectedLanguage: String { + if chatResponseLocale == "" { + return "English" + } + + return localeLanguageMap[chatResponseLocale] ?? "English" + } + + // Display name to locale code mapping (for the picker UI) + var sortedLanguageOptions: [(displayName: String, localeCode: String)] { + localeLanguageMap.map { (displayName: $0.value, localeCode: $0.key) } + .sorted { $0.displayName < $1.displayName } + } + + var body: some View { + WithPerceptionTracking { + HStack { + VStack(alignment: .leading) { + Text("Response Language") + .font(.body) + Text("This change applies only to new chat sessions. Existing ones won’t be impacted.") + .font(.footnote) + } + + Spacer() + + Picker("", selection: $chatResponseLocale) { + ForEach(sortedLanguageOptions, id: \.localeCode) { option in + Text(option.displayName).tag(option.localeCode) + } + } + .frame(maxWidth: 200, alignment: .leading) + } + } + } +} + +#Preview { + ChatSection() + .frame(width: 600) +} diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift index 263c9ee9..d5506e43 100644 --- a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift +++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift @@ -15,6 +15,7 @@ extension ChatMessage { var errorMessage: String? var steps: [ConversationProgressStep] var editAgentRounds: [AgentRound] + var panelMessages: [CopilotShowMessageParams] // Custom decoder to provide default value for steps init(from decoder: Decoder) throws { @@ -27,10 +28,21 @@ extension ChatMessage { errorMessage = try container.decodeIfPresent(String.self, forKey: .errorMessage) steps = try container.decodeIfPresent([ConversationProgressStep].self, forKey: .steps) ?? [] editAgentRounds = try container.decodeIfPresent([AgentRound].self, forKey: .editAgentRounds) ?? [] + panelMessages = try container.decodeIfPresent([CopilotShowMessageParams].self, forKey: .panelMessages) ?? [] } // Default memberwise init for encoding - init(content: String, rating: ConversationRating, references: [ConversationReference], followUp: ConversationFollowUp?, suggestedTitle: String?, errorMessage: String?, steps: [ConversationProgressStep]?, editAgentRounds: [AgentRound]? = nil) { + init( + content: String, + rating: ConversationRating, + references: [ConversationReference], + followUp: ConversationFollowUp?, + suggestedTitle: String?, + errorMessage: String?, + steps: [ConversationProgressStep]?, + editAgentRounds: [AgentRound]? = nil, + panelMessages: [CopilotShowMessageParams]? = nil + ) { self.content = content self.rating = rating self.references = references @@ -39,6 +51,7 @@ extension ChatMessage { self.errorMessage = errorMessage self.steps = steps ?? [] self.editAgentRounds = editAgentRounds ?? [] + self.panelMessages = panelMessages ?? [] } } @@ -51,7 +64,8 @@ extension ChatMessage { suggestedTitle: self.suggestedTitle, errorMessage: self.errorMessage, steps: self.steps, - editAgentRounds: self.editAgentRounds + editAgentRounds: self.editAgentRounds, + panelMessages: self.panelMessages ) // TODO: handle exception @@ -83,6 +97,7 @@ extension ChatMessage { rating: turnItemData.rating, steps: turnItemData.steps, editAgentRounds: turnItemData.editAgentRounds, + panelMessages: turnItemData.panelMessages, createdAt: turnItem.createdAt, updatedAt: turnItem.updatedAt ) diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift index 849f10b2..f642cb71 100644 --- a/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift +++ b/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift @@ -35,3 +35,14 @@ extension Array where Element == ChatTabInfo { return self.map { $0.toConversationItem() } } } + +extension ChatTabPreviewInfo { + static func from(_ conversationPreviewItem: ConversationPreviewItem) -> ChatTabPreviewInfo { + return .init( + id: conversationPreviewItem.id, + title: conversationPreviewItem.title, + isSelected: conversationPreviewItem.isSelected, + updatedAt: conversationPreviewItem.updatedAt + ) + } +} diff --git a/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift b/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift index ddbe2dac..da9bccd3 100644 --- a/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift +++ b/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift @@ -16,13 +16,37 @@ public struct ChatTabInfoStore { } public static func getAll(with metadata: StorageMetadata) -> [ChatTabInfo] { - var chatTabInfos: [ChatTabInfo] = [] + return fetchChatTabInfos(.all, metadata: metadata) + } + + public static func getSelected(with metadata: StorageMetadata) -> ChatTabInfo? { + return fetchChatTabInfos(.selected, metadata: metadata).first + } + + public static func getLatest(with metadata: StorageMetadata) -> ChatTabInfo? { + return fetchChatTabInfos(.latest, metadata: metadata).first + } + + public static func getByID(_ id: String, with metadata: StorageMetadata) -> ChatTabInfo? { + return fetchChatTabInfos(.id(id), metadata: metadata).first + } + + private static func fetchChatTabInfos(_ type: ConversationFetchType, metadata: StorageMetadata) -> [ChatTabInfo] { + let items = ConversationStorageService.shared.fetchConversationItems(type, metadata: metadata) + + return items.compactMap { ChatTabInfo.from($0, with: metadata) } + } +} + +public struct ChatTabPreviewInfoStore { + public static func getAll(with metadata: StorageMetadata) -> [ChatTabPreviewInfo] { + var previewInfos: [ChatTabPreviewInfo] = [] - let conversationItems = ConversationStorageService.shared.fetchConversationItems(.all, metadata: metadata) - if conversationItems.count > 0 { - chatTabInfos = conversationItems.compactMap { ChatTabInfo.from($0, with: metadata) } + let conversationPreviewItems = ConversationStorageService.shared.fetchConversationPreviewItems(metadata: metadata) + if conversationPreviewItems.count > 0 { + previewInfos = conversationPreviewItems.compactMap { ChatTabPreviewInfo.from($0) } } - return chatTabInfos + return previewInfos } } diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index af7f3bb1..117977b9 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -10,6 +10,7 @@ import SuggestionBasic import SuggestionWidget import PersistMiddleware import ChatService +import Persist #if canImport(ChatTabPersistent) import ChatTabPersistent @@ -86,6 +87,23 @@ struct GUI { await send(.appendAndSelectTab(chatTabInfo)) } } + case .restoreTabByInfo(let info): + guard let currentChatWorkspace = state.currentChatWorkspace else { return .none } + + return .run { send in + if let _ = await chatTabPool.restoreTab(by: info, with: currentChatWorkspace) { + await send(.appendAndSelectTab(info)) + } + } + + case .createNewTabByID(let id): + guard let currentChatWorkspace = state.currentChatWorkspace else { return .none } + + return .run { send in + if let (_, info) = await chatTabPool.createTab(id: id, with: currentChatWorkspace) { + await send(.appendAndSelectTab(info)) + } + } // case let .closeTabButtonClicked(id): // return .run { _ in @@ -421,11 +439,17 @@ extension ChatTabPool { @MainActor func createTab( id: String = UUID().uuidString, - from builder: ChatTabBuilder, + from builder: ChatTabBuilder? = nil, with chatWorkspace: ChatWorkspace ) async -> (any ChatTab, ChatTabInfo)? { let id = id let info = ChatTabInfo(id: id, workspacePath: chatWorkspace.workspacePath, username: chatWorkspace.username) + guard let builder else { + let chatTab = ConversationTab(store: createStore(info), with: info) + setTab(chatTab) + return (chatTab, info) + } + guard let chatTab = await builder.build(store: createStore(info)) else { return nil } setTab(chatTab) return (chatTab, info) @@ -448,6 +472,16 @@ extension ChatTabPool { setTab(chatTab) return (chatTab, info) } + + @MainActor + func restoreTab( + by info: ChatTabInfo, + with chaWorkspace: ChatWorkspace + ) async -> (any ChatTab)? { + let chatTab = ConversationTab.restoreConversation(by: info, store: createStore(info)) + setTab(chatTab) + return chatTab + } } @@ -461,23 +495,22 @@ extension GraphicalUserInterfaceController { // only restore once regardless of success or fail restoredChatHistory.insert(workspaceIdentifier) - let storedChatTabInfos = ChatTabInfoStore.getAll(with: .init(workspacePath: workspacePath, username: username)) - if storedChatTabInfos.count > 0 - { - var tabInfo: IdentifiedArray = [] - for info in storedChatTabInfos { - tabInfo[id: info.id] = info - let chatTab = ConversationTab.restoreConversation(by: info, store: chatTabPool.createStore(info)) - chatTabPool.setTab(chatTab) - } + let metadata = StorageMetadata(workspacePath: workspacePath, username: username) + let selectedChatTabInfo = ChatTabInfoStore.getSelected(with: metadata) ?? ChatTabInfoStore.getLatest(with: metadata) + + if let selectedChatTabInfo { + let chatTab = ConversationTab.restoreConversation(by: selectedChatTabInfo, store: chatTabPool.createStore(selectedChatTabInfo)) + chatTabPool.setTab(chatTab) let chatWorkspace = ChatWorkspace( id: .init(path: workspacePath, username: username), - tabInfo: tabInfo, + tabInfo: [selectedChatTabInfo], tabCollection: [], - selectedTabId: storedChatTabInfos.first(where: { $0.isSelected })?.id - ) - self.store.send(.suggestionWidget(.chatPanel(.restoreWorkspace(chatWorkspace)))) + selectedTabId: selectedChatTabInfo.id + ) { [weak self] in + self?.chatTabPool.removeTab(of: $0) + } + await self.store.send(.suggestionWidget(.chatPanel(.restoreWorkspace(chatWorkspace)))).finish() } } } diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift index 73b891d9..7cf55d8a 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUI import ChatTab import SharedUIComponents +import PersistMiddleware struct ChatHistoryView: View { @@ -15,7 +16,6 @@ struct ChatHistoryView: View { var body: some View { WithPerceptionTracking { - let _ = store.currentChatWorkspace?.tabInfo VStack(alignment: .center, spacing: 0) { Header(isChatHistoryVisible: $isChatHistoryVisible) @@ -62,6 +62,7 @@ struct ChatHistoryView: View { let store: StoreOf @Binding var searchText: String @Binding var isChatHistoryVisible: Bool + @State private var storedChatTabPreviewInfos: [ChatTabPreviewInfo] = [] @Environment(\.chatTabPool) var chatTabPool @@ -69,41 +70,43 @@ struct ChatHistoryView: View { WithPerceptionTracking { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(filteredTabInfo, id: \.id) { info in - if let _ = chatTabPool.getTab(of: info.id){ - ChatHistoryItemView( - store: store, - info: info, - isChatHistoryVisible: $isChatHistoryVisible - ) - .id(info.id) - .frame(height: 61) - } - else { - EmptyView() + ForEach(filteredTabInfo, id: \.id) { previewInfo in + ChatHistoryItemView( + store: store, + previewInfo: previewInfo, + isChatHistoryVisible: $isChatHistoryVisible + ) { + refreshStoredChatTabInfos() } + .id(previewInfo.id) + .frame(height: 61) } } } + .onAppear { refreshStoredChatTabInfos() } } } - var filteredTabInfo: IdentifiedArray { - guard let tabInfo = store.currentChatWorkspace?.tabInfo else { - return [] + func refreshStoredChatTabInfos() -> Void { + Task { + if let workspacePath = store.chatHistory.selectedWorkspacePath, + let username = store.chatHistory.currentUsername + { + storedChatTabPreviewInfos = ChatTabPreviewInfoStore.getAll(with: .init(workspacePath: workspacePath, username: username)) + } + } + } + + var filteredTabInfo: IdentifiedArray { + // Only compute when view is visible to prevent unnecessary computation + if !isChatHistoryVisible { + return IdentifiedArray(uniqueElements: []) } - // sort by updatedAt by descending order - let sortedTabInfo = tabInfo.sorted { $0.updatedAt > $1.updatedAt } - - guard !searchText.isEmpty else { return IdentifiedArray(uniqueElements: sortedTabInfo) } + guard !searchText.isEmpty else { return IdentifiedArray(uniqueElements: storedChatTabPreviewInfos) } - let result = sortedTabInfo.filter { info in - if let tab = chatTabPool.getTab(of: info.id) { - return tab.title.localizedCaseInsensitiveContains(searchText) - } - - return false + let result = storedChatTabPreviewInfos.filter { info in + return (info.title ?? "New Chat").localizedCaseInsensitiveContains(searchText) } return IdentifiedArray(uniqueElements: result) @@ -141,12 +144,14 @@ struct ChatHistorySearchBarView: View { struct ChatHistoryItemView: View { let store: StoreOf - let info: ChatTabInfo + let previewInfo: ChatTabPreviewInfo @Binding var isChatHistoryVisible: Bool @State private var isHovered = false + let onDelete: () -> Void + func isTabSelected() -> Bool { - return store.state.currentChatWorkspace?.selectedTabId == info.id + return store.state.currentChatWorkspace?.selectedTabId == previewInfo.id } func formatDate(_ date: Date) -> String { @@ -163,7 +168,7 @@ struct ChatHistoryItemView: View { HStack(spacing: 8) { // Do not use the `ChatConversationItemView` any more // directly get title from chat tab info - Text(info.title ?? "New Chat") + Text(previewInfo.title ?? "New Chat") .frame(alignment: .leading) .font(.system(size: 14, weight: .regular)) .lineLimit(1) @@ -178,7 +183,7 @@ struct ChatHistoryItemView: View { } HStack(spacing: 0) { - Text(formatDate(info.updatedAt)) + Text(formatDate(previewInfo.updatedAt)) .frame(alignment: .leading) .font(.system(size: 13, weight: .thin)) .lineLimit(1) @@ -192,7 +197,8 @@ struct ChatHistoryItemView: View { if !isTabSelected() { if isHovered { Button(action: { - store.send(.chatHisotryDeleteButtonClicked(id: info.id)) + store.send(.chatHisotryDeleteButtonClicked(id: previewInfo.id)) + onDelete() }) { Image(systemName: "trash") } @@ -209,8 +215,10 @@ struct ChatHistoryItemView: View { }) .hoverRadiusBackground(isHovered: isHovered, cornerRadius: 4) .onTapGesture { - store.send(.chatHistoryItemClicked(id: info.id)) - isChatHistoryVisible = false + Task { @MainActor in + await store.send(.chatHistoryItemClicked(id: previewInfo.id)).finish() + isChatHistoryVisible = false + } } } } @@ -239,7 +247,7 @@ struct ChatHistoryView_Previews: PreviewProvider { .init(id: "6", title: "Empty-6", workspacePath: "path", username: "username") ] as IdentifiedArray, selectedTabId: "2" - )] as IdentifiedArray, + ) { _ in }] as IdentifiedArray, selectedWorkspacePath: "activeWorkspacePath", selectedWorkspaceName: "activeWorkspacePath" ), diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index f85cdce2..f0596ff7 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -499,7 +499,7 @@ struct ChatWindowView_Previews: PreviewProvider { .init(id: "7", title: "Empty-7", workspacePath: "path", username: "username"), ] as IdentifiedArray, selectedTabId: "2" - ) + ) { _ in } ] as IdentifiedArray, selectedWorkspacePath: "activeWorkspacePath", selectedWorkspaceName: "activeWorkspacePath" diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index c1c1424d..cc9e84da 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -87,17 +87,48 @@ public struct ChatWorkspace: Identifiable, Equatable { public var workspacePath: String { get { id.path} } public var username: String { get { id.username } } + + private var onTabInfoDeleted: (String) -> Void public init( id: WorkspaceIdentifier, tabInfo: IdentifiedArray = [], tabCollection: [ChatTabBuilderCollection] = [], - selectedTabId: String? = nil + selectedTabId: String? = nil, + onTabInfoDeleted: @escaping (String) -> Void ) { self.id = id self.tabInfo = tabInfo self.tabCollection = tabCollection self.selectedTabId = selectedTabId + self.onTabInfoDeleted = onTabInfoDeleted + } + + /// Walkaround `Equatable` error for `onTabInfoDeleted` + public static func == (lhs: ChatWorkspace, rhs: ChatWorkspace) -> Bool { + lhs.id == rhs.id && + lhs.tabInfo == rhs.tabInfo && + lhs.tabCollection == rhs.tabCollection && + lhs.selectedTabId == rhs.selectedTabId + } + + public mutating func applyLRULimit(maxSize: Int = 5) { + guard tabInfo.count > maxSize else { return } + + // Tabs not selected + let nonSelectedTabs = Array(tabInfo.filter { $0.id != selectedTabId }) + let sortedByUpdatedAt = nonSelectedTabs.sorted { $0.updatedAt < $1.updatedAt } + + let tabsToRemove = Array(sortedByUpdatedAt.prefix(tabInfo.count - maxSize)) + + // Remove Tabs + for tab in tabsToRemove { + // destroy tab + onTabInfoDeleted(tab.id) + + // remove from workspace + tabInfo.remove(id: tab.id) + } } } @@ -135,6 +166,8 @@ public struct ChatPanelFeature { // case createNewTapButtonHovered case closeTabButtonClicked(id: String) case createNewTapButtonClicked(kind: ChatTabKind?) + case restoreTabByInfo(info: ChatTabInfo) + case createNewTabByID(id: String) case tabClicked(id: String) case appendAndSelectTab(ChatTabInfo) case appendTabToWorkspace(ChatTabInfo, ChatWorkspace) @@ -152,6 +185,10 @@ public struct ChatPanelFeature { case saveChatTabInfo([ChatTabInfo?], ChatWorkspace) case deleteChatTabInfo(id: String, ChatWorkspace) case restoreWorkspace(ChatWorkspace) + + // ChatWorkspace cleanup + case scheduleLRUCleanup(ChatWorkspace) + case performLRUCleanup(ChatWorkspace) } @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @@ -245,7 +282,9 @@ public struct ChatPanelFeature { state.chatHistory.currentUsername = username if state.chatHistory.currentChatWorkspace == nil { let identifier = WorkspaceIdentifier(path: path, username: username) - state.chatHistory.addWorkspace(ChatWorkspace(id: identifier)) + state.chatHistory.addWorkspace( + ChatWorkspace(id: identifier) { chatTabPool.removeTab(of: $0) } + ) } return .none case .openSettings: @@ -314,7 +353,13 @@ public struct ChatPanelFeature { // return .none case .createNewTapButtonClicked: - return .none // handled elsewhere + return .none // handled in GUI Reducer + + case .restoreTabByInfo(_): + return .none // handled in GUI Reducer + + case .createNewTabByID(_): + return .none // handled in GUI Reducer case let .tabClicked(id): guard var currentChatWorkspace = state.currentChatWorkspace, @@ -334,27 +379,46 @@ public struct ChatPanelFeature { case let .chatHistoryItemClicked(id): guard var chatWorkspace = state.currentChatWorkspace, - var chatTabInfo = chatWorkspace.tabInfo.first(where: { $0.id == id }), // No Need to swicth selected Tab when already selected id != chatWorkspace.selectedTabId - else { -// state.chatGroupCollection.selectedChatGroup?.selectedTabId = nil - return .none + else { return .none } + + // Try to find the tab in three places: + // 1. In current workspace's open tabs + let existingTab = chatWorkspace.tabInfo.first(where: { $0.id == id }) + + // 2. In persistent storage + let storedTab = existingTab == nil + ? ChatTabInfoStore.getByID(id, with: .init(workspacePath: chatWorkspace.workspacePath, username: chatWorkspace.username)) + : nil + + if var tabInfo = existingTab ?? storedTab { + // Tab found in workspace or storage - switch to it + let (originalTab, currentTab) = chatWorkspace.switchTab(to: &tabInfo) + state.chatHistory.updateHistory(chatWorkspace) + + let workspace = chatWorkspace + let info = tabInfo + return .run { send in + // For stored tabs that aren't in the workspace yet, restore them first + if storedTab != nil { + await send(.restoreTabByInfo(info: info)) + } + + // as converstaion tab is lazy restore + // should restore tab when switching + if let chatTab = chatTabPool.getTab(of: id), + let conversationTab = chatTab as? ConversationTab { + await conversationTab.restoreIfNeeded() + } + + await send(.saveChatTabInfo([originalTab, currentTab], workspace)) + } } - let (originalTab, currentTab) = chatWorkspace.switchTab(to: &chatTabInfo) - state.chatHistory.updateHistory(chatWorkspace) - let currentChatWorkspace = chatWorkspace + // 3. Tab not found - create a new one return .run { send in - // as converstaion tab is lazy restore - // should restore tab when switching - if let chatTab = chatTabPool.getTab(of: id), - let conversationTab = chatTab as? ConversationTab { - await conversationTab.restoreIfNeeded() - } - - await send(.focusActiveChatTab) - await send(.saveChatTabInfo([originalTab, currentTab], currentChatWorkspace)) + await send(.createNewTabByID(id: id)) } case var .appendAndSelectTab(tab): @@ -370,6 +434,7 @@ public struct ChatPanelFeature { return .run { send in await send(.focusActiveChatTab) await send(.saveChatTabInfo([originalTab, currentTab], currentChatWorkspace)) + await send(.scheduleLRUCleanup(currentChatWorkspace)) } case .appendTabToWorkspace(var tab, let chatWorkspace): guard !chatWorkspace.tabInfo.contains(where: { $0.id == tab.id }) @@ -382,6 +447,7 @@ public struct ChatPanelFeature { let currentChatWorkspace = chatWorkspace return .run { send in await send(.saveChatTabInfo([originalTab, currentTab], currentChatWorkspace)) + await send(.scheduleLRUCleanup(currentChatWorkspace)) } // case .switchToNextTab: @@ -499,8 +565,11 @@ public struct ChatPanelFeature { let workspacePath = chatWorkspace.workspacePath let username = chatWorkspace.username - ChatTabInfoStore.saveAll(toSaveInfo, with: .init(workspacePath: workspacePath, username: username)) - return .none + return .run { _ in + Task(priority: .background) { + ChatTabInfoStore.saveAll(toSaveInfo, with: .init(workspacePath: workspacePath, username: username)) + } + } case let .deleteChatTabInfo(id, chatWorkspace): let workspacePath = chatWorkspace.workspacePath @@ -525,21 +594,37 @@ public struct ChatPanelFeature { state.chatHistory.updateHistory(existChatWorkspace) let chatTabInfo = selectedChatTabInfo - let workspace = chatWorkspace + let workspace = existChatWorkspace return .run { send in // update chat tab info await send(.saveChatTabInfo([chatTabInfo], workspace)) + await send(.scheduleLRUCleanup(workspace)) } } // merge tab info existChatWorkspace.tabInfo.append(contentsOf: chatWorkspace.tabInfo) state.chatHistory.updateHistory(existChatWorkspace) - return .none + + let workspace = existChatWorkspace + return .run { send in + await send(.scheduleLRUCleanup(workspace)) + } } state.chatHistory.addWorkspace(chatWorkspace) return .none + + // MARK: - Clean up ChatWorkspace + case .scheduleLRUCleanup(let chatWorkspace): + return .run { send in + await send(.performLRUCleanup(chatWorkspace)) + }.cancellable(id: "lru-cleanup-\(chatWorkspace.id)", cancelInFlight: true) // apply built-in race condition prevention + + case .performLRUCleanup(var chatWorkspace): + chatWorkspace.applyLRULimit() + state.chatHistory.updateHistory(chatWorkspace) + return .none } } // .forEach(\.chatGroupCollection.selectedChatGroup?.tabInfo, action: /Action.chatTab) { @@ -548,6 +633,16 @@ public struct ChatPanelFeature { } } +extension ChatPanelFeature { + + func restoreConversationTabIfNeeded(_ id: String) async { + if let chatTab = chatTabPool.getTab(of: id), + let conversationTab = chatTab as? ConversationTab { + await conversationTab.restoreIfNeeded() + } + } +} + extension ChatWorkspace { public mutating func switchTab(to chatTabInfo: inout ChatTabInfo) -> (originalTab: ChatTabInfo?, currentTab: ChatTabInfo) { guard self.selectedTabId != chatTabInfo.id else { return (nil, chatTabInfo) } @@ -564,7 +659,12 @@ extension ChatWorkspace { chatTabInfo.isSelected = true // update tab back to chatWorkspace + let isNewTab = self.tabInfo[id: chatTabInfo.id] == nil self.tabInfo[id: chatTabInfo.id] = chatTabInfo + if isNewTab { + applyLRULimit() + } + if let originalTabInfo { self.tabInfo[id: originalTabInfo.id] = originalTabInfo } diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 02445af5..4dfc0da1 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -6,6 +6,7 @@ import SuggestionBasic import XcodeInspector import Logger import StatusBarItemView +import GitHubCopilotViewModel extension AppDelegate { fileprivate var statusBarMenuIdentifier: NSUserInterfaceItemIdentifier { @@ -101,13 +102,28 @@ extension AppDelegate { keyEquivalent: "" ) authStatusItem.isHidden = true - - upSellItem = NSMenuItem( - title: "", - action: #selector(openUpSellLink), - keyEquivalent: "" + + quotaItem = NSMenuItem() + quotaItem.view = QuotaView( + chat: .init( + percentRemaining: 0, + unlimited: false, + overagePermitted: false + ), + completions: .init( + percentRemaining: 0, + unlimited: false, + overagePermitted: false + ), + premiumInteractions: .init( + percentRemaining: 0, + unlimited: false, + overagePermitted: false + ), + resetDate: "", + copilotPlan: "" ) - upSellItem.isHidden = true + quotaItem.isHidden = true let openDocs = NSMenuItem( title: "View Documentation", @@ -136,7 +152,8 @@ extension AppDelegate { statusBarMenu.addItem(accountItem) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(authStatusItem) - statusBarMenu.addItem(upSellItem) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(quotaItem) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(axStatusItem) statusBarMenu.addItem(extensionStatusItem) @@ -188,6 +205,11 @@ extension AppDelegate: NSMenuDelegate { } } + Task { + await forceAuthStatusCheck() + updateStatusBarItem() + } + case xcodeInspectorDebugMenuIdentifier: let inspector = XcodeInspector.shared menu.items.removeAll() @@ -349,15 +371,8 @@ 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) - } + if let url = URL(string: "https://aka.ms/github-copilot-settings") { + NSWorkspace.shared.open(url) } } } diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index dc81bcd6..48001d40 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -39,7 +39,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { var openCopilotForXcodeItem: NSMenuItem! var accountItem: NSMenuItem! var authStatusItem: NSMenuItem! - var upSellItem: NSMenuItem! + var quotaItem: NSMenuItem! var toggleCompletions: NSMenuItem! var toggleIgnoreLanguage: NSMenuItem! var openChat: NSMenuItem! @@ -259,7 +259,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func forceAuthStatusCheck() async { do { let service = try await GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() - _ = try await service.checkStatus() + let accountStatus = try await service.checkStatus() + if accountStatus == .ok || accountStatus == .maybeOk { + let quota = try await service.checkQuota() + Logger.service.info("User quota checked successfully: \(quota)") + } } catch { Logger.service.error("Failed to read auth status: \(error)") } @@ -271,7 +275,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { action: #selector(signIntoGitHub) ) self.authStatusItem.isHidden = true - self.upSellItem.isHidden = true + self.quotaItem.isHidden = true self.toggleCompletions.isHidden = true self.toggleIgnoreLanguage.isHidden = true self.signOutItem.isHidden = true @@ -283,36 +287,61 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { action: nil, userName: status.userName ?? "" ) - if !status.clsMessage.isEmpty { - self.authStatusItem.isHidden = false + if !status.clsMessage.isEmpty { 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: "" + // If the quota is nil, keep the original auth status item + // Else only log the CLS error other than quota limit reached error + if CLSMessageSummary.summary == CLSMessageType.other.summary || status.quotaInfo == nil { + self.authStatusItem.isHidden = false + self.authStatusItem.title = CLSMessageSummary.summary + + let submenu = NSMenu() + let attributedCLSErrorItem = NSMenuItem() + attributedCLSErrorItem.view = ErrorMessageView( + errorMessage: CLSMessageSummary.detail ) - ) - - self.authStatusItem.submenu = submenu - self.authStatusItem.isEnabled = true - - self.upSellItem.title = "Upgrade Now" - self.upSellItem.isHidden = false - self.upSellItem.isEnabled = true + 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 + } } else { self.authStatusItem.isHidden = true - self.upSellItem.isHidden = true } + + if let quotaInfo = status.quotaInfo, !quotaInfo.resetDate.isEmpty { + self.quotaItem.isHidden = false + self.quotaItem.view = QuotaView( + chat: .init( + percentRemaining: quotaInfo.chat.percentRemaining, + unlimited: quotaInfo.chat.unlimited, + overagePermitted: quotaInfo.chat.overagePermitted + ), + completions: .init( + percentRemaining: quotaInfo.completions.percentRemaining, + unlimited: quotaInfo.completions.unlimited, + overagePermitted: quotaInfo.completions.overagePermitted + ), + premiumInteractions: .init( + percentRemaining: quotaInfo.premiumInteractions.percentRemaining, + unlimited: quotaInfo.premiumInteractions.unlimited, + overagePermitted: quotaInfo.premiumInteractions.overagePermitted + ), + resetDate: quotaInfo.resetDate, + copilotPlan: quotaInfo.copilotPlan + ) + } else { + self.quotaItem.isHidden = true + } + self.toggleCompletions.isHidden = false self.toggleIgnoreLanguage.isHidden = false self.signOutItem.isHidden = false @@ -338,9 +367,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.authStatusItem.submenu = submenu self.authStatusItem.isEnabled = true - self.upSellItem.title = "Check Subscription Plans" - self.upSellItem.isHidden = false - self.upSellItem.isEnabled = true + self.quotaItem.isHidden = true self.toggleCompletions.isHidden = true self.toggleIgnoreLanguage.isHidden = true self.signOutItem.isHidden = false @@ -353,7 +380,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { userName: "Unknown User" ) self.authStatusItem.isHidden = true - self.upSellItem.isHidden = true + self.quotaItem.isHidden = true self.toggleCompletions.isHidden = false self.toggleIgnoreLanguage.isHidden = false self.signOutItem.isHidden = false @@ -453,6 +480,23 @@ extension NSRunningApplication { } } +enum CLSMessageType { + case chatLimitReached + case completionLimitReached + case other + + var summary: String { + switch self { + case .chatLimitReached: + return "Monthly Chat Limit Reached" + case .completionLimitReached: + return "Monthly Completion Limit Reached" + case .other: + return "CLS Error" + } + } +} + struct CLSMessage { let summary: String let detail: String @@ -467,13 +511,15 @@ func extractDateFromCLSMessage(_ message: String) -> String? { } func getCLSMessageSummary(_ message: String) -> CLSMessage { - let summary: String - if message.contains("You've reached your monthly chat messages limit") { - summary = "Monthly Chat Limit Reached" + let messageType: CLSMessageType + + if message.contains("You've reached your monthly chat messages limit") || + message.contains("You've reached your monthly chat messages quota") { + messageType = .chatLimitReached } else if message.contains("Completions limit reached") { - summary = "Monthly Completion Limit Reached" + messageType = .completionLimitReached } else { - summary = "CLS Error" + messageType = .other } let detail: String @@ -483,5 +529,5 @@ func getCLSMessageSummary(_ message: String) -> CLSMessage { detail = message } - return CLSMessage(summary: summary, detail: detail) + return CLSMessage(summary: messageType.summary, detail: detail) } diff --git a/ExtensionService/Assets.xcassets/MenuBarErrorIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarErrorIcon.imageset/Contents.json new file mode 100644 index 00000000..4ebbfc18 --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarErrorIcon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Status=error, Mode=dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Status=error, Mode=dark.svg b/ExtensionService/Assets.xcassets/MenuBarErrorIcon.imageset/Status=error, Mode=dark.svg similarity index 100% rename from ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Status=error, Mode=dark.svg rename to ExtensionService/Assets.xcassets/MenuBarErrorIcon.imageset/Status=error, Mode=dark.svg diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json index 4ebbfc18..c9b66241 100644 --- a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Status=error, Mode=dark.svg", + "filename" : "Status=warning, Mode=dark.svg", "idiom" : "universal" } ], diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Status=warning, Mode=dark.svg b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Status=warning, Mode=dark.svg new file mode 100644 index 00000000..6f037e5d --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Status=warning, Mode=dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Server/package-lock.json b/Server/package-lock.json index d2271df6..bd762c6a 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.321.0", + "@github/copilot-language-server": "^1.327.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,9 +36,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.321.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.321.0.tgz", - "integrity": "sha512-IblryaajOPfGOSaeVSpu+NUxiodXIInmWcV1YQgmvmKSdcclzt4FxAnu/szRHuh0yIaZlldQ6lBRPFIVeuXv+g==", + "version": "1.327.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.327.0.tgz", + "integrity": "sha512-hyFCpyfURQ+NUDUM0QQMB1+Bju38LuaG6jM/1rHc7Limh/yEeobjFNSG4OnR2xi9lC0CwHfwSBii+NYN0WfCLA==", "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" diff --git a/Server/package.json b/Server/package.json index 245bad10..b4c461c3 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.321.0", + "@github/copilot-language-server": "^1.327.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift index 811f2b65..355ca323 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -65,7 +65,12 @@ public final class BuiltinExtensionConversationServiceProvider< return } - try await conversationService.createTurn(with: conversationId, request: request, workspace: workspaceInfo) + try await conversationService + .createTurn( + with: conversationId, + request: request, + workspace: workspaceInfo + ) } public func stopReceivingMessage(_ workDoneToken: String, workspaceURL: URL?) async throws { diff --git a/Tool/Sources/ChatAPIService/APIs/ChatCompletionsAPIDefinition.swift b/Tool/Sources/ChatAPIService/APIs/ChatCompletionsAPIDefinition.swift index 2b7dede5..165ea645 100644 --- a/Tool/Sources/ChatAPIService/APIs/ChatCompletionsAPIDefinition.swift +++ b/Tool/Sources/ChatAPIService/APIs/ChatCompletionsAPIDefinition.swift @@ -5,14 +5,11 @@ import Preferences struct ChatCompletionsRequestBody: Codable, Equatable { struct Message: Codable, Equatable { enum Role: String, Codable, Equatable { - case system case user case assistant var asChatMessageRole: ChatMessage.Role { switch self { - case .system: - return .system case .user: return .user case .assistant: diff --git a/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift index 556e008c..5460fb00 100644 --- a/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift @@ -63,6 +63,13 @@ public actor AutoManagedChatMemory: ChatMemory { contextSystemPrompt = "" self.composeHistory = composeHistory } + + deinit { + history.removeAll() + onHistoryChange = {} + + retrievedContent.removeAll() + } public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) { update(&history) diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift index 23691491..eb320ba9 100644 --- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift @@ -13,7 +13,6 @@ public extension ChatMemory { await mutateHistory { history in if let index = history.firstIndex(where: { $0.id == message.id }) { history[index].mergeMessage(with: message) - } else { history.append(message) } @@ -54,6 +53,8 @@ extension ChatMessage { self.errorMessage = (self.errorMessage ?? "") + errorMessage } + self.panelMessages = self.panelMessages + message.panelMessages + // merge steps if !message.steps.isEmpty { var mergedSteps = self.steps diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index 0cd4bbb8..2afea171 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -67,9 +67,9 @@ public struct ChatMessage: Equatable, Codable { public typealias ID = String public enum Role: String, Codable, Equatable { - case system case user case assistant + case system } /// The role of a message. @@ -106,6 +106,8 @@ public struct ChatMessage: Equatable, Codable { public var editAgentRounds: [AgentRound] + public var panelMessages: [CopilotShowMessageParams] + /// The timestamp of the message. public var createdAt: Date public var updatedAt: Date @@ -123,6 +125,7 @@ public struct ChatMessage: Equatable, Codable { rating: ConversationRating = .unrated, steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], + panelMessages: [CopilotShowMessageParams] = [], createdAt: Date? = nil, updatedAt: Date? = nil ) { @@ -138,6 +141,7 @@ public struct ChatMessage: Equatable, Codable { self.rating = rating self.steps = steps self.editAgentRounds = editAgentRounds + self.panelMessages = panelMessages let now = Date.now self.createdAt = createdAt ?? now diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 54bc5781..0612cca5 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -2,6 +2,21 @@ import ComposableArchitecture import Foundation import SwiftUI +/// Preview info used in ChatHistoryView +public struct ChatTabPreviewInfo: Identifiable, Equatable, Codable { + public let id: String + public let title: String? + public let isSelected: Bool + public let updatedAt: Date + + public init(id: String, title: String?, isSelected: Bool, updatedAt: Date) { + self.id = id + self.title = title + self.isSelected = isSelected + self.updatedAt = updatedAt + } +} + /// The information of a tab. @ObservableState public struct ChatTabInfo: Identifiable, Equatable, Codable { diff --git a/Tool/Sources/ChatTab/ChatTabPool.swift b/Tool/Sources/ChatTab/ChatTabPool.swift index 6a3769d1..116070fd 100644 --- a/Tool/Sources/ChatTab/ChatTabPool.swift +++ b/Tool/Sources/ChatTab/ChatTabPool.swift @@ -27,6 +27,8 @@ public final class ChatTabPool { } public func removeTab(of id: String) { + guard getTab(of: id) != nil else { return } + pool.removeValue(forKey: id) } } diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index bb53fbc9..1ba19b74 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -96,6 +96,8 @@ public struct ConversationRequest { public var model: String? public var turns: [TurnSchema] public var agentMode: Bool = false + public var userLanguage: String? = nil + public var turnId: String? = nil public init( workDoneToken: String, @@ -107,7 +109,9 @@ public struct ConversationRequest { references: [FileReference]? = nil, model: String? = nil, turns: [TurnSchema] = [], - agentMode: Bool = false + agentMode: Bool = false, + userLanguage: String?, + turnId: String? = nil ) { self.workDoneToken = workDoneToken self.content = content @@ -119,6 +123,8 @@ public struct ConversationRequest { self.model = model self.turns = turns self.agentMode = agentMode + self.userLanguage = userLanguage + self.turnId = turnId } } diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift index 1b2f4ccf..636d1e0b 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift @@ -1,5 +1,6 @@ import Foundation import JSONRPC +import LanguageServerProtocol // MARK: Conversation template public struct ChatTemplate: Codable, Equatable { @@ -59,7 +60,7 @@ public struct CopilotModelCapabilitiesSupports: Codable, Equatable { public let vision: Bool } -public struct CopilotModelBilling: Codable, Equatable { +public struct CopilotModelBilling: Codable, Equatable, Hashable { public let isPremium: Bool public let multiplier: Float } @@ -287,3 +288,49 @@ public struct LanguageModelToolConfirmationResult: Codable, Equatable { } public typealias InvokeClientToolConfirmationRequest = JSONRPCRequest + +// MARK: CLS ShowMessage Notification +public struct CopilotShowMessageParams: Codable, Equatable, Hashable { + public var type: MessageType + public var title: String + public var message: String + public var actions: [CopilotMessageActionItem]? + public var location: CopilotMessageLocation + public var panelContext: CopilotMessagePanelContext? + + public init( + type: MessageType, + title: String, + message: String, + actions: [CopilotMessageActionItem]? = nil, + location: CopilotMessageLocation, + panelContext: CopilotMessagePanelContext? = nil + ) { + self.type = type + self.title = title + self.message = message + self.actions = actions + self.location = location + self.panelContext = panelContext + } +} + +public enum CopilotMessageLocation: String, Codable, Equatable, Hashable { + case Panel = "Panel" + case Inline = "Inline" +} + +public struct CopilotMessagePanelContext: Codable, Equatable, Hashable { + public var conversationId: String + public var turnId: String +} + +public struct CopilotMessageActionItem: Codable, Equatable, Hashable { + public var title: String + public var command: ActionCommand? +} + +public struct ActionCommand: Codable, Equatable, Hashable { + public var commandId: String + public var args: LSPAny? +} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift index 664100cc..6b68117f 100644 --- a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift +++ b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift @@ -24,7 +24,7 @@ public final class WatchedFilesHandlerImpl: WatchedFilesHandler { projectURL: projectURL, excludeGitIgnoredFiles: params.excludeGitignoredFiles, excludeIDEIgnoredFiles: params.excludeIDEIgnoredFiles - ) + ).prefix(10000) // Set max number of indexing file to 10000 let batchSize = BatchingFileChangeWatcher.maxEventPublishSize /// only `batchSize`(100) files to complete this event for setup watching workspace in CLS side diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotModelManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotModelManager.swift index ea4b9e23..898dd5b0 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotModelManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotModelManager.swift @@ -4,15 +4,19 @@ import Foundation public extension Notification.Name { static let gitHubCopilotModelsDidChange = Notification .Name("com.github.CopilotForXcode.CopilotModelsDidChange") + static let gitHubCopilotShouldSwitchFallbackModel = Notification + .Name("com.github.CopilotForXcode.CopilotShouldSwitchFallbackModel") } public class CopilotModelManager { private static var availableLLMs: [CopilotModel] = [] + private static var fallbackLLMs: [CopilotModel] = [] public static func updateLLMs(_ models: [CopilotModel]) { let sortedModels = models.sorted(by: { $0.modelName.lowercased() < $1.modelName.lowercased() }) guard sortedModels != availableLLMs else { return } availableLLMs = sortedModels + fallbackLLMs = models.filter({ $0.isChatFallback}) NotificationCenter.default.post(name: .gitHubCopilotModelsDidChange, object: nil) } @@ -23,6 +27,14 @@ public class CopilotModelManager { public static func hasLLMs() -> Bool { return !availableLLMs.isEmpty } + + public static func getFallbackLLM(scope: PromptTemplateScope) -> CopilotModel? { + return fallbackLLMs.first(where: { $0.scopes.contains(scope) && $0.billing?.isPremium == false}) + } + + public static func switchToFallbackModel() { + NotificationCenter.default.post(name: .gitHubCopilotShouldSwitchFallbackModel, object: nil) + } public static func clearLLMs() { availableLLMs = [] diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift index ed0b0c02..b00a2ee2 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift @@ -34,6 +34,7 @@ struct ConversationCreateParams: Codable { var model: String? var chatMode: String? var needToolCallConfirmation: Bool? + var userLanguage: String? struct Capabilities: Codable { var skills: [String] @@ -131,6 +132,7 @@ struct ConversationTurn: Codable { struct TurnCreateParams: Codable { var workDoneToken: String var conversationId: String + var turnId: String? var message: String var textDocument: Doc? var ignoredSkills: [String]? diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 086769cb..f454ce26 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -127,6 +127,14 @@ enum GitHubCopilotRequest { .custom("checkStatus", .hash([:])) } } + + struct CheckQuota: GitHubCopilotRequestType { + typealias Response = GitHubCopilotQuotaInfo + + var request: ClientRequest { + .custom("checkQuota", .hash([:])) + } + } struct SignInInitiate: GitHubCopilotRequestType { struct Response: Codable { diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index ae9ac1a9..327f9cbe 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -15,6 +15,7 @@ import Persist public protocol GitHubCopilotAuthServiceType { func checkStatus() async throws -> GitHubCopilotAccountStatus + func checkQuota() async throws -> GitHubCopilotQuotaInfo func signInInitiate() async throws -> (status: SignInInitiateStatus, verificationUri: String?, userCode: String?, user: String?) func signInConfirm(userCode: String) async throws -> (username: String, status: GitHubCopilotAccountStatus) @@ -62,10 +63,12 @@ public protocol GitHubCopilotConversationServiceType { references: [FileReference], model: String?, turns: [TurnSchema], - agentMode: Bool) async throws + agentMode: Bool, + userLanguage: String?) async throws func createTurn(_ message: String, workDoneToken: String, conversationId: String, + turnId: String?, activeDoc: Doc?, ignoredSkills: [String]?, references: [FileReference], @@ -587,7 +590,8 @@ public final class GitHubCopilotService: references: [FileReference], model: String?, turns: [TurnSchema], - agentMode: Bool) async throws { + agentMode: Bool, + userLanguage: String?) async throws { var conversationCreateTurns: [ConversationTurn] = [] // invoke conversation history if turns.count > 0 { @@ -618,7 +622,8 @@ public final class GitHubCopilotService: ignoredSkills: ignoredSkills, model: model, chatMode: agentMode ? "Agent" : nil, - needToolCallConfirmation: true) + needToolCallConfirmation: true, + userLanguage: userLanguage) do { _ = try await sendRequest( GitHubCopilotRequest.CreateConversation(params: params), timeout: conversationRequestTimeout(agentMode)) @@ -629,10 +634,21 @@ public final class GitHubCopilotService: } @GitHubCopilotSuggestionActor - public func createTurn(_ message: String, workDoneToken: String, conversationId: String, activeDoc: Doc?, ignoredSkills: [String]?, references: [FileReference], model: String?, workspaceFolder: String, workspaceFolders: [WorkspaceFolder]? = nil, agentMode: Bool) async throws { + public func createTurn(_ message: String, + workDoneToken: String, + conversationId: String, + turnId: String?, + activeDoc: Doc?, + ignoredSkills: [String]?, + references: [FileReference], + model: String?, + workspaceFolder: String, + workspaceFolders: [WorkspaceFolder]? = nil, + agentMode: Bool) async throws { do { let params = TurnCreateParams(workDoneToken: workDoneToken, conversationId: conversationId, + turnId: turnId, message: message, textDocument: activeDoc, ignoredSkills: ignoredSkills, @@ -861,6 +877,19 @@ public final class GitHubCopilotService: throw error } } + + @GitHubCopilotSuggestionActor + public func checkQuota() async throws -> GitHubCopilotQuotaInfo { + do { + let response = try await sendRequest(GitHubCopilotRequest.CheckQuota()) + await Status.shared.updateQuotaInfo(response) + return response + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } public func updateStatusInBackground() { Task { @GitHubCopilotSuggestionActor in diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift index 0958470e..b1b00e75 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -33,7 +33,8 @@ public final class GitHubCopilotConversationService: ConversationServiceType { references: request.references ?? [], model: request.model, turns: request.turns, - agentMode: request.agentMode) + agentMode: request.agentMode, + userLanguage: request.userLanguage) } public func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws { @@ -42,6 +43,7 @@ public final class GitHubCopilotConversationService: ConversationServiceType { return try await service.createTurn(request.content, workDoneToken: request.workDoneToken, conversationId: conversationId, + turnId: request.turnId, activeDoc: request.activeDoc, ignoredSkills: request.ignoredSkills, references: request.references ?? [], diff --git a/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift b/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift index 720faae0..2ec2f53c 100644 --- a/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift +++ b/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift @@ -167,11 +167,19 @@ public final class ConversationStorage: ConversationStorageProtocol { switch type { case .all: - query = query.order(column.updatedAt.asc) + query = query.order(column.updatedAt.desc) case .selected: query = query .filter(column.isSelected == true) .limit(1) + case .latest: + query = query + .order(column.updatedAt.desc) + .limit(1) + case .id(let id): + query = query + .filter(conversationTable.column.id == id) + .limit(1) } let rowIterator = try db.prepareRowIterator(query) @@ -190,6 +198,30 @@ public final class ConversationStorage: ConversationStorageProtocol { return items } + + public func fetchConversationPreviewItems() throws -> [ConversationPreviewItem] { + var items: [ConversationPreviewItem] = [] + + try withDB { db in + let table = conversationTable.table + let column = conversationTable.column + let query = table + .select(column.id, column.title, column.isSelected, column.updatedAt) + .order(column.updatedAt.desc) + + let rowIterator = try db.prepareRowIterator(query) + items = try rowIterator.map { row in + ConversationPreviewItem( + id: row[column.id], + title: row[column.title], + isSelected: row[column.isSelected], + updatedAt: row[column.updatedAt].toDate() + ) + } + } + + return items + } } diff --git a/Tool/Sources/Persist/Storage/ConversationStorage/Model.swift b/Tool/Sources/Persist/Storage/ConversationStorage/Model.swift index 0684b8cb..6193f4d5 100644 --- a/Tool/Sources/Persist/Storage/ConversationStorage/Model.swift +++ b/Tool/Sources/Persist/Storage/ConversationStorage/Model.swift @@ -40,6 +40,13 @@ public struct ConversationItem: Codable, Equatable { } } +public struct ConversationPreviewItem: Codable, Equatable { + public let id: String + public let title: String? + public let isSelected: Bool + public let updatedAt: Date +} + public enum DeleteType { case conversation(id: String) case turn(id: String) @@ -62,5 +69,5 @@ public struct OperationRequest { } public enum ConversationFetchType { - case all, selected + case all, selected, latest, id(String) } diff --git a/Tool/Sources/Persist/Storage/ConversationStorageService.swift b/Tool/Sources/Persist/Storage/ConversationStorageService.swift index 14102792..113eafa2 100644 --- a/Tool/Sources/Persist/Storage/ConversationStorageService.swift +++ b/Tool/Sources/Persist/Storage/ConversationStorageService.swift @@ -97,6 +97,20 @@ public final class ConversationStorageService: ConversationStorageServiceProtoco return items } + public func fetchConversationPreviewItems(metadata: StorageMetadata) -> [ConversationPreviewItem] { + var items: [ConversationPreviewItem] = [] + + do { + try withStorage(metadata) { conversationStorage in + items = try conversationStorage.fetchConversationPreviewItems() + } + } catch { + Logger.client.error("Failed to fetch conversation preview items: \(error)") + } + + return items + } + public func fetchTurnItems(for conversationID: String, metadata: StorageMetadata) -> [TurnItem] { var items: [TurnItem] = [] diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 398640bd..79e7e84e 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -295,6 +295,10 @@ public extension UserDefaultPreferenceKeys { var enableCurrentEditorContext: PreferenceKey { .init(defaultValue: true, key: "EnableCurrentEditorContext") } + + var chatResponseLocale: PreferenceKey { + .init(defaultValue: "en", key: "ChatResponseLocale") + } } // MARK: - Theme diff --git a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift index 9023b4e7..e1ba7578 100644 --- a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift +++ b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift @@ -5,46 +5,44 @@ public struct AutoresizingCustomTextEditor: View { public let font: NSFont public let isEditable: Bool public let maxHeight: Double + public let minHeight: Double public let onSubmit: () -> Void - public var completions: (_ text: String, _ words: [String], _ range: NSRange) -> [String] - + + @State private var textEditorHeight: CGFloat + public init( text: Binding, font: NSFont, isEditable: Bool, maxHeight: Double, - onSubmit: @escaping () -> Void, - completions: @escaping (_ text: String, _ words: [String], _ range: NSRange) - -> [String] = { _, _, _ in [] } + onSubmit: @escaping () -> Void ) { _text = text self.font = font self.isEditable = isEditable self.maxHeight = maxHeight + self.minHeight = Double(font.ascender + abs(font.descender) + font.leading) // Following the original padding: .top(1), .bottom(2) self.onSubmit = onSubmit - self.completions = completions + + // Initialize with font height + 3 as in the original logic + _textEditorHeight = State(initialValue: self.minHeight) } public var body: some View { - ZStack(alignment: .center) { - // a hack to support dynamic height of TextEditor - Text(text.isEmpty ? "Hi" : text).opacity(0) - .font(.init(font)) - .frame(maxWidth: .infinity, maxHeight: maxHeight) - .padding(.top, 1) - .padding(.bottom, 2) - .padding(.horizontal, 4) - - CustomTextEditor( - text: $text, - font: font, - maxHeight: maxHeight, - onSubmit: onSubmit, - completions: completions - ) - .padding(.top, 1) - .padding(.bottom, -1) - } + CustomTextEditor( + text: $text, + font: font, + isEditable: isEditable, + maxHeight: maxHeight, + minHeight: minHeight, + onSubmit: onSubmit, + heightDidChange: { height in + self.textEditorHeight = min(height, maxHeight) + } + ) + .frame(height: textEditorHeight) + .padding(.top, 1) + .padding(.bottom, -1) } } @@ -56,29 +54,30 @@ public struct CustomTextEditor: NSViewRepresentable { @Binding public var text: String public let font: NSFont public let maxHeight: Double + public let minHeight: Double public let isEditable: Bool public let onSubmit: () -> Void - public var completions: (_ text: String, _ words: [String], _ range: NSRange) -> [String] + public let heightDidChange: (CGFloat) -> Void public init( text: Binding, font: NSFont, isEditable: Bool = true, maxHeight: Double, + minHeight: Double, onSubmit: @escaping () -> Void, - completions: @escaping (_ text: String, _ words: [String], _ range: NSRange) - -> [String] = { _, _, _ in [] } + heightDidChange: @escaping (CGFloat) -> Void ) { _text = text self.font = font self.isEditable = isEditable self.maxHeight = maxHeight + self.minHeight = minHeight self.onSubmit = onSubmit - self.completions = completions + self.heightDidChange = heightDidChange } public func makeNSView(context: Context) -> NSScrollView { -// context.coordinator.completions = completions let textView = (context.coordinator.theTextView.documentView as! NSTextView) textView.delegate = context.coordinator textView.string = text @@ -89,21 +88,34 @@ public struct CustomTextEditor: NSViewRepresentable { textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.setAccessibilityLabel("Chat Input, Ask Copilot. Type to ask questions or type / for topics, press enter to send out the request. Use the Chat Accessibility Help command for more information.") + + // Set up text container for dynamic height + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.textContainer?.containerSize = NSSize(width: textView.frame.width, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true // Configure scroll view let scrollView = context.coordinator.theTextView scrollView.hasHorizontalScroller = false - context.coordinator.observeHeight(scrollView: scrollView, maxHeight: maxHeight) + scrollView.hasVerticalScroller = false // We'll manage the scrolling ourselves + + // Initialize height calculation + context.coordinator.view = self + context.coordinator.calculateAndUpdateHeight(textView: textView) + return scrollView } public func updateNSView(_ nsView: NSScrollView, context: Context) { -// context.coordinator.completions = completions let textView = (context.coordinator.theTextView.documentView as! NSTextView) textView.isEditable = isEditable guard textView.string != text else { return } textView.string = text textView.undoManager?.removeAllActions() + + // Update height calculation when text changes + context.coordinator.calculateAndUpdateHeight(textView: textView) } } @@ -112,20 +124,47 @@ public extension CustomTextEditor { var view: CustomTextEditor var theTextView = NSTextView.scrollableTextView() var affectedCharRange: NSRange? - var completions: (String, [String], _ range: NSRange) -> [String] = { _, _, _ in [] } - var heightObserver: NSKeyValueObservation? init(_ view: CustomTextEditor) { self.view = view } + + func calculateAndUpdateHeight(textView: NSTextView) { + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { + return + } + + let usedRect = layoutManager.usedRect(for: textContainer) + + // Add padding for text insets if needed + let textInsets = textView.textContainerInset + let newHeight = max(view.minHeight, usedRect.height + textInsets.height * 2) + + // Update scroll behavior based on height vs maxHeight + theTextView.hasVerticalScroller = newHeight >= view.maxHeight + + // Only report the height that will be used for display + let heightToReport = min(newHeight, view.maxHeight) + + // Inform the SwiftUI view of the height + DispatchQueue.main.async { + self.view.heightDidChange(heightToReport) + } + } public func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } - - view.text = textView.string - textView.complete(nil) + + // Defer updating the binding for large text changes + DispatchQueue.main.async { + self.view.text = textView.string + } + + // Update height after text changes + calculateAndUpdateHeight(textView: textView) } public func textView( @@ -152,29 +191,6 @@ public extension CustomTextEditor { ) -> Bool { return true } - - public func textView( - _ textView: NSTextView, - completions words: [String], - forPartialWordRange charRange: NSRange, - indexOfSelectedItem index: UnsafeMutablePointer? - ) -> [String] { - index?.pointee = -1 - return completions(textView.textStorage?.string ?? "", words, charRange) - } - - func observeHeight(scrollView: NSScrollView, maxHeight: Double) { - let textView = scrollView.documentView as! NSTextView - heightObserver = textView.observe(\NSTextView.frame) { [weak scrollView] _, _ in - guard let scrollView = scrollView else { return } - let contentHeight = textView.frame.height - scrollView.hasVerticalScroller = contentHeight >= maxHeight - } - } - - deinit { - heightObserver?.invalidate() - } } } diff --git a/Tool/Sources/Status/Status.swift b/Tool/Sources/Status/Status.swift index 7e6baec3..d3cd8339 100644 --- a/Tool/Sources/Status/Status.swift +++ b/Tool/Sources/Status/Status.swift @@ -9,23 +9,6 @@ import Foundation case unknown = -1, granted = 1, notGranted = 0 } -public struct CLSStatus: Equatable { - public enum Status { case unknown, normal, error, warning, inactive } - public let status: Status - public let busy: Bool - public let message: String - - public var isInactiveStatus: Bool { status == .inactive && !message.isEmpty } - public var isErrorStatus: Bool { (status == .warning || status == .error) && !message.isEmpty } -} - -public struct AuthStatus: Equatable { - public enum Status { case unknown, loggedIn, notLoggedIn, notAuthorized } - public let status: Status - public let username: String? - public let message: String? -} - private struct AuthStatusInfo { let authIcon: StatusResponse.Icon? let authStatus: AuthStatus.Status @@ -48,38 +31,6 @@ public extension Notification.Name { static let serviceStatusDidChange = Notification.Name("com.github.CopilotForXcode.serviceStatusDidChange") } -public struct StatusResponse { - public struct Icon { - /// Name of the icon resource - public let name: String - - public init(name: String) { - self.name = name - } - - public var nsImage: NSImage? { - return NSImage(named: name) - } - } - - /// The icon to display in the menu bar - public let icon: Icon - /// Indicates if an operation is in progress - public let inProgress: Bool - /// Message from the CLS (Copilot Language Server) status - public let clsMessage: String - /// Additional message (for accessibility or extension status) - public let message: String? - /// Extension status - public let extensionStatus: ExtensionPermissionStatus - /// URL for system preferences or other actions - public let url: String? - /// Current authentication status - public let authStatus: AuthStatus.Status - /// GitHub username of the authenticated user - public let userName: String? -} - private var currentUserName: String? = nil public final actor Status { public static let shared = Status() @@ -88,9 +39,12 @@ public final actor Status { private var axStatus: ObservedAXStatus = .unknown private var clsStatus = CLSStatus(status: .unknown, busy: false, message: "") private var authStatus = AuthStatus(status: .unknown, username: nil, message: nil) + + private var currentUserQuotaInfo: GitHubCopilotQuotaInfo? = nil private let okIcon = StatusResponse.Icon(name: "MenuBarIcon") - private let errorIcon = StatusResponse.Icon(name: "MenuBarWarningIcon") + private let errorIcon = StatusResponse.Icon(name: "MenuBarErrorIcon") + private let warningIcon = StatusResponse.Icon(name: "MenuBarWarningIcon") private let inactiveIcon = StatusResponse.Icon(name: "MenuBarInactiveIcon") private init() {} @@ -98,6 +52,12 @@ public final actor Status { public static func currentUser() -> String? { return currentUserName } + + public func updateQuotaInfo(_ quotaInfo: GitHubCopilotQuotaInfo?) { + guard quotaInfo != currentUserQuotaInfo else { return } + currentUserQuotaInfo = quotaInfo + broadcast() + } public func updateExtensionStatus(_ status: ExtensionPermissionStatus) { guard status != extensionStatus else { return } @@ -169,7 +129,8 @@ public final actor Status { extensionStatus: extensionStatus, url: accessibilityStatusInfo.url, authStatus: authStatusInfo.authStatus, - userName: authStatusInfo.userName + userName: authStatusInfo.userName, + quotaInfo: currentUserQuotaInfo ) } @@ -200,6 +161,9 @@ public final actor Status { if clsStatus.isInactiveStatus { return CLSStatusInfo(icon: inactiveIcon, message: clsStatus.message) } + if clsStatus.isWarningStatus { + return CLSStatusInfo(icon: warningIcon, message: clsStatus.message) + } if clsStatus.isErrorStatus { return CLSStatusInfo(icon: errorIcon, message: clsStatus.message) } diff --git a/Tool/Sources/Status/Types/AuthStatus.swift b/Tool/Sources/Status/Types/AuthStatus.swift new file mode 100644 index 00000000..8253a6a5 --- /dev/null +++ b/Tool/Sources/Status/Types/AuthStatus.swift @@ -0,0 +1,6 @@ +public struct AuthStatus: Equatable { + public enum Status { case unknown, loggedIn, notLoggedIn, notAuthorized } + public let status: Status + public let username: String? + public let message: String? +} diff --git a/Tool/Sources/Status/Types/CLSStatus.swift b/Tool/Sources/Status/Types/CLSStatus.swift new file mode 100644 index 00000000..07b5d765 --- /dev/null +++ b/Tool/Sources/Status/Types/CLSStatus.swift @@ -0,0 +1,10 @@ +public struct CLSStatus: Equatable { + public enum Status { case unknown, normal, error, warning, inactive } + public let status: Status + public let busy: Bool + public let message: String + + public var isInactiveStatus: Bool { status == .inactive && !message.isEmpty } + public var isErrorStatus: Bool { status == .error && !message.isEmpty } + public var isWarningStatus: Bool { status == .warning && !message.isEmpty } +} diff --git a/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift b/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift new file mode 100644 index 00000000..8e4b3d23 --- /dev/null +++ b/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct QuotaSnapshot: Codable, Equatable, Hashable { + public var percentRemaining: Float + public var unlimited: Bool + public var overagePermitted: Bool +} + +public struct GitHubCopilotQuotaInfo: Codable, Equatable, Hashable { + public var chat: QuotaSnapshot + public var completions: QuotaSnapshot + public var premiumInteractions: QuotaSnapshot + public var resetDate: String + public var copilotPlan: String +} diff --git a/Tool/Sources/Status/Types/StatusResponse.swift b/Tool/Sources/Status/Types/StatusResponse.swift new file mode 100644 index 00000000..3842c088 --- /dev/null +++ b/Tool/Sources/Status/Types/StatusResponse.swift @@ -0,0 +1,35 @@ +import AppKit + +public struct StatusResponse { + public struct Icon { + /// Name of the icon resource + public let name: String + + public init(name: String) { + self.name = name + } + + public var nsImage: NSImage? { + return NSImage(named: name) + } + } + + /// The icon to display in the menu bar + public let icon: Icon + /// Indicates if an operation is in progress + public let inProgress: Bool + /// Message from the CLS (Copilot Language Server) status + public let clsMessage: String + /// Additional message (for accessibility or extension status) + public let message: String? + /// Extension status + public let extensionStatus: ExtensionPermissionStatus + /// URL for system preferences or other actions + public let url: String? + /// Current authentication status + public let authStatus: AuthStatus.Status + /// GitHub username of the authenticated user + public let userName: String? + /// Quota information for GitHub Copilot + public let quotaInfo: GitHubCopilotQuotaInfo? +} diff --git a/Tool/Sources/StatusBarItemView/AccountItemView.swift b/Tool/Sources/StatusBarItemView/AccountItemView.swift index e545cb82..3eff1406 100644 --- a/Tool/Sources/StatusBarItemView/AccountItemView.swift +++ b/Tool/Sources/StatusBarItemView/AccountItemView.swift @@ -38,7 +38,7 @@ public class AccountItemView: NSView { self.visualEffect.isHidden = true self.visualEffect.wantsLayer = true self.visualEffect.layer?.cornerRadius = 4 - self.visualEffect.layer?.backgroundColor = NSColor.systemBlue.cgColor + self.visualEffect.layer?.backgroundColor = NSColor.controlAccentColor.cgColor self.visualEffect.isEmphasized = true // Initialize with a reasonable starting size diff --git a/Tool/Sources/StatusBarItemView/HoverButton.swift b/Tool/Sources/StatusBarItemView/HoverButton.swift new file mode 100644 index 00000000..66b58bb8 --- /dev/null +++ b/Tool/Sources/StatusBarItemView/HoverButton.swift @@ -0,0 +1,145 @@ +import AppKit + +class HoverButton: NSButton { + private var isLinkMode = false + + override func awakeFromNib() { + super.awakeFromNib() + setupButton() + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupButton() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupButton() + } + + private func setupButton() { + self.wantsLayer = true + self.layer?.backgroundColor = NSColor.clear.cgColor + self.layer?.cornerRadius = 3 + } + + private func resetToDefaultState() { + self.layer?.backgroundColor = NSColor.clear.cgColor + if isLinkMode { + updateLinkAppearance(isHovered: false) + } + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + DispatchQueue.main.async { + self.updateTrackingAreas() + } + } + + override func layout() { + super.layout() + updateTrackingAreas() + } + + func configureLinkMode() { + isLinkMode = true + self.isBordered = false + self.setButtonType(.momentaryChange) + self.layer?.backgroundColor = NSColor.clear.cgColor + } + + func setLinkStyle(title: String, fontSize: CGFloat) { + configureLinkMode() + updateLinkAppearance(title: title, fontSize: fontSize, isHovered: false) + } + + override func mouseEntered(with event: NSEvent) { + if isLinkMode { + updateLinkAppearance(isHovered: true) + } else { + self.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.15).cgColor + super.mouseEntered(with: event) + } + } + + override func mouseExited(with event: NSEvent) { + if isLinkMode { + updateLinkAppearance(isHovered: false) + } else { + super.mouseExited(with: event) + resetToDefaultState() + } + } + + private func updateLinkAppearance(title: String? = nil, fontSize: CGFloat? = nil, isHovered: Bool = false) { + let buttonTitle = title ?? self.title + let font = fontSize != nil ? NSFont.systemFont(ofSize: fontSize!, weight: .regular) : NSFont.systemFont(ofSize: 11) + + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.controlAccentColor, + .font: font, + .underlineStyle: isHovered ? NSUnderlineStyle.single.rawValue : 0 + ] + + let attributedTitle = NSAttributedString(string: buttonTitle, attributes: attributes) + self.attributedTitle = attributedTitle + } + + override func mouseDown(with event: NSEvent) { + super.mouseDown(with: event) + // Reset state immediately after click + DispatchQueue.main.async { + self.resetToDefaultState() + } + } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + // Ensure state is reset + DispatchQueue.main.async { + self.resetToDefaultState() + } + } + + override func viewDidHide() { + super.viewDidHide() + // Reset state when view is hidden (like when menu closes) + resetToDefaultState() + } + + override func viewDidUnhide() { + super.viewDidUnhide() + // Ensure clean state when view reappears + resetToDefaultState() + } + + override func removeFromSuperview() { + super.removeFromSuperview() + // Reset state when removed from superview + resetToDefaultState() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + for trackingArea in self.trackingAreas { + self.removeTrackingArea(trackingArea) + } + + guard self.bounds.width > 0 && self.bounds.height > 0 else { return } + + let trackingArea = NSTrackingArea( + rect: self.bounds, + options: [ + .mouseEnteredAndExited, + .activeAlways, + .inVisibleRect + ], + owner: self, + userInfo: nil + ) + self.addTrackingArea(trackingArea) + } +} diff --git a/Tool/Sources/StatusBarItemView/QuotaView.swift b/Tool/Sources/StatusBarItemView/QuotaView.swift new file mode 100644 index 00000000..780dcc3e --- /dev/null +++ b/Tool/Sources/StatusBarItemView/QuotaView.swift @@ -0,0 +1,578 @@ +import SwiftUI +import Foundation + +// MARK: - QuotaSnapshot Model +public struct QuotaSnapshot { + public var percentRemaining: Float + public var unlimited: Bool + public var overagePermitted: Bool + + public init(percentRemaining: Float, unlimited: Bool, overagePermitted: Bool) { + self.percentRemaining = percentRemaining + self.unlimited = unlimited + self.overagePermitted = overagePermitted + } +} + +// MARK: - QuotaView Main Class +public class QuotaView: NSView { + + // MARK: - Properties + private let chat: QuotaSnapshot + private let completions: QuotaSnapshot + private let premiumInteractions: QuotaSnapshot + private let resetDate: String + private let copilotPlan: String + + private var isFreeUser: Bool { + return copilotPlan == "free" + } + + private var isOrgUser: Bool { + return copilotPlan == "business" || copilotPlan == "enterprise" + } + + // MARK: - Initialization + public init( + chat: QuotaSnapshot, + completions: QuotaSnapshot, + premiumInteractions: QuotaSnapshot, + resetDate: String, + copilotPlan: String + ) { + self.chat = chat + self.completions = completions + self.premiumInteractions = premiumInteractions + self.resetDate = resetDate + self.copilotPlan = copilotPlan + + super.init(frame: NSRect(x: 0, y: 0, width: Layout.viewWidth, height: 0)) + + configureView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Configuration + private func configureView() { + autoresizingMask = [.width] + setupView() + + layoutSubtreeIfNeeded() + let calculatedHeight = fittingSize.height + frame = NSRect(x: 0, y: 0, width: Layout.viewWidth, height: calculatedHeight) + } + + private func setupView() { + let components = createViewComponents() + addSubviewsToHierarchy(components) + setupLayoutConstraints(components) + } + + // MARK: - Component Creation + private func createViewComponents() -> ViewComponents { + return ViewComponents( + titleContainer: createTitleContainer(), + progressViews: createProgressViews(), + statusMessageLabel: createStatusMessageLabel(), + resetTextLabel: createResetTextLabel(), + linkLabel: createLinkLabel() + ) + } + + private func addSubviewsToHierarchy(_ components: ViewComponents) { + addSubview(components.titleContainer) + components.progressViews.forEach { addSubview($0) } + if !isFreeUser { + addSubview(components.statusMessageLabel) + } + addSubview(components.resetTextLabel) + if !isOrgUser { + addSubview(components.linkLabel) + } + } +} + +// MARK: - Title Section +extension QuotaView { + private func createTitleContainer() -> NSView { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = createTitleLabel() + let settingsButton = createSettingsButton() + + container.addSubview(titleLabel) + container.addSubview(settingsButton) + + setupTitleConstraints(container: container, titleLabel: titleLabel, settingsButton: settingsButton) + + return container + } + + private func createTitleLabel() -> NSTextField { + let label = NSTextField(labelWithString: "Copilot Usage") + label.font = NSFont.systemFont(ofSize: Style.titleFontSize, weight: .medium) + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .systemGray + return label + } + + private func createSettingsButton() -> HoverButton { + let button = HoverButton() + + if let image = NSImage(systemSymbolName: "slider.horizontal.3", accessibilityDescription: "Manage Copilot") { + image.isTemplate = true + button.image = image + } + + button.imagePosition = .imageOnly + button.alphaValue = Style.buttonAlphaValue + button.toolTip = "Manage Copilot" + button.setButtonType(.momentaryChange) + button.isBordered = false + button.translatesAutoresizingMaskIntoConstraints = false + button.target = self + button.action = #selector(openCopilotSettings) + + return button + } + + private func setupTitleConstraints(container: NSView, titleLabel: NSTextField, settingsButton: HoverButton) { + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor), + titleLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor), + + settingsButton.trailingAnchor.constraint(equalTo: container.trailingAnchor), + settingsButton.centerYAnchor.constraint(equalTo: container.centerYAnchor), + settingsButton.widthAnchor.constraint(equalToConstant: Layout.settingsButtonSize), + settingsButton.heightAnchor.constraint(equalToConstant: Layout.settingsButtonHoverSize), + + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: settingsButton.leadingAnchor, constant: -Layout.settingsButtonSpacing) + ]) + } +} + +// MARK: - Progress Bars Section +extension QuotaView { + private func createProgressViews() -> [NSView] { + let completionsView = createProgressBarSection( + title: "Code Completions", + snapshot: completions + ) + + let chatView = createProgressBarSection( + title: "Chat Messages", + snapshot: chat + ) + + if isFreeUser { + return [completionsView, chatView] + } + + let premiumView = createProgressBarSection( + title: "Premium Requests", + snapshot: premiumInteractions + ) + + return [completionsView, chatView, premiumView] + } + + private func createProgressBarSection(title: String, snapshot: QuotaSnapshot) -> NSView { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = createProgressTitleLabel(title: title) + let percentageLabel = createPercentageLabel(snapshot: snapshot) + + container.addSubview(titleLabel) + container.addSubview(percentageLabel) + + if !snapshot.unlimited { + addProgressBar(to: container, snapshot: snapshot, titleLabel: titleLabel, percentageLabel: percentageLabel) + } else { + setupUnlimitedLayout(container: container, titleLabel: titleLabel, percentageLabel: percentageLabel) + } + + return container + } + + private func createProgressTitleLabel(title: String) -> NSTextField { + let label = NSTextField(labelWithString: title) + label.font = NSFont.systemFont(ofSize: Style.progressFontSize, weight: .regular) + label.textColor = .labelColor + label.translatesAutoresizingMaskIntoConstraints = false + return label + } + + private func createPercentageLabel(snapshot: QuotaSnapshot) -> NSTextField { + let usedPercentage = (100.0 - snapshot.percentRemaining) + let numberPart = usedPercentage.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", usedPercentage) + : String(format: "%.1f", usedPercentage) + let text = snapshot.unlimited ? "Included" : "\(numberPart)%" + + let label = NSTextField(labelWithString: text) + label.font = NSFont.systemFont(ofSize: Style.percentageFontSize, weight: .regular) + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .secondaryLabelColor + label.alignment = .right + + return label + } + + private func addProgressBar(to container: NSView, snapshot: QuotaSnapshot, titleLabel: NSTextField, percentageLabel: NSTextField) { + let usedPercentage = 100.0 - snapshot.percentRemaining + let color = getProgressBarColor(for: usedPercentage) + + let progressBackground = createProgressBackground(color: color) + let progressFill = createProgressFill(color: color, usedPercentage: usedPercentage) + + progressBackground.addSubview(progressFill) + container.addSubview(progressBackground) + + setupProgressBarConstraints( + container: container, + titleLabel: titleLabel, + percentageLabel: percentageLabel, + progressBackground: progressBackground, + progressFill: progressFill, + usedPercentage: usedPercentage + ) + } + + private func createProgressBackground(color: NSColor) -> NSView { + let background = NSView() + background.wantsLayer = true + background.layer?.backgroundColor = color.cgColor.copy(alpha: Style.progressBarBackgroundAlpha) + background.layer?.cornerRadius = Layout.progressBarCornerRadius + background.translatesAutoresizingMaskIntoConstraints = false + return background + } + + private func createProgressFill(color: NSColor, usedPercentage: Float) -> NSView { + let fill = NSView() + fill.wantsLayer = true + fill.translatesAutoresizingMaskIntoConstraints = false + fill.layer?.backgroundColor = color.cgColor + fill.layer?.cornerRadius = Layout.progressBarCornerRadius + return fill + } + + private func setupProgressBarConstraints( + container: NSView, + titleLabel: NSTextField, + percentageLabel: NSTextField, + progressBackground: NSView, + progressFill: NSView, + usedPercentage: Float + ) { + NSLayoutConstraint.activate([ + // Title and percentage on the same line + titleLabel.topAnchor.constraint(equalTo: container.topAnchor), + titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: percentageLabel.leadingAnchor, constant: -Layout.percentageLabelSpacing), + + percentageLabel.topAnchor.constraint(equalTo: container.topAnchor), + percentageLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor), + percentageLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: Layout.percentageLabelMinWidth), + + // Progress bar background + progressBackground.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: Layout.progressBarVerticalOffset), + progressBackground.leadingAnchor.constraint(equalTo: container.leadingAnchor), + progressBackground.trailingAnchor.constraint(equalTo: container.trailingAnchor), + progressBackground.bottomAnchor.constraint(equalTo: container.bottomAnchor), + progressBackground.heightAnchor.constraint(equalToConstant: Layout.progressBarThickness), + + // Progress bar fill + progressFill.topAnchor.constraint(equalTo: progressBackground.topAnchor), + progressFill.leadingAnchor.constraint(equalTo: progressBackground.leadingAnchor), + progressFill.bottomAnchor.constraint(equalTo: progressBackground.bottomAnchor), + progressFill.widthAnchor.constraint(equalTo: progressBackground.widthAnchor, multiplier: CGFloat(usedPercentage / 100.0)) + ]) + } + + private func setupUnlimitedLayout(container: NSView, titleLabel: NSTextField, percentageLabel: NSTextField) { + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: container.topAnchor), + titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: percentageLabel.leadingAnchor, constant: -Layout.percentageLabelSpacing), + titleLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor), + + percentageLabel.topAnchor.constraint(equalTo: container.topAnchor), + percentageLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor), + percentageLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: Layout.percentageLabelMinWidth), + percentageLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + } + + private func getProgressBarColor(for usedPercentage: Float) -> NSColor { + switch usedPercentage { + case 90...: + return .systemRed + case 75..<90: + return .systemYellow + default: + return .systemBlue + } + } +} + +// MARK: - Footer Section +extension QuotaView { + private func createStatusMessageLabel() -> NSTextField { + let message = premiumInteractions.overagePermitted ? + "Additional paid premium requests enabled." : + "Additional paid premium requests disabled." + + let label = NSTextField(labelWithString: isFreeUser ? "" : message) + label.font = NSFont.systemFont(ofSize: Style.footerFontSize, weight: .regular) + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .secondaryLabelColor + label.alignment = .left + return label + } + + private func createResetTextLabel() -> NSTextField { + + // Format reset date + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd" + + var resetText = "Allowance resets \(resetDate)." + + if let date = formatter.date(from: resetDate) { + let outputFormatter = DateFormatter() + outputFormatter.dateFormat = "MMMM d, yyyy" + let formattedDate = outputFormatter.string(from: date) + resetText = "Allowance resets \(formattedDate)." + } + + let label = NSTextField(labelWithString: resetText) + label.font = NSFont.systemFont(ofSize: Style.footerFontSize, weight: .regular) + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .secondaryLabelColor + label.alignment = .left + return label + } + + private func createLinkLabel() -> HoverButton { + let button = HoverButton() + let title = isFreeUser ? "Upgrade to Copilot Pro" : "Manage paid premium requests" + + button.setLinkStyle(title: title, fontSize: Style.footerFontSize) + button.translatesAutoresizingMaskIntoConstraints = false + button.alphaValue = Style.labelAlphaValue + button.alignment = .left + button.target = self + button.action = #selector(openCopilotManageOverage) + + return button + } +} + +// MARK: - Layout Constraints +extension QuotaView { + private func setupLayoutConstraints(_ components: ViewComponents) { + let constraints = buildConstraints(components) + NSLayoutConstraint.activate(constraints) + } + + private func buildConstraints(_ components: ViewComponents) -> [NSLayoutConstraint] { + var constraints: [NSLayoutConstraint] = [] + + // Title constraints + constraints.append(contentsOf: buildTitleConstraints(components.titleContainer)) + + // Progress view constraints + constraints.append(contentsOf: buildProgressViewConstraints(components)) + + // Footer constraints + constraints.append(contentsOf: buildFooterConstraints(components)) + + return constraints + } + + private func buildTitleConstraints(_ titleContainer: NSView) -> [NSLayoutConstraint] { + return [ + titleContainer.topAnchor.constraint(equalTo: topAnchor, constant: 0), + titleContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + titleContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + titleContainer.heightAnchor.constraint(equalToConstant: Layout.titleHeight) + ] + } + + private func buildProgressViewConstraints(_ components: ViewComponents) -> [NSLayoutConstraint] { + let completionsView = components.progressViews[0] + let chatView = components.progressViews[1] + + var constraints: [NSLayoutConstraint] = [] + + if !isFreeUser { + let premiumView = components.progressViews[2] + constraints.append(contentsOf: buildPremiumProgressConstraints(premiumView, titleContainer: components.titleContainer)) + constraints.append(contentsOf: buildCompletionsProgressConstraints(completionsView, topView: premiumView, isPremiumUnlimited: premiumInteractions.unlimited)) + } else { + constraints.append(contentsOf: buildCompletionsProgressConstraints(completionsView, topView: components.titleContainer, isPremiumUnlimited: false)) + } + + constraints.append(contentsOf: buildChatProgressConstraints(chatView, topView: completionsView)) + + return constraints + } + + private func buildPremiumProgressConstraints(_ premiumView: NSView, titleContainer: NSView) -> [NSLayoutConstraint] { + return [ + premiumView.topAnchor.constraint(equalTo: titleContainer.bottomAnchor, constant: Layout.verticalSpacing), + premiumView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + premiumView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + premiumView.heightAnchor.constraint( + equalToConstant: premiumInteractions.unlimited ? Layout.unlimitedProgressBarHeight : Layout.progressBarHeight + ) + ] + } + + private func buildCompletionsProgressConstraints(_ completionsView: NSView, topView: NSView, isPremiumUnlimited: Bool) -> [NSLayoutConstraint] { + let topSpacing = isPremiumUnlimited ? Layout.unlimitedVerticalSpacing : Layout.verticalSpacing + + return [ + completionsView.topAnchor.constraint(equalTo: topView.bottomAnchor, constant: topSpacing), + completionsView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + completionsView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + completionsView.heightAnchor.constraint( + equalToConstant: completions.unlimited ? Layout.unlimitedProgressBarHeight : Layout.progressBarHeight + ) + ] + } + + private func buildChatProgressConstraints(_ chatView: NSView, topView: NSView) -> [NSLayoutConstraint] { + let topSpacing = completions.unlimited ? Layout.unlimitedVerticalSpacing : Layout.verticalSpacing + + return [ + chatView.topAnchor.constraint(equalTo: topView.bottomAnchor, constant: topSpacing), + chatView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + chatView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + chatView.heightAnchor.constraint( + equalToConstant: chat.unlimited ? Layout.unlimitedProgressBarHeight : Layout.progressBarHeight + ) + ] + } + + private func buildFooterConstraints(_ components: ViewComponents) -> [NSLayoutConstraint] { + let chatView = components.progressViews[1] + let topSpacing = chat.unlimited ? Layout.unlimitedVerticalSpacing : Layout.verticalSpacing + + var constraints = [NSLayoutConstraint]() + + if !isFreeUser { + // Add status message label constraints + constraints.append(contentsOf: [ + components.statusMessageLabel.topAnchor.constraint(equalTo: chatView.bottomAnchor, constant: topSpacing), + components.statusMessageLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + components.statusMessageLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + components.statusMessageLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight) + ]) + + // Add reset text label constraints with status message label as the top anchor + constraints.append(contentsOf: [ + components.resetTextLabel.topAnchor.constraint(equalTo: components.statusMessageLabel.bottomAnchor), + components.resetTextLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + components.resetTextLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + components.resetTextLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight) + ]) + } else { + // For free users, only show reset text label + constraints.append(contentsOf: [ + components.resetTextLabel.topAnchor.constraint(equalTo: chatView.bottomAnchor, constant: topSpacing), + components.resetTextLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + components.resetTextLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + components.resetTextLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight) + ]) + } + + if isOrgUser { + // Do not show link label for business or enterprise users + constraints.append(components.resetTextLabel.bottomAnchor.constraint(equalTo: bottomAnchor)) + return constraints + } + + // Add link label constraints + constraints.append(contentsOf: [ + components.linkLabel.topAnchor.constraint(equalTo: components.resetTextLabel.bottomAnchor), + components.linkLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + components.linkLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + components.linkLabel.heightAnchor.constraint(equalToConstant: Layout.linkLabelHeight), + + components.linkLabel.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + return constraints + } +} + +// MARK: - Actions +extension QuotaView { + @objc private func openCopilotSettings() { + Task { + if let url = URL(string: "https://aka.ms/github-copilot-settings") { + NSWorkspace.shared.open(url) + } + } + } + + @objc private func openCopilotManageOverage() { + Task { + if let url = URL(string: "https://aka.ms/github-copilot-manage-overage") { + NSWorkspace.shared.open(url) + } + } + } +} + +// MARK: - Helper Types +private struct ViewComponents { + let titleContainer: NSView + let progressViews: [NSView] + let statusMessageLabel: NSTextField + let resetTextLabel: NSTextField + let linkLabel: NSButton +} + +// MARK: - Layout Constants +private struct Layout { + static let viewWidth: CGFloat = 256 + static let horizontalMargin: CGFloat = 14 + static let verticalSpacing: CGFloat = 8 + static let unlimitedVerticalSpacing: CGFloat = 6 + static let smallVerticalSpacing: CGFloat = 4 + + static let titleHeight: CGFloat = 20 + static let progressBarHeight: CGFloat = 22 + static let unlimitedProgressBarHeight: CGFloat = 16 + static let footerTextHeight: CGFloat = 16 + static let linkLabelHeight: CGFloat = 16 + + static let settingsButtonSize: CGFloat = 20 + static let settingsButtonHoverSize: CGFloat = 14 + static let settingsButtonSpacing: CGFloat = 8 + + static let progressBarThickness: CGFloat = 3 + static let progressBarCornerRadius: CGFloat = 1.5 + static let progressBarVerticalOffset: CGFloat = -10 + static let percentageLabelMinWidth: CGFloat = 35 + static let percentageLabelSpacing: CGFloat = 8 +} + +// MARK: - Style Constants +private struct Style { + static let labelAlphaValue: CGFloat = 0.85 + static let progressBarBackgroundAlpha: CGFloat = 0.3 + static let buttonAlphaValue: CGFloat = 0.85 + + static let titleFontSize: CGFloat = 11 + static let progressFontSize: CGFloat = 13 + static let percentageFontSize: CGFloat = 11 + static let footerFontSize: CGFloat = 11 +} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift b/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift index f89a90de..80b668fb 100644 --- a/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift +++ b/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift @@ -203,6 +203,7 @@ public final class BatchingFileChangeWatcher: FileChangeWatcher { fsEventProvider.invalidateStream(eventStream) fsEventProvider.releaseStream(eventStream) self.eventStream = nil + isWatching = false Logger.client.info("Stoped watching for file changes in \(watchedPaths)") @@ -267,7 +268,7 @@ public class FileChangeWatcherService { internal var watcher: BatchingFileChangeWatcher? /// for watching projects added or removed private var timer: Timer? - private var projectWatchingInterval: TimeInterval = 3.0 + private var projectWatchingInterval: TimeInterval private(set) public var workspaceURL: URL private(set) public var publisher: PublisherType @@ -280,7 +281,7 @@ public class FileChangeWatcherService { _ workspaceURL: URL, publisher: @escaping PublisherType, publishInterval: TimeInterval = 3.0, - projectWatchingInterval: TimeInterval = 3.0, + projectWatchingInterval: TimeInterval = 300.0, workspaceFileProvider: WorkspaceFileProvider = FileChangeWatcherWorkspaceFileProvider(), watcherFactory: (([URL], @escaping PublisherType) -> BatchingFileChangeWatcher)? = nil ) { @@ -290,6 +291,7 @@ public class FileChangeWatcherService { self.watcherFactory = watcherFactory ?? { projectURLs, publisher in BatchingFileChangeWatcher(watchedPaths: projectURLs, changePublisher: publisher, publishInterval: publishInterval) } + self.projectWatchingInterval = projectWatchingInterval } deinit { From 041a8981f81515771893fabb9435e0fa9d3d4706 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 4 Jun 2025 02:46:05 +0000 Subject: [PATCH 04/26] Pre-release 0.35.121 --- Server/package-lock.json | 8 ++++---- Server/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Server/package-lock.json b/Server/package-lock.json index bd762c6a..c93a25b7 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.327.0", + "@github/copilot-language-server": "^1.328.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,9 +36,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.327.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.327.0.tgz", - "integrity": "sha512-hyFCpyfURQ+NUDUM0QQMB1+Bju38LuaG6jM/1rHc7Limh/yEeobjFNSG4OnR2xi9lC0CwHfwSBii+NYN0WfCLA==", + "version": "1.328.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.328.0.tgz", + "integrity": "sha512-Sy8UBTaTRwg2GE+ZuXBAIGdaYkOfvyGMBdExAEkC+bYUL4mxfq8T2dHD3Zc9jCAbFaEOiiQj63/TOotPjRdCtQ==", "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" diff --git a/Server/package.json b/Server/package.json index b4c461c3..3ae6c11f 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.327.0", + "@github/copilot-language-server": "^1.328.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" From 2e8e989c9942ae89714f0cebceb46fdf46ac10b8 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 4 Jun 2025 09:17:28 +0000 Subject: [PATCH 05/26] Release 0.36.0 --- CHANGELOG.md | 13 +++++++++++++ .../FeatureReducers/ChatPanelFeature.swift | 2 +- ReleaseNotes.md | 19 +++++++++---------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8692d75..a90c89ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.36.0 - June 4, 2025 +### Added +- Introduced a new chat setting "**Response Language**" under **Advanced** settings to customize the natural language used in chat replies. +- Enabled support for custom instructions defined in _.github/copilot-instructions.md_ within your workspace. +- Added support for premium request handling. + +### Fixed +- Performance: Improved UI responsiveness by lazily restoring chat history. +- Performance: Fixed lagging issue when pasting large text into the chat input. +- Performance: Improved project indexing performance. +- Don't trigger / (slash) commands when pasting a file path into the chat input. +- Adjusted terminal text styling to align with Xcode’s theme. + ## 0.35.0 - May 19, 2025 ### Added - Launched Agent Mode. Copilot will automatically use multiple requests to edit files, run terminal commands, and fix errors. diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index cc9e84da..a308600c 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -444,7 +444,7 @@ public struct ChatPanelFeature { let (originalTab, currentTab) = targetWorkspace.switchTab(to: &tab) state.chatHistory.updateHistory(targetWorkspace) - let currentChatWorkspace = chatWorkspace + let currentChatWorkspace = targetWorkspace return .run { send in await send(.saveChatTabInfo([originalTab, currentTab], currentChatWorkspace)) await send(.scheduleLRUCleanup(currentChatWorkspace)) diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 38eb6c14..18e88745 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,19 +1,18 @@ -### GitHub Copilot for Xcode 0.35.0 +### GitHub Copilot for Xcode 0.36.0 **🚀 Highlights** -* **Agent Mode**: Copilot will automatically use multiple requests to edit files, run terminal commands, and fix errors. -* **Model Context Protocol (MCP)**: Integrated with Agent Mode, allowing you to configure MCP tools to extend capabilities. +* Introduced a new chat setting "**Response Language**" under **Advanced** settings to customize the natural language used in chat replies. +* Enabled support for custom instructions defined in _.github/copilot-instructions.md_ within your workspace. +* Added support for premium request handling. **💪 Improvements** -* Added a button to enable/disable referencing current file in conversations -* Added an animated progress icon in the response section -* Refined onboarding experience with updated instruction screens and welcome views -* Improved conversation reliability with extended timeout limits for agent requests +* Performance: Improved UI responsiveness by lazily restoring chat history. +* Performance: Fixed lagging issue when pasting large text into the chat input. +* Performance: Improved project indexing performance. **🛠️ Bug Fixes** -* Addressed critical error handling issues in core functionality -* Resolved UI inconsistencies with chat interface padding adjustments -* Improved network access with automatic detection of system environment variables for custom certificates +* Don't trigger / (slash) commands when pasting a file path into the chat input. +* Adjusted terminal text styling to align with Xcode’s theme. From d3cd006e3c7b366fec801e18a9ce5167a4f7da65 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 13 Jun 2025 06:23:15 +0000 Subject: [PATCH 06/26] Pre-release 0.36.123 --- Core/Package.swift | 3 +- Core/Sources/ChatService/ChatService.swift | 177 ++++++++++++------ .../Skills/CurrentEditorSkill.swift | 13 ++ Core/Sources/ConversationTab/Chat.swift | 8 +- Core/Sources/ConversationTab/ChatPanel.swift | 36 +++- .../ConversationTab/ContextUtils.swift | 11 ++ Core/Sources/ConversationTab/FilePicker.swift | 28 ++- .../ModelPicker/ModelPicker.swift | 144 ++++++++++---- .../ConversationTab/Views/BotMessage.swift | 17 +- .../Views/FunctionMessage.swift | 48 ++--- .../Views/NotificationBanner.swift | 44 +++++ .../AdvancedSettings/ChatSection.swift | 83 +++++++- .../DisabledLanguageList.swift | 27 +-- .../GlobalInstructionsView.swift | 82 ++++++++ .../Extensions/ChatMessage+Storage.swift | 12 +- Core/Sources/Service/XPCService.swift | 23 +++ .../SuggestionPanelContent/WarningPanel.swift | 101 +++++----- Server/package-lock.json | 16 +- Server/package.json | 4 +- .../ChatAPIService/Memory/ChatMemory.swift | 4 +- Tool/Sources/ChatAPIService/Models.swift | 6 +- .../LanguageServer/GitHubCopilotRequest.swift | 10 +- Tool/Sources/Preferences/Keys.swift | 4 + .../Sources/StatusBarItemView/QuotaView.swift | 87 ++++++--- Tool/Sources/SystemUtils/FileUtils.swift | 47 +++++ .../FileChangeWatcher/FileChangeWatcher.swift | 7 +- .../WorkspaceFileProvider.swift | 11 +- Tool/Sources/Workspace/WorkspaceFile.swift | 62 ++++-- .../XPCShared/XPCExtensionService.swift | 26 ++- .../XPCShared/XPCServiceProtocol.swift | 1 + .../XPCShared/XcodeInspectorData.swift | 23 +++ .../XcodeInspector/XcodeInspector.swift | 17 +- .../FileChangeWatcherTests.swift | 9 +- .../Tests/WorkspaceTests/WorkspaceTests.swift | 130 ++++++++++++- 34 files changed, 1036 insertions(+), 285 deletions(-) create mode 100644 Core/Sources/ConversationTab/Views/NotificationBanner.swift create mode 100644 Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift create mode 100644 Tool/Sources/SystemUtils/FileUtils.swift create mode 100644 Tool/Sources/XPCShared/XcodeInspectorData.swift diff --git a/Core/Package.swift b/Core/Package.swift index 5ae7a86c..b367157b 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -180,7 +180,8 @@ let package = Package( .product(name: "ConversationServiceProvider", package: "Tool"), .product(name: "GitHubCopilotService", package: "Tool"), .product(name: "Workspace", package: "Tool"), - .product(name: "Terminal", package: "Tool") + .product(name: "Terminal", package: "Tool"), + .product(name: "SystemUtils", package: "Tool") ]), .testTarget( name: "ChatServiceTests", diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 0162dfa8..0374e6f3 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -14,6 +14,7 @@ import Logger import Workspace import XcodeInspector import OrderedCollections +import SystemUtils public protocol ChatServiceType { var memory: ContextAwareAutoManagedChatMemory { get set } @@ -330,7 +331,7 @@ public final class ChatService: ChatServiceType, ObservableObject { let workDoneToken = UUID().uuidString activeRequestId = workDoneToken - let chatMessage = ChatMessage( + var chatMessage = ChatMessage( id: id, chatTabID: self.chatTabInfo.id, role: .user, @@ -338,14 +339,34 @@ public final class ChatService: ChatServiceType, ObservableObject { references: references.toConversationReferences() ) + let currentEditorSkill = skillSet.first(where: { $0.id == CurrentEditorSkill.ID }) as? CurrentEditorSkill + let currentFileReadability = currentEditorSkill == nil + ? nil + : FileUtils.checkFileReadability(at: currentEditorSkill!.currentFilePath) + var errorMessage: ChatMessage? + + var currentTurnId: String? = turnId // If turnId is provided, it is used to update the existing message, no need to append the user message if turnId == nil { + if let currentFileReadability, !currentFileReadability.isReadable { + // For associating error message with user message + currentTurnId = UUID().uuidString + chatMessage.clsTurnID = currentTurnId + errorMessage = buildErrorMessage( + turnId: currentTurnId!, + errorMessages: [ + currentFileReadability.errorMessage( + using: CurrentEditorSkill.readabilityErrorMessageProvider + ) + ].compactMap { $0 }.filter { !$0.isEmpty } + ) + } await memory.appendMessage(chatMessage) } // reset file edits self.resetFileEdits() - + // persist saveChatMessageToStorage(chatMessage) @@ -370,32 +391,68 @@ public final class ChatService: ChatServiceType, ObservableObject { return } - let skillCapabilities: [String] = [ CurrentEditorSkill.ID, ProblemsInActiveDocumentSkill.ID ] + if let errorMessage { + Task { await memory.appendMessage(errorMessage) } + } + + var activeDoc: Doc? + var validSkillSet: [ConversationSkill] = skillSet + if let currentEditorSkill, currentFileReadability?.isReadable == true { + activeDoc = Doc(uri: currentEditorSkill.currentFile.url.absoluteString) + } else { + validSkillSet.removeAll(where: { $0.id == CurrentEditorSkill.ID || $0.id == ProblemsInActiveDocumentSkill.ID }) + } + + let request = createConversationRequest( + workDoneToken: workDoneToken, + content: content, + activeDoc: activeDoc, + references: references, + model: model, + agentMode: agentMode, + userLanguage: userLanguage, + turnId: currentTurnId, + skillSet: validSkillSet + ) + + self.lastUserRequest = request + self.skillSet = validSkillSet + try await send(request) + } + + private func createConversationRequest( + workDoneToken: String, + content: String, + activeDoc: Doc?, + references: [FileReference], + model: String? = nil, + agentMode: Bool = false, + userLanguage: String? = nil, + turnId: String? = nil, + skillSet: [ConversationSkill] + ) -> ConversationRequest { + let skillCapabilities: [String] = [CurrentEditorSkill.ID, ProblemsInActiveDocumentSkill.ID] let supportedSkills: [String] = skillSet.map { $0.id } let ignoredSkills: [String] = skillCapabilities.filter { !supportedSkills.contains($0) } - let currentEditorSkill = skillSet.first { $0.id == CurrentEditorSkill.ID } - let activeDoc: Doc? = (currentEditorSkill as? CurrentEditorSkill).map { Doc(uri: $0.currentFile.url.absoluteString) } /// replace the `@workspace` to `@project` let newContent = replaceFirstWord(in: content, from: "@workspace", to: "@project") - let request = ConversationRequest(workDoneToken: workDoneToken, - content: newContent, - workspaceFolder: "", - activeDoc: activeDoc, - skills: skillCapabilities, - ignoredSkills: ignoredSkills, - references: references, - model: model, - agentMode: agentMode, - userLanguage: userLanguage, - turnId: turnId + return ConversationRequest( + workDoneToken: workDoneToken, + content: newContent, + workspaceFolder: "", + activeDoc: activeDoc, + skills: skillCapabilities, + ignoredSkills: ignoredSkills, + references: references, + model: model, + agentMode: agentMode, + userLanguage: userLanguage, + turnId: turnId ) - self.lastUserRequest = request - self.skillSet = skillSet - try await send(request) } public func sendAndWait(_ id: String, content: String) async throws -> String { @@ -444,20 +501,16 @@ public final class ChatService: ChatServiceType, ObservableObject { { // TODO: clean up contents for resend message activeRequestId = nil - do { - try await send( - id, - content: lastUserRequest.content, - skillSet: skillSet, - references: lastUserRequest.references ?? [], - model: model != nil ? model : lastUserRequest.model, - agentMode: lastUserRequest.agentMode, - userLanguage: lastUserRequest.userLanguage, - turnId: id - ) - } catch { - print("Failed to resend message") - } + try await send( + id, + content: lastUserRequest.content, + skillSet: skillSet, + references: lastUserRequest.references ?? [], + model: model != nil ? model : lastUserRequest.model, + agentMode: lastUserRequest.agentMode, + userLanguage: lastUserRequest.userLanguage, + turnId: id + ) } } @@ -569,6 +622,19 @@ public final class ChatService: ChatServiceType, ObservableObject { Task { if var lastUserMessage = await memory.history.last(where: { $0.role == .user }) { + + // Case: New conversation where error message was generated before CLS request + // Using clsTurnId to associate this error message with the corresponding user message + // When merging error messages with bot responses from CLS, these properties need to be updated + await memory.mutateHistory { history in + if let existingBotIndex = history.lastIndex(where: { + $0.role == .assistant && $0.clsTurnID == lastUserMessage.clsTurnID + }) { + history[existingBotIndex].id = turnId + history[existingBotIndex].clsTurnID = turnId + } + } + lastUserMessage.clsTurnID = progress.turnId saveChatMessageToStorage(lastUserMessage) } @@ -653,14 +719,9 @@ public final class ChatService: ChatServiceType, ObservableObject { Task { await Status.shared .updateCLSStatus(.warning, busy: false, message: CLSError.message) - let errorMessage = ChatMessage( - id: progress.turnId, - chatTabID: self.chatTabInfo.id, - clsTurnID: progress.turnId, - role: .assistant, - content: "", - panelMessages: [.init(type: .error, title: String(CLSError.code ?? 0), message: CLSError.message, location: .Panel)] - ) + let errorMessage = buildErrorMessage( + turnId: progress.turnId, + panelMessages: [.init(type: .error, title: String(CLSError.code ?? 0), message: CLSError.message, location: .Panel)]) // will persist in resetongoingRequest() await memory.appendMessage(errorMessage) @@ -683,12 +744,9 @@ public final class ChatService: ChatServiceType, ObservableObject { } } else if CLSError.code == 400 && CLSError.message.contains("model is not supported") { Task { - let errorMessage = ChatMessage( - id: progress.turnId, - chatTabID: self.chatTabInfo.id, - role: .assistant, - content: "", - errorMessage: "Oops, the model is not supported. Please enable it first in [GitHub Copilot settings](https://github.com/settings/copilot)." + let errorMessage = buildErrorMessage( + turnId: progress.turnId, + errorMessages: ["Oops, the model is not supported. Please enable it first in [GitHub Copilot settings](https://github.com/settings/copilot)."] ) await memory.appendMessage(errorMessage) resetOngoingRequest() @@ -696,14 +754,7 @@ public final class ChatService: ChatServiceType, ObservableObject { } } else { Task { - let errorMessage = ChatMessage( - id: progress.turnId, - chatTabID: self.chatTabInfo.id, - clsTurnID: progress.turnId, - role: .assistant, - content: "", - errorMessage: CLSError.message - ) + let errorMessage = buildErrorMessage(turnId: progress.turnId, errorMessages: [CLSError.message]) // will persist in resetOngoingRequest() await memory.appendMessage(errorMessage) resetOngoingRequest() @@ -728,6 +779,22 @@ public final class ChatService: ChatServiceType, ObservableObject { } } + private func buildErrorMessage( + turnId: String, + errorMessages: [String] = [], + panelMessages: [CopilotShowMessageParams] = [] + ) -> ChatMessage { + return .init( + id: turnId, + chatTabID: chatTabInfo.id, + clsTurnID: turnId, + role: .assistant, + content: "", + errorMessages: errorMessages, + panelMessages: panelMessages + ) + } + private func resetOngoingRequest() { activeRequestId = nil isReceivingMessage = false diff --git a/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift index 5d9af556..5800820a 100644 --- a/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift +++ b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift @@ -2,6 +2,7 @@ import ConversationServiceProvider import Foundation import GitHubCopilotService import JSONRPC +import SystemUtils public class CurrentEditorSkill: ConversationSkill { public static let ID = "current-editor" @@ -9,6 +10,7 @@ public class CurrentEditorSkill: ConversationSkill { public var id: String { return CurrentEditorSkill.ID } + public var currentFilePath: String { currentFile.url.path } public init( currentFile: FileReference @@ -20,6 +22,17 @@ public class CurrentEditorSkill: ConversationSkill { return params.skillId == self.id } + public static let readabilityErrorMessageProvider: FileUtils.ReadabilityErrorMessageProvider = { status in + switch status { + case .readable: + return nil + case .notFound: + return "Copilot can’t find the current file, so it's not included." + case .permissionDenied: + return "Copilot can't access the current file. Enable \"Files & Folders\" access in [System Settings](x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders)." + } + } + public func resolveSkill(request: ConversationContextRequest, completion: JSONRPCResponseHandler){ let uri: String? = self.currentFile.url.absoluteString completion( diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index abde0b74..5fb327a3 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -24,7 +24,7 @@ public struct DisplayedChatMessage: Equatable { public var references: [ConversationReference] = [] public var followUp: ConversationFollowUp? = nil public var suggestedTitle: String? = nil - public var errorMessage: String? = nil + public var errorMessages: [String] = [] public var steps: [ConversationProgressStep] = [] public var editAgentRounds: [AgentRound] = [] public var panelMessages: [CopilotShowMessageParams] = [] @@ -36,7 +36,7 @@ public struct DisplayedChatMessage: Equatable { references: [ConversationReference] = [], followUp: ConversationFollowUp? = nil, suggestedTitle: String? = nil, - errorMessage: String? = nil, + errorMessages: [String] = [], steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], panelMessages: [CopilotShowMessageParams] = [] @@ -47,7 +47,7 @@ public struct DisplayedChatMessage: Equatable { self.references = references self.followUp = followUp self.suggestedTitle = suggestedTitle - self.errorMessage = errorMessage + self.errorMessages = errorMessages self.steps = steps self.editAgentRounds = editAgentRounds self.panelMessages = panelMessages @@ -371,7 +371,7 @@ struct Chat { }, followUp: message.followUp, suggestedTitle: message.suggestedTitle, - errorMessage: message.errorMessage, + errorMessages: message.errorMessages, steps: message.steps, editAgentRounds: message.editAgentRounds, panelMessages: message.panelMessages diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index 63b99607..cd7c313b 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -13,6 +13,7 @@ import ChatTab import Workspace import HostAppActivator import Persist +import UniformTypeIdentifiers private let r: Double = 8 @@ -58,8 +59,40 @@ public struct ChatPanel: View { .onAppear { chat.send(.appear) } + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + onFileDrop(providers) + } } } + + private func onFileDrop(_ providers: [NSItemProvider]) -> Bool { + for provider in providers { + if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { item, error in + let url: URL? = { + if let data = item as? Data { + return URL(dataRepresentation: data, relativeTo: nil) + } else if let url = item as? URL { + return url + } + return nil + }() + + guard let url, + let isValidFile = try? WorkspaceFile.isValidFile(url), + isValidFile + else { return } + + DispatchQueue.main.async { + let fileReference = FileReference(url: url, isCurrentEditor: false) + chat.send(.addSelectedFile(fileReference)) + } + } + } + } + + return true + } } private struct ScrollViewOffsetPreferenceKey: PreferenceKey { @@ -339,7 +372,7 @@ struct ChatHistoryItem: View { text: text, references: message.references, followUp: message.followUp, - errorMessage: message.errorMessage, + errorMessages: message.errorMessages, chat: chat, steps: message.steps, editAgentRounds: message.editAgentRounds, @@ -476,6 +509,7 @@ struct ChatPanelInputArea: View { if isFilePickerPresented { FilePicker( allFiles: $allFiles, + workspaceURL: chat.workspaceURL, onSubmit: { file in chat.send(.addSelectedFile(file)) }, diff --git a/Core/Sources/ConversationTab/ContextUtils.swift b/Core/Sources/ConversationTab/ContextUtils.swift index 51cab9bf..34f44e7d 100644 --- a/Core/Sources/ConversationTab/ContextUtils.swift +++ b/Core/Sources/ConversationTab/ContextUtils.swift @@ -3,6 +3,7 @@ import XcodeInspector import Foundation import Logger import Workspace +import SystemUtils public struct ContextUtils { @@ -20,4 +21,14 @@ public struct ContextUtils { return files } + + public static let workspaceReadabilityErrorMessageProvider: FileUtils.ReadabilityErrorMessageProvider = { status in + switch status { + case .readable: return nil + case .notFound: + return "Copilot can't access this workspace. It may have been removed or is temporarily unavailable." + case .permissionDenied: + return "Copilot can't access this workspace. Enable \"Files & Folders\" access in [System Settings](x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders)" + } + } } diff --git a/Core/Sources/ConversationTab/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift index f1a00966..338aa9c8 100644 --- a/Core/Sources/ConversationTab/FilePicker.swift +++ b/Core/Sources/ConversationTab/FilePicker.swift @@ -2,9 +2,11 @@ import ComposableArchitecture import ConversationServiceProvider import SharedUIComponents import SwiftUI +import SystemUtils public struct FilePicker: View { @Binding var allFiles: [FileReference] + let workspaceURL: URL? var onSubmit: (_ file: FileReference) -> Void var onExit: () -> Void @FocusState private var isSearchBarFocused: Bool @@ -21,6 +23,30 @@ public struct FilePicker: View { (doc.fileName ?? doc.url.lastPathComponent) .localizedCaseInsensitiveContains(searchText) } } + + private static let defaultEmptyStateText = "No results found." + + private var emptyStateAttributedString: AttributedString? { + var message = FilePicker.defaultEmptyStateText + if let workspaceURL = workspaceURL { + let status = FileUtils.checkFileReadability(at: workspaceURL.path) + if let errorMessage = status.errorMessage(using: ContextUtils.workspaceReadabilityErrorMessageProvider) { + message = errorMessage + } + } + + return try? AttributedString(markdown: message) + } + + private var emptyStateView: some View { + Group { + if let attributedString = emptyStateAttributedString { + Text(attributedString) + } else { + Text(FilePicker.defaultEmptyStateText) + } + } + } public var body: some View { WithPerceptionTracking { @@ -75,7 +101,7 @@ public struct FilePicker: View { } if filteredFiles.isEmpty { - Text("No results found") + emptyStateView .foregroundColor(.secondary) .padding(.leading, 4) .padding(.vertical, 4) diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift index 221500e4..2fceb491 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift @@ -169,6 +169,12 @@ struct LLMModel: Codable, Hashable { let billing: CopilotModelBilling? } +struct ScopeCache { + var modelMultiplierCache: [String: String] = [:] + var cachedMaxWidth: CGFloat = 0 + var lastModelsHash: Int = 0 +} + struct ModelPicker: View { @State private var selectedModel = "" @State private var isHovered = false @@ -178,6 +184,21 @@ struct ModelPicker: View { @State private var chatMode = "Ask" @State private var isAgentPickerHovered = false + + // Separate caches for both scopes + @State private var askScopeCache: ScopeCache = ScopeCache() + @State private var agentScopeCache: ScopeCache = ScopeCache() + + let minimumPadding: Int = 48 + let attributes: [NSAttributedString.Key: NSFont] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] + + var spaceWidth: CGFloat { + "\u{200A}".size(withAttributes: attributes).width + } + + var minimumPaddingWidth: CGFloat { + spaceWidth * CGFloat(minimumPadding) + } init() { let initialModel = AppState.shared.getSelectedModelName() ?? CopilotModelManager.getDefaultChatModel()?.modelName ?? "" @@ -193,6 +214,67 @@ struct ModelPicker: View { AppState.shared.isAgentModeEnabled() ? modelManager.defaultAgentModel : modelManager.defaultChatModel } + // Get the current cache based on scope + var currentCache: ScopeCache { + AppState.shared.isAgentModeEnabled() ? agentScopeCache : askScopeCache + } + + // Helper method to format multiplier text + func formatMultiplierText(for billing: CopilotModelBilling?) -> String { + guard let billingInfo = billing else { return "" } + + let multiplier = billingInfo.multiplier + if multiplier == 0 { + return "Included" + } else { + let numberPart = multiplier.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", multiplier) + : String(format: "%.2f", multiplier) + return "\(numberPart)x" + } + } + + // Update cache for specific scope only if models changed + func updateModelCacheIfNeeded(for scope: PromptTemplateScope) { + let currentModels = scope == .agentPanel ? modelManager.availableAgentModels : modelManager.availableChatModels + let modelsHash = currentModels.hashValue + + if scope == .agentPanel { + guard agentScopeCache.lastModelsHash != modelsHash else { return } + agentScopeCache = buildCache(for: currentModels, currentHash: modelsHash) + } else { + guard askScopeCache.lastModelsHash != modelsHash else { return } + askScopeCache = buildCache(for: currentModels, currentHash: modelsHash) + } + } + + // Build cache for given models + private func buildCache(for models: [LLMModel], currentHash: Int) -> ScopeCache { + var newCache: [String: String] = [:] + var maxWidth: CGFloat = 0 + + for model in models { + let multiplierText = formatMultiplierText(for: model.billing) + newCache[model.modelName] = multiplierText + + let displayName = "✓ \(model.modelName)" + let displayNameWidth = displayName.size(withAttributes: attributes).width + let multiplierWidth = multiplierText.isEmpty ? 0 : multiplierText.size(withAttributes: attributes).width + let totalWidth = displayNameWidth + minimumPaddingWidth + multiplierWidth + maxWidth = max(maxWidth, totalWidth) + } + + if maxWidth == 0 { + maxWidth = selectedModel.size(withAttributes: attributes).width + } + + return ScopeCache( + modelMultiplierCache: newCache, + cachedMaxWidth: maxWidth, + lastModelsHash: currentHash + ) + } + func updateCurrentModel() { selectedModel = AppState.shared.getSelectedModelName() ?? defaultModel?.modelName ?? "" } @@ -215,8 +297,8 @@ struct ModelPicker: View { } } - // Force refresh models self.updateCurrentModel() + updateModelCacheIfNeeded(for: scope) } // Model picker menu component @@ -231,6 +313,10 @@ struct ModelPicker: View { // Display premium models section if available modelSection(title: "Premium Models", models: premiumModels) + + if standardModels.isEmpty { + Link("Add Premium Models", destination: URL(string: "https://aka.ms/github-copilot-upgrade-plan")!) + } } .menuStyle(BorderlessButtonMenuStyle()) .frame(maxWidth: labelWidth()) @@ -264,7 +350,7 @@ struct ModelPicker: View { Text(createModelMenuItemAttributedString( modelName: model.modelName, isSelected: selectedModel == model.modelName, - billing: model.billing + cachedMultiplierText: currentCache.modelMultiplierCache[model.modelName] ?? "" )) } } @@ -290,6 +376,9 @@ struct ModelPicker: View { } .onAppear() { updateCurrentModel() + // Initialize both caches + updateModelCacheIfNeeded(for: .chatPanel) + updateModelCacheIfNeeded(for: .agentPanel) Task { await refreshModels() } @@ -297,8 +386,13 @@ struct ModelPicker: View { .onChange(of: defaultModel) { _ in updateCurrentModel() } - .onChange(of: models) { _ in + .onChange(of: modelManager.availableChatModels) { _ in updateCurrentModel() + updateModelCacheIfNeeded(for: .chatPanel) + } + .onChange(of: modelManager.availableAgentModels) { _ in + updateCurrentModel() + updateModelCacheIfNeeded(for: .agentPanel) } .onChange(of: chatMode) { _ in updateCurrentModel() @@ -310,8 +404,6 @@ struct ModelPicker: View { } func labelWidth() -> CGFloat { - let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) - let attributes = [NSAttributedString.Key.font: font] let width = selectedModel.size(withAttributes: attributes).width return CGFloat(width + 20) } @@ -330,45 +422,29 @@ struct ModelPicker: View { } } - private func createModelMenuItemAttributedString(modelName: String, isSelected: Bool, billing: CopilotModelBilling?) -> AttributedString { + private func createModelMenuItemAttributedString( + modelName: String, + isSelected: Bool, + cachedMultiplierText: String + ) -> AttributedString { let displayName = isSelected ? "✓ \(modelName)" : " \(modelName)" - let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) - let attributes: [NSAttributedString.Key: Any] = [.font: font] - let spaceWidth = "\u{200A}".size(withAttributes: attributes).width - - let targetXPositionForMultiplier: CGFloat = 230 var fullString = displayName var attributedString = AttributedString(fullString) - if let billingInfo = billing { - let multiplier = billingInfo.multiplier - - let effectiveMultiplierText: String - if multiplier == 0 { - effectiveMultiplierText = "Included" - } else { - let numberPart = multiplier.truncatingRemainder(dividingBy: 1) == 0 - ? String(format: "%.0f", multiplier) - : String(format: "%.2f", multiplier) - effectiveMultiplierText = "\(numberPart)x" - } - + if !cachedMultiplierText.isEmpty { let displayNameWidth = displayName.size(withAttributes: attributes).width - let multiplierTextWidth = effectiveMultiplierText.size(withAttributes: attributes).width - let neededPaddingWidth = targetXPositionForMultiplier - displayNameWidth - multiplierTextWidth + let multiplierTextWidth = cachedMultiplierText.size(withAttributes: attributes).width + let neededPaddingWidth = currentCache.cachedMaxWidth - displayNameWidth - multiplierTextWidth + let finalPaddingWidth = max(neededPaddingWidth, minimumPaddingWidth) - if neededPaddingWidth > 0 { - let numberOfSpaces = Int(round(neededPaddingWidth / spaceWidth)) - let padding = String(repeating: "\u{200A}", count: max(0, numberOfSpaces)) - fullString = "\(displayName)\(padding)\(effectiveMultiplierText)" - } else { - fullString = "\(displayName) \(effectiveMultiplierText)" - } + let numberOfSpaces = Int(round(finalPaddingWidth / spaceWidth)) + let padding = String(repeating: "\u{200A}", count: max(minimumPadding, numberOfSpaces)) + fullString = "\(displayName)\(padding)\(cachedMultiplierText)" attributedString = AttributedString(fullString) - if let range = attributedString.range(of: effectiveMultiplierText) { + if let range = attributedString.range(of: cachedMultiplierText) { attributedString[range].foregroundColor = .secondary } } diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index a6c0d8f5..2f0bf835 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -14,7 +14,7 @@ struct BotMessage: View { let text: String let references: [ConversationReference] let followUp: ConversationFollowUp? - let errorMessage: String? + let errorMessages: [String] let chat: StoreOf let steps: [ConversationProgressStep] let editAgentRounds: [AgentRound] @@ -154,10 +154,15 @@ struct BotMessage: View { ThemedMarkdownText(text: text, chat: chat) } - if errorMessage != nil { - HStack(spacing: 4) { - Image(systemName: "info.circle") - ThemedMarkdownText(text: errorMessage!, chat: chat) + if !errorMessages.isEmpty { + VStack(spacing: 4) { + ForEach(errorMessages.indices, id: \.self) { index in + if let attributedString = try? AttributedString(markdown: errorMessages[index]) { + NotificationBanner(style: .warning) { + Text(attributedString) + } + } + } } } @@ -330,7 +335,7 @@ struct BotMessage_Previews: PreviewProvider { kind: .class ), count: 2), followUp: ConversationFollowUp(message: "followup question", id: "id", type: "type"), - errorMessage: "Sorry, an error occurred while generating a response.", + errorMessages: ["Sorry, an error occurred while generating a response."], chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }), steps: steps, editAgentRounds: agentRounds, diff --git a/Core/Sources/ConversationTab/Views/FunctionMessage.swift b/Core/Sources/ConversationTab/Views/FunctionMessage.swift index ae9cf2c9..8fbd6ac9 100644 --- a/Core/Sources/ConversationTab/Views/FunctionMessage.swift +++ b/Core/Sources/ConversationTab/Views/FunctionMessage.swift @@ -62,41 +62,25 @@ struct FunctionMessage: View { } var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 6) { - Image(systemName: "exclamationmark.triangle") - .font(Font.system(size: 12)) - .foregroundColor(.orange) - - VStack(alignment: .leading, spacing: 8) { - errorContent - - if isFreePlanUser { - Button("Update to Copilot Pro") { - if let url = URL(string: "https://aka.ms/github-copilot-upgrade-plan") { - openURL(url) - } - } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - .onHover { isHovering in - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } + NotificationBanner(style: .warning) { + errorContent + + if isFreePlanUser { + Button("Update to Copilot Pro") { + if let url = URL(string: "https://aka.ms/github-copilot-upgrade-plan") { + openURL(url) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .onHover { isHovering in + if isHovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() } } } - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(.vertical, 10) - .padding(.horizontal, 12) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) - .padding(.vertical, 4) } } } diff --git a/Core/Sources/ConversationTab/Views/NotificationBanner.swift b/Core/Sources/ConversationTab/Views/NotificationBanner.swift new file mode 100644 index 00000000..68c40d57 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/NotificationBanner.swift @@ -0,0 +1,44 @@ +import SwiftUI + +public enum BannerStyle { + case warning + + var iconName: String { + switch self { + case .warning: return "exclamationmark.triangle" + } + } + + var color: Color { + switch self { + case .warning: return .orange + } + } +} + +struct NotificationBanner: View { + var style: BannerStyle + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 6) { + Image(systemName: style.iconName) + .font(Font.system(size: 12)) + .foregroundColor(style.color) + + VStack(alignment: .leading, spacing: 8) { + content() + } + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .padding(.vertical, 4) + } +} diff --git a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift index 0ce7a006..e9935b00 100644 --- a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift @@ -1,5 +1,8 @@ -import SwiftUI +import Client import ComposableArchitecture +import SwiftUI +import Toast +import XcodeInspector struct ChatSection: View { var body: some View { @@ -7,8 +10,15 @@ struct ChatSection: View { VStack(spacing: 10) { // Response language picker ResponseLanguageSetting() + .padding(.horizontal, 10) + + Divider() + + // Custom instructions + CustomInstructionSetting() + .padding(.horizontal, 10) } - .padding(10) + .padding(.vertical, 10) } } } @@ -73,6 +83,75 @@ struct ResponseLanguageSetting: View { } } +struct CustomInstructionSetting: View { + @State var isGlobalInstructionsViewOpen = false + @Environment(\.toast) var toast + + var body: some View { + WithPerceptionTracking { + HStack { + VStack(alignment: .leading) { + Text("Custom Instructions") + .font(.body) + Text("Configure custom instructions for GitHub Copilot to follow during chat sessions.") + .font(.footnote) + } + + Spacer() + + Button("Current Workspace") { + openCustomInstructions() + } + + Button("Global") { + isGlobalInstructionsViewOpen = true + } + } + .sheet(isPresented: $isGlobalInstructionsViewOpen) { + GlobalInstructionsView(isOpen: $isGlobalInstructionsViewOpen) + } + } + } + + func openCustomInstructions() { + Task { + let service = try? getService() + let inspectorData = try? await service?.getXcodeInspectorData() + var currentWorkspace: URL? = nil + if let url = inspectorData?.realtimeActiveWorkspaceURL, let workspaceURL = URL(string: url), workspaceURL.path != "/" { + currentWorkspace = workspaceURL + } else if let url = inspectorData?.latestNonRootWorkspaceURL { + currentWorkspace = URL(string: url) + } + + // Open custom instructions for the current workspace + if let workspaceURL = currentWorkspace, let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(workspaceURL: workspaceURL, documentURL: nil) { + + let configFile = projectURL.appendingPathComponent(".github/copilot-instructions.md") + + // If the file doesn't exist, create one with a proper structure + if !FileManager.default.fileExists(atPath: configFile.path) { + do { + // Create directory if it doesn't exist + try FileManager.default.createDirectory( + at: projectURL.appendingPathComponent(".github"), + withIntermediateDirectories: true + ) + // Create empty file + try "".write(to: configFile, atomically: true, encoding: .utf8) + } catch { + toast("Failed to create config file .github/copilot-instructions.md: \(error)", .error) + } + } + + if FileManager.default.fileExists(atPath: configFile.path) { + NSWorkspace.shared.open(configFile) + } + } + } + } +} + #Preview { ChatSection() .frame(width: 600) diff --git a/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift index cec78edc..d869b9ca 100644 --- a/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift +++ b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift @@ -33,19 +33,24 @@ struct DisabledLanguageList: View { var body: some View { VStack(spacing: 0) { - HStack { - Button(action: { - self.isOpen.wrappedValue = false - }) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - .padding() + ZStack(alignment: .topLeading) { + Rectangle().fill(Color(nsColor: .separatorColor)).frame(height: 28) + + HStack { + Button(action: { + self.isOpen.wrappedValue = false + }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + Text("Disabled Languages") + .font(.system(size: 13, weight: .bold)) + Spacer() } - .buttonStyle(.plain) - Text("Disabled Languages") - Spacer() + .frame(height: 28) } - .background(Color(nsColor: .separatorColor)) List { ForEach( diff --git a/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift b/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift new file mode 100644 index 00000000..9b763ade --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift @@ -0,0 +1,82 @@ +import Client +import SwiftUI +import Toast + +struct GlobalInstructionsView: View { + var isOpen: Binding + @State var initValue: String = "" + @AppStorage(\.globalCopilotInstructions) var globalInstructions: String + @Environment(\.toast) var toast + + init(isOpen: Binding) { + self.isOpen = isOpen + self.initValue = globalInstructions + } + + var body: some View { + VStack(spacing: 0) { + ZStack(alignment: .topLeading) { + Rectangle().fill(Color(nsColor: .separatorColor)).frame(height: 28) + + HStack { + Button(action: { + self.isOpen.wrappedValue = false + }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + Text("Global Copilot Instructions") + .font(.system(size: 13, weight: .bold)) + Spacer() + } + .frame(height: 28) + } + + ZStack(alignment: .topLeading) { + TextEditor(text: $globalInstructions) + .font(.body) + + if globalInstructions.isEmpty { + Text("Type your global instructions here...") + .foregroundColor(Color(nsColor: .placeholderTextColor)) + .font(.body) + .allowsHitTesting(false) + } + } + .padding(8) + .background(Color(nsColor: .textBackgroundColor)) + } + .focusable(false) + .frame(width: 300, height: 400) + .onAppear() { + self.initValue = globalInstructions + } + .onDisappear(){ + self.isOpen.wrappedValue = false + if globalInstructions != initValue { + refreshConfiguration() + } + } + } + + func refreshConfiguration() { + NotificationCenter.default.post( + name: .gitHubCopilotShouldRefreshEditorInformation, + object: nil + ) + Task { + let service = try getService() + do { + // Notify extension service process to refresh all its CLS subprocesses to apply new configuration + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + } catch { + toast(error.localizedDescription, .error) + } + } + } +} diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift index d5506e43..77d91bb0 100644 --- a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift +++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift @@ -12,7 +12,7 @@ extension ChatMessage { var references: [ConversationReference] var followUp: ConversationFollowUp? var suggestedTitle: String? - var errorMessage: String? + var errorMessages: [String] = [] var steps: [ConversationProgressStep] var editAgentRounds: [AgentRound] var panelMessages: [CopilotShowMessageParams] @@ -25,7 +25,7 @@ extension ChatMessage { references = try container.decode([ConversationReference].self, forKey: .references) followUp = try container.decodeIfPresent(ConversationFollowUp.self, forKey: .followUp) suggestedTitle = try container.decodeIfPresent(String.self, forKey: .suggestedTitle) - errorMessage = try container.decodeIfPresent(String.self, forKey: .errorMessage) + errorMessages = try container.decodeIfPresent([String].self, forKey: .errorMessages) ?? [] steps = try container.decodeIfPresent([ConversationProgressStep].self, forKey: .steps) ?? [] editAgentRounds = try container.decodeIfPresent([AgentRound].self, forKey: .editAgentRounds) ?? [] panelMessages = try container.decodeIfPresent([CopilotShowMessageParams].self, forKey: .panelMessages) ?? [] @@ -38,7 +38,7 @@ extension ChatMessage { references: [ConversationReference], followUp: ConversationFollowUp?, suggestedTitle: String?, - errorMessage: String?, + errorMessages: [String] = [], steps: [ConversationProgressStep]?, editAgentRounds: [AgentRound]? = nil, panelMessages: [CopilotShowMessageParams]? = nil @@ -48,7 +48,7 @@ extension ChatMessage { self.references = references self.followUp = followUp self.suggestedTitle = suggestedTitle - self.errorMessage = errorMessage + self.errorMessages = errorMessages self.steps = steps ?? [] self.editAgentRounds = editAgentRounds ?? [] self.panelMessages = panelMessages ?? [] @@ -62,7 +62,7 @@ extension ChatMessage { references: self.references, followUp: self.followUp, suggestedTitle: self.suggestedTitle, - errorMessage: self.errorMessage, + errorMessages: self.errorMessages, steps: self.steps, editAgentRounds: self.editAgentRounds, panelMessages: self.panelMessages @@ -93,7 +93,7 @@ extension ChatMessage { references: turnItemData.references, followUp: turnItemData.followUp, suggestedTitle: turnItemData.suggestedTitle, - errorMessage: turnItemData.errorMessage, + errorMessages: turnItemData.errorMessages, rating: turnItemData.rating, steps: turnItemData.steps, editAgentRounds: turnItemData.editAgentRounds, diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 9327d8f6..1f4ce005 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -7,6 +7,7 @@ import Preferences import Status import XPCShared import HostAppActivator +import XcodeInspector public class XPCService: NSObject, XPCServiceProtocol { // MARK: - Service @@ -239,6 +240,28 @@ public class XPCService: NSObject, XPCServiceProtocol { reply: reply ) } + + // MARK: - XcodeInspector + + public func getXcodeInspectorData(withReply reply: @escaping (Data?, Error?) -> Void) { + do { + // Capture current XcodeInspector data + let inspectorData = XcodeInspectorData( + activeWorkspaceURL: XcodeInspector.shared.activeWorkspaceURL?.absoluteString, + activeProjectRootURL: XcodeInspector.shared.activeProjectRootURL?.absoluteString, + realtimeActiveWorkspaceURL: XcodeInspector.shared.realtimeActiveWorkspaceURL?.absoluteString, + realtimeActiveProjectURL: XcodeInspector.shared.realtimeActiveProjectURL?.absoluteString, + latestNonRootWorkspaceURL: XcodeInspector.shared.latestNonRootWorkspaceURL?.absoluteString + ) + + // Encode and send the data + let data = try JSONEncoder().encode(inspectorData) + reply(data, nil) + } catch { + Logger.service.error("Failed to encode XcodeInspector data: \(error.localizedDescription)") + reply(nil, error) + } + } } struct NoAccessToAccessibilityAPIError: Error, LocalizedError { diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift index f6c429c2..c06a915a 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift @@ -1,6 +1,7 @@ import SwiftUI import SharedUIComponents import XcodeInspector +import ComposableArchitecture struct WarningPanel: View { let message: String @@ -17,62 +18,64 @@ struct WarningPanel: View { } 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) + WithPerceptionTracking { + 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() - 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)!) + 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(.plain) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(Color(nsColor: .controlAccentColor)) - .foregroundColor(Color(nsColor: .white)) - .cornerRadius(6) + .buttonStyle(.bordered) .font(.system(size: 12)) + .keyboardShortcut(.escape, modifiers: []) .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 + .padding(.top, 24) + .padding( + .leading, + firstLineIndent + 20 + CGFloat( + cursorPositionTracker.cursorPosition.character + ) ) - ) - .background(.clear) + .background(.clear) + } } } } diff --git a/Server/package-lock.json b/Server/package-lock.json index c93a25b7..aae308f1 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.328.0", + "@github/copilot-language-server": "^1.334.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -21,7 +21,7 @@ "terser-webpack-plugin": "^5.3.14", "ts-loader": "^9.5.2", "typescript": "^5.8.3", - "webpack": "^5.99.8", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1" } }, @@ -36,9 +36,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.328.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.328.0.tgz", - "integrity": "sha512-Sy8UBTaTRwg2GE+ZuXBAIGdaYkOfvyGMBdExAEkC+bYUL4mxfq8T2dHD3Zc9jCAbFaEOiiQj63/TOotPjRdCtQ==", + "version": "1.334.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.334.0.tgz", + "integrity": "sha512-VDFaG1ULdBSuyqXhidr9iVA5e9YfUzmDrRobnIBMYBdnhkqH+hCSRui/um6E8KB5EEiGbSLi6qA5XBnCCibJ0w==", "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" @@ -1953,9 +1953,9 @@ } }, "node_modules/webpack": { - "version": "5.99.8", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz", - "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", + "version": "5.99.9", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.9.tgz", + "integrity": "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/Server/package.json b/Server/package.json index 3ae6c11f..d5c061cf 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.328.0", + "@github/copilot-language-server": "^1.334.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -20,7 +20,7 @@ "terser-webpack-plugin": "^5.3.14", "ts-loader": "^9.5.2", "typescript": "^5.8.3", - "webpack": "^5.99.8", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1" } } diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift index eb320ba9..bde4a954 100644 --- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift @@ -49,9 +49,7 @@ extension ChatMessage { self.suggestedTitle = message.suggestedTitle ?? self.suggestedTitle // merge error message - if let errorMessage = message.errorMessage { - self.errorMessage = (self.errorMessage ?? "") + errorMessage - } + self.errorMessages = self.errorMessages + message.errorMessages self.panelMessages = self.panelMessages + message.panelMessages diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index 2afea171..7f1697f4 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -99,7 +99,7 @@ public struct ChatMessage: Equatable, Codable { public var suggestedTitle: String? /// The error occurred during responding chat in server - public var errorMessage: String? + public var errorMessages: [String] /// The steps of conversation progress public var steps: [ConversationProgressStep] @@ -121,7 +121,7 @@ public struct ChatMessage: Equatable, Codable { references: [ConversationReference] = [], followUp: ConversationFollowUp? = nil, suggestedTitle: String? = nil, - errorMessage: String? = nil, + errorMessages: [String] = [], rating: ConversationRating = .unrated, steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], @@ -137,7 +137,7 @@ public struct ChatMessage: Equatable, Codable { self.references = references self.followUp = followUp self.suggestedTitle = suggestedTitle - self.errorMessage = errorMessage + self.errorMessages = errorMessages self.rating = rating self.steps = steps self.editAgentRounds = editAgentRounds diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index f454ce26..b5b15e50 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -87,14 +87,20 @@ public func editorConfiguration() -> JSONValue { let mcpConfig = UserDefaults.shared.value(for: \.gitHubCopilotMCPConfig) return JSONValue.string(mcpConfig) } - + + var customInstructions: JSONValue? { + let instructions = UserDefaults.shared.value(for: \.globalCopilotInstructions) + return .string(instructions) + } + var d: [String: JSONValue] = [:] if let http { d["http"] = http } if let authProvider { d["github-enterprise"] = authProvider } - if let mcp { + if mcp != nil || customInstructions != nil { var github: [String: JSONValue] = [:] var copilot: [String: JSONValue] = [:] copilot["mcp"] = mcp + copilot["globalCopilotInstructions"] = customInstructions github["copilot"] = .hash(copilot) d["github"] = .hash(github) } diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 79e7e84e..1b3b734c 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -299,6 +299,10 @@ public extension UserDefaultPreferenceKeys { var chatResponseLocale: PreferenceKey { .init(defaultValue: "en", key: "ChatResponseLocale") } + + var globalCopilotInstructions: PreferenceKey { + .init(defaultValue: "", key: "GlobalCopilotInstructions") + } } // MARK: - Theme diff --git a/Tool/Sources/StatusBarItemView/QuotaView.swift b/Tool/Sources/StatusBarItemView/QuotaView.swift index 780dcc3e..f1b2d1d3 100644 --- a/Tool/Sources/StatusBarItemView/QuotaView.swift +++ b/Tool/Sources/StatusBarItemView/QuotaView.swift @@ -32,6 +32,14 @@ public class QuotaView: NSView { return copilotPlan == "business" || copilotPlan == "enterprise" } + private var isFreeQuotaUsedUp: Bool { + return chat.percentRemaining == 0 && completions.percentRemaining == 0 + } + + private var isFreeQuotaRemaining: Bool { + return chat.percentRemaining > 25 && completions.percentRemaining > 25 + } + // MARK: - Initialization public init( chat: QuotaSnapshot, @@ -78,7 +86,7 @@ public class QuotaView: NSView { progressViews: createProgressViews(), statusMessageLabel: createStatusMessageLabel(), resetTextLabel: createResetTextLabel(), - linkLabel: createLinkLabel() + upsellLabel: createUpsellLabel() ) } @@ -89,8 +97,8 @@ public class QuotaView: NSView { addSubview(components.statusMessageLabel) } addSubview(components.resetTextLabel) - if !isOrgUser { - addSubview(components.linkLabel) + if !(isOrgUser || (isFreeUser && isFreeQuotaRemaining)) { + addSubview(components.upsellLabel) } } } @@ -323,8 +331,8 @@ extension QuotaView { // MARK: - Footer Section extension QuotaView { private func createStatusMessageLabel() -> NSTextField { - let message = premiumInteractions.overagePermitted ? - "Additional paid premium requests enabled." : + let message = premiumInteractions.overagePermitted ? + "Additional paid premium requests enabled." : "Additional paid premium requests disabled." let label = NSTextField(labelWithString: isFreeUser ? "" : message) @@ -358,18 +366,40 @@ extension QuotaView { return label } - private func createLinkLabel() -> HoverButton { - let button = HoverButton() - let title = isFreeUser ? "Upgrade to Copilot Pro" : "Manage paid premium requests" - - button.setLinkStyle(title: title, fontSize: Style.footerFontSize) - button.translatesAutoresizingMaskIntoConstraints = false - button.alphaValue = Style.labelAlphaValue - button.alignment = .left - button.target = self - button.action = #selector(openCopilotManageOverage) - - return button + private func createUpsellLabel() -> NSButton { + if isFreeUser { + let button = NSButton() + let upgradeTitle = "Upgrade to Copilot Pro" + + button.translatesAutoresizingMaskIntoConstraints = false + button.bezelStyle = .push + if isFreeQuotaUsedUp { + button.attributedTitle = NSAttributedString( + string: upgradeTitle, + attributes: [.foregroundColor: NSColor.white] + ) + button.bezelColor = .controlAccentColor + } else { + button.title = upgradeTitle + } + button.controlSize = .large + button.target = self + button.action = #selector(openCopilotUpgradePlan) + + return button + } else { + let button = HoverButton() + let title = "Manage paid premium requests" + + button.setLinkStyle(title: title, fontSize: Style.footerFontSize) + button.translatesAutoresizingMaskIntoConstraints = false + button.alphaValue = Style.labelAlphaValue + button.alignment = .left + button.target = self + button.action = #selector(openCopilotManageOverage) + + return button + } } } @@ -492,7 +522,7 @@ extension QuotaView { ]) } - if isOrgUser { + if isOrgUser || (isFreeUser && isFreeQuotaRemaining) { // Do not show link label for business or enterprise users constraints.append(components.resetTextLabel.bottomAnchor.constraint(equalTo: bottomAnchor)) return constraints @@ -500,12 +530,12 @@ extension QuotaView { // Add link label constraints constraints.append(contentsOf: [ - components.linkLabel.topAnchor.constraint(equalTo: components.resetTextLabel.bottomAnchor), - components.linkLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), - components.linkLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), - components.linkLabel.heightAnchor.constraint(equalToConstant: Layout.linkLabelHeight), + components.upsellLabel.topAnchor.constraint(equalTo: components.resetTextLabel.bottomAnchor), + components.upsellLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + components.upsellLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + components.upsellLabel.heightAnchor.constraint(equalToConstant: isFreeUser ? Layout.upgradeButtonHeight : Layout.linkLabelHeight), - components.linkLabel.bottomAnchor.constraint(equalTo: bottomAnchor) + components.upsellLabel.bottomAnchor.constraint(equalTo: bottomAnchor) ]) return constraints @@ -529,6 +559,14 @@ extension QuotaView { } } } + + @objc private func openCopilotUpgradePlan() { + Task { + if let url = URL(string: "https://aka.ms/github-copilot-upgrade-plan") { + NSWorkspace.shared.open(url) + } + } + } } // MARK: - Helper Types @@ -537,7 +575,7 @@ private struct ViewComponents { let progressViews: [NSView] let statusMessageLabel: NSTextField let resetTextLabel: NSTextField - let linkLabel: NSButton + let upsellLabel: NSButton } // MARK: - Layout Constants @@ -553,6 +591,7 @@ private struct Layout { static let unlimitedProgressBarHeight: CGFloat = 16 static let footerTextHeight: CGFloat = 16 static let linkLabelHeight: CGFloat = 16 + static let upgradeButtonHeight: CGFloat = 40 static let settingsButtonSize: CGFloat = 20 static let settingsButtonHoverSize: CGFloat = 14 diff --git a/Tool/Sources/SystemUtils/FileUtils.swift b/Tool/Sources/SystemUtils/FileUtils.swift new file mode 100644 index 00000000..0af7e34e --- /dev/null +++ b/Tool/Sources/SystemUtils/FileUtils.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct FileUtils{ + public typealias ReadabilityErrorMessageProvider = (ReadabilityStatus) -> String? + + public enum ReadabilityStatus { + case readable + case notFound + case permissionDenied + + public var isReadable: Bool { + switch self { + case .readable: true + case .notFound, .permissionDenied: false + } + } + + public func errorMessage(using provider: ReadabilityErrorMessageProvider? = nil) -> String? { + if let provider = provider { + return provider(self) + } + + // Default error messages + switch self { + case .readable: + return nil + case .notFound: + return "File may have been removed or is unavailable." + case .permissionDenied: + return "Permission Denied to access file." + } + } + } + + public static func checkFileReadability(at path: String) -> ReadabilityStatus { + let fileManager = FileManager.default + if fileManager.fileExists(atPath: path) { + if fileManager.isReadableFile(atPath: path) { + return .readable + } else { + return .permissionDenied + } + } else { + return .notFound + } + } +} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift b/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift index 80b668fb..9bcc6cf4 100644 --- a/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift +++ b/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift @@ -309,7 +309,7 @@ public class FileChangeWatcherService { guard let self, let watcher = self.watcher else { return } let watchingProjects = Set(watcher.paths) - let projects = Set(self.workspaceFileProvider.getSubprojectURLs(in: self.workspaceURL)) + let projects = Set(self.workspaceFileProvider.getProjects(by: self.workspaceURL)) /// find added projects let addedProjects = projects.subtracting(watchingProjects) @@ -326,8 +326,9 @@ public class FileChangeWatcherService { guard workspaceURL.path != "/" else { return } guard watcher == nil else { return } - - let projects = workspaceFileProvider.getSubprojectURLs(in: workspaceURL) + + let projects = workspaceFileProvider.getProjects(by: workspaceURL) + guard projects.count > 0 else { return } watcher = watcherFactory(projects, publisher) Logger.client.info("Started watching for file changes in \(projects)") diff --git a/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift b/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift index 65a3c56b..76a1a00f 100644 --- a/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift +++ b/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift @@ -1,8 +1,9 @@ -import Foundation import ConversationServiceProvider +import CopilotForXcodeKit +import Foundation public protocol WorkspaceFileProvider { - func getSubprojectURLs(in workspaceURL: URL) -> [URL] + func getProjects(by workspaceURL: URL) -> [URL] func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference] func isXCProject(_ url: URL) -> Bool func isXCWorkspace(_ url: URL) -> Bool @@ -11,8 +12,10 @@ public protocol WorkspaceFileProvider { public class FileChangeWatcherWorkspaceFileProvider: WorkspaceFileProvider { public init() {} - public func getSubprojectURLs(in workspaceURL: URL) -> [URL] { - return WorkspaceFile.getSubprojectURLs(in: workspaceURL) + public func getProjects(by workspaceURL: URL) -> [URL] { + guard let workspaceInfo = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) + else { return [] } + return WorkspaceFile.getProjects(workspace: workspaceInfo).map { URL(fileURLWithPath: $0.uri) } } public func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference] { diff --git a/Tool/Sources/Workspace/WorkspaceFile.swift b/Tool/Sources/Workspace/WorkspaceFile.swift index ce3eb64c..dc129624 100644 --- a/Tool/Sources/Workspace/WorkspaceFile.swift +++ b/Tool/Sources/Workspace/WorkspaceFile.swift @@ -21,6 +21,13 @@ public struct ProjectInfo { public let name: String } +extension NSError { + var isPermissionDenied: Bool { + return (domain == NSCocoaErrorDomain && code == 257) || + (domain == NSPOSIXErrorDomain && code == 1) + } +} + public struct WorkspaceFile { static func isXCWorkspace(_ url: URL) -> Bool { @@ -83,8 +90,12 @@ public struct WorkspaceFile { do { let data = try Data(contentsOf: workspaceFile) return getSubprojectURLs(workspaceURL: workspaceURL, data: data) - } catch { - Logger.client.error("Failed to read workspace file at \(workspaceFile.path): \(error)") + } catch let error as NSError { + if error.isPermissionDenied { + Logger.client.info("Permission denied for accessing file at \(workspaceFile.path)") + } else { + Logger.client.error("Failed to read workspace file at \(workspaceFile.path): \(error)") + } return [] } } @@ -131,6 +142,35 @@ public struct WorkspaceFile { } return name } + + private static func shouldSkipFile(_ url: URL) -> Bool { + return matchesPatterns(url, patterns: skipPatterns) + || isXCWorkspace(url) + || isXCProject(url) + } + + public static func isValidFile( + _ url: URL, + shouldExcludeFile: ((URL) -> Bool)? = nil + ) throws -> Bool { + if shouldSkipFile(url) { return false } + + let resourceValues = try url.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) + + // Handle directories if needed + if resourceValues.isDirectory == true { return false } + + guard resourceValues.isRegularFile == true else { return false } + if supportedFileExtensions.contains(url.pathExtension.lowercased()) == false { + return false + } + + // Apply the custom file exclusion check if provided + if let shouldExcludeFile = shouldExcludeFile, + shouldExcludeFile(url) { return false } + + return true + } public static func getFilesInActiveWorkspace( workspaceURL: URL, @@ -159,26 +199,12 @@ public struct WorkspaceFile { while let fileURL = enumerator?.nextObject() as? URL { // Skip items matching the specified pattern - if matchesPatterns(fileURL, patterns: skipPatterns) || isXCWorkspace(fileURL) || - isXCProject(fileURL) { + if shouldSkipFile(fileURL) { 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 - } - - // Apply the custom file exclusion check if provided - if let shouldExcludeFile = shouldExcludeFile, - shouldExcludeFile(fileURL) { continue } + guard try isValidFile(fileURL, shouldExcludeFile: shouldExcludeFile) else { continue } let relativePath = fileURL.path.replacingOccurrences(of: workspaceRootURL.path, with: "") let fileName = fileURL.lastPathComponent diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index b5309612..9319045a 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -322,5 +322,29 @@ extension XPCExtensionService { } } } -} + @XPCServiceActor + public func getXcodeInspectorData() async throws -> XcodeInspectorData { + return try await withXPCServiceConnected { + service, continuation in + service.getXcodeInspectorData { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.reject(NoDataError()) + return + } + + do { + let inspectorData = try JSONDecoder().decode(XcodeInspectorData.self, from: data) + continuation.resume(inspectorData) + } catch { + continuation.reject(error) + } + } + } + } +} diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift index 00bf4307..803e2502 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -55,6 +55,7 @@ public protocol XPCServiceProtocol { func getXPCServiceVersion(withReply reply: @escaping (String, String) -> Void) func getXPCServiceAccessibilityPermission(withReply reply: @escaping (ObservedAXStatus) -> Void) func getXPCServiceExtensionPermission(withReply reply: @escaping (ExtensionPermissionStatus) -> Void) + func getXcodeInspectorData(withReply reply: @escaping (Data?, Error?) -> Void) func postNotification(name: String, withReply reply: @escaping () -> Void) func send(endpoint: String, requestBody: Data, reply: @escaping (Data?, Error?) -> Void) func quit(reply: @escaping () -> Void) diff --git a/Tool/Sources/XPCShared/XcodeInspectorData.swift b/Tool/Sources/XPCShared/XcodeInspectorData.swift new file mode 100644 index 00000000..defe76b4 --- /dev/null +++ b/Tool/Sources/XPCShared/XcodeInspectorData.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct XcodeInspectorData: Codable { + public let activeWorkspaceURL: String? + public let activeProjectRootURL: String? + public let realtimeActiveWorkspaceURL: String? + public let realtimeActiveProjectURL: String? + public let latestNonRootWorkspaceURL: String? + + public init( + activeWorkspaceURL: String?, + activeProjectRootURL: String?, + realtimeActiveWorkspaceURL: String?, + realtimeActiveProjectURL: String?, + latestNonRootWorkspaceURL: String? + ) { + self.activeWorkspaceURL = activeWorkspaceURL + self.activeProjectRootURL = activeProjectRootURL + self.realtimeActiveWorkspaceURL = realtimeActiveWorkspaceURL + self.realtimeActiveProjectURL = realtimeActiveProjectURL + self.latestNonRootWorkspaceURL = latestNonRootWorkspaceURL + } +} diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 65ad5170..2b2ea1e8 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -55,6 +55,7 @@ public final class XcodeInspector: ObservableObject { @Published public fileprivate(set) var focusedEditor: SourceEditor? @Published public fileprivate(set) var focusedElement: AXUIElement? @Published public fileprivate(set) var completionPanel: AXUIElement? + @Published public fileprivate(set) var latestNonRootWorkspaceURL: URL? = nil /// Get the content of the source editor. /// @@ -136,6 +137,7 @@ public final class XcodeInspector: ObservableObject { focusedEditor = nil focusedElement = nil completionPanel = nil + latestNonRootWorkspaceURL = nil } let runningApplications = NSWorkspace.shared.runningApplications @@ -283,6 +285,7 @@ public final class XcodeInspector: ObservableObject { activeProjectRootURL = xcode.projectRootURL activeWorkspaceURL = xcode.workspaceURL focusedWindow = xcode.focusedWindow + storeLatestNonRootWorkspaceURL(xcode.workspaceURL) // Add this call let setFocusedElement = { @XcodeInspectorActor [weak self] in guard let self else { return } @@ -360,7 +363,10 @@ public final class XcodeInspector: ObservableObject { }.store(in: &activeXcodeCancellable) xcode.$workspaceURL.sink { [weak self] url in - Task { @XcodeInspectorActor in self?.activeWorkspaceURL = url } + Task { @XcodeInspectorActor in + self?.activeWorkspaceURL = url + self?.storeLatestNonRootWorkspaceURL(url) + } }.store(in: &activeXcodeCancellable) xcode.$projectRootURL.sink { [weak self] url in @@ -415,5 +421,12 @@ public final class XcodeInspector: ObservableObject { activeXcode.observeAXNotifications() } } -} + @XcodeInspectorActor + private func storeLatestNonRootWorkspaceURL(_ newWorkspaceURL: URL?) { + if let url = newWorkspaceURL, url.path != "/" { + self.latestNonRootWorkspaceURL = url + } + // If newWorkspaceURL is nil or its path is "/", latestNonRootWorkspaceURL remains unchanged. + } +} diff --git a/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift b/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift index f69da9ad..fd5ed987 100644 --- a/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift +++ b/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift @@ -1,9 +1,9 @@ -import XCTest -import Foundation +import ConversationServiceProvider import CoreServices +import Foundation import LanguageServerProtocol -import ConversationServiceProvider @testable import Workspace +import XCTest // MARK: - Mocks for Testing @@ -55,13 +55,12 @@ class MockFSEventProvider: FSEventProvider { } class MockWorkspaceFileProvider: WorkspaceFileProvider { - var subprojects: [URL] = [] var filesInWorkspace: [FileReference] = [] var xcProjectPaths: Set = [] var xcWorkspacePaths: Set = [] - func getSubprojectURLs(in workspace: URL) -> [URL] { + func getProjects(by workspaceURL: URL) -> [URL] { return subprojects } diff --git a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift index 091f26af..a5cf0f3a 100644 --- a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift +++ b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift @@ -14,30 +14,32 @@ class WorkspaceFileTests: XCTestCase { func testIsXCWorkspace() throws { let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } do { let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "myWorkspace.xcworkspace") XCTAssertFalse(WorkspaceFile.isXCWorkspace(xcworkspaceURL)) let xcworkspaceDataURL = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "") XCTAssertTrue(WorkspaceFile.isXCWorkspace(xcworkspaceURL)) } catch { - deleteDirectoryIfExists(at: tmpDir) throw error } - deleteDirectoryIfExists(at: tmpDir) } func testIsXCProject() throws { let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } do { let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "myProject.xcodeproj") XCTAssertFalse(WorkspaceFile.isXCProject(xcprojectURL)) let xcprojectDataURL = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") XCTAssertTrue(WorkspaceFile.isXCProject(xcprojectURL)) } catch { - deleteDirectoryIfExists(at: tmpDir) throw error } - deleteDirectoryIfExists(at: tmpDir) } func testGetFilesInActiveProject() throws { @@ -62,6 +64,9 @@ class WorkspaceFileTests: XCTestCase { func testGetFilesInActiveWorkspace() throws { let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } do { let myWorkspaceRoot = try createSubdirectory(in: tmpDir, withName: "myWorkspace") let xcWorkspaceURL = try createSubdirectory(in: myWorkspaceRoot, withName: "myWorkspace.xcworkspace") @@ -96,14 +101,15 @@ class WorkspaceFileTests: XCTestCase { XCTAssertTrue(fileNames.contains("file1.swift")) XCTAssertTrue(fileNames.contains("depFile1.swift")) } catch { - deleteDirectoryIfExists(at: tmpDir) throw error } - deleteDirectoryIfExists(at: tmpDir) } func testGetSubprojectURLsFromXCWorkspace() throws { let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } do { let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "myWorkspace.xcworkspace") _ = try createFileFor_contents_dot_xcworkspacedata(directory: xcworkspaceURL, fileRefs: [ @@ -114,10 +120,8 @@ class WorkspaceFileTests: XCTestCase { XCTAssertEqual(subprojectURLs[0].path, tmpDir.path) XCTAssertEqual(subprojectURLs[1].path, tmpDir.appendingPathComponent("myDependency").path) } catch { - deleteDirectoryIfExists(at: tmpDir) throw error } - deleteDirectoryIfExists(at: tmpDir) } func testGetSubprojectURLs() { @@ -211,4 +215,114 @@ class WorkspaceFileTests: XCTestCase { contents += "" return contents } + + func testIsValidFile() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + // Test valid Swift file + let swiftFileURL = try createFile(in: tmpDir, withName: "ValidFile.swift", contents: "// Swift code") + XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL)) + + // Test valid files with different supported extensions + let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript") + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL)) + + let mdFileURL = try createFile(in: tmpDir, withName: "README.md", contents: "# Markdown") + XCTAssertTrue(try WorkspaceFile.isValidFile(mdFileURL)) + + let jsonFileURL = try createFile(in: tmpDir, withName: "config.json", contents: "{}") + XCTAssertTrue(try WorkspaceFile.isValidFile(jsonFileURL)) + + // Test case insensitive extension matching + let swiftUpperURL = try createFile(in: tmpDir, withName: "File.SWIFT", contents: "// Swift") + XCTAssertTrue(try WorkspaceFile.isValidFile(swiftUpperURL)) + + // Test unsupported file extension + let unsupportedFileURL = try createFile(in: tmpDir, withName: "file.xyz", contents: "unsupported") + XCTAssertFalse(try WorkspaceFile.isValidFile(unsupportedFileURL)) + + // Test files matching skip patterns + let gitFileURL = try createFile(in: tmpDir, withName: ".git", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(gitFileURL)) + + let dsStoreURL = try createFile(in: tmpDir, withName: ".DS_Store", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(dsStoreURL)) + + let nodeModulesURL = try createFile(in: tmpDir, withName: "node_modules", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(nodeModulesURL)) + + // Test directory (should return false) + let subdirURL = try createSubdirectory(in: tmpDir, withName: "subdir") + XCTAssertFalse(try WorkspaceFile.isValidFile(subdirURL)) + + // Test Xcode workspace (should return false) + let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "test.xcworkspace") + _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(xcworkspaceURL)) + + // Test Xcode project (should return false) + let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "test.xcodeproj") + _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(xcprojectURL)) + + } catch { + throw error + } + } + + func testIsValidFileWithCustomExclusionFilter() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let swiftFileURL = try createFile(in: tmpDir, withName: "TestFile.swift", contents: "// Swift code") + let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript") + + // Test without custom exclusion filter + XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL)) + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL)) + + // Test with custom exclusion filter that excludes Swift files + let excludeSwiftFilter: (URL) -> Bool = { url in + return url.pathExtension.lowercased() == "swift" + } + + XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeSwiftFilter)) + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeSwiftFilter)) + + // Test with custom exclusion filter that excludes files with "Test" in name + let excludeTestFilter: (URL) -> Bool = { url in + return url.lastPathComponent.contains("Test") + } + + XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeTestFilter)) + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeTestFilter)) + + } catch { + throw error + } + } + + func testIsValidFileWithAllSupportedExtensions() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let supportedExtensions = supportedFileExtensions + + for (index, ext) in supportedExtensions.enumerated() { + let fileName = "testfile\(index).\(ext)" + let fileURL = try createFile(in: tmpDir, withName: fileName, contents: "test content") + XCTAssertTrue(try WorkspaceFile.isValidFile(fileURL), "File with extension .\(ext) should be valid") + } + + } catch { + throw error + } + } } From fabc66e9602a5f70ae3f06301e310a35b5b4fec3 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 17 Jun 2025 09:20:18 +0000 Subject: [PATCH 07/26] Pre-release 0.36.124 --- .../AdvancedSettings/ChatSection.swift | 31 ++- .../SharedComponents/SettingsToggle.swift | 4 +- .../SuggestionWidget/ChatPanelWindow.swift | 7 + .../SuggestionWidget/ChatWindowView.swift | 25 ++- Core/Sources/SuggestionWidget/Styles.swift | 1 + .../WidgetPositionStrategy.swift | 42 +++- .../WidgetWindowsController.swift | 41 ++-- Server/package-lock.json | 8 +- Server/package.json | 2 +- Tool/Sources/Preferences/Keys.swift | 4 + Tool/Sources/Preferences/UserDefaults.swift | 1 + Tool/Sources/Workspace/WorkspaceFile.swift | 139 ++++++++---- .../Apps/XcodeAppInstanceInspector.swift | 32 +++ .../Tests/WorkspaceTests/WorkspaceTests.swift | 210 ++++++++++++++---- 14 files changed, 414 insertions(+), 133 deletions(-) diff --git a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift index e9935b00..a71e2aa3 100644 --- a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift @@ -5,20 +5,27 @@ import Toast import XcodeInspector struct ChatSection: View { + @AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode + var body: some View { SettingsSection(title: "Chat Settings") { - VStack(spacing: 10) { - // Response language picker - ResponseLanguageSetting() - .padding(.horizontal, 10) - - Divider() - - // Custom instructions - CustomInstructionSetting() - .padding(.horizontal, 10) - } - .padding(.vertical, 10) + // Auto Attach toggle + SettingsToggle( + title: "Auto-attach Chat Window to Xcode", + isOn: $autoAttachChatToXcode + ) + + Divider() + + // Response language picker + ResponseLanguageSetting() + .padding(SettingsToggle.defaultPadding) + + Divider() + + // Custom instructions + CustomInstructionSetting() + .padding(SettingsToggle.defaultPadding) } } } diff --git a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift index af681465..5c51d21f 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift @@ -1,6 +1,8 @@ import SwiftUI struct SettingsToggle: View { + static let defaultPadding: CGFloat = 10 + let title: String let isOn: Binding @@ -11,7 +13,7 @@ struct SettingsToggle: View { Toggle(isOn: isOn) {} .toggleStyle(.switch) } - .padding(10) + .padding(SettingsToggle.defaultPadding) } } diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index 9cdabd21..59027eaa 100644 --- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -76,6 +76,13 @@ final class ChatPanelWindow: NSWindow { } } } + + setInitialFrame() + } + + private func setInitialFrame() { + let frame = UpdateLocationStrategy.getChatPanelFrame(isAttachedToXcodeEnabled: false) + setFrame(frame, display: false, animate: true) } func setFloatOnTop(_ isFloatOnTop: Bool) { diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index f0596ff7..cedbe798 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -141,6 +141,7 @@ struct ChatLoadingView: View { struct ChatTitleBar: View { let store: StoreOf @State var isHovering = false + @AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode var body: some View { WithPerceptionTracking { @@ -167,18 +168,20 @@ struct ChatTitleBar: View { Spacer() - TrafficLightButton( - isHovering: isHovering, - isActive: store.isDetached, - color: Color(nsColor: .systemCyan), - action: { - store.send(.toggleChatPanelDetachedButtonClicked) + if !autoAttachChatToXcode { + TrafficLightButton( + isHovering: isHovering, + isActive: store.isDetached, + color: Color(nsColor: .systemCyan), + action: { + store.send(.toggleChatPanelDetachedButtonClicked) + } + ) { + Image(systemName: "pin.fill") + .foregroundStyle(.black.opacity(0.5)) + .font(Font.system(size: 6).weight(.black)) + .transformEffect(.init(translationX: 0, y: 0.5)) } - ) { - Image(systemName: "pin.fill") - .foregroundStyle(.black.opacity(0.5)) - .font(Font.system(size: 6).weight(.black)) - .transformEffect(.init(translationX: 0, y: 0.5)) } } .buttonStyle(.plain) diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index c2720772..382771cf 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -6,6 +6,7 @@ import SwiftUI enum Style { static let panelHeight: Double = 560 static let panelWidth: Double = 504 + static let minChatPanelWidth: Double = 242 // Following the minimal width of Navigator in Xcode static let inlineSuggestionMaxHeight: Double = 400 static let inlineSuggestionPadding: Double = 25 static let widgetHeight: Double = 20 diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index a7dcae3f..08033f2b 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import XcodeInspector public struct WidgetLocation: Equatable { struct PanelLocation: Equatable { @@ -319,14 +320,41 @@ enum UpdateLocationStrategy { return selectionFrame } - static func getChatPanelFrame(_ screen: NSScreen) -> CGRect { + static func getChatPanelFrame(isAttachedToXcodeEnabled: Bool = false) -> CGRect { + let screen = NSScreen.main ?? NSScreen.screens.first! + return getChatPanelFrame(screen, isAttachedToXcodeEnabled: isAttachedToXcodeEnabled) + } + + static func getChatPanelFrame(_ screen: NSScreen, isAttachedToXcodeEnabled: Bool = false) -> 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) + + // Default Frame + var width = min(Style.panelWidth, visibleScreenFrame.width * 0.3) + var height = visibleScreenFrame.height + var x = visibleScreenFrame.maxX - width + var y = visibleScreenFrame.minY + + if isAttachedToXcodeEnabled, + let latestActiveXcode = XcodeInspector.shared.latestActiveXcode, + let xcodeWindow = latestActiveXcode.appElement.focusedWindow, + let xcodeScreen = latestActiveXcode.appScreen, + let xcodeRect = xcodeWindow.rect, + let mainDisplayScreen = NSScreen.screens.first(where: { $0.frame.origin == .zero }) // The main display should exist + { + let minWidth = Style.minChatPanelWidth + let visibleXcodeScreenFrame = xcodeScreen.visibleFrame + + width = max(visibleXcodeScreenFrame.maxX - xcodeRect.maxX, minWidth) + height = xcodeRect.height + x = visibleXcodeScreenFrame.maxX - width + + // AXUIElement coordinates: Y=0 at top-left + // NSWindow coordinates: Y=0 at bottom-left + y = mainDisplayScreen.frame.maxY - xcodeRect.maxY + mainDisplayScreen.frame.minY + } + + + return CGRect(x: x, y: y, width: width, height: height) } } diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index e21f4fb0..217ca1d9 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -142,13 +142,14 @@ private extension WidgetWindowsController { await updateWidgetsAndNotifyChangeOfEditor(immediately: false) case .mainWindowChanged: await updateWidgetsAndNotifyChangeOfEditor(immediately: false) - case .moved, - .resized, - .windowMoved, - .windowResized, - .windowMiniaturized, - .windowDeminiaturized: + case .windowMiniaturized, .windowDeminiaturized: await updateWidgets(immediately: false) + case .resized, + .moved, + .windowMoved, + .windowResized: + await updateWidgets(immediately: false) + await updateChatWindowLocation() case .created, .uiElementDestroyed, .xcodeCompletionPanelChanged, .applicationDeactivated: continue @@ -339,8 +340,7 @@ extension WidgetWindowsController { // Generate a default location when no workspace is opened private func generateDefaultLocation() -> WidgetLocation { - let mainScreen = NSScreen.main ?? NSScreen.screens.first! - let chatPanelFrame = UpdateLocationStrategy.getChatPanelFrame(mainScreen) + let chatPanelFrame = UpdateLocationStrategy.getChatPanelFrame(isAttachedToXcodeEnabled: false) return WidgetLocation( widgetFrame: .zero, @@ -444,6 +444,18 @@ extension WidgetWindowsController { updateWindowOpacityTask = task } + + @MainActor + func updateChatWindowLocation() { + let state = store.withState { $0 } + let isAttachedToXcodeEnabled = UserDefaults.shared.value(for: \.autoAttachChatToXcode) + if isAttachedToXcodeEnabled { + if state.chatPanelState.isPanelDisplayed && !windows.chatPanelWindow.isWindowHidden { + let frame = UpdateLocationStrategy.getChatPanelFrame(isAttachedToXcodeEnabled: isAttachedToXcodeEnabled) + windows.chatPanelWindow.setFrame(frame, display: true, animate: true) + } + } + } func updateWindowLocation( animated: Bool, @@ -481,8 +493,11 @@ extension WidgetWindowsController { animate: animated ) } - - if isChatPanelDetached { + + let isAttachedToXcodeEnabled = UserDefaults.shared.value(for: \.autoAttachChatToXcode) + if isAttachedToXcodeEnabled { + // update in `updateChatWindowLocation` + } else if isChatPanelDetached { // don't update it! } else { windows.chatPanelWindow.setFrame( @@ -523,10 +538,10 @@ extension WidgetWindowsController { @MainActor func adjustChatPanelWindowLevel() async { + let window = windows.chatPanelWindow + let disableFloatOnTopWhenTheChatPanelIsDetached = UserDefaults.shared .value(for: \.disableFloatOnTopWhenTheChatPanelIsDetached) - - let window = windows.chatPanelWindow guard disableFloatOnTopWhenTheChatPanelIsDetached else { window.setFloatOnTop(true) return @@ -549,7 +564,7 @@ extension WidgetWindowsController { } else { false } - + if !floatOnTopWhenOverlapsXcode || !latestAppIsXcodeOrExtension { window.setFloatOnTop(false) } else { diff --git a/Server/package-lock.json b/Server/package-lock.json index aae308f1..490aa158 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.334.0", + "@github/copilot-language-server": "^1.335.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,9 +36,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.334.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.334.0.tgz", - "integrity": "sha512-VDFaG1ULdBSuyqXhidr9iVA5e9YfUzmDrRobnIBMYBdnhkqH+hCSRui/um6E8KB5EEiGbSLi6qA5XBnCCibJ0w==", + "version": "1.335.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.335.0.tgz", + "integrity": "sha512-uX5t6kOlWau4WtpL/WQLL8qADE4iHSfbDojYRVq8kTIjg1u5w6Ty7wqddnfyPUIpTltifsBVoHjHpW5vdhf55g==", "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" diff --git a/Server/package.json b/Server/package.json index d5c061cf..92ed5789 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.334.0", + "@github/copilot-language-server": "^1.335.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 1b3b734c..c648fbf2 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -303,6 +303,10 @@ public extension UserDefaultPreferenceKeys { var globalCopilotInstructions: PreferenceKey { .init(defaultValue: "", key: "GlobalCopilotInstructions") } + + var autoAttachChatToXcode: PreferenceKey { + .init(defaultValue: true, key: "AutoAttachChatToXcode") + } } // MARK: - Theme diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift index 6971134f..dfaa5b67 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -15,6 +15,7 @@ public extension UserDefaults { shared.setupDefaultValue(for: \.realtimeSuggestionToggle) shared.setupDefaultValue(for: \.realtimeSuggestionDebounce) shared.setupDefaultValue(for: \.suggestionPresentationMode) + shared.setupDefaultValue(for: \.autoAttachChatToXcode) shared.setupDefaultValue(for: \.widgetColorScheme) shared.setupDefaultValue(for: \.customCommands) shared.setupDefaultValue( diff --git a/Tool/Sources/Workspace/WorkspaceFile.swift b/Tool/Sources/Workspace/WorkspaceFile.swift index dc129624..c653220e 100644 --- a/Tool/Sources/Workspace/WorkspaceFile.swift +++ b/Tool/Sources/Workspace/WorkspaceFile.swift @@ -29,7 +29,8 @@ extension NSError { } public struct WorkspaceFile { - + private static let wellKnownBundleExtensions: Set = ["app", "xcarchive"] + static func isXCWorkspace(_ url: URL) -> Bool { return url.pathExtension == "xcworkspace" && FileManager.default.fileExists(atPath: url.appendingPathComponent("contents.xcworkspacedata").path) } @@ -38,53 +39,22 @@ public struct WorkspaceFile { return url.pathExtension == "xcodeproj" && FileManager.default.fileExists(atPath: url.appendingPathComponent("project.pbxproj").path) } + static func isKnownPackageFolder(_ url: URL) -> Bool { + guard wellKnownBundleExtensions.contains(url.pathExtension) else { + return false + } + + let resourceValues = try? url.resourceValues(forKeys: [.isPackageKey]) + return resourceValues?.isPackage == true + } + static func getWorkspaceByProject(_ url: URL) -> URL? { guard isXCProject(url) else { return nil } let workspaceURL = url.appendingPathComponent("project.xcworkspace") return isXCWorkspace(workspaceURL) ? workspaceURL : nil } - - static func getSubprojectURLs(workspaceURL: URL, data: Data) -> [URL] { - var subprojectURLs: [URL] = [] - do { - let xml = try XMLDocument(data: data) - let fileRefs = try xml.nodes(forXPath: "//FileRef") - for fileRef in fileRefs { - if let fileRefElement = fileRef as? XMLElement, - let location = fileRefElement.attribute(forName: "location")?.stringValue { - var path = "" - if location.starts(with: "group:") { - path = location.replacingOccurrences(of: "group:", with: "") - } else if location.starts(with: "container:") { - path = location.replacingOccurrences(of: "container:", with: "") - } else if location.starts(with: "self:") { - // Handle "self:" referece - refers to the containing project directory - var workspaceURLCopy = workspaceURL - workspaceURLCopy.deleteLastPathComponent() - path = workspaceURLCopy.path - - } else { - // Skip absolute paths such as absolute:/path/to/project - continue - } - - if path.hasSuffix(".xcodeproj") { - path = (path as NSString).deletingLastPathComponent - } - let subprojectURL = path.isEmpty ? workspaceURL.deletingLastPathComponent() : workspaceURL.deletingLastPathComponent().appendingPathComponent(path) - if !subprojectURLs.contains(subprojectURL) { - subprojectURLs.append(subprojectURL) - } - } - } - } catch { - Logger.client.error("Failed to parse workspace file: \(error)") - } - return subprojectURLs - } - static func getSubprojectURLs(in workspaceURL: URL) -> [URL] { let workspaceFile = workspaceURL.appendingPathComponent("contents.xcworkspacedata") do { @@ -99,7 +69,84 @@ public struct WorkspaceFile { return [] } } - + + static func getSubprojectURLs(workspaceURL: URL, data: Data) -> [URL] { + do { + let xml = try XMLDocument(data: data) + let workspaceBaseURL = workspaceURL.deletingLastPathComponent() + // Process all FileRefs and Groups recursively + return processWorkspaceNodes(xml.rootElement()?.children ?? [], baseURL: workspaceBaseURL) + } catch { + Logger.client.error("Failed to parse workspace file: \(error)") + } + + return [] + } + + /// Recursively processes all nodes in a workspace file, collecting project URLs + private static func processWorkspaceNodes(_ nodes: [XMLNode], baseURL: URL, currentGroupPath: String = "") -> [URL] { + var results: [URL] = [] + + for node in nodes { + guard let element = node as? XMLElement else { continue } + + let location = element.attribute(forName: "location")?.stringValue ?? "" + if element.name == "FileRef" { + if let url = resolveProjectLocation(location: location, baseURL: baseURL, groupPath: currentGroupPath), + !results.contains(url) { + results.append(url) + } + } else if element.name == "Group" { + var groupPath = currentGroupPath + if !location.isEmpty, let path = extractPathFromLocation(location) { + groupPath = (groupPath as NSString).appendingPathComponent(path) + } + + // Process all children of this group, passing the updated group path + let childResults = processWorkspaceNodes(element.children ?? [], baseURL: baseURL, currentGroupPath: groupPath) + + for url in childResults { + if !results.contains(url) { + results.append(url) + } + } + } + } + + return results + } + + /// Extracts path component from a location string + private static func extractPathFromLocation(_ location: String) -> String? { + for prefix in ["group:", "container:", "self:"] { + if location.starts(with: prefix) { + return location.replacingOccurrences(of: prefix, with: "") + } + } + return nil + } + + static func resolveProjectLocation(location: String, baseURL: URL, groupPath: String = "") -> URL? { + var path = "" + + // Extract the path from the location string + if let extractedPath = extractPathFromLocation(location) { + path = extractedPath + } else { + // Unknown location format + return nil + } + + var url: URL = groupPath.isEmpty ? baseURL : baseURL.appendingPathComponent(groupPath) + url = path.isEmpty ? url : url.appendingPathComponent(path) + url = url.standardized // normalize “..” or “.” in the path + if isXCProject(url) { // return the containing directory of the .xcodeproj file + url.deleteLastPathComponent() + } + + return url + } + static func matchesPatterns(_ url: URL, patterns: [String]) -> Bool { let fileName = url.lastPathComponent for pattern in patterns { @@ -144,9 +191,11 @@ public struct WorkspaceFile { } private static func shouldSkipFile(_ url: URL) -> Bool { - return matchesPatterns(url, patterns: skipPatterns) - || isXCWorkspace(url) - || isXCProject(url) + return matchesPatterns(url, patterns: skipPatterns) + || isXCWorkspace(url) + || isXCProject(url) + || isKnownPackageFolder(url) + || url.pathExtension == "xcassets" } public static func isValidFile( diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 29964b12..54865f1d 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -401,6 +401,11 @@ extension XcodeAppInstanceInspector { } return updated } + + // The screen that Xcode App located at + public var appScreen: NSScreen? { + appElement.focusedWindow?.maxIntersectionScreen + } } public extension AXUIElement { @@ -447,4 +452,31 @@ public extension AXUIElement { } return tabBars } + + var maxIntersectionScreen: NSScreen? { + guard let rect = rect else { return nil } + + var bestScreen: NSScreen? + var maxIntersectionArea: CGFloat = 0 + + for screen in NSScreen.screens { + // Skip screens that are in full-screen mode + // Full-screen detection: visible frame equals total frame (no menu bar/dock) + if screen.frame == screen.visibleFrame { + continue + } + + // Calculate intersection area between Xcode frame and screen frame + let intersection = rect.intersection(screen.frame) + let intersectionArea = intersection.width * intersection.height + + // Update best screen if this intersection is larger + if intersectionArea > maxIntersectionArea { + maxIntersectionArea = intersectionArea + bestScreen = screen + } + } + + return bestScreen + } } diff --git a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift index a5cf0f3a..87276a06 100644 --- a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift +++ b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift @@ -45,8 +45,7 @@ class WorkspaceFileTests: XCTestCase { func testGetFilesInActiveProject() throws { let tmpDir = try createTemporaryDirectory() do { - let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "myProject.xcodeproj") - _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + let xcprojectURL = try createXCProjectFolder(in: tmpDir, withName: "myProject.xcodeproj") _ = try createFile(in: tmpDir, withName: "file1.swift", contents: "") _ = try createFile(in: tmpDir, withName: "file2.swift", contents: "") _ = try createSubdirectory(in: tmpDir, withName: ".git") @@ -69,14 +68,12 @@ class WorkspaceFileTests: XCTestCase { } do { let myWorkspaceRoot = try createSubdirectory(in: tmpDir, withName: "myWorkspace") - let xcWorkspaceURL = try createSubdirectory(in: myWorkspaceRoot, withName: "myWorkspace.xcworkspace") - let xcprojectURL = try createSubdirectory(in: myWorkspaceRoot, withName: "myProject.xcodeproj") - let myDependencyURL = try createSubdirectory(in: tmpDir, withName: "myDependency") - _ = try createFileFor_contents_dot_xcworkspacedata(directory: xcWorkspaceURL, fileRefs: [ + let xcWorkspaceURL = try createXCWorkspaceFolder(in: myWorkspaceRoot, withName: "myWorkspace.xcworkspace", fileRefs: [ "container:myProject.xcodeproj", "group:../notExistedDir/notExistedProject.xcodeproj", "group:../myDependency",]) - _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + let xcprojectURL = try createXCProjectFolder(in: myWorkspaceRoot, withName: "myProject.xcodeproj") + let myDependencyURL = try createSubdirectory(in: tmpDir, withName: "myDependency") // Files under workspace should be included _ = try createFile(in: myWorkspaceRoot, withName: "file1.swift", contents: "") @@ -91,7 +88,7 @@ class WorkspaceFileTests: XCTestCase { _ = try createFile(in: myDependencyURL, withName: "depFile1.swift", contents: "") // Should be excluded _ = try createSubdirectory(in: myDependencyURL, withName: ".git") - + // Files under unrelated directories should be excluded _ = try createFile(in: tmpDir, withName: "unrelatedFile1.swift", contents: "") @@ -110,54 +107,167 @@ class WorkspaceFileTests: XCTestCase { defer { deleteDirectoryIfExists(at: tmpDir) } - do { - let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "myWorkspace.xcworkspace") - _ = try createFileFor_contents_dot_xcworkspacedata(directory: xcworkspaceURL, fileRefs: [ - "container:myProject.xcodeproj", - "group:myDependency"]) - let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: xcworkspaceURL) - XCTAssertEqual(subprojectURLs.count, 2) - XCTAssertEqual(subprojectURLs[0].path, tmpDir.path) - XCTAssertEqual(subprojectURLs[1].path, tmpDir.appendingPathComponent("myDependency").path) - } catch { - throw error - } - } - func testGetSubprojectURLs() { - let workspaceURL = URL(fileURLWithPath: "/path/to/workspace.xcworkspace") + let workspaceDir = try createSubdirectory(in: tmpDir, withName: "workspace") + + // Create tryapp directory and project + let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp") + _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj") + + // Create Copilot for Xcode project + _ = try createXCProjectFolder(in: workspaceDir, withName: "Copilot for Xcode.xcodeproj") + + // Create Test1 directory + let test1Dir = try createSubdirectory(in: tmpDir, withName: "Test1") + + // Create Test2 directory and project + let test2Dir = try createSubdirectory(in: tmpDir, withName: "Test2") + _ = try createXCProjectFolder(in: test2Dir, withName: "project2.xcodeproj") + + // Create the workspace data file with our references let xcworkspaceData = """ + location = "container:../tryapp/tryapp.xcodeproj"> + location = "group:../Test1"> + location = "group:../Test2/project2.xcodeproj"> + location = "absolute:/Test3/project3"> + + """ + let workspaceURL = try createXCWorkspaceFolder(in: workspaceDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData) + + let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL) + + XCTAssertEqual(subprojectURLs.count, 4) + let resolvedPaths = subprojectURLs.map { $0.path } + let expectedPaths = [ + tryappDir.path, + workspaceDir.path, // For Copilot for Xcode.xcodeproj + test1Dir.path, + test2Dir.path + ] + XCTAssertEqual(resolvedPaths, expectedPaths) + } + + func testGetSubprojectURLsFromEmbeddedXCWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + // Create the workspace data file with a self reference + let xcworkspaceData = """ + + + + + + """ + + // Create the MyApp directory structure + let myAppDir = try createSubdirectory(in: tmpDir, withName: "MyApp") + let xcodeProjectDir = try createXCProjectFolder(in: myAppDir, withName: "MyApp.xcodeproj") + let embeddedWorkspaceDir = try createXCWorkspaceFolder(in: xcodeProjectDir, withName: "MyApp.xcworkspace", xcworkspacedata: xcworkspaceData) + + let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: embeddedWorkspaceDir) + XCTAssertEqual(subprojectURLs.count, 1) + XCTAssertEqual(subprojectURLs[0].lastPathComponent, "MyApp") + XCTAssertEqual(subprojectURLs[0].path, myAppDir.path) + } + + func testGetSubprojectURLsFromXCWorkspaceOrganizedByGroup() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + // Create directories for the projects and groups + let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp") + _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj") + + let webLibraryDir = try createSubdirectory(in: tmpDir, withName: "WebLibrary") + + // Create the group directories + let group1Dir = try createSubdirectory(in: tmpDir, withName: "group1") + let group2Dir = try createSubdirectory(in: group1Dir, withName: "group2") + _ = try createSubdirectory(in: group2Dir, withName: "group3") + _ = try createSubdirectory(in: group1Dir, withName: "group4") + + // Create the MyProjects directory + let myProjectsDir = try createSubdirectory(in: tmpDir, withName: "MyProjects") + + // Create the copilot-xcode directory and project + let copilotXcodeDir = try createSubdirectory(in: myProjectsDir, withName: "copilot-xcode") + _ = try createXCProjectFolder(in: copilotXcodeDir, withName: "Copilot for Xcode.xcodeproj") + + // Create the SwiftLanguageWeather directory and project + let swiftWeatherDir = try createSubdirectory(in: myProjectsDir, withName: "SwiftLanguageWeather") + _ = try createXCProjectFolder(in: swiftWeatherDir, withName: "SwiftWeather.xcodeproj") + + // Create the workspace data file with a complex group structure + let xcworkspaceData = """ + + + + + + + + + + + + + + + + location = "group:../MyProjects/SwiftLanguageWeather/SwiftWeather.xcodeproj"> - - """.data(using: .utf8)! - - let subprojectURLs = WorkspaceFile.getSubprojectURLs(workspaceURL: workspaceURL, data: xcworkspaceData) - XCTAssertEqual(subprojectURLs.count, 5) - XCTAssertEqual(subprojectURLs[0].path, "/path/to/tryapp") - XCTAssertEqual(subprojectURLs[1].path, "/path/to") - XCTAssertEqual(subprojectURLs[2].path, "/path/to/Test1") - XCTAssertEqual(subprojectURLs[3].path, "/path/to/Test2") - XCTAssertEqual(subprojectURLs[4].path, "/path/to/../Test4") + + + """ + + // Create a test workspace structure + let workspaceURL = try createXCWorkspaceFolder(in: tmpDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData) + + let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL) + XCTAssertEqual(subprojectURLs.count, 4) + let expectedPaths = [ + tryappDir.path, + webLibraryDir.path, + copilotXcodeDir.path, + swiftWeatherDir.path + ] + for expectedPath in expectedPaths { + XCTAssertTrue(subprojectURLs.contains { $0.path == expectedPath }, "Expected path not found: \(expectedPath)") + } } func deleteDirectoryIfExists(at url: URL) { @@ -193,8 +303,30 @@ class WorkspaceFileTests: XCTestCase { FileManager.default.createFile(atPath: fileURL.path, contents: data, attributes: nil) return fileURL } + + func createXCProjectFolder(in baseDirectory: URL, withName projectName: String) throws -> URL { + let projectURL = try createSubdirectory(in: baseDirectory, withName: projectName) + if projectName.hasSuffix(".xcodeproj") { + _ = try createFile(in: projectURL, withName: "project.pbxproj", contents: "// Project file contents") + } + return projectURL + } + + func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, fileRefs: [String]?) throws -> URL { + let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName) + if let fileRefs { + _ = try createXCworkspacedataFile(directory: xcworkspaceURL, fileRefs: fileRefs) + } + return xcworkspaceURL + } + + func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, xcworkspacedata: String) throws -> URL { + let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName) + _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: xcworkspacedata) + return xcworkspaceURL + } - func createFileFor_contents_dot_xcworkspacedata(directory: URL, fileRefs: [String]) throws -> URL { + func createXCworkspacedataFile(directory: URL, fileRefs: [String]) throws -> URL { let contents = generateXCWorkspacedataContents(fileRefs: fileRefs) return try createFile(in: directory, withName: "contents.xcworkspacedata", contents: contents) } From 64a06917d4faf0a56126789054ec07e03d5e5f1d Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 18 Jun 2025 08:33:03 +0000 Subject: [PATCH 08/26] Release 0.37.0 --- CHANGELOG.md | 12 ++++ .../WidgetPositionStrategy.swift | 15 +++-- .../WidgetWindowsController.swift | 66 ++++++++++++++++--- ReleaseNotes.md | 18 ++--- 4 files changed, 87 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a90c89ee..e631d45d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.37.0 - June 18, 2025 +### Added +- **Advanced** settings: Added option to configure **Custom Instructions** for GitHub Copilot during chat sessions. +- **Advanced** settings: Added option to keep the chat window automatically attached to Xcode. + +### Changed +- Enabled support for dragging-and-dropping files into the chat panel to provide context. + +### Fixed +- "Add Context" menu didn’t show files in workspaces organized with Xcode’s group feature. +- Chat didn’t respond when the workspace was in a system folder (like Desktop, Downloads, or Documents) and access permission hadn’t been granted. + ## 0.36.0 - June 4, 2025 ### Added - Introduced a new chat setting "**Response Language**" under **Advanced** settings to customize the natural language used in chat replies. diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 08033f2b..17c70602 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -320,12 +320,19 @@ enum UpdateLocationStrategy { return selectionFrame } - static func getChatPanelFrame(isAttachedToXcodeEnabled: Bool = false) -> CGRect { + static func getChatPanelFrame( + isAttachedToXcodeEnabled: Bool = false, + xcodeApp: XcodeAppInstanceInspector? = nil + ) -> CGRect { let screen = NSScreen.main ?? NSScreen.screens.first! - return getChatPanelFrame(screen, isAttachedToXcodeEnabled: isAttachedToXcodeEnabled) + return getChatPanelFrame(screen, isAttachedToXcodeEnabled: isAttachedToXcodeEnabled, xcodeApp: xcodeApp) } - static func getChatPanelFrame(_ screen: NSScreen, isAttachedToXcodeEnabled: Bool = false) -> CGRect { + static func getChatPanelFrame( + _ screen: NSScreen, + isAttachedToXcodeEnabled: Bool = false, + xcodeApp: XcodeAppInstanceInspector? = nil + ) -> CGRect { let visibleScreenFrame = screen.visibleFrame // Default Frame @@ -335,7 +342,7 @@ enum UpdateLocationStrategy { var y = visibleScreenFrame.minY if isAttachedToXcodeEnabled, - let latestActiveXcode = XcodeInspector.shared.latestActiveXcode, + let latestActiveXcode = xcodeApp ?? XcodeInspector.shared.latestActiveXcode, let xcodeWindow = latestActiveXcode.appElement.focusedWindow, let xcodeScreen = latestActiveXcode.appScreen, let xcodeRect = xcodeWindow.rect, diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 217ca1d9..cd085db2 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -17,6 +17,9 @@ actor WidgetWindowsController: NSObject { nonisolated let chatTabPool: ChatTabPool var currentApplicationProcessIdentifier: pid_t? + + weak var currentXcodeApp: XcodeAppInstanceInspector? + weak var previousXcodeApp: XcodeAppInstanceInspector? var cancellable: Set = [] var observeToAppTask: Task? @@ -84,6 +87,12 @@ private extension WidgetWindowsController { if app.isXcode { updateWindowLocation(animated: false, immediately: true) updateWindowOpacity(immediately: false) + + if let xcodeApp = app as? XcodeAppInstanceInspector { + previousXcodeApp = currentXcodeApp ?? xcodeApp + currentXcodeApp = xcodeApp + } + } else { updateWindowOpacity(immediately: true) updateWindowLocation(animated: false, immediately: false) @@ -149,7 +158,7 @@ private extension WidgetWindowsController { .windowMoved, .windowResized: await updateWidgets(immediately: false) - await updateChatWindowLocation() + await updateAttachedChatWindowLocation(notification) case .created, .uiElementDestroyed, .xcodeCompletionPanelChanged, .applicationDeactivated: continue @@ -446,14 +455,55 @@ extension WidgetWindowsController { } @MainActor - func updateChatWindowLocation() { - let state = store.withState { $0 } + func updateAttachedChatWindowLocation(_ notif: XcodeAppInstanceInspector.AXNotification? = nil) async { + guard let currentXcodeApp = (await currentXcodeApp), + let currentFocusedWindow = currentXcodeApp.appElement.focusedWindow, + let currentXcodeScreen = currentXcodeApp.appScreen, + let currentXcodeRect = currentFocusedWindow.rect + else { return } + + if let previousXcodeApp = (await previousXcodeApp), + currentXcodeApp.processIdentifier == previousXcodeApp.processIdentifier { + if currentFocusedWindow.isFullScreen == true { + return + } + } + let isAttachedToXcodeEnabled = UserDefaults.shared.value(for: \.autoAttachChatToXcode) - if isAttachedToXcodeEnabled { - if state.chatPanelState.isPanelDisplayed && !windows.chatPanelWindow.isWindowHidden { - let frame = UpdateLocationStrategy.getChatPanelFrame(isAttachedToXcodeEnabled: isAttachedToXcodeEnabled) - windows.chatPanelWindow.setFrame(frame, display: true, animate: true) + guard isAttachedToXcodeEnabled else { return } + + if let notif = notif { + let dialogIdentifiers = ["open_quickly", "alert"] + if dialogIdentifiers.contains(notif.element.identifier) { return } + } + + let state = store.withState { $0 } + if state.chatPanelState.isPanelDisplayed && !windows.chatPanelWindow.isWindowHidden { + var frame = UpdateLocationStrategy.getChatPanelFrame( + isAttachedToXcodeEnabled: true, + xcodeApp: currentXcodeApp + ) + + let screenMaxX = currentXcodeScreen.visibleFrame.maxX + if screenMaxX - currentXcodeRect.maxX < Style.minChatPanelWidth + { + if let previousXcodeRect = (await previousXcodeApp?.appElement.focusedWindow?.rect), + screenMaxX - previousXcodeRect.maxX < Style.minChatPanelWidth + { + let isSameScreen = currentXcodeScreen.visibleFrame.intersects(windows.chatPanelWindow.frame) + // Only update y and height + frame = .init( + x: isSameScreen ? windows.chatPanelWindow.frame.minX : frame.minX, + y: frame.minY, + width: isSameScreen ? windows.chatPanelWindow.frame.width : frame.width, + height: frame.height + ) + } } + + windows.chatPanelWindow.setFrame(frame, display: true, animate: true) + + await adjustChatPanelWindowLevel() } } @@ -496,7 +546,7 @@ extension WidgetWindowsController { let isAttachedToXcodeEnabled = UserDefaults.shared.value(for: \.autoAttachChatToXcode) if isAttachedToXcodeEnabled { - // update in `updateChatWindowLocation` + // update in `updateAttachedChatWindowLocation` } else if isChatPanelDetached { // don't update it! } else { diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 18e88745..4f458bb2 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,18 +1,12 @@ -### GitHub Copilot for Xcode 0.36.0 +### GitHub Copilot for Xcode 0.37.0 **🚀 Highlights** -* Introduced a new chat setting "**Response Language**" under **Advanced** settings to customize the natural language used in chat replies. -* Enabled support for custom instructions defined in _.github/copilot-instructions.md_ within your workspace. -* Added support for premium request handling. - -**💪 Improvements** - -* Performance: Improved UI responsiveness by lazily restoring chat history. -* Performance: Fixed lagging issue when pasting large text into the chat input. -* Performance: Improved project indexing performance. +* **Advanced** settings: Added option to configure **Custom Instructions** for GitHub Copilot during chat sessions. +* **Advanced** settings: Added option to keep the chat window automatically attached to Xcode. +* Added support for dragging-and-dropping files into the chat panel to provide context. **🛠️ Bug Fixes** -* Don't trigger / (slash) commands when pasting a file path into the chat input. -* Adjusted terminal text styling to align with Xcode’s theme. +* "Add Context" menu didn’t show files in workspaces organized with Xcode’s group feature. +* Chat didn’t respond when the workspace was in a system folder (like Desktop, Downloads, or Documents) and access permission hadn’t been granted. From 81fc588cf1e5696c69e97165d51ca1552edd9516 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 24 Jun 2025 08:24:52 +0000 Subject: [PATCH 09/26] Pre-release 0.37.126 --- Core/Package.swift | 3 +- Core/Sources/ChatService/ChatService.swift | 51 +++- .../ToolCalls/CreateFileTool.swift | 22 +- .../ChatService/ToolCalls/ICopilotTool.swift | 11 +- .../ToolCalls/InsertEditIntoFileTool.swift | 276 +++++++++++++----- .../Sources/ChatService/ToolCalls/Utils.swift | 37 ++- Core/Sources/ConversationTab/Chat.swift | 38 ++- Core/Sources/ConversationTab/ChatPanel.swift | 149 ++++------ .../ConversationTab/ConversationTab.swift | 32 ++ .../ModelPicker/ModelPicker.swift | 46 ++- Core/Sources/ConversationTab/Styles.swift | 1 + .../Views/ImageReferenceItemView.swift | 69 +++++ .../ConversationTab/Views/UserMessage.swift | 10 + .../VisionViews/HoverableImageView.swift | 159 ++++++++++ .../VisionViews/ImagesScrollView.swift | 19 ++ .../VisionViews/PopoverImageView.swift | 18 ++ .../VisionViews/VisionMenuView.swift | 130 +++++++++ .../CopilotConnectionView.swift | 3 + .../SuggestionWidget/ChatPanelWindow.swift | 1 + .../ChatWindow/ChatHistoryView.swift | 16 +- .../SuggestionWidget/ChatWindowView.swift | 46 ++- .../FeatureReducers/ChatPanelFeature.swift | 4 +- ExtensionService/AppDelegate.swift | 12 +- Server/package-lock.json | 8 +- Server/package.json | 2 +- Tool/Package.swift | 13 +- Tool/Sources/AXExtension/AXUIElement.swift | 8 + .../NSWorkspace+Extension.swift | 22 ++ Tool/Sources/ChatAPIService/Models.swift | 5 + .../ConversationServiceProvider.swift | 176 ++++++++++- .../GitHubCopilotRequest+Conversation.swift | 11 +- .../LanguageServer/GitHubCopilotService.swift | 19 +- .../GitHubCopilotConversationService.swift | 24 +- Tool/Sources/Persist/AppState.swift | 7 + Tool/Sources/Preferences/Keys.swift | 5 + Tool/Sources/Status/Status.swift | 7 + Tool/Sources/Toast/Toast.swift | 18 +- .../XcodeInspector/AppInstanceInspector.swift | 2 +- 38 files changed, 1209 insertions(+), 271 deletions(-) create mode 100644 Core/Sources/ConversationTab/Views/ImageReferenceItemView.swift create mode 100644 Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift create mode 100644 Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift create mode 100644 Core/Sources/ConversationTab/VisionViews/PopoverImageView.swift create mode 100644 Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift create mode 100644 Tool/Sources/AppKitExtension/NSWorkspace+Extension.swift diff --git a/Core/Package.swift b/Core/Package.swift index b367157b..1508eead 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -181,7 +181,8 @@ let package = Package( .product(name: "GitHubCopilotService", package: "Tool"), .product(name: "Workspace", package: "Tool"), .product(name: "Terminal", package: "Tool"), - .product(name: "SystemUtils", package: "Tool") + .product(name: "SystemUtils", package: "Tool"), + .product(name: "AppKitExtension", package: "Tool") ]), .testTarget( name: "ChatServiceTests", diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 0374e6f3..023397a3 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -18,7 +18,7 @@ import SystemUtils public protocol ChatServiceType { var memory: ContextAwareAutoManagedChatMemory { get set } - func send(_ id: String, content: String, skillSet: [ConversationSkill], references: [FileReference], model: String?, agentMode: Bool, userLanguage: String?, turnId: String?) async throws + func send(_ id: String, content: String, contentImages: [ChatCompletionContentPartImage], contentImageReferences: [ImageReference], skillSet: [ConversationSkill], references: [FileReference], model: String?, agentMode: Bool, userLanguage: String?, turnId: String?) async throws func stopReceivingMessage() async func upvote(_ id: String, _ rating: ConversationRating) async func downvote(_ id: String, _ rating: ConversationRating) async @@ -316,10 +316,23 @@ public final class ChatService: ChatServiceType, ObservableObject { } } } + + public enum ChatServiceError: Error, LocalizedError { + case conflictingImageFormats(String) + + public var errorDescription: String? { + switch self { + case .conflictingImageFormats(let message): + return message + } + } + } public func send( _ id: String, content: String, + contentImages: Array = [], + contentImageReferences: Array = [], skillSet: Array, references: Array, model: String? = nil, @@ -331,11 +344,31 @@ public final class ChatService: ChatServiceType, ObservableObject { let workDoneToken = UUID().uuidString activeRequestId = workDoneToken + let finalImageReferences: [ImageReference] + let finalContentImages: [ChatCompletionContentPartImage] + + if !contentImageReferences.isEmpty { + // User attached images are all parsed as ImageReference + finalImageReferences = contentImageReferences + finalContentImages = contentImageReferences + .map { + ChatCompletionContentPartImage( + url: $0.dataURL(imageType: $0.source == .screenshot ? "png" : "") + ) + } + } else { + // In current implementation, only resend message will have contentImageReferences + // No need to convert ChatCompletionContentPartImage to ImageReference for persistence + finalImageReferences = [] + finalContentImages = contentImages + } + var chatMessage = ChatMessage( id: id, chatTabID: self.chatTabInfo.id, role: .user, content: content, + contentImageReferences: finalImageReferences, references: references.toConversationReferences() ) @@ -406,6 +439,7 @@ public final class ChatService: ChatServiceType, ObservableObject { let request = createConversationRequest( workDoneToken: workDoneToken, content: content, + contentImages: finalContentImages, activeDoc: activeDoc, references: references, model: model, @@ -417,12 +451,13 @@ public final class ChatService: ChatServiceType, ObservableObject { self.lastUserRequest = request self.skillSet = validSkillSet - try await send(request) + try await sendConversationRequest(request) } private func createConversationRequest( workDoneToken: String, content: String, + contentImages: [ChatCompletionContentPartImage] = [], activeDoc: Doc?, references: [FileReference], model: String? = nil, @@ -443,6 +478,7 @@ public final class ChatService: ChatServiceType, ObservableObject { return ConversationRequest( workDoneToken: workDoneToken, content: newContent, + contentImages: contentImages, workspaceFolder: "", activeDoc: activeDoc, skills: skillCapabilities, @@ -504,6 +540,7 @@ public final class ChatService: ChatServiceType, ObservableObject { try await send( id, content: lastUserRequest.content, + contentImages: lastUserRequest.contentImages, skillSet: skillSet, references: lastUserRequest.references ?? [], model: model != nil ? model : lastUserRequest.model, @@ -720,12 +757,14 @@ public final class ChatService: ChatServiceType, ObservableObject { await Status.shared .updateCLSStatus(.warning, busy: false, message: CLSError.message) let errorMessage = buildErrorMessage( - turnId: progress.turnId, + turnId: progress.turnId, panelMessages: [.init(type: .error, title: String(CLSError.code ?? 0), message: CLSError.message, location: .Panel)]) // will persist in resetongoingRequest() await memory.appendMessage(errorMessage) - if let lastUserRequest { + if let lastUserRequest, + let currentUserPlan = await Status.shared.currentUserPlan(), + currentUserPlan != "free" { guard let fallbackModel = CopilotModelManager.getFallbackLLM( scope: lastUserRequest.agentMode ? .agentPanel : .chatPanel ) else { @@ -852,7 +891,7 @@ public final class ChatService: ChatServiceType, ObservableObject { } } - private func send(_ request: ConversationRequest) async throws { + private func sendConversationRequest(_ request: ConversationRequest) async throws { guard !isReceivingMessage else { throw CancellationError() } isReceivingMessage = true @@ -892,7 +931,7 @@ public final class ChatService: ChatServiceType, ObservableObject { switch fileEdit.toolName { case .insertEditIntoFile: - try InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent, contextProvider: self) + InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent, contextProvider: self) case .createFile: try CreateFileTool.undo(for: fileURL) default: diff --git a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift index c314724f..4154cda6 100644 --- a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift @@ -17,7 +17,7 @@ public class CreateFileTool: ICopilotTool { let filePath = input["filePath"]?.value as? String, let content = input["content"]?.value as? String else { - completeResponse(request, response: "Invalid parameters", completion: completion) + completeResponse(request, status: .error, response: "Invalid parameters", completion: completion) return true } @@ -25,39 +25,35 @@ public class CreateFileTool: ICopilotTool { guard !FileManager.default.fileExists(atPath: filePath) else { - completeResponse(request, response: "File already exists at \(filePath)", completion: completion) + completeResponse(request, status: .error, response: "File already exists at \(filePath)", completion: completion) return true } do { try content.write(to: fileURL, atomically: true, encoding: .utf8) } catch { - completeResponse(request, response: "Failed to write content to file: \(error)", completion: completion) + completeResponse(request, status: .error, response: "Failed to write content to file: \(error)", completion: completion) return true } guard FileManager.default.fileExists(atPath: filePath), - let writtenContent = try? String(contentsOf: fileURL, encoding: .utf8), - !writtenContent.isEmpty + let writtenContent = try? String(contentsOf: fileURL, encoding: .utf8) else { - completeResponse(request, response: "Failed to verify file creation.", completion: completion) + completeResponse(request, status: .error, response: "Failed to verify file creation.", completion: completion) return true } contextProvider?.updateFileEdits(by: .init( fileURL: URL(fileURLWithPath: filePath), originalContent: "", - modifiedContent: content, + modifiedContent: writtenContent, toolName: CreateFileTool.name )) - do { - if let workspacePath = contextProvider?.chatTabInfo.workspacePath, - let xcodeIntance = Utils.getXcode(by: workspacePath) { - try Utils.openFileInXcode(fileURL: URL(fileURLWithPath: filePath), xcodeInstance: xcodeIntance) + Utils.openFileInXcode(fileURL: URL(fileURLWithPath: filePath)) { _, error in + if let error = error { + Logger.client.info("Failed to open file at \(filePath), \(error)") } - } catch { - Logger.client.info("Failed to open file in Xcode, \(error)") } let editAgentRounds: [AgentRound] = [ diff --git a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift index 76a35772..479e93b1 100644 --- a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift +++ b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift @@ -2,6 +2,10 @@ import ConversationServiceProvider import JSONRPC import ChatTab +enum ToolInvocationStatus: String { + case success, error, cancelled +} + public protocol ToolContextProvider { // MARK: insert_edit_into_file var chatTabInfo: ChatTabInfo { get } @@ -34,16 +38,21 @@ extension ICopilotTool { * Completes a tool response. * - Parameters: * - request: The original tool invocation request. + * - status: The completion status of the tool execution (success, error, or cancelled). * - response: The string value to include in the response content. * - completion: The completion handler to call with the response. */ func completeResponse( _ request: InvokeClientToolRequest, + status: ToolInvocationStatus = .success, response: String = "", completion: @escaping (AnyJSONRPCResponse) -> Void ) { let result: JSONValue = .array([ - .hash(["content": .array([.hash(["value": .string(response)])])]), + .hash([ + "status": .string(status.rawValue), + "content": .array([.hash(["value": .string(response)])]) + ]), .null ]) completion(AnyJSONRPCResponse(id: request.id, result: result)) diff --git a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift index 95185c28..22700a9a 100644 --- a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift @@ -1,10 +1,11 @@ -import ConversationServiceProvider import AppKit -import JSONRPC +import AXExtension +import AXHelper +import ConversationServiceProvider import Foundation -import XcodeInspector +import JSONRPC import Logger -import AXHelper +import XcodeInspector public class InsertEditIntoFileTool: ICopilotTool { public static let name = ToolName.insertEditIntoFile @@ -21,106 +22,231 @@ public class InsertEditIntoFileTool: ICopilotTool { let filePath = input["filePath"]?.value as? String, let contextProvider else { + completeResponse(request, status: .error, response: "Invalid parameters", completion: completion) return true } - let fileURL = URL(fileURLWithPath: filePath) do { + let fileURL = URL(fileURLWithPath: filePath) let originalContent = try String(contentsOf: fileURL, encoding: .utf8) - try InsertEditIntoFileTool.applyEdit(for: fileURL, content: code, contextProvider: contextProvider) - - contextProvider.updateFileEdits( - by: .init(fileURL: fileURL, originalContent: originalContent, modifiedContent: code, toolName: InsertEditIntoFileTool.name) - ) - - let editAgentRounds: [AgentRound] = [ - .init( - roundId: params.roundId, - reply: "", - toolCalls: [ - .init( - id: params.toolCallId, - name: params.name, - status: .completed, - invokeParams: params - ) - ] + InsertEditIntoFileTool.applyEdit(for: fileURL, content: code, contextProvider: contextProvider) { newContent, error in + if let error = error { + self.completeResponse( + request, + status: .error, + response: error.localizedDescription, + completion: completion + ) + return + } + + guard let newContent = newContent + else { + self.completeResponse(request, status: .error, response: "Failed to apply edit", completion: completion) + return + } + + contextProvider.updateFileEdits( + by: .init(fileURL: fileURL, originalContent: originalContent, modifiedContent: code, toolName: InsertEditIntoFileTool.name) ) - ] - - if let chatHistoryUpdater { - chatHistoryUpdater(params.turnId, editAgentRounds) + + let editAgentRounds: [AgentRound] = [ + .init( + roundId: params.roundId, + reply: "", + toolCalls: [ + .init( + id: params.toolCallId, + name: params.name, + status: .completed, + invokeParams: params + ) + ] + ) + ] + + if let chatHistoryUpdater { + chatHistoryUpdater(params.turnId, editAgentRounds) + } + + self.completeResponse(request, response: newContent, completion: completion) } - completeResponse(request, response: code, completion: completion) } catch { - Logger.client.error("Failed to apply edits, \(error)") - completeResponse(request, response: error.localizedDescription, completion: completion) + completeResponse( + request, + status: .error, + response: error.localizedDescription, + completion: completion + ) } return true } - public static func applyEdit(for fileURL: URL, content: String, contextProvider: (any ToolContextProvider), xcodeInstance: XcodeAppInstanceInspector) throws { - - /// wait a while for opening file in xcode. (3 seconds) - var retryCount = 6 - while retryCount > 0 { - guard xcodeInstance.realtimeDocumentURL != fileURL else { break } - - retryCount -= 1 - - /// Failed to get the target documentURL - if retryCount == 0 { - return - } - - Thread.sleep(forTimeInterval: 0.5) + public static func applyEdit( + for fileURL: URL, + content: String, + contextProvider: any ToolContextProvider, + xcodeInstance: AppInstanceInspector + ) throws -> String { + // Get the focused element directly from the app (like XcodeInspector does) + guard let focusedElement: AXUIElement = try? xcodeInstance.appElement.copyValue(key: kAXFocusedUIElementAttribute) + else { + throw NSError(domain: "Failed to access xcode element", code: 0) } - guard xcodeInstance.realtimeDocumentURL == fileURL - else { throw NSError(domain: "The file \(fileURL) is not opened in Xcode", code: 0)} + // Find the source editor element using XcodeInspector's logic + let editorElement = try findSourceEditorElement(from: focusedElement, xcodeInstance: xcodeInstance) - /// keep change - guard let element: AXUIElement = try? xcodeInstance.appElement.copyValue(key: kAXFocusedUIElementAttribute) - else { - throw NSError(domain: "Failed to access xcode element", code: 0) + // Check if element supports kAXValueAttribute before reading + var value: String = "" + do { + value = try editorElement.copyValue(key: kAXValueAttribute) + } catch { + if let axError = error as? AXError { + Logger.client.error("AX Error code: \(axError.rawValue)") + } + throw error } - let value: String = (try? element.copyValue(key: kAXValueAttribute)) ?? "" + let lines = value.components(separatedBy: .newlines) var isInjectedSuccess = false - try AXHelper().injectUpdatedCodeWithAccessibilityAPI( - .init( - content: content, - newSelection: nil, - modifications: [ - .deletedSelection( - .init(start: .init(line: 0, character: 0), end: .init(line: lines.count - 1, character: (lines.last?.count ?? 100) - 1)) - ), - .inserted(0, [content]) - ] - ), - focusElement: element, - onSuccess: { - isInjectedSuccess = true + var injectionError: Error? + + do { + try AXHelper().injectUpdatedCodeWithAccessibilityAPI( + .init( + content: content, + newSelection: nil, + modifications: [ + .deletedSelection( + .init(start: .init(line: 0, character: 0), end: .init(line: lines.count - 1, character: (lines.last?.count ?? 100) - 1)) + ), + .inserted(0, [content]) + ] + ), + focusElement: editorElement, + onSuccess: { + Logger.client.info("Content injection succeeded") + isInjectedSuccess = true + }, + onError: { + Logger.client.error("Content injection failed in onError callback") + } + ) + } catch { + Logger.client.error("Content injection threw error: \(error)") + if let axError = error as? AXError { + Logger.client.error("AX Error code during injection: \(axError.rawValue)") } - ) + injectionError = error + } if !isInjectedSuccess { - throw NSError(domain: "Failed to apply edit", code: 0) + let errorMessage = injectionError?.localizedDescription ?? "Failed to apply edit" + Logger.client.error("Edit application failed: \(errorMessage)") + throw NSError(domain: "Failed to apply edit: \(errorMessage)", code: 0) + } + + // Verify the content was applied by reading it back + do { + let newContent: String = try editorElement.copyValue(key: kAXValueAttribute) + Logger.client.info("Successfully read back new content, length: \(newContent.count)") + return newContent + } catch { + Logger.client.error("Failed to read back new content: \(error)") + if let axError = error as? AXError { + Logger.client.error("AX Error code when reading back: \(axError.rawValue)") + } + throw error } - } - public static func applyEdit(for fileURL: URL, content: String, contextProvider: (any ToolContextProvider)) throws { - guard let xcodeInstance = Utils.getXcode(by: contextProvider.chatTabInfo.workspacePath) - else { - throw NSError(domain: "The workspace \(contextProvider.chatTabInfo.workspacePath) is not opened in xcode", code: 0, userInfo: nil) + private static func findSourceEditorElement( + from element: AXUIElement, + xcodeInstance: AppInstanceInspector, + shouldRetry: Bool = true + ) throws -> AXUIElement { + // 1. Check if the current element is a source editor + if element.isSourceEditor { + return element + } + + // 2. Search for child that is a source editor + if let sourceEditorChild = element.firstChild(where: \.isSourceEditor) { + return sourceEditorChild } - try Utils.openFileInXcode(fileURL: fileURL, xcodeInstance: xcodeInstance) - try applyEdit(for: fileURL, content: content, contextProvider: contextProvider, xcodeInstance: xcodeInstance) + // 3. Search for parent that is a source editor (XcodeInspector's approach) + if let sourceEditorParent = element.firstParent(where: \.isSourceEditor) { + return sourceEditorParent + } + + // 4. Search for parent that is an editor area + if let editorAreaParent = element.firstParent(where: \.isEditorArea) { + // 3.1 Search for child that is a source editor + if let sourceEditorChild = editorAreaParent.firstChild(where: \.isSourceEditor) { + return sourceEditorChild + } + } + + // 5. Search for the workspace window + if let xcodeWorkspaceWindowParent = element.firstParent(where: \.isXcodeWorkspaceWindow) { + // 4.1 Search for child that is an editor area + if let editorAreaChild = xcodeWorkspaceWindowParent.firstChild(where: \.isEditorArea) { + // 4.2 Search for child that is a source editor + if let sourceEditorChild = editorAreaChild.firstChild(where: \.isSourceEditor) { + return sourceEditorChild + } + } + } + + // 6. retry + if shouldRetry { + Thread.sleep(forTimeInterval: 1) + return try findSourceEditorElement(from: element, xcodeInstance: xcodeInstance, shouldRetry: false) + } + + + throw NSError(domain: "Could not find source editor element", code: 0) + } + + public static func applyEdit( + for fileURL: URL, + content: String, + contextProvider: any ToolContextProvider, + completion: ((String?, Error?) -> Void)? = nil + ) { + Utils.openFileInXcode(fileURL: fileURL) { app, error in + do { + if let error = error { throw error } + + guard let app = app + else { + throw NSError(domain: "Failed to get the app that opens file.", code: 0) + } + + let appInstanceInspector = AppInstanceInspector(runningApplication: app) + guard appInstanceInspector.isXcode + else { + throw NSError(domain: "The file is not opened in Xcode.", code: 0) + } + + let newContent = try applyEdit( + for: fileURL, + content: content, + contextProvider: contextProvider, + xcodeInstance: appInstanceInspector + ) + + if let completion = completion { completion(newContent, nil) } + } catch { + if let completion = completion { completion(nil, error) } + Logger.client.info("Failed to apply edit for file at \(fileURL), \(error)") + } + } } } diff --git a/Core/Sources/ChatService/ToolCalls/Utils.swift b/Core/Sources/ChatService/ToolCalls/Utils.swift index 30cc3b06..e4cfcf0b 100644 --- a/Core/Sources/ChatService/ToolCalls/Utils.swift +++ b/Core/Sources/ChatService/ToolCalls/Utils.swift @@ -1,35 +1,42 @@ -import Foundation -import XcodeInspector import AppKit +import AppKitExtension +import Foundation import Logger +import XcodeInspector class Utils { - public static func openFileInXcode(fileURL: URL, xcodeInstance: XcodeAppInstanceInspector) throws { - /// TODO: when xcode minimized, the activate not work. - guard xcodeInstance.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) else { - throw NSError(domain: "Failed to activate xcode instance", code: 0) + public static func openFileInXcode( + fileURL: URL, + completion: ((NSRunningApplication?, Error?) -> Void)? = nil + ) { + guard let xcodeBundleURL = NSWorkspace.getXcodeBundleURL() + else { + if let completion = completion { + completion(nil, NSError(domain: "The Xcode app is not found.", code: 0)) + } + return } - - /// wait for a while to allow activation (especially un-minimizing) to complete - Thread.sleep(forTimeInterval: 0.3) let configuration = NSWorkspace.OpenConfiguration() configuration.activates = true NSWorkspace.shared.open( [fileURL], - withApplicationAt: xcodeInstance.runningApplication.bundleURL!, - configuration: configuration) { app, error in - if error != nil { - Logger.client.error("Failed to open file \(String(describing: error))") - } + withApplicationAt: xcodeBundleURL, + configuration: configuration + ) { app, error in + if let completion = completion { + completion(app, error) + } else if let error = error { + Logger.client.error("Failed to open file \(String(describing: error))") } + } } public static func getXcode(by workspacePath: String) -> XcodeAppInstanceInspector? { return XcodeInspector.shared.xcodes.first( where: { - return $0.workspaceURL?.path == workspacePath + $0.workspaceURL?.path == workspacePath }) } } diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index 5fb327a3..0750d6fe 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -21,6 +21,7 @@ public struct DisplayedChatMessage: Equatable { public var id: String public var role: Role public var text: String + public var imageReferences: [ImageReference] = [] public var references: [ConversationReference] = [] public var followUp: ConversationFollowUp? = nil public var suggestedTitle: String? = nil @@ -33,6 +34,7 @@ public struct DisplayedChatMessage: Equatable { id: String, role: Role, text: String, + imageReferences: [ImageReference] = [], references: [ConversationReference] = [], followUp: ConversationFollowUp? = nil, suggestedTitle: String? = nil, @@ -44,6 +46,7 @@ public struct DisplayedChatMessage: Equatable { self.id = id self.role = role self.text = text + self.imageReferences = imageReferences self.references = references self.followUp = followUp self.suggestedTitle = suggestedTitle @@ -74,6 +77,7 @@ struct Chat { var focusedField: Field? var currentEditor: FileReference? = nil var selectedFiles: [FileReference] = [] + var attachedImages: [ImageReference] = [] /// Cache the original content var fileEditMap: OrderedDictionary = [:] var diffViewerController: DiffViewWindowController? = nil @@ -118,12 +122,16 @@ struct Chat { case chatMenu(ChatMenu.Action) - // context + // File context case addSelectedFile(FileReference) case removeSelectedFile(FileReference) case resetCurrentEditor case setCurrentEditor(FileReference) + // Image context + case addSelectedImage(ImageReference) + case removeSelectedImage(ImageReference) + case followUpButtonClicked(String, String) // Agent File Edit @@ -192,8 +200,22 @@ struct Chat { let selectedFiles = state.selectedFiles let selectedModelFamily = AppState.shared.getSelectedModelFamily() ?? CopilotModelManager.getDefaultChatModel(scope: AppState.shared.modelScope())?.modelFamily let agentMode = AppState.shared.isAgentModeEnabled() + + let shouldAttachImages = AppState.shared.isSelectedModelSupportVision() ?? CopilotModelManager.getDefaultChatModel(scope: AppState.shared.modelScope())?.supportVision ?? false + let attachedImages: [ImageReference] = shouldAttachImages ? state.attachedImages : [] + state.attachedImages = [] return .run { _ in - try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles, model: selectedModelFamily, agentMode: agentMode, userLanguage: chatResponseLocale) + try await service + .send( + id, + content: message, + contentImageReferences: attachedImages, + skillSet: skillSet, + references: selectedFiles, + model: selectedModelFamily, + agentMode: agentMode, + userLanguage: chatResponseLocale + ) }.cancellable(id: CancelID.sendMessage(self.id)) case let .toolCallAccepted(toolCallId): @@ -362,6 +384,7 @@ struct Chat { } }(), text: message.content, + imageReferences: message.contentImageReferences, references: message.references.map { .init( uri: $0.uri, @@ -444,6 +467,7 @@ struct Chat { ChatInjector().insertCodeBlock(codeBlock: code) return .none + // MARK: - File Context case let .addSelectedFile(fileReference): guard !state.selectedFiles.contains(fileReference) else { return .none } state.selectedFiles.append(fileReference) @@ -459,6 +483,16 @@ struct Chat { state.currentEditor = fileReference return .none + // MARK: - Image Context + case let .addSelectedImage(imageReference): + guard !state.attachedImages.contains(imageReference) else { return .none } + state.attachedImages.append(imageReference) + return .none + case let .removeSelectedImage(imageReference): + guard let index = state.attachedImages.firstIndex(of: imageReference) else { return .none } + state.attachedImages.remove(at: index) + return .none + // MARK: - Agent Edits case let .undoEdits(fileURLs): diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index cd7c313b..f7f872c7 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -11,11 +11,10 @@ import SwiftUIFlowLayout import XcodeInspector import ChatTab import Workspace -import HostAppActivator import Persist import UniformTypeIdentifiers -private let r: Double = 8 +private let r: Double = 4 public struct ChatPanel: View { @Perception.Bindable var chat: StoreOf @@ -78,14 +77,17 @@ public struct ChatPanel: View { return nil }() - guard let url, - let isValidFile = try? WorkspaceFile.isValidFile(url), - isValidFile - else { return } - - DispatchQueue.main.async { - let fileReference = FileReference(url: url, isCurrentEditor: false) - chat.send(.addSelectedFile(fileReference)) + guard let url else { return } + if let isValidFile = try? WorkspaceFile.isValidFile(url), isValidFile { + DispatchQueue.main.async { + let fileReference = FileReference(url: url, isCurrentEditor: false) + chat.send(.addSelectedFile(fileReference)) + } + } else if let data = try? Data(contentsOf: url), + ["png", "jpeg", "jpg", "bmp", "gif", "tiff", "tif", "webp"].contains(url.pathExtension.lowercased()) { + DispatchQueue.main.async { + chat.send(.addSelectedImage(ImageReference(data: data, fileUrl: url))) + } } } } @@ -95,6 +97,8 @@ public struct ChatPanel: View { } } + + private struct ScrollViewOffsetPreferenceKey: PreferenceKey { static var defaultValue = CGFloat.zero @@ -365,7 +369,12 @@ struct ChatHistoryItem: View { let text = message.text switch message.role { case .user: - UserMessage(id: message.id, text: text, chat: chat) + UserMessage( + id: message.id, + text: text, + imageReferences: message.imageReferences, + chat: chat + ) case .assistant: BotMessage( id: message.id, @@ -523,6 +532,10 @@ struct ChatPanelInputArea: View { } } + if !chat.state.attachedImages.isEmpty { + ImagesScrollView(chat: chat) + } + ZStack(alignment: .topLeading) { if chat.typedMessage.isEmpty { Group { @@ -669,10 +682,10 @@ struct ChatPanelInputArea: View { } } - enum ChatContextButtonType { case mcpConfig, contextAttach} + enum ChatContextButtonType { case imageAttach, contextAttach} private var chatContextView: some View { - let buttonItems: [ChatContextButtonType] = chat.isAgentMode ? [.mcpConfig, .contextAttach] : [.contextAttach] + let buttonItems: [ChatContextButtonType] = [.contextAttach, .imageAttach] let currentEditorItem: [FileReference] = [chat.state.currentEditor].compactMap { $0 } @@ -682,25 +695,8 @@ struct ChatPanelInputArea: View { } + currentEditorItem + selectedFileItems return FlowLayout(mode: .scrollable, items: chatContextItems, itemSpacing: 4) { item in if let buttonType = item as? ChatContextButtonType { - if buttonType == .mcpConfig { - // MCP Settings button - Button(action: { - try? launchHostAppMCPSettings() - }) { - Image(systemName: "wrench.and.screwdriver") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundColor(.primary.opacity(0.85)) - .padding(4) - } - .buttonStyle(HoverButtonStyle(padding: 0)) - .help("Configure your MCP server") - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: r) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) + if buttonType == .imageAttach { + VisionMenuView(chat: chat) } else if buttonType == .contextAttach { // File picker button Button(action: { @@ -711,25 +707,17 @@ struct ChatPanelInputArea: View { } } }) { - HStack(spacing: 4) { - Image(systemName: "paperclip") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundColor(.primary.opacity(0.85)) - Text("Add Context...") - .foregroundColor(.primary.opacity(0.85)) - .lineLimit(1) - } - .padding(4) + Image(systemName: "paperclip") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 16, height: 16) + .padding(4) + .foregroundColor(.primary.opacity(0.85)) + .font(Font.system(size: 11, weight: .semibold)) } .buttonStyle(HoverButtonStyle(padding: 0)) .help("Add Context") .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: r) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) } } else if let select = item as? FileReference { HStack(spacing: 0) { @@ -739,6 +727,7 @@ struct ChatPanelInputArea: View { .frame(width: 16, height: 16) .foregroundColor(.primary.opacity(0.85)) .padding(4) + .opacity(select.isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) Text(select.url.lastPathComponent) .lineLimit(1) @@ -748,66 +737,36 @@ struct ChatPanelInputArea: View { ? .secondary : .primary.opacity(0.85) ) - .font(select.isCurrentEditor && !isCurrentEditorContextEnabled - ? .body.italic() - : .body - ) + .font(.body) + .opacity(select.isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) .help(select.getPathRelativeToHome()) if select.isCurrentEditor { - Text("Current file") - .foregroundStyle(.secondary) - .font(select.isCurrentEditor && !isCurrentEditorContextEnabled - ? .callout.italic() - : .callout - ) - .padding(.leading, 4) - } - - Button(action: { - if select.isCurrentEditor { - enableCurrentEditorContext.toggle() - isCurrentEditorContextEnabled = enableCurrentEditorContext - } else { - chat.send(.removeSelectedFile(select)) - } - }) { - if select.isCurrentEditor { - if isCurrentEditorContextEnabled { - Image("Eye") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundColor(.secondary) - .help("Disable current file context") - } else { - Image("EyeClosed") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundColor(.secondary) - .help("Enable current file context") + Toggle("", isOn: $isCurrentEditorContextEnabled) + .toggleStyle(SwitchToggleStyle(tint: .blue)) + .controlSize(.mini) + .padding(.trailing, 4) + .onChange(of: isCurrentEditorContextEnabled) { newValue in + enableCurrentEditorContext = newValue } - } else { + } else { + Button(action: { chat.send(.removeSelectedFile(select)) }) { Image(systemName: "xmark") .resizable() .frame(width: 8, height: 8) - .foregroundColor(.secondary) + .foregroundColor(.primary.opacity(0.85)) .padding(4) } + .buttonStyle(HoverButtonStyle()) } - .buttonStyle(HoverButtonStyle()) } - .cornerRadius(6) + .background( + Color(nsColor: .windowBackgroundColor).opacity(0.5) + ) + .cornerRadius(select.isCurrentEditor ? 99 : r) .overlay( - RoundedRectangle(cornerRadius: r) - .stroke( - Color(nsColor: .separatorColor), - style: .init( - lineWidth: 1, - dash: select.isCurrentEditor && !isCurrentEditorContextEnabled ? [4, 2] : [] - ) - ) + RoundedRectangle(cornerRadius: select.isCurrentEditor ? 99 : r) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) ) } } diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift index 2c6f674f..50ebe68f 100644 --- a/Core/Sources/ConversationTab/ConversationTab.swift +++ b/Core/Sources/ConversationTab/ConversationTab.swift @@ -8,6 +8,9 @@ import Foundation import ChatAPIService import Preferences import SwiftUI +import AppKit +import Workspace +import ConversationServiceProvider /// A chat tab that provides a context aware chat bot, powered by Chat. public class ConversationTab: ChatTab { @@ -241,5 +244,34 @@ public class ConversationTab: ChatTab { } } } + + public func handlePasteEvent() -> Bool { + let pasteboard = NSPasteboard.general + if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], !urls.isEmpty { + for url in urls { + if let isValidFile = try? WorkspaceFile.isValidFile(url), isValidFile { + DispatchQueue.main.async { + let fileReference = FileReference(url: url, isCurrentEditor: false) + self.chat.send(.addSelectedFile(fileReference)) + } + } else if let data = try? Data(contentsOf: url), + ["png", "jpeg", "jpg", "bmp", "gif", "tiff", "tif", "webp"].contains(url.pathExtension.lowercased()) { + DispatchQueue.main.async { + self.chat.send(.addSelectedImage(ImageReference(data: data, fileUrl: url))) + } + } + } + } else if let data = pasteboard.data(forType: .png) { + chat.send(.addSelectedImage(ImageReference(data: data, source: .pasted))) + } else if let tiffData = pasteboard.data(forType: .tiff), + let imageRep = NSBitmapImageRep(data: tiffData), + let pngData = imageRep.representation(using: .png, properties: [:]) { + chat.send(.addSelectedImage(ImageReference(data: pngData, source: .pasted))) + } else { + return false + } + + return true + } } diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift index 2fceb491..7dd48183 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift @@ -4,6 +4,8 @@ import Persist import ComposableArchitecture import GitHubCopilotService import Combine +import HostAppActivator +import SharedUIComponents import ConversationServiceProvider public let SELECTED_LLM_KEY = "selectedLLM" @@ -29,6 +31,13 @@ extension AppState { } return nil } + + func isSelectedModelSupportVision() -> Bool? { + if let savedModel = get(key: SELECTED_LLM_KEY) { + return savedModel["supportVision"]?.boolValue + } + return nil + } func setSelectedModel(_ model: LLMModel) { update(key: SELECTED_LLM_KEY, value: model) @@ -104,7 +113,8 @@ class CopilotModelManagerObservable: ObservableObject { .init( modelName: fallbackModel.modelName, modelFamily: fallbackModel.id, - billing: fallbackModel.billing + billing: fallbackModel.billing, + supportVision: fallbackModel.capabilities.supports.vision ) ) } @@ -122,7 +132,8 @@ extension CopilotModelManager { return LLMModel( modelName: $0.modelName, modelFamily: $0.isChatFallback ? $0.id : $0.modelFamily, - billing: $0.billing + billing: $0.billing, + supportVision: $0.capabilities.supports.vision ) } } @@ -136,7 +147,8 @@ extension CopilotModelManager { return LLMModel( modelName: defaultModel.modelName, modelFamily: defaultModel.modelFamily, - billing: defaultModel.billing + billing: defaultModel.billing, + supportVision: defaultModel.capabilities.supports.vision ) } @@ -146,7 +158,8 @@ extension CopilotModelManager { return LLMModel( modelName: gpt4_1.modelName, modelFamily: gpt4_1.modelFamily, - billing: gpt4_1.billing + billing: gpt4_1.billing, + supportVision: gpt4_1.capabilities.supports.vision ) } @@ -155,7 +168,8 @@ extension CopilotModelManager { return LLMModel( modelName: firstModel.modelName, modelFamily: firstModel.modelFamily, - billing: firstModel.billing + billing: firstModel.billing, + supportVision: firstModel.capabilities.supports.vision ) } @@ -167,6 +181,7 @@ struct LLMModel: Codable, Hashable { let modelName: String let modelFamily: String let billing: CopilotModelBilling? + let supportVision: Bool } struct ScopeCache { @@ -355,6 +370,23 @@ struct ModelPicker: View { } } + private var mcpButton: some View { + Button(action: { + try? launchHostAppMCPSettings() + }) { + Image(systemName: "wrench.and.screwdriver") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .padding(4) + .foregroundColor(.primary.opacity(0.85)) + .font(Font.system(size: 11, weight: .semibold)) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .help("Configure your MCP server") + .cornerRadius(6) + } + // Main view body var body: some View { WithPerceptionTracking { @@ -365,6 +397,10 @@ struct ModelPicker: View { updateAgentPicker() } + if chatMode == "Agent" { + mcpButton + } + // Model Picker Group { if !models.isEmpty && !selectedModel.isEmpty { diff --git a/Core/Sources/ConversationTab/Styles.swift b/Core/Sources/ConversationTab/Styles.swift index a4b5ddf1..0306e4c7 100644 --- a/Core/Sources/ConversationTab/Styles.swift +++ b/Core/Sources/ConversationTab/Styles.swift @@ -35,6 +35,7 @@ extension NSAppearance { extension View { var messageBubbleCornerRadius: Double { 8 } + var hoverableImageCornerRadius: Double { 4 } func codeBlockLabelStyle() -> some View { relativeLineSpacing(.em(0.225)) diff --git a/Core/Sources/ConversationTab/Views/ImageReferenceItemView.swift b/Core/Sources/ConversationTab/Views/ImageReferenceItemView.swift new file mode 100644 index 00000000..ef2ac6c7 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ImageReferenceItemView.swift @@ -0,0 +1,69 @@ +import ConversationServiceProvider +import SwiftUI +import Foundation + +struct ImageReferenceItemView: View { + let item: ImageReference + @State private var showPopover = false + + private func getImageTitle() -> String { + switch item.source { + case .file: + if let fileUrl = item.fileUrl { + return fileUrl.lastPathComponent + } else { + return "Attached Image" + } + case .pasted: + return "Pasted Image" + case .screenshot: + return "Screenshot" + } + } + + var body: some View { + HStack(alignment: .center, spacing: 4) { + let image = loadImageFromData(data: item.data).image + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 28, height: 28) + .clipShape(RoundedRectangle(cornerRadius: 1.72)) + .overlay( + RoundedRectangle(cornerRadius: 1.72) + .inset(by: 0.21) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.43) + ) + + let text = getImageTitle() + let font = NSFont.systemFont(ofSize: 12) + let attributes = [NSAttributedString.Key.font: font] + let size = (text as NSString).size(withAttributes: attributes) + let textWidth = min(size.width, 105) + + Text(text) + .lineLimit(1) + .font(.system(size: 12)) + .foregroundColor(.primary.opacity(0.85)) + .truncationMode(.middle) + .frame(width: textWidth, alignment: .leading) + } + .padding(4) + .background( + Color(nsColor: .windowBackgroundColor).opacity(0.5) + ) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .inset(by: 0.5) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .popover(isPresented: $showPopover, arrowEdge: .bottom) { + PopoverImageView(data: item.data) + } + .onTapGesture { + self.showPopover = true + } + } +} + diff --git a/Core/Sources/ConversationTab/Views/UserMessage.swift b/Core/Sources/ConversationTab/Views/UserMessage.swift index f2aea5f6..19a2ca00 100644 --- a/Core/Sources/ConversationTab/Views/UserMessage.swift +++ b/Core/Sources/ConversationTab/Views/UserMessage.swift @@ -7,11 +7,14 @@ import SwiftUI import Status import Cache import ChatTab +import ConversationServiceProvider +import SwiftUIFlowLayout struct UserMessage: View { var r: Double { messageBubbleCornerRadius } let id: String let text: String + let imageReferences: [ImageReference] let chat: StoreOf @Environment(\.colorScheme) var colorScheme @ObservedObject private var statusObserver = StatusObserver.shared @@ -49,6 +52,12 @@ struct UserMessage: View { ThemedMarkdownText(text: text, chat: chat) .frame(maxWidth: .infinity, alignment: .leading) + + if !imageReferences.isEmpty { + FlowLayout(mode: .scrollable, items: imageReferences, itemSpacing: 4) { item in + ImageReferenceItemView(item: item) + } + } } } .shadow(color: .black.opacity(0.05), radius: 6) @@ -73,6 +82,7 @@ struct UserMessage_Previews: PreviewProvider { - (void)bar {} ``` """#, + imageReferences: [], chat: .init( initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) } diff --git a/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift b/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift new file mode 100644 index 00000000..56383d34 --- /dev/null +++ b/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift @@ -0,0 +1,159 @@ +import SwiftUI +import ComposableArchitecture +import Persist +import ConversationServiceProvider +import GitHubCopilotService + +public struct HoverableImageView: View { + @Environment(\.colorScheme) var colorScheme + + let image: ImageReference + let chat: StoreOf + @State private var isHovered = false + @State private var hoverTask: Task? + @State private var isSelectedModelSupportVision = AppState.shared.isSelectedModelSupportVision() ?? CopilotModelManager.getDefaultChatModel(scope: AppState.shared.modelScope())?.supportVision ?? false + @State private var showPopover = false + + let maxWidth: CGFloat = 330 + let maxHeight: CGFloat = 160 + + private var visionNotSupportedOverlay: some View { + Group { + if !isSelectedModelSupportVision { + ZStack { + Color.clear + .background(.regularMaterial) + .opacity(0.4) + .clipShape(RoundedRectangle(cornerRadius: hoverableImageCornerRadius)) + + VStack(alignment: .center, spacing: 8) { + Image(systemName: "eye.slash") + .font(.system(size: 14, weight: .semibold)) + Text("Vision not supported by current model") + .font(.system(size: 12, weight: .semibold)) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + .foregroundColor(colorScheme == .dark ? .primary : .white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .colorScheme(colorScheme == .dark ? .light : .dark) + } + } + } + + private var borderOverlay: some View { + RoundedRectangle(cornerRadius: hoverableImageCornerRadius) + .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 1) + } + + private var removeButton: some View { + Button(action: { + chat.send(.removeSelectedImage(image)) + }) { + Image(systemName: "xmark") + .foregroundColor(.primary) + .font(.system(size: 13)) + .frame(width: 24, height: 24) + .background( + RoundedRectangle(cornerRadius: hoverableImageCornerRadius) + .fill(Color.contentBackground.opacity(0.72)) + .shadow(color: .black.opacity(0.3), radius: 1.5, x: 0, y: 0) + .shadow(color: .black.opacity(0.25), radius: 50, x: 0, y: 36) + ) + } + .buttonStyle(.plain) + .padding(1) + .onHover { buttonHovering in + hoverTask?.cancel() + if buttonHovering { + isHovered = true + } + } + } + + private var hoverOverlay: some View { + Group { + if isHovered { + VStack { + Spacer() + HStack { + removeButton + Spacer() + } + } + } + } + } + + private var baseImageView: some View { + let (image, nsImage) = loadImageFromData(data: image.data) + let imageSize = nsImage?.size ?? CGSize(width: maxWidth, height: maxHeight) + let isWideImage = imageSize.height < 160 && imageSize.width >= maxWidth + + return image + .resizable() + .aspectRatio(contentMode: isWideImage ? .fill : .fit) + .blur(radius: !isSelectedModelSupportVision ? 2.5 : 0) + .frame( + width: isWideImage ? min(imageSize.width, maxWidth) : nil, + height: isWideImage ? min(imageSize.height, maxHeight) : maxHeight, + alignment: .leading + ) + .clipShape( + RoundedRectangle(cornerRadius: hoverableImageCornerRadius), + style: .init(eoFill: true, antialiased: true) + ) + } + + private func handleHover(_ hovering: Bool) { + hoverTask?.cancel() + + if hovering { + isHovered = true + } else { + // Add a small delay before hiding to prevent flashing + hoverTask = Task { + try? await Task.sleep(nanoseconds: 10_000_000) // 0.01 seconds + if !Task.isCancelled { + isHovered = false + } + } + } + } + + private func updateVisionSupport() { + isSelectedModelSupportVision = AppState.shared.isSelectedModelSupportVision() ?? CopilotModelManager.getDefaultChatModel(scope: AppState.shared.modelScope())?.supportVision ?? false + } + + public var body: some View { + if NSImage(data: image.data) != nil { + baseImageView + .frame(height: maxHeight, alignment: .leading) + .background( + Color(nsColor: .windowBackgroundColor).opacity(0.5) + ) + .overlay(visionNotSupportedOverlay) + .overlay(borderOverlay) + .onHover(perform: handleHover) + .overlay(hoverOverlay) + .onReceive(NotificationCenter.default.publisher(for: .gitHubCopilotSelectedModelDidChange)) { _ in + updateVisionSupport() + } + .onTapGesture { + showPopover.toggle() + } + .popover(isPresented: $showPopover, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) { + PopoverImageView(data: image.data) + } + } + } +} + +public func loadImageFromData(data: Data) -> (image: Image, nsImage: NSImage?) { + if let nsImage = NSImage(data: data) { + return (Image(nsImage: nsImage), nsImage) + } else { + return (Image(systemName: "photo.trianglebadge.exclamationmark"), nil) + } +} diff --git a/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift b/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift new file mode 100644 index 00000000..87e7179a --- /dev/null +++ b/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift @@ -0,0 +1,19 @@ +import SwiftUI +import ComposableArchitecture + +public struct ImagesScrollView: View { + let chat: StoreOf + + public var body: some View { + let attachedImages = chat.state.attachedImages.reversed() + return ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { + ForEach(attachedImages, id: \.self) { image in + HoverableImageView(image: image, chat: chat) + } + } + } + .padding(.horizontal, 8) + .padding(.top, 8) + } +} diff --git a/Core/Sources/ConversationTab/VisionViews/PopoverImageView.swift b/Core/Sources/ConversationTab/VisionViews/PopoverImageView.swift new file mode 100644 index 00000000..0beddb8c --- /dev/null +++ b/Core/Sources/ConversationTab/VisionViews/PopoverImageView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +public struct PopoverImageView: View { + let data: Data + + public var body: some View { + let maxHeight: CGFloat = 400 + let (image, nsImage) = loadImageFromData(data: data) + let height = nsImage.map { min($0.size.height, maxHeight) } ?? maxHeight + + return image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: height) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(10) + } +} diff --git a/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift b/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift new file mode 100644 index 00000000..8e18d40d --- /dev/null +++ b/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift @@ -0,0 +1,130 @@ +import SwiftUI +import SharedUIComponents +import Logger +import ComposableArchitecture +import ConversationServiceProvider +import AppKit +import UniformTypeIdentifiers + +public struct VisionMenuView: View { + let chat: StoreOf + @AppStorage(\.capturePermissionShown) var capturePermissionShown: Bool + @State private var shouldPresentScreenRecordingPermissionAlert: Bool = false + + func showImagePicker() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.png, .jpeg, .bmp, .gif, .tiff, .webP] + panel.allowsMultipleSelection = true + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.level = .modalPanel + + // Position the panel relative to the current window + if let window = NSApplication.shared.keyWindow { + let windowFrame = window.frame + let panelSize = CGSize(width: 600, height: 400) + let x = windowFrame.midX - panelSize.width / 2 + let y = windowFrame.midY - panelSize.height / 2 + panel.setFrame(NSRect(origin: CGPoint(x: x, y: y), size: panelSize), display: true) + } + + panel.begin { response in + if response == .OK { + let selectedImageURLs = panel.urls + handleSelectedImages(selectedImageURLs) + } + } + } + + func handleSelectedImages(_ urls: [URL]) { + for url in urls { + let gotAccess = url.startAccessingSecurityScopedResource() + if gotAccess { + // Process the image file + if let imageData = try? Data(contentsOf: url) { + // imageData now contains the binary data of the image + Logger.client.info("Add selected image from URL: \(url)") + let imageReference = ImageReference(data: imageData, fileUrl: url) + chat.send(.addSelectedImage(imageReference)) + } + + url.stopAccessingSecurityScopedResource() + } + } + } + + func runScreenCapture(args: [String] = []) { + let hasScreenRecordingPermission = CGPreflightScreenCaptureAccess() + if !hasScreenRecordingPermission { + if capturePermissionShown { + shouldPresentScreenRecordingPermissionAlert = true + } else { + CGRequestScreenCaptureAccess() + capturePermissionShown = true + } + return + } + + let task = Process() + task.launchPath = "/usr/sbin/screencapture" + task.arguments = args + task.terminationHandler = { _ in + DispatchQueue.main.async { + if task.terminationStatus == 0 { + if let data = NSPasteboard.general.data(forType: .png) { + chat.send(.addSelectedImage(ImageReference(data: data, source: .screenshot))) + } else if let tiffData = NSPasteboard.general.data(forType: .tiff), + let imageRep = NSBitmapImageRep(data: tiffData), + let pngData = imageRep.representation(using: .png, properties: [:]) { + chat.send(.addSelectedImage(ImageReference(data: pngData, source: .screenshot))) + } + } + } + } + task.launch() + task.waitUntilExit() + } + + public var body: some View { + Menu { + Button(action: { runScreenCapture(args: ["-w", "-c"]) }) { + Image(systemName: "macwindow") + Text("Capture Window") + } + + Button(action: { runScreenCapture(args: ["-s", "-c"]) }) { + Image(systemName: "macwindow.and.cursorarrow") + Text("Capture Selection") + } + + Button(action: { showImagePicker() }) { + Image(systemName: "photo") + Text("Attach File") + } + } label: { + Image(systemName: "photo.badge.plus") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 16, height: 16) + .padding(4) + .foregroundColor(.primary.opacity(0.85)) + .font(Font.system(size: 11, weight: .semibold)) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .help("Attach images") + .cornerRadius(6) + .alert( + "Enable Screen & System Recording Permission", + isPresented: $shouldPresentScreenRecordingPermissionAlert + ) { + Button( + "Open System Settings", + action: { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture")!) + }).keyboardShortcut(.defaultAction) + Button("Deny", role: .cancel, action: {}) + } message: { + Text("Grant access to this application in Privacy & Security settings, located in System Settings") + } + } +} diff --git a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift index aeb8bd70..347f202d 100644 --- a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift +++ b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift @@ -80,6 +80,9 @@ struct CopilotConnectionView: View { title: "GitHub Copilot Account Settings" ) } + .onReceive(DistributedNotificationCenter.default().publisher(for: .authStatusDidChange)) { _ in + viewModel.checkStatus() + } } var copilotResources: some View { diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index 59027eaa..0a6c6475 100644 --- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -3,6 +3,7 @@ import ChatTab import ComposableArchitecture import Foundation import SwiftUI +import ConversationTab final class ChatPanelWindow: NSWindow { override var canBecomeKey: Bool { true } diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift index 7cf55d8a..64a1c28a 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift @@ -195,16 +195,18 @@ struct ChatHistoryItemView: View { Spacer() if !isTabSelected() { - if isHovered { - Button(action: { - store.send(.chatHisotryDeleteButtonClicked(id: previewInfo.id)) + Button(action: { + Task { @MainActor in + await store.send(.chatHistoryDeleteButtonClicked(id: previewInfo.id)).finish() onDelete() - }) { - Image(systemName: "trash") } - .buttonStyle(HoverButtonStyle()) - .help("Delete") + }) { + Image(systemName: "trash") + .opacity(isHovered ? 1 : 0) } + .buttonStyle(HoverButtonStyle()) + .help("Delete") + .allowsHitTesting(isHovered) } } .padding(.horizontal, 12) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index cedbe798..68bdeb5a 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -7,6 +7,8 @@ import SwiftUI import SharedUIComponents import GitHubCopilotViewModel import Status +import ChatService +import Workspace private let r: Double = 8 @@ -20,7 +22,7 @@ struct ChatWindowView: View { WithPerceptionTracking { // Force re-evaluation when workspace state changes let currentWorkspace = store.currentChatWorkspace - let selectedTabId = currentWorkspace?.selectedTabId + let _ = currentWorkspace?.selectedTabId ZStack { if statusObserver.observedAXStatus == .notGranted { ChatNoAXPermissionView() @@ -251,7 +253,7 @@ struct ChatBar: View { var body: some View { WithPerceptionTracking { HStack(spacing: 0) { - if let name = store.chatHistory.selectedWorkspaceName { + if store.chatHistory.selectedWorkspaceName != nil { ChatWindowHeader(store: store) } @@ -419,6 +421,7 @@ struct ChatTabBarButton: View { struct ChatTabContainer: View { let store: StoreOf @Environment(\.chatTabPool) var chatTabPool + @State private var pasteMonitor: Any? var body: some View { WithPerceptionTracking { @@ -437,6 +440,12 @@ struct ChatTabContainer: View { EmptyView().frame(maxWidth: .infinity, maxHeight: .infinity) } } + .onAppear { + setupPasteMonitor() + } + .onDisappear { + removePasteMonitor() + } } // View displayed when there are active tabs @@ -462,6 +471,39 @@ struct ChatTabContainer: View { } } } + + private func setupPasteMonitor() { + pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + guard event.modifierFlags.contains(.command), + event.charactersIgnoringModifiers?.lowercased() == "v" else { + return event + } + + // Find the active chat tab and forward paste event to it + if let activeConversationTab = getActiveConversationTab() { + if !activeConversationTab.handlePasteEvent() { + return event + } + } + + return nil + } + } + + private func removePasteMonitor() { + if let monitor = pasteMonitor { + NSEvent.removeMonitor(monitor) + pasteMonitor = nil + } + } + + private func getActiveConversationTab() -> ConversationTab? { + guard let selectedTabId = store.currentChatWorkspace?.selectedTabId, + let chatTab = chatTabPool.getTab(of: selectedTabId) as? ConversationTab else { + return nil + } + return chatTab + } } struct CreateOtherChatTabMenuStyle: MenuStyle { diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index a308600c..d22b6024 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -178,7 +178,7 @@ public struct ChatPanelFeature { // Chat History case chatHistoryItemClicked(id: String) - case chatHisotryDeleteButtonClicked(id: String) + case chatHistoryDeleteButtonClicked(id: String) case chatTab(id: String, action: ChatTabItem.Action) // persist @@ -335,7 +335,7 @@ public struct ChatPanelFeature { state.chatHistory.updateHistory(currentChatWorkspace) return .none - case let .chatHisotryDeleteButtonClicked(id): + case let .chatHistoryDeleteButtonClicked(id): // the current chat should not be deleted guard var currentChatWorkspace = state.currentChatWorkspace, id != currentChatWorkspace.selectedTabId else { return .none diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 48001d40..5ad06d7a 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -239,8 +239,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let notifications = DistributedNotificationCenter.default().notifications(named: .authStatusDidChange) Task { [weak self] in for await _ in notifications { - guard let self else { return } - await self.forceAuthStatusCheck() + guard self != nil else { return } + do { + let service = try await GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let accountStatus = try await service.checkStatus() + if accountStatus == .notSignedIn { + try await GitHubCopilotService.signOutAll() + } + } catch { + Logger.service.error("Failed to watch auth status: \(error)") + } } } } diff --git a/Server/package-lock.json b/Server/package-lock.json index 490aa158..dfbc7944 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.335.0", + "@github/copilot-language-server": "^1.337.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,9 +36,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.335.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.335.0.tgz", - "integrity": "sha512-uX5t6kOlWau4WtpL/WQLL8qADE4iHSfbDojYRVq8kTIjg1u5w6Ty7wqddnfyPUIpTltifsBVoHjHpW5vdhf55g==", + "version": "1.337.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.337.0.tgz", + "integrity": "sha512-tvCgScCaZrHlrQgDqXACH9DzI9uA+EYIMJVMaEyfCE46fbkfDQEixascbpKiRW2cB4eMFnxXlU+m2x8KH54XuA==", "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" diff --git a/Server/package.json b/Server/package.json index 92ed5789..822a3bc6 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.335.0", + "@github/copilot-language-server": "^1.337.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/Tool/Package.swift b/Tool/Package.swift index cfdc50b7..3391cd51 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -63,6 +63,7 @@ let package = Package( .library(name: "Cache", targets: ["Cache"]), .library(name: "StatusBarItemView", targets: ["StatusBarItemView"]), .library(name: "HostAppActivator", targets: ["HostAppActivator"]), + .library(name: "AppKitExtension", targets: ["AppKitExtension"]) ], dependencies: [ // TODO: Update LanguageClient some day. @@ -105,10 +106,10 @@ let package = Package( .target( name: "Toast", - dependencies: [.product( - name: "ComposableArchitecture", - package: "swift-composable-architecture" - )] + dependencies: [ + "AppKitExtension", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture") + ] ), .target(name: "DebounceFunction"), @@ -352,6 +353,10 @@ let package = Package( dependencies: ["Logger"] ), .testTarget(name: "SystemUtilsTests", dependencies: ["SystemUtils"]), + + // MARK: - AppKitExtension + + .target(name: "AppKitExtension") ] ) diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index f32f4d44..9199fa49 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -59,6 +59,14 @@ public extension AXUIElement { var isSourceEditor: Bool { description == "Source Editor" } + + var isEditorArea: Bool { + description == "editor area" + } + + var isXcodeWorkspaceWindow: Bool { + description == "Xcode.WorkspaceWindow" + } var selectedTextRange: ClosedRange? { guard let value: AXValue = try? copyValue(key: kAXSelectedTextRangeAttribute) diff --git a/Tool/Sources/AppKitExtension/NSWorkspace+Extension.swift b/Tool/Sources/AppKitExtension/NSWorkspace+Extension.swift new file mode 100644 index 00000000..9cc54ede --- /dev/null +++ b/Tool/Sources/AppKitExtension/NSWorkspace+Extension.swift @@ -0,0 +1,22 @@ +import AppKit + +extension NSWorkspace { + public static func getXcodeBundleURL() -> URL? { + var xcodeBundleURL: URL? + + // Get currently running Xcode application URL + if let xcodeApp = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == "com.apple.dt.Xcode" }) { + xcodeBundleURL = xcodeApp.bundleURL + } + + // Fallback to standard path if we couldn't get the running instance + if xcodeBundleURL == nil { + let standardPath = "/Applications/Xcode.app" + if FileManager.default.fileExists(atPath: standardPath) { + xcodeBundleURL = URL(fileURLWithPath: standardPath) + } + } + + return xcodeBundleURL + } +} diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index 7f1697f4..9706a4bd 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -77,6 +77,9 @@ 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 attached image content of the message + public var contentImageReferences: [ImageReference] /// The id of the message. public var id: ID @@ -118,6 +121,7 @@ public struct ChatMessage: Equatable, Codable { clsTurnID: String? = nil, role: Role, content: String, + contentImageReferences: [ImageReference] = [], references: [ConversationReference] = [], followUp: ConversationFollowUp? = nil, suggestedTitle: String? = nil, @@ -131,6 +135,7 @@ public struct ChatMessage: Equatable, Codable { ) { self.role = role self.content = content + self.contentImageReferences = contentImageReferences self.id = id self.chatTabID = chatTabID self.clsTurnID = clsTurnID diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index 1ba19b74..1c4a2407 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -71,13 +71,184 @@ extension FileReference { } } +public enum ImageReferenceSource: String, Codable { + case file = "file" + case pasted = "pasted" + case screenshot = "screenshot" +} + +public struct ImageReference: Equatable, Codable, Hashable { + public var data: Data + public var fileUrl: URL? + public var source: ImageReferenceSource + + public init(data: Data, source: ImageReferenceSource) { + self.data = data + self.source = source + } + + public init(data: Data, fileUrl: URL) { + self.data = data + self.fileUrl = fileUrl + self.source = .file + } + + public func dataURL(imageType: String = "") -> String { + let base64String = data.base64EncodedString() + var type = imageType + if let url = fileUrl, imageType.isEmpty { + type = url.pathExtension + } + + let mimeType: String + switch type { + case "png": + mimeType = "image/png" + case "jpeg", "jpg": + mimeType = "image/jpeg" + case "bmp": + mimeType = "image/bmp" + case "gif": + mimeType = "image/gif" + case "webp": + mimeType = "image/webp" + case "tiff", "tif": + mimeType = "image/tiff" + default: + mimeType = "image/png" + } + + return "data:\(mimeType);base64,\(base64String)" + } +} + +public enum MessageContentType: String, Codable { + case text = "text" + case imageUrl = "image_url" +} + +public enum ImageDetail: String, Codable { + case low = "low" + case high = "high" +} + +public struct ChatCompletionImageURL: Codable,Equatable { + let url: String + let detail: ImageDetail? + + public init(url: String, detail: ImageDetail? = nil) { + self.url = url + self.detail = detail + } +} + +public struct ChatCompletionContentPartText: Codable, Equatable { + public let type: MessageContentType + public let text: String + + public init(text: String) { + self.type = .text + self.text = text + } +} + +public struct ChatCompletionContentPartImage: Codable, Equatable { + public let type: MessageContentType + public let imageUrl: ChatCompletionImageURL + + public init(imageUrl: ChatCompletionImageURL) { + self.type = .imageUrl + self.imageUrl = imageUrl + } + + public init(url: String, detail: ImageDetail? = nil) { + self.type = .imageUrl + self.imageUrl = ChatCompletionImageURL(url: url, detail: detail) + } +} + +public enum ChatCompletionContentPart: Codable, Equatable { + case text(ChatCompletionContentPartText) + case imageUrl(ChatCompletionContentPartImage) + + private enum CodingKeys: String, CodingKey { + case type + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(MessageContentType.self, forKey: .type) + + switch type { + case .text: + self = .text(try ChatCompletionContentPartText(from: decoder)) + case .imageUrl: + self = .imageUrl(try ChatCompletionContentPartImage(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .text(let content): + try content.encode(to: encoder) + case .imageUrl(let content): + try content.encode(to: encoder) + } + } +} + +public enum MessageContent: Codable, Equatable { + case string(String) + case messageContentArray([ChatCompletionContentPart]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + } else if let arrayValue = try? container.decode([ChatCompletionContentPart].self) { + self = .messageContentArray(arrayValue) + } else { + throw DecodingError.typeMismatch(MessageContent.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Expected String or Array of MessageContent")) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .messageContentArray(let value): + try container.encode(value) + } + } +} + public struct TurnSchema: Codable { - public var request: String + public var request: MessageContent public var response: String? public var agentSlug: String? public var turnId: String? public init(request: String, response: String? = nil, agentSlug: String? = nil, turnId: String? = nil) { + self.request = .string(request) + self.response = response + self.agentSlug = agentSlug + self.turnId = turnId + } + + public init( + request: [ChatCompletionContentPart], + response: String? = nil, + agentSlug: String? = nil, + turnId: String? = nil + ) { + self.request = .messageContentArray(request) + self.response = response + self.agentSlug = agentSlug + self.turnId = turnId + } + + public init(request: MessageContent, response: String? = nil, agentSlug: String? = nil, turnId: String? = nil) { self.request = request self.response = response self.agentSlug = agentSlug @@ -88,6 +259,7 @@ public struct TurnSchema: Codable { public struct ConversationRequest { public var workDoneToken: String public var content: String + public var contentImages: [ChatCompletionContentPartImage] = [] public var workspaceFolder: String public var activeDoc: Doc? public var skills: [String] @@ -102,6 +274,7 @@ public struct ConversationRequest { public init( workDoneToken: String, content: String, + contentImages: [ChatCompletionContentPartImage] = [], workspaceFolder: String, activeDoc: Doc? = nil, skills: [String], @@ -115,6 +288,7 @@ public struct ConversationRequest { ) { self.workDoneToken = workDoneToken self.content = content + self.contentImages = contentImages self.workspaceFolder = workspaceFolder self.activeDoc = activeDoc self.skills = skills diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift index b00a2ee2..4c1ca9e7 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift @@ -22,7 +22,7 @@ public struct Reference: Codable, Equatable, Hashable { struct ConversationCreateParams: Codable { var workDoneToken: String - var turns: [ConversationTurn] + var turns: [TurnSchema] var capabilities: Capabilities var textDocument: Doc? var references: [Reference]? @@ -122,18 +122,11 @@ struct ConversationRatingParams: Codable { } // MARK: Conversation turn - -struct ConversationTurn: Codable { - var request: String - var response: String? - var turnId: String? -} - struct TurnCreateParams: Codable { var workDoneToken: String var conversationId: String var turnId: String? - var message: String + var message: MessageContent var textDocument: Doc? var ignoredSkills: [String]? var references: [Reference]? diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 327f9cbe..0d4da90f 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -53,7 +53,7 @@ public protocol GitHubCopilotTelemetryServiceType { } public protocol GitHubCopilotConversationServiceType { - func createConversation(_ message: String, + func createConversation(_ message: MessageContent, workDoneToken: String, workspaceFolder: String, workspaceFolders: [WorkspaceFolder]?, @@ -65,7 +65,7 @@ public protocol GitHubCopilotConversationServiceType { turns: [TurnSchema], agentMode: Bool, userLanguage: String?) async throws - func createTurn(_ message: String, + func createTurn(_ message: MessageContent, workDoneToken: String, conversationId: String, turnId: String?, @@ -580,7 +580,7 @@ public final class GitHubCopilotService: } @GitHubCopilotSuggestionActor - public func createConversation(_ message: String, + public func createConversation(_ message: MessageContent, workDoneToken: String, workspaceFolder: String, workspaceFolders: [WorkspaceFolder]? = nil, @@ -592,16 +592,21 @@ public final class GitHubCopilotService: turns: [TurnSchema], agentMode: Bool, userLanguage: String?) async throws { - var conversationCreateTurns: [ConversationTurn] = [] + var conversationCreateTurns: [TurnSchema] = [] // invoke conversation history if turns.count > 0 { conversationCreateTurns.append( contentsOf: turns.map { - ConversationTurn(request: $0.request, response: $0.response, turnId: $0.turnId) + TurnSchema( + request: $0.request, + response: $0.response, + agentSlug: $0.agentSlug, + turnId: $0.turnId + ) } ) } - conversationCreateTurns.append(ConversationTurn(request: message)) + conversationCreateTurns.append(TurnSchema(request: message)) let params = ConversationCreateParams(workDoneToken: workDoneToken, turns: conversationCreateTurns, capabilities: ConversationCreateParams.Capabilities( @@ -634,7 +639,7 @@ public final class GitHubCopilotService: } @GitHubCopilotSuggestionActor - public func createTurn(_ message: String, + public func createTurn(_ message: MessageContent, workDoneToken: String, conversationId: String, turnId: String?, diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift index b1b00e75..cb3f5006 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -19,11 +19,29 @@ public final class GitHubCopilotConversationService: ConversationServiceType { WorkspaceFolder(uri: project.uri, name: project.name) } } + + private func getMessageContent(_ request: ConversationRequest) -> MessageContent { + let contentImages = request.contentImages + let message: MessageContent + if contentImages.count > 0 { + var chatCompletionContentParts: [ChatCompletionContentPart] = contentImages.map { + .imageUrl($0) + } + chatCompletionContentParts.append(.text(ChatCompletionContentPartText(text: request.content))) + message = .messageContentArray(chatCompletionContentParts) + } else { + message = .string(request.content) + } + + return message + } public func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws { guard let service = await serviceLocator.getService(from: workspace) else { return } - return try await service.createConversation(request.content, + let message = getMessageContent(request) + + return try await service.createConversation(message, workDoneToken: request.workDoneToken, workspaceFolder: workspace.projectURL.absoluteString, workspaceFolders: getWorkspaceFolders(workspace: workspace), @@ -40,7 +58,9 @@ public final class GitHubCopilotConversationService: ConversationServiceType { public func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws { guard let service = await serviceLocator.getService(from: workspace) else { return } - return try await service.createTurn(request.content, + let message = getMessageContent(request) + + return try await service.createTurn(message, workDoneToken: request.workDoneToken, conversationId: conversationId, turnId: request.turnId, diff --git a/Tool/Sources/Persist/AppState.swift b/Tool/Sources/Persist/AppState.swift index 8decd65c..3b7f8cc2 100644 --- a/Tool/Sources/Persist/AppState.swift +++ b/Tool/Sources/Persist/AppState.swift @@ -18,6 +18,13 @@ public extension JSONValue { } return nil } + + var boolValue: Bool? { + if case .bool(let value) = self { + return value + } + return nil + } static func convertToJSONValue(_ object: T) -> JSONValue? { do { diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index c648fbf2..c4296fc7 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -116,6 +116,11 @@ public struct UserDefaultPreferenceKeys { defaultValue: false, key: "ExtensionPermissionShown" ) + + public let capturePermissionShown = PreferenceKey( + defaultValue: false, + key: "CapturePermissionShown" + ) } // MARK: - Prompt to Code diff --git a/Tool/Sources/Status/Status.swift b/Tool/Sources/Status/Status.swift index d3cd8339..08910e03 100644 --- a/Tool/Sources/Status/Status.swift +++ b/Tool/Sources/Status/Status.swift @@ -32,6 +32,8 @@ public extension Notification.Name { } private var currentUserName: String? = nil +private var currentUserCopilotPlan: String? = nil + public final actor Status { public static let shared = Status() @@ -53,9 +55,14 @@ public final actor Status { return currentUserName } + public func currentUserPlan() -> String? { + return currentUserCopilotPlan + } + public func updateQuotaInfo(_ quotaInfo: GitHubCopilotQuotaInfo?) { guard quotaInfo != currentUserQuotaInfo else { return } currentUserQuotaInfo = quotaInfo + currentUserCopilotPlan = quotaInfo?.copilotPlan broadcast() } diff --git a/Tool/Sources/Toast/Toast.swift b/Tool/Sources/Toast/Toast.swift index d6132e86..704af7df 100644 --- a/Tool/Sources/Toast/Toast.swift +++ b/Tool/Sources/Toast/Toast.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import Dependencies import Foundation import SwiftUI +import AppKitExtension public enum ToastLevel { case info @@ -295,23 +296,8 @@ public extension NSWorkspace { static func restartXcode() { // Find current Xcode path before quitting - var xcodeURL: URL? - - // Get currently running Xcode application URL - if let xcodeApp = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == "com.apple.dt.Xcode" }) { - xcodeURL = xcodeApp.bundleURL - } - - // Fallback to standard path if we couldn't get the running instance - if xcodeURL == nil { - let standardPath = "/Applications/Xcode.app" - if FileManager.default.fileExists(atPath: standardPath) { - xcodeURL = URL(fileURLWithPath: standardPath) - } - } - // Restart if we found a valid path - if let xcodeURL = xcodeURL { + if let xcodeURL = getXcodeBundleURL() { // Quit Xcode let script = NSAppleScript(source: "tell application \"Xcode\" to quit") script?.executeAndReturnError(nil) diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift index b842c3ba..8c678aec 100644 --- a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -40,7 +40,7 @@ public class AppInstanceInspector: ObservableObject { return runningApplication.activate(options: options) } - init(runningApplication: NSRunningApplication) { + public init(runningApplication: NSRunningApplication) { self.runningApplication = runningApplication processIdentifier = runningApplication.processIdentifier bundleURL = runningApplication.bundleURL From 9788b5cbd770deab27dcc29be1f93c192807c387 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 27 Jun 2025 06:11:17 +0000 Subject: [PATCH 10/26] Pre-release 0.37.127 --- Core/Sources/HostApp/General.swift | 16 ++- .../HostApp/GeneralSettings/AppInfoView.swift | 73 +++++----- .../CopilotConnectionView.swift | 46 ++++-- Core/Sources/HostApp/GeneralView.swift | 25 ++-- Core/Sources/HostApp/MCPConfigView.swift | 5 - .../CopilotMCPToolManagerObservable.swift | 43 ++++-- .../MCPSettings/MCPServerToolsSection.swift | 77 +++------- .../MCPSettings/MCPToolsListView.swift | 11 +- .../SharedComponents/SettingsButtonRow.swift | 25 ++-- .../RealtimeSuggestionController.swift | 2 +- Core/Sources/Service/XPCService.swift | 66 +++++++++ ExtensionService/AppDelegate.swift | 2 +- Server/package-lock.json | 8 +- Server/package.json | 2 +- Tool/Package.swift | 2 +- Tool/Sources/AXExtension/AXUIElement.swift | 2 +- .../CopilotLocalProcessServer.swift | 7 +- .../CopilotMCPToolManager.swift | 26 +++- .../LanguageServer/GitHubCopilotRequest.swift | 8 +- .../LanguageServer/GitHubCopilotService.swift | 133 ++++++++++-------- Tool/Sources/Status/Status.swift | 4 +- Tool/Sources/Status/StatusObserver.swift | 2 +- Tool/Sources/Status/Types/AuthStatus.swift | 15 +- .../XPCShared/XPCExtensionService.swift | 71 ++++++++++ .../XPCShared/XPCServiceProtocol.swift | 64 +++------ 25 files changed, 442 insertions(+), 293 deletions(-) diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index f2b2abe8..92d78a25 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -12,8 +12,10 @@ public struct General { @ObservableState public struct State: Equatable { var xpcServiceVersion: String? + var xpcCLSVersion: String? var isAccessibilityPermissionGranted: ObservedAXStatus = .unknown var isExtensionPermissionGranted: ExtensionPermissionStatus = .unknown + var xpcServiceAuthStatus: AuthStatus = .init(status: .unknown) var isReloading = false } @@ -24,8 +26,10 @@ public struct General { case reloadStatus case finishReloading( xpcServiceVersion: String, + xpcCLSVersion: String?, axStatus: ObservedAXStatus, - extensionStatus: ExtensionPermissionStatus + extensionStatus: ExtensionPermissionStatus, + authStatus: AuthStatus ) case failedReloading case retryReloading @@ -90,10 +94,14 @@ public struct General { let isAccessibilityPermissionGranted = try await service .getXPCServiceAccessibilityPermission() let isExtensionPermissionGranted = try await service.getXPCServiceExtensionPermission() + let xpcServiceAuthStatus = try await service.getXPCServiceAuthStatus() ?? .init(status: .unknown) + let xpcCLSVersion = try await service.getXPCCLSVersion() await send(.finishReloading( xpcServiceVersion: xpcServiceVersion, + xpcCLSVersion: xpcCLSVersion, axStatus: isAccessibilityPermissionGranted, - extensionStatus: isExtensionPermissionGranted + extensionStatus: isExtensionPermissionGranted, + authStatus: xpcServiceAuthStatus )) } else { toast("Launching service app.", .info) @@ -114,10 +122,12 @@ public struct General { } }.cancellable(id: ReloadStatusCancellableId(), cancelInFlight: true) - case let .finishReloading(version, axStatus, extensionStatus): + case let .finishReloading(version, clsVersion, axStatus, extensionStatus, authStatus): state.xpcServiceVersion = version state.isAccessibilityPermissionGranted = axStatus state.isExtensionPermissionGranted = extensionStatus + state.xpcServiceAuthStatus = authStatus + state.xpcCLSVersion = clsVersion state.isReloading = false return .none diff --git a/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift b/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift index 837f3047..0cf5e8af 100644 --- a/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift +++ b/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift @@ -1,6 +1,5 @@ import ComposableArchitecture import GitHubCopilotService -import GitHubCopilotViewModel import SwiftUI struct AppInfoView: View { @@ -15,7 +14,6 @@ struct AppInfoView: View { @Environment(\.toast) var toast @StateObject var settings = Settings() - @StateObject var viewModel: GitHubCopilotViewModel @State var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String @State var automaticallyCheckForUpdates: Bool? @@ -23,53 +21,54 @@ struct AppInfoView: View { let store: StoreOf var body: some View { - HStack(alignment: .center, spacing: 16) { - let appImage = if let nsImage = NSImage(named: "AppIcon") { - Image(nsImage: nsImage) - } else { - Image(systemName: "app") - } - appImage - .resizable() - .frame(width: 110, height: 110) - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String ?? "GitHub Copilot for Xcode") - .font(.title) - Text("(\(appVersion ?? ""))") - .font(.title) + WithPerceptionTracking { + HStack(alignment: .center, spacing: 16) { + let appImage = if let nsImage = NSImage(named: "AppIcon") { + Image(nsImage: nsImage) + } else { + Image(systemName: "app") } - Text("Language Server Version: \(viewModel.version ?? "Loading...")") - Button(action: { - updateChecker.checkForUpdates() - }) { - HStack(spacing: 2) { - Text("Check for Updates") + appImage + .resizable() + .frame(width: 110, height: 110) + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String ?? "GitHub Copilot for Xcode") + .font(.title) + Text("(\(appVersion ?? ""))") + .font(.title) } - } - HStack { - Toggle(isOn: .init( - get: { automaticallyCheckForUpdates ?? updateChecker.getAutomaticallyChecksForUpdates() }, - set: { updateChecker.setAutomaticallyChecksForUpdates($0); automaticallyCheckForUpdates = $0 } - )) { - Text("Automatically Check for Updates") + Text("Language Server Version: \(store.xpcCLSVersion ?? "Loading...")") + Button(action: { + updateChecker.checkForUpdates() + }) { + HStack(spacing: 2) { + Text("Check for Updates") + } } - - Toggle(isOn: $settings.installPrereleases) { - Text("Install pre-releases") + HStack { + Toggle(isOn: .init( + get: { automaticallyCheckForUpdates ?? updateChecker.getAutomaticallyChecksForUpdates() }, + set: { updateChecker.setAutomaticallyChecksForUpdates($0); automaticallyCheckForUpdates = $0 } + )) { + Text("Automatically Check for Updates") + } + + Toggle(isOn: $settings.installPrereleases) { + Text("Install pre-releases") + } } } + Spacer() } - Spacer() + .padding(.horizontal, 2) + .padding(.vertical, 15) } - .padding(.horizontal, 2) - .padding(.vertical, 15) } } #Preview { AppInfoView( - 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 347f202d..5a454b7a 100644 --- a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift +++ b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import GitHubCopilotViewModel import SwiftUI +import Client struct CopilotConnectionView: View { @AppStorage("username") var username: String = "" @@ -18,23 +19,36 @@ struct CopilotConnectionView: View { } } } + + var accountStatusString: String { + switch store.xpcServiceAuthStatus.status { + case .loggedIn: + return "Active" + case .notLoggedIn: + return "Not Signed In" + case .notAuthorized: + return "No Subscription" + case .unknown: + return "Loading..." + } + } var accountStatus: some View { SettingsButtonRow( title: "GitHub Account Status Permissions", - subtitle: "GitHub Account: \(viewModel.status?.description ?? "Loading...")" + subtitle: "GitHub Account: \(accountStatusString)" ) { if viewModel.isRunningAction || viewModel.waitingForSignIn { ProgressView().controlSize(.small) } Button("Refresh Connection") { - viewModel.checkStatus() + store.send(.reloadStatus) } if viewModel.waitingForSignIn { Button("Cancel") { viewModel.cancelWaiting() } - } else if viewModel.status == .notSignedIn { + } else if store.xpcServiceAuthStatus.status == .notLoggedIn { Button("Log in to GitHub") { viewModel.signIn() } @@ -54,21 +68,31 @@ struct CopilotConnectionView: View { """) } } - if viewModel.status == .ok || viewModel.status == .alreadySignedIn || - viewModel.status == .notAuthorized - { - Button("Log Out from GitHub") { viewModel.signOut() - viewModel.isSignInAlertPresented = false + if store.xpcServiceAuthStatus.status == .loggedIn || store.xpcServiceAuthStatus.status == .notAuthorized { + Button("Log Out from GitHub") { + Task { + viewModel.signOut() + viewModel.isSignInAlertPresented = false + let service = try getService() + do { + try await service.signOutAllGitHubCopilotService() + } catch { + toast(error.localizedDescription, .error) + } + } } } } } var connection: some View { - SettingsSection(title: "Account Settings", showWarning: viewModel.status == .notAuthorized) { + SettingsSection( + title: "Account Settings", + showWarning: store.xpcServiceAuthStatus.status == .notAuthorized + ) { accountStatus Divider() - if viewModel.status == .notAuthorized { + if store.xpcServiceAuthStatus.status == .notAuthorized { SettingsLink( url: "https://github.com/features/copilot/plans", title: "Enable powerful AI features for free with the GitHub Copilot Free plan" @@ -81,7 +105,7 @@ struct CopilotConnectionView: View { ) } .onReceive(DistributedNotificationCenter.default().publisher(for: .authStatusDidChange)) { _ in - viewModel.checkStatus() + store.send(.reloadStatus) } } diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index 7ba62833..e80c9491 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -7,24 +7,25 @@ struct GeneralView: View { @StateObject private var viewModel = GitHubCopilotViewModel.shared var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - generalView.padding(20) - Divider() - rightsView.padding(20) + WithPerceptionTracking { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + generalView.padding(20) + Divider() + rightsView.padding(20) + } + .frame(maxWidth: .infinity) + } + .task { + if isPreview { return } + await store.send(.appear).finish() } - .frame(maxWidth: .infinity) - } - .task { - if isPreview { return } - viewModel.checkStatus() - await store.send(.appear).finish() } } private var generalView: some View { VStack(alignment: .leading, spacing: 30) { - AppInfoView(viewModel: viewModel, store: store) + AppInfoView(store: store) GeneralSettingsView(store: store) CopilotConnectionView(viewModel: viewModel, store: store) } diff --git a/Core/Sources/HostApp/MCPConfigView.swift b/Core/Sources/HostApp/MCPConfigView.swift index 3f72daf7..5008cc44 100644 --- a/Core/Sources/HostApp/MCPConfigView.swift +++ b/Core/Sources/HostApp/MCPConfigView.swift @@ -161,11 +161,6 @@ struct MCPConfigView: View { UserDefaults.shared.set(jsonString, for: \.gitHubCopilotMCPConfig) } - NotificationCenter.default.post( - name: .gitHubCopilotShouldRefreshEditorInformation, - object: nil - ) - Task { let service = try getService() do { diff --git a/Core/Sources/HostApp/MCPSettings/CopilotMCPToolManagerObservable.swift b/Core/Sources/HostApp/MCPSettings/CopilotMCPToolManagerObservable.swift index 5799c58d..d493b8be 100644 --- a/Core/Sources/HostApp/MCPSettings/CopilotMCPToolManagerObservable.swift +++ b/Core/Sources/HostApp/MCPSettings/CopilotMCPToolManagerObservable.swift @@ -2,6 +2,8 @@ import SwiftUI import Combine import Persist import GitHubCopilotService +import Client +import Logger class CopilotMCPToolManagerObservable: ObservableObject { static let shared = CopilotMCPToolManagerObservable() @@ -10,23 +12,42 @@ class CopilotMCPToolManagerObservable: ObservableObject { private var cancellables = Set() private init() { - // Initial load - availableMCPServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() - - // Setup notification to update when MCP server tools collections change - NotificationCenter.default + DistributedNotificationCenter.default() .publisher(for: .gitHubCopilotMCPToolsDidChange) .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } - self.refreshTools() + Task { + await self.refreshMCPServerTools() + } } .store(in: &cancellables) + + Task { + // Initial load of MCP server tools collections from ExtensionService process + await refreshMCPServerTools() + } + } + + @MainActor + private func refreshMCPServerTools() async { + do { + let service = try getService() + let mcpTools = try await service.getAvailableMCPServerToolsCollections() + refreshTools(tools: mcpTools) + } catch { + Logger.client.error("Failed to fetch MCP server tools: \(error)") + } } - - private func refreshTools() { - self.availableMCPServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() - AppState.shared.cleanupMCPToolsStatus(availableTools: self.availableMCPServerTools) - AppState.shared.createMCPToolsStatus(self.availableMCPServerTools) + + private func refreshTools(tools: [MCPServerToolsCollection]?) { + guard let tools = tools else { + // nil means the tools data is ready, and skip it first. + return + } + + AppState.shared.cleanupMCPToolsStatus(availableTools: tools) + AppState.shared.createMCPToolsStatus(tools) + self.availableMCPServerTools = tools } } diff --git a/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift b/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift index 5464a6f3..9641a45a 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift +++ b/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift @@ -17,12 +17,8 @@ struct MCPServerToolsSection: View { HStack(spacing: 8) { Text("MCP Server: \(serverTools.name)").fontWeight(.medium) if serverTools.status == .error { - if hasUnsupportedServerType() { - Badge(text: getUnsupportedServerTypeMessage(), level: .danger, icon: "xmark.circle.fill") - } else { - let message = extractErrorMessage(serverTools.error?.description ?? "") - Badge(text: message, level: .danger, icon: "xmark.circle.fill") - } + let message = extractErrorMessage(serverTools.error?.description ?? "") + Badge(text: message, level: .danger, icon: "xmark.circle.fill") } Spacer() } @@ -59,32 +55,11 @@ struct MCPServerToolsSection: View { ) } } - } - - // Function to check if the MCP config contains unsupported server types - private func hasUnsupportedServerType() -> Bool { - let mcpConfig = UserDefaults.shared.value(for: \.gitHubCopilotMCPConfig) - // Check if config contains a URL field for this server - guard !mcpConfig.isEmpty else { return false } - - do { - guard let jsonData = mcpConfig.data(using: .utf8), - let jsonObject = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any], - let serverConfig = jsonObject[serverTools.name] as? [String: Any], - let url = serverConfig["url"] as? String else { - return false - } - - return true - } catch { - return false + .onChange(of: serverTools) { newValue in + initializeToolStates(server: newValue) } } - - // Get the warning message for unsupported server types - private func getUnsupportedServerTypeMessage() -> String { - return "SSE/HTTP transport is not yet supported" - } + var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -103,7 +78,7 @@ struct MCPServerToolsSection: View { serverToggle } .onAppear { - initializeToolStates() + initializeToolStates(server: serverTools) if forceExpand { isExpanded = true } @@ -131,17 +106,16 @@ struct MCPServerToolsSection: View { return description[start..: View { let title: String @@ -6,20 +7,22 @@ struct SettingsButtonRow: View { @ViewBuilder let content: () -> Content var body: some View { - HStack(alignment: .center, spacing: 8) { - VStack(alignment: .leading) { - Text(title) - .font(.body) - if let subtitle = subtitle { - Text(subtitle) - .font(.footnote) + WithPerceptionTracking{ + HStack(alignment: .center, spacing: 8) { + VStack(alignment: .leading) { + Text(title) + .font(.body) + if let subtitle = subtitle { + Text(subtitle) + .font(.footnote) + } } + Spacer() + content() } - Spacer() - content() + .foregroundStyle(.primary) + .padding(10) } - .foregroundStyle(.primary) - .padding(10) } } diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 517717be..899865f1 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -153,7 +153,7 @@ public actor RealtimeSuggestionController { // check if user loggin let authStatus = await Status.shared.getAuthStatus() - guard authStatus == .loggedIn else { return } + guard authStatus.status == .loggedIn else { return } guard UserDefaults.shared.value(for: \.realtimeSuggestionToggle) else { return } diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 1f4ce005..84ce30e5 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -8,6 +8,7 @@ import Status import XPCShared import HostAppActivator import XcodeInspector +import GitHubCopilotViewModel public class XPCService: NSObject, XPCServiceProtocol { // MARK: - Service @@ -18,6 +19,19 @@ public class XPCService: NSObject, XPCServiceProtocol { Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "N/A" ) } + + public func getXPCCLSVersion(withReply reply: @escaping (String?) -> Void) { + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let version = try await service.version() + reply(version) + } catch { + Logger.service.error("Failed to get CLS version: \(error.localizedDescription)") + reply(nil) + } + } + } public func getXPCServiceAccessibilityPermission(withReply reply: @escaping (ObservedAXStatus) -> Void) { Task { @@ -262,6 +276,58 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(nil, error) } } + + // MARK: - MCP Server Tools + public func getAvailableMCPServerToolsCollections(withReply reply: @escaping (Data?) -> Void) { + let availableMCPServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() + if let availableMCPServerTools = availableMCPServerTools { + // Encode and send the data + let data = try? JSONEncoder().encode(availableMCPServerTools) + reply(data) + } else { + reply(nil) + } + } + + public func updateMCPServerToolsStatus(tools: Data) { + // Decode the data + let decoder = JSONDecoder() + var collections: [UpdateMCPToolsStatusServerCollection] = [] + do { + collections = try decoder.decode([UpdateMCPToolsStatusServerCollection].self, from: tools) + if collections.isEmpty { + return + } + } catch { + Logger.service.error("Failed to decode MCP server collections: \(error)") + return + } + + Task { @MainActor in + await GitHubCopilotService.updateAllClsMCP(collections: collections) + } + } + + // MARK: - Auth + public func signOutAllGitHubCopilotService() { + Task { @MainActor in + do { + try await GitHubCopilotService.signOutAll() + } catch { + Logger.service.error("Failed to sign out all: \(error)") + } + } + } + + public func getXPCServiceAuthStatus(withReply reply: @escaping (Data?) -> Void) { + Task { @MainActor in + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + _ = try await service.checkStatus() + let authStatus = await Status.shared.getAuthStatus() + let data = try? JSONEncoder().encode(authStatus) + reply(data) + } + } } struct NoAccessToAccessibilityAPIError: Error, LocalizedError { diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 5ad06d7a..7f89e6cf 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -256,7 +256,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func setInitialStatusBarStatus() { Task { let authStatus = await Status.shared.getAuthStatus() - if authStatus == .unknown { + if authStatus.status == .unknown { // temporarily kick off a language server instance to prime the initial auth status await forceAuthStatusCheck() } diff --git a/Server/package-lock.json b/Server/package-lock.json index dfbc7944..d31c6de2 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.337.0", + "@github/copilot-language-server": "^1.338.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,9 +36,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.337.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.337.0.tgz", - "integrity": "sha512-tvCgScCaZrHlrQgDqXACH9DzI9uA+EYIMJVMaEyfCE46fbkfDQEixascbpKiRW2cB4eMFnxXlU+m2x8KH54XuA==", + "version": "1.338.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.338.0.tgz", + "integrity": "sha512-QPg4Gn/IWON6J+fqeEHvFMxaHOi+AmBq3jc8ySat2isQ8gL5cWvd/mThXvqeJ9XeHLAsojWvS6YitFCntZviSg==", "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" diff --git a/Server/package.json b/Server/package.json index 822a3bc6..73223fa6 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.337.0", + "@github/copilot-language-server": "^1.338.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/Tool/Package.swift b/Tool/Package.swift index 3391cd51..1e040128 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -85,7 +85,7 @@ let package = Package( targets: [ // MARK: - Helpers - .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger", "Status", "HostAppActivator"]), + .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger", "Status", "HostAppActivator", "GitHubCopilotService"]), .target(name: "Configs"), diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index 9199fa49..1a790e20 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -65,7 +65,7 @@ public extension AXUIElement { } var isXcodeWorkspaceWindow: Bool { - description == "Xcode.WorkspaceWindow" + description == "Xcode.WorkspaceWindow" || identifier == "Xcode.WorkspaceWindow" } var selectedTextRange: ClosedRange? { diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index 8182833f..65a972d6 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -310,12 +310,7 @@ extension CustomJSONRPCLanguageServer { block(nil) return true case "copilot/mcpTools": - if let payload = GetAllToolsParams.decode( - fromParams: anyNotification.params - ) { - Logger.gitHubCopilot.info("MCPTools: \(payload)") - CopilotMCPToolManager.updateMCPTools(payload.servers) - } + notificationPublisher.send(anyNotification) block(nil) return true case "conversation/preconditionsNotification", "statusNotification": diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift index f64c58e7..a2baecbc 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift @@ -1,4 +1,5 @@ import Foundation +import Logger public extension Notification.Name { static let gitHubCopilotMCPToolsDidChange = Notification @@ -6,34 +7,45 @@ public extension Notification.Name { } public class CopilotMCPToolManager { - private static var availableMCPServerTools: [MCPServerToolsCollection] = [] + private static var availableMCPServerTools: [MCPServerToolsCollection]? public static func updateMCPTools(_ serverToolsCollections: [MCPServerToolsCollection]) { let sortedMCPServerTools = serverToolsCollections.sorted(by: { $0.name.lowercased() < $1.name.lowercased() }) guard sortedMCPServerTools != availableMCPServerTools else { return } availableMCPServerTools = sortedMCPServerTools DispatchQueue.main.async { - NotificationCenter.default.post(name: .gitHubCopilotMCPToolsDidChange, object: nil) + Logger.client.info("Notify about MCP tools change: \(getToolsSummary())") + DistributedNotificationCenter.default().post(name: .gitHubCopilotMCPToolsDidChange, object: nil) } } - public static func getAvailableMCPTools() -> [MCPTool] { + private static func getToolsSummary() -> String { + var summary = "" + guard let tools = availableMCPServerTools else { return summary } + for server in tools { + summary += "Server: \(server.name) with \(server.tools.count) tools (\(server.tools.filter { $0._status == .enabled }.count) enabled, \(server.tools.filter { $0._status == .disabled }.count) disabled). " + } + + return summary + } + + public static func getAvailableMCPTools() -> [MCPTool]? { // Flatten all tools from all servers into a single array - return availableMCPServerTools.flatMap { $0.tools } + return availableMCPServerTools?.flatMap { $0.tools } } - public static func getAvailableMCPServerToolsCollections() -> [MCPServerToolsCollection] { + public static func getAvailableMCPServerToolsCollections() -> [MCPServerToolsCollection]? { return availableMCPServerTools } public static func hasMCPTools() -> Bool { - return !availableMCPServerTools.isEmpty + return availableMCPServerTools != nil && !availableMCPServerTools!.isEmpty } public static func clearMCPTools() { availableMCPServerTools = [] DispatchQueue.main.async { - NotificationCenter.default.post(name: .gitHubCopilotMCPToolsDidChange, object: nil) + DistributedNotificationCenter.default().post(name: .gitHubCopilotMCPToolsDidChange, object: nil) } } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index b5b15e50..c750f4a8 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -51,7 +51,7 @@ public struct GitHubCopilotCodeSuggestion: Codable, Equatable { public var displayText: String } -public func editorConfiguration() -> JSONValue { +public func editorConfiguration(includeMCP: Bool) -> JSONValue { var proxyAuthorization: String? { let username = UserDefaults.shared.value(for: \.gitHubCopilotProxyUsername) if username.isEmpty { return nil } @@ -96,10 +96,12 @@ public func editorConfiguration() -> JSONValue { var d: [String: JSONValue] = [:] if let http { d["http"] = http } if let authProvider { d["github-enterprise"] = authProvider } - if mcp != nil || customInstructions != nil { + if (includeMCP && mcp != nil) || customInstructions != nil { var github: [String: JSONValue] = [:] var copilot: [String: JSONValue] = [:] - copilot["mcp"] = mcp + if includeMCP { + copilot["mcp"] = mcp + } copilot["globalCopilotInstructions"] = customInstructions github["copilot"] = .hash(copilot) d["github"] = .hash(github) diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 0d4da90f..74469e2f 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -143,8 +143,6 @@ public enum GitHubCopilotError: Error, LocalizedError { public extension Notification.Name { static let gitHubCopilotShouldRefreshEditorInformation = Notification .Name("com.github.CopilotForXcode.GitHubCopilotShouldRefreshEditorInformation") - static let gitHubCopilotShouldUpdateMCPToolsStatus = Notification - .Name("com.github.CopilotForXcode.gitHubCopilotShouldUpdateMCPToolsStatus") } public class GitHubCopilotBaseService { @@ -291,8 +289,6 @@ public class GitHubCopilotBaseService { let notifications = NotificationCenter.default .notifications(named: .gitHubCopilotShouldRefreshEditorInformation) - let mcpNotifications = NotificationCenter.default - .notifications(named: .gitHubCopilotShouldUpdateMCPToolsStatus) Task { [weak self] in if projectRootURL.path != "/" { try? await server.sendNotification( @@ -301,27 +297,22 @@ public class GitHubCopilotBaseService { ) ) } + + let includeMCP = projectRootURL.path != "/" // Send workspace/didChangeConfiguration once after initalize _ = try? await server.sendNotification( .workspaceDidChangeConfiguration( - .init(settings: editorConfiguration()) + .init(settings: editorConfiguration(includeMCP: includeMCP)) ) ) - if let copilotService = self as? GitHubCopilotService { - _ = await copilotService.initializeMCP() - } for await _ in notifications { guard self != nil else { return } _ = try? await server.sendNotification( .workspaceDidChangeConfiguration( - .init(settings: editorConfiguration()) + .init(settings: editorConfiguration(includeMCP: includeMCP)) ) ) } - for await _ in mcpNotifications { - guard self != nil else { return } - _ = await GitHubCopilotService.updateAllMCP() - } } } @@ -428,6 +419,8 @@ public final class GitHubCopilotService: private var cancellables = Set() private var statusWatcher: CopilotAuthStatusWatcher? private static var services: [GitHubCopilotService] = [] // cache all alive copilot service instances + private var isMCPInitialized = false + private var unrestoredMcpServers: [String] = [] override init(designatedServer: any GitHubCopilotLSP) { super.init(designatedServer: designatedServer) @@ -436,7 +429,17 @@ public final class GitHubCopilotService: override public init(projectRootURL: URL = URL(fileURLWithPath: "/"), workspaceURL: URL = URL(fileURLWithPath: "/")) throws { do { try super.init(projectRootURL: projectRootURL, workspaceURL: workspaceURL) + localProcessServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in + if notification.method == "copilot/mcpTools" && projectRootURL.path != "/" { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + Task { @MainActor in + await self.handleMCPToolsNotification(notification) + } + } + } + self?.serverNotificationHandler.handleNotification(notification) }).store(in: &cancellables) localProcessServer?.serverRequestPublisher.sink(receiveValue: { [weak self] (request, callback) in @@ -445,8 +448,6 @@ public final class GitHubCopilotService: updateStatusInBackground() GitHubCopilotService.services.append(self) - - setupMCPInformationObserver() Task { await registerClientTools(server: self) @@ -461,20 +462,7 @@ public final class GitHubCopilotService: deinit { GitHubCopilotService.services.removeAll { $0 === self } } - - // Setup notification observer for refreshing MCP information - private func setupMCPInformationObserver() { - NotificationCenter.default.addObserver( - forName: .gitHubCopilotShouldUpdateMCPToolsStatus, - object: nil, - queue: .main - ) { _ in - Task { - await GitHubCopilotService.updateAllMCP() - } - } - } - + @GitHubCopilotSuggestionActor public func getCompletions( fileURL: URL, @@ -1112,32 +1100,15 @@ public final class GitHubCopilotService: } } - public static func updateAllMCP() async { + public static func updateAllClsMCP(collections: [UpdateMCPToolsStatusServerCollection]) async { var updateError: Error? = nil var servers: [MCPServerToolsCollection] = [] - - // Get and validate data from UserDefaults only once, outside the loop - let jsonString: String = UserDefaults.shared.value(for: \.gitHubCopilotMCPUpdatedStatus) - guard !jsonString.isEmpty, let data = jsonString.data(using: .utf8) else { - Logger.gitHubCopilot.info("No MCP data found in UserDefaults") - return - } - - // Decode the data - let decoder = JSONDecoder() - var collections: [UpdateMCPToolsStatusServerCollection] = [] - do { - collections = try decoder.decode([UpdateMCPToolsStatusServerCollection].self, from: data) - if collections.isEmpty { - Logger.gitHubCopilot.info("No MCP server collections found in UserDefaults") - return - } - } catch { - Logger.gitHubCopilot.error("Failed to decode MCP server collections: \(error)") - return - } for service in services { + if service.projectRootURL.path == "/" { + continue // Skip services with root project URL + } + do { servers = try await service.updateMCPToolsStatus( params: .init(servers: collections) @@ -1150,29 +1121,77 @@ public final class GitHubCopilotService: } CopilotMCPToolManager.updateMCPTools(servers) - + Logger.gitHubCopilot.info("Updated All MCPTools: \(servers.count) servers") + if let updateError { Logger.gitHubCopilot.error("Failed to update MCP Tools status: \(updateError)") } } - public func initializeMCP() async { + private func loadUnrestoredMCPServers() -> [String] { + if let savedJSON = AppState.shared.get(key: "mcpToolsStatus"), + let data = try? JSONEncoder().encode(savedJSON), + let savedStatus = try? JSONDecoder().decode([UpdateMCPToolsStatusServerCollection].self, from: data) { + return savedStatus + .filter { !$0.tools.isEmpty } + .map { $0.name } + } + + return [] + } + + private func restoreMCPToolsStatus(_ mcpServers: [String]) async -> [MCPServerToolsCollection]? { guard let savedJSON = AppState.shared.get(key: "mcpToolsStatus"), let data = try? JSONEncoder().encode(savedJSON), let savedStatus = try? JSONDecoder().decode([UpdateMCPToolsStatusServerCollection].self, from: data) else { Logger.gitHubCopilot.info("Failed to get MCP Tools status") - return + return nil } do { - _ = try await updateMCPToolsStatus( - params: .init(servers: savedStatus) - ) + let savedServers = savedStatus.filter { mcpServers.contains($0.name) } + if savedServers.isEmpty { + return nil + } else { + return try await updateMCPToolsStatus( + params: .init(servers: savedServers) + ) + } } catch let error as ServerError { Logger.gitHubCopilot.error("Failed to update MCP Tools status: \(GitHubCopilotError.languageServerError(error))") } catch { Logger.gitHubCopilot.error("Failed to update MCP Tools status: \(error)") } + + return nil + } + + public func handleMCPToolsNotification(_ notification: AnyJSONRPCNotification) async { + defer { + self.isMCPInitialized = true + } + + if !self.isMCPInitialized { + self.unrestoredMcpServers = self.loadUnrestoredMCPServers() + } + + if let payload = GetAllToolsParams.decode(fromParams: notification.params) { + if !self.unrestoredMcpServers.isEmpty { + // Find servers that need to be restored + let toRestore = payload.servers.filter { !$0.tools.isEmpty } + .filter { self.unrestoredMcpServers.contains($0.name) } + .map { $0.name } + self.unrestoredMcpServers.removeAll { toRestore.contains($0) } + + if let tools = await self.restoreMCPToolsStatus(toRestore) { + Logger.gitHubCopilot.info("Restore MCP tools status for servers: \(toRestore)") + CopilotMCPToolManager.updateMCPTools(tools) + return + } + } + + CopilotMCPToolManager.updateMCPTools(payload.servers) + } } } diff --git a/Tool/Sources/Status/Status.swift b/Tool/Sources/Status/Status.swift index 08910e03..be005f5f 100644 --- a/Tool/Sources/Status/Status.swift +++ b/Tool/Sources/Status/Status.swift @@ -113,8 +113,8 @@ public final actor Status { ).isEmpty } - public func getAuthStatus() -> AuthStatus.Status { - authStatus.status + public func getAuthStatus() -> AuthStatus { + authStatus } public func getCLSStatus() -> CLSStatus { diff --git a/Tool/Sources/Status/StatusObserver.swift b/Tool/Sources/Status/StatusObserver.swift index 2fce99ac..2bda2b2b 100644 --- a/Tool/Sources/Status/StatusObserver.swift +++ b/Tool/Sources/Status/StatusObserver.swift @@ -37,7 +37,7 @@ public class StatusObserver: ObservableObject { let statusInfo = await Status.shared.getStatus() self.authStatus = AuthStatus( - status: authStatus, + status: authStatus.status, username: statusInfo.userName, message: nil ) diff --git a/Tool/Sources/Status/Types/AuthStatus.swift b/Tool/Sources/Status/Types/AuthStatus.swift index 8253a6a5..668b4a11 100644 --- a/Tool/Sources/Status/Types/AuthStatus.swift +++ b/Tool/Sources/Status/Types/AuthStatus.swift @@ -1,6 +1,17 @@ -public struct AuthStatus: Equatable { - public enum Status { case unknown, loggedIn, notLoggedIn, notAuthorized } +public struct AuthStatus: Codable, Equatable, Hashable { + public enum Status: Codable, Equatable, Hashable { + case unknown + case loggedIn + case notLoggedIn + case notAuthorized + } public let status: Status public let username: String? public let message: String? + + public init(status: Status, username: String? = nil, message: String? = nil) { + self.status = status + self.username = username + self.message = message + } } diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index 9319045a..bcf82c19 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -1,4 +1,5 @@ import Foundation +import GitHubCopilotService import Logger import Status @@ -48,6 +49,15 @@ public class XPCExtensionService { } } } + + public func getXPCCLSVersion() async throws -> String? { + try await withXPCServiceConnected { + service, continuation in + service.getXPCCLSVersion { version in + continuation.resume(version) + } + } + } public func getXPCServiceAccessibilityPermission() async throws -> ObservedAXStatus { try await withXPCServiceConnected { @@ -347,4 +357,65 @@ extension XPCExtensionService { } } } + + @XPCServiceActor + public func getAvailableMCPServerToolsCollections() async throws -> [MCPServerToolsCollection]? { + return try await withXPCServiceConnected { + service, continuation in + service.getAvailableMCPServerToolsCollections { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let tools = try JSONDecoder().decode([MCPServerToolsCollection].self, from: data) + continuation.resume(tools) + } catch { + continuation.reject(error) + } + } + } + } + + @XPCServiceActor + public func updateMCPServerToolsStatus(_ update: [UpdateMCPToolsStatusServerCollection]) async throws { + return try await withXPCServiceConnected { + service, continuation in + do { + let data = try JSONEncoder().encode(update) + service.updateMCPServerToolsStatus(tools: data) + continuation.resume(()) + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func signOutAllGitHubCopilotService() async throws { + return try await withXPCServiceConnected { + service, _ in service.signOutAllGitHubCopilotService() + } + } + + @XPCServiceActor + public func getXPCServiceAuthStatus() async throws -> AuthStatus? { + return try await withXPCServiceConnected { + service, continuation in + service.getXPCServiceAuthStatus { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let authStatus = try JSONDecoder().decode(AuthStatus.self, from: data) + continuation.resume(authStatus) + } catch { + continuation.reject(error) + } + } + } + } } diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift index 803e2502..dbc64f4d 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -4,58 +4,30 @@ import SuggestionBasic @objc(XPCServiceProtocol) public protocol XPCServiceProtocol { - func getSuggestedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getNextSuggestedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getPreviousSuggestedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getSuggestionAcceptedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getSuggestionRejectedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getRealtimeSuggestedCode( - editorContent: Data, - withReply reply: @escaping (Data?, Error?) -> Void - ) - func getPromptToCodeAcceptedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func openChat( - withReply reply: @escaping (Error?) -> Void - ) - func promptToCode( - editorContent: Data, - withReply reply: @escaping (Data?, Error?) -> Void - ) - func customCommand( - id: String, - editorContent: Data, - withReply reply: @escaping (Data?, Error?) -> Void - ) - + func getSuggestedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getNextSuggestedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getPreviousSuggestedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getSuggestionAcceptedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getSuggestionRejectedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getRealtimeSuggestedCode(editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func getPromptToCodeAcceptedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func openChat(withReply reply: @escaping (Error?) -> Void) + func promptToCode(editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func customCommand(id: String, editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void) func toggleRealtimeSuggestion(withReply reply: @escaping (Error?) -> Void) - - func prefetchRealtimeSuggestions( - editorContent: Data, - withReply reply: @escaping () -> Void - ) + func prefetchRealtimeSuggestions(editorContent: Data, withReply reply: @escaping () -> Void) func getXPCServiceVersion(withReply reply: @escaping (String, String) -> Void) + func getXPCCLSVersion(withReply reply: @escaping (String?) -> Void) func getXPCServiceAccessibilityPermission(withReply reply: @escaping (ObservedAXStatus) -> Void) func getXPCServiceExtensionPermission(withReply reply: @escaping (ExtensionPermissionStatus) -> Void) func getXcodeInspectorData(withReply reply: @escaping (Data?, Error?) -> Void) + func getAvailableMCPServerToolsCollections(withReply reply: @escaping (Data?) -> Void) + func updateMCPServerToolsStatus(tools: Data) + + func signOutAllGitHubCopilotService() + func getXPCServiceAuthStatus(withReply reply: @escaping (Data?) -> Void) + func postNotification(name: String, withReply reply: @escaping () -> Void) func send(endpoint: String, requestBody: Data, reply: @escaping (Data?, Error?) -> Void) func quit(reply: @escaping () -> Void) From d1f7de3673c189fa563394db3dbbb66085bb5687 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 30 Jun 2025 07:56:22 +0000 Subject: [PATCH 11/26] Release 0.38.0 --- CHANGELOG.md | 15 +++++ .../ToolCalls/CreateFileTool.swift | 6 ++ .../SuggestionWidget/ChatPanelWindow.swift | 2 +- .../WidgetPositionStrategy.swift | 60 ++++++++----------- .../WidgetWindowsController.swift | 16 +++-- ReleaseNotes.md | 17 ++++-- 6 files changed, 66 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e631d45d..bafeb44f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.38.0 - June 30, 2025 +### Added +- Support for Claude 4 in Chat. +- Support for Copilot Vision (image attachments). +- Support for remote MCP servers. + +### Changed +- Automatically suggests a title for conversations created in agent mode. +- Improved restoration of MCP tool status after Copilot restarts. +- Reduced duplication of MCP server instances. + +### Fixed +- Switching accounts now correctly refreshes the auth token and models. +- Fixed file create/edit issues in agent mode. + ## 0.37.0 - June 18, 2025 ### Added - **Advanced** settings: Added option to configure **Custom Instructions** for GitHub Copilot during chat sessions. diff --git a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift index 4154cda6..08343963 100644 --- a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift @@ -25,13 +25,18 @@ public class CreateFileTool: ICopilotTool { guard !FileManager.default.fileExists(atPath: filePath) else { + Logger.client.info("CreateFileTool: File already exists at \(filePath)") completeResponse(request, status: .error, response: "File already exists at \(filePath)", completion: completion) return true } do { + // Create intermediate directories if they don't exist + let parentDirectory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil) try content.write(to: fileURL, atomically: true, encoding: .utf8) } catch { + Logger.client.error("CreateFileTool: Failed to write content to file at \(filePath): \(error)") completeResponse(request, status: .error, response: "Failed to write content to file: \(error)", completion: completion) return true } @@ -39,6 +44,7 @@ public class CreateFileTool: ICopilotTool { guard FileManager.default.fileExists(atPath: filePath), let writtenContent = try? String(contentsOf: fileURL, encoding: .utf8) else { + Logger.client.info("CreateFileTool: Failed to verify file creation at \(filePath)") completeResponse(request, status: .error, response: "Failed to verify file creation.", completion: completion) return true } diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index 0a6c6475..d6cf456d 100644 --- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -82,7 +82,7 @@ final class ChatPanelWindow: NSWindow { } private func setInitialFrame() { - let frame = UpdateLocationStrategy.getChatPanelFrame(isAttachedToXcodeEnabled: false) + let frame = UpdateLocationStrategy.getChatPanelFrame() setFrame(frame, display: false, animate: true) } diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 17c70602..d6e6e60c 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -320,46 +320,38 @@ enum UpdateLocationStrategy { return selectionFrame } - static func getChatPanelFrame( - isAttachedToXcodeEnabled: Bool = false, - xcodeApp: XcodeAppInstanceInspector? = nil - ) -> CGRect { - let screen = NSScreen.main ?? NSScreen.screens.first! - return getChatPanelFrame(screen, isAttachedToXcodeEnabled: isAttachedToXcodeEnabled, xcodeApp: xcodeApp) - } - - static func getChatPanelFrame( - _ screen: NSScreen, - isAttachedToXcodeEnabled: Bool = false, - xcodeApp: XcodeAppInstanceInspector? = nil - ) -> CGRect { + static func getChatPanelFrame(_ screen: NSScreen? = nil) -> CGRect { + let screen = screen ?? NSScreen.main ?? NSScreen.screens.first! + let visibleScreenFrame = screen.visibleFrame // Default Frame - var width = min(Style.panelWidth, visibleScreenFrame.width * 0.3) - var height = visibleScreenFrame.height - var x = visibleScreenFrame.maxX - width - var y = visibleScreenFrame.minY + let width = min(Style.panelWidth, visibleScreenFrame.width * 0.3) + let height = visibleScreenFrame.height + let x = visibleScreenFrame.maxX - width + let y = visibleScreenFrame.minY - if isAttachedToXcodeEnabled, - let latestActiveXcode = xcodeApp ?? XcodeInspector.shared.latestActiveXcode, - let xcodeWindow = latestActiveXcode.appElement.focusedWindow, - let xcodeScreen = latestActiveXcode.appScreen, - let xcodeRect = xcodeWindow.rect, - let mainDisplayScreen = NSScreen.screens.first(where: { $0.frame.origin == .zero }) // The main display should exist - { - let minWidth = Style.minChatPanelWidth - let visibleXcodeScreenFrame = xcodeScreen.visibleFrame - - width = max(visibleXcodeScreenFrame.maxX - xcodeRect.maxX, minWidth) - height = xcodeRect.height - x = visibleXcodeScreenFrame.maxX - width - - // AXUIElement coordinates: Y=0 at top-left - // NSWindow coordinates: Y=0 at bottom-left - y = mainDisplayScreen.frame.maxY - xcodeRect.maxY + mainDisplayScreen.frame.minY + return CGRect(x: x, y: y, width: width, height: height) + } + + static func getAttachedChatPanelFrame(_ screen: NSScreen, workspaceWindowElement: AXUIElement) -> CGRect { + guard let xcodeScreen = workspaceWindowElement.maxIntersectionScreen, + let xcodeRect = workspaceWindowElement.rect, + let mainDisplayScreen = NSScreen.screens.first(where: { $0.frame.origin == .zero }) + else { + return getChatPanelFrame() } + let minWidth = Style.minChatPanelWidth + let visibleXcodeScreenFrame = xcodeScreen.visibleFrame + + let width = max(visibleXcodeScreenFrame.maxX - xcodeRect.maxX, minWidth) + let height = xcodeRect.height + let x = visibleXcodeScreenFrame.maxX - width + + // AXUIElement coordinates: Y=0 at top-left + // NSWindow coordinates: Y=0 at bottom-left + let y = mainDisplayScreen.frame.maxY - xcodeRect.maxY + mainDisplayScreen.frame.minY return CGRect(x: x, y: y, width: width, height: height) } diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index cd085db2..9c4feb0f 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -349,7 +349,7 @@ extension WidgetWindowsController { // Generate a default location when no workspace is opened private func generateDefaultLocation() -> WidgetLocation { - let chatPanelFrame = UpdateLocationStrategy.getChatPanelFrame(isAttachedToXcodeEnabled: false) + let chatPanelFrame = UpdateLocationStrategy.getChatPanelFrame() return WidgetLocation( widgetFrame: .zero, @@ -459,7 +459,8 @@ extension WidgetWindowsController { guard let currentXcodeApp = (await currentXcodeApp), let currentFocusedWindow = currentXcodeApp.appElement.focusedWindow, let currentXcodeScreen = currentXcodeApp.appScreen, - let currentXcodeRect = currentFocusedWindow.rect + let currentXcodeRect = currentFocusedWindow.rect, + let notif = notif else { return } if let previousXcodeApp = (await previousXcodeApp), @@ -472,16 +473,13 @@ extension WidgetWindowsController { let isAttachedToXcodeEnabled = UserDefaults.shared.value(for: \.autoAttachChatToXcode) guard isAttachedToXcodeEnabled else { return } - if let notif = notif { - let dialogIdentifiers = ["open_quickly", "alert"] - if dialogIdentifiers.contains(notif.element.identifier) { return } - } + guard notif.element.isXcodeWorkspaceWindow else { return } let state = store.withState { $0 } if state.chatPanelState.isPanelDisplayed && !windows.chatPanelWindow.isWindowHidden { - var frame = UpdateLocationStrategy.getChatPanelFrame( - isAttachedToXcodeEnabled: true, - xcodeApp: currentXcodeApp + var frame = UpdateLocationStrategy.getAttachedChatPanelFrame( + NSScreen.main ?? NSScreen.screens.first!, + workspaceWindowElement: notif.element ) let screenMaxX = currentXcodeScreen.visibleFrame.maxX diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 4f458bb2..a44299a2 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,12 +1,17 @@ -### GitHub Copilot for Xcode 0.37.0 +### GitHub Copilot for Xcode 0.38.0 **🚀 Highlights** -* **Advanced** settings: Added option to configure **Custom Instructions** for GitHub Copilot during chat sessions. -* **Advanced** settings: Added option to keep the chat window automatically attached to Xcode. -* Added support for dragging-and-dropping files into the chat panel to provide context. +* Support for Claude 4 in Chat. +* Support for Copilot Vision (image attachments). +* Support for remote MCP servers. + +**💪 Improvements** +* Automatically suggests a title for conversations created in agent mode. +* Improved restoration of MCP tool status after Copilot restarts. +* Reduced duplication of MCP server instances. **🛠️ Bug Fixes** -* "Add Context" menu didn’t show files in workspaces organized with Xcode’s group feature. -* Chat didn’t respond when the workspace was in a system folder (like Desktop, Downloads, or Documents) and access permission hadn’t been granted. +* Switching accounts now correctly refreshes the auth token and models. +* Fixed file create/edit issues in agent mode. From e7fd64d3d4228d3f7f1a08d511203c6d7cdce723 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 9 Jul 2025 07:03:37 +0000 Subject: [PATCH 12/26] Pre-release 0.38.129 --- Core/Sources/ChatService/ChatService.swift | 28 --- Core/Sources/ConversationTab/ChatPanel.swift | 4 +- .../ConversationTab/ContextUtils.swift | 5 + Core/Sources/ConversationTab/FilePicker.swift | 47 ++-- .../GitHubCopilotViewModel.swift | 188 +++++++++++++++- .../AdvancedSettings/EnterpriseSection.swift | 19 +- .../GlobalInstructionsView.swift | 2 +- .../AdvancedSettings/ProxySection.swift | 23 +- Core/Sources/HostApp/MCPConfigView.swift | 6 +- .../SharedComponents/DebouncedBinding.swift | 25 --- .../SharedComponents/SettingsTextField.swift | 57 +++-- .../SuggestionWidget/ChatWindowView.swift | 4 +- Server/package-lock.json | 9 +- Server/package.json | 2 +- TestPlan.xctestplan | 4 - .../Conversation/WatchedFilesHandler.swift | 51 +++-- .../LanguageServer/GitHubCopilotService.swift | 2 +- Tool/Sources/Persist/ConfigPathUtils.swift | 8 +- ....swift => BatchingFileChangeWatcher.swift} | 169 +------------- .../DefaultFileWatcherFactory.swift | 24 ++ .../FileChangeWatcher/FSEventProvider.swift | 2 +- .../FileChangeWatcherService.swift | 206 ++++++++++++++++++ .../FileWatcherProtocol.swift | 31 +++ .../FileChangeWatcher/SingleFileWatcher.swift | 81 +++++++ .../WorkspaceFileProvider.swift | 8 +- Tool/Sources/Workspace/WorkspaceFile.swift | 4 +- .../Workspace/WorkspaceFileIndex.swift | 60 +++++ .../FileChangeWatcherTests.swift | 85 ++++++-- 28 files changed, 820 insertions(+), 334 deletions(-) delete mode 100644 Core/Sources/HostApp/SharedComponents/DebouncedBinding.swift rename Tool/Sources/Workspace/FileChangeWatcher/{FileChangeWatcher.swift => BatchingFileChangeWatcher.swift} (61%) create mode 100644 Tool/Sources/Workspace/FileChangeWatcher/DefaultFileWatcherFactory.swift create mode 100644 Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcherService.swift create mode 100644 Tool/Sources/Workspace/FileChangeWatcher/FileWatcherProtocol.swift create mode 100644 Tool/Sources/Workspace/FileChangeWatcher/SingleFileWatcher.swift create mode 100644 Tool/Sources/Workspace/WorkspaceFileIndex.swift diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 023397a3..c420afe1 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -95,7 +95,6 @@ public final class ChatService: ChatServiceType, ObservableObject { subscribeToNotifications() subscribeToConversationContextRequest() - subscribeToWatchedFilesHandler() subscribeToClientToolInvokeEvent() subscribeToClientToolConfirmationEvent() } @@ -143,13 +142,6 @@ public final class ChatService: ChatServiceType, ObservableObject { } }).store(in: &cancellables) } - - private func subscribeToWatchedFilesHandler() { - self.watchedFilesHandler.onWatchedFiles.sink(receiveValue: { [weak self] (request, completion) in - guard let self, request.params!.workspaceFolder.uri != "/" else { return } - self.startFileChangeWatcher() - }).store(in: &cancellables) - } private func subscribeToClientToolConfirmationEvent() { ClientToolHandlerImpl.shared.onClientToolConfirmationEvent.sink(receiveValue: { [weak self] (request, completion) in @@ -1042,26 +1034,6 @@ extension ChatService { func fetchAllChatMessagesFromStorage() -> [ChatMessage] { return ChatMessageStore.getAll(by: self.chatTabInfo.id, metadata: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username)) } - - /// for file change watcher - func startFileChangeWatcher() { - Task { [weak self] in - guard let self else { return } - let workspaceURL = URL(fileURLWithPath: self.chatTabInfo.workspacePath) - let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(workspaceURL: workspaceURL, documentURL: nil) ?? workspaceURL - await FileChangeWatcherServicePool.shared.watch( - for: workspaceURL - ) { fileEvents in - Task { [weak self] in - guard let self else { return } - try? await self.conversationProvider?.notifyDidChangeWatchedFiles( - .init(workspaceUri: projectURL.path, changes: fileEvents), - workspace: .init(workspaceURL: workspaceURL, projectURL: projectURL) - ) - } - } - } - } } func replaceFirstWord(in content: String, from oldWord: String, to newWord: String) -> String { diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index f7f872c7..5b11637b 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -499,7 +499,7 @@ struct ChatPanelInputArea: View { var focusedField: FocusState.Binding @State var cancellable = Set() @State private var isFilePickerPresented = false - @State private var allFiles: [FileReference] = [] + @State private var allFiles: [FileReference]? = nil @State private var filteredTemplates: [ChatTemplate] = [] @State private var filteredAgent: [ChatAgent] = [] @State private var showingTemplates = false @@ -528,7 +528,7 @@ struct ChatPanelInputArea: View { } ) .onAppear() { - allFiles = ContextUtils.getFilesInActiveWorkspace(workspaceURL: chat.workspaceURL) + allFiles = ContextUtils.getFilesFromWorkspaceIndex(workspaceURL: chat.workspaceURL) } } diff --git a/Core/Sources/ConversationTab/ContextUtils.swift b/Core/Sources/ConversationTab/ContextUtils.swift index 34f44e7d..5e05927a 100644 --- a/Core/Sources/ConversationTab/ContextUtils.swift +++ b/Core/Sources/ConversationTab/ContextUtils.swift @@ -7,6 +7,11 @@ import SystemUtils public struct ContextUtils { + public static func getFilesFromWorkspaceIndex(workspaceURL: URL?) -> [FileReference]? { + guard let workspaceURL = workspaceURL else { return [] } + return WorkspaceFileIndex.shared.getFiles(for: workspaceURL) + } + public static func getFilesInActiveWorkspace(workspaceURL: URL?) -> [FileReference] { if let workspaceURL = workspaceURL, let info = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) { return WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: info.workspaceURL, workspaceRootURL: info.projectURL) diff --git a/Core/Sources/ConversationTab/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift index 338aa9c8..8ae83e10 100644 --- a/Core/Sources/ConversationTab/FilePicker.swift +++ b/Core/Sources/ConversationTab/FilePicker.swift @@ -5,7 +5,7 @@ import SwiftUI import SystemUtils public struct FilePicker: View { - @Binding var allFiles: [FileReference] + @Binding var allFiles: [FileReference]? let workspaceURL: URL? var onSubmit: (_ file: FileReference) -> Void var onExit: () -> Void @@ -14,20 +14,21 @@ public struct FilePicker: View { @State private var selectedId: Int = 0 @State private var localMonitor: Any? = nil - private var filteredFiles: [FileReference] { + private var filteredFiles: [FileReference]? { if searchText.isEmpty { return allFiles } - return allFiles.filter { doc in + return allFiles?.filter { doc in (doc.fileName ?? doc.url.lastPathComponent) .localizedCaseInsensitiveContains(searchText) } } private static let defaultEmptyStateText = "No results found." + private static let isIndexingStateText = "Indexing files, try later..." private var emptyStateAttributedString: AttributedString? { - var message = FilePicker.defaultEmptyStateText + var message = allFiles == nil ? FilePicker.isIndexingStateText : FilePicker.defaultEmptyStateText if let workspaceURL = workspaceURL { let status = FileUtils.checkFileReadability(at: workspaceURL.path) if let errorMessage = status.errorMessage(using: ContextUtils.workspaceReadabilityErrorMessageProvider) { @@ -89,25 +90,25 @@ public struct FilePicker: View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 4) { - ForEach(Array(filteredFiles.enumerated()), id: \.element) { index, doc in - FileRowView(doc: doc, id: index, selectedId: $selectedId) - .contentShape(Rectangle()) - .onTapGesture { - onSubmit(doc) - selectedId = index - isSearchBarFocused = true - } - .id(index) - } - - if filteredFiles.isEmpty { + if allFiles == nil || filteredFiles?.isEmpty == true { emptyStateView .foregroundColor(.secondary) .padding(.leading, 4) .padding(.vertical, 4) + } else { + ForEach(Array((filteredFiles ?? []).enumerated()), id: \.element) { index, doc in + FileRowView(doc: doc, id: index, selectedId: $selectedId) + .contentShape(Rectangle()) + .onTapGesture { + onSubmit(doc) + selectedId = index + isSearchBarFocused = true + } + .id(index) + } } } - .id(filteredFiles.hashValue) + .id(filteredFiles?.hashValue) } .frame(maxHeight: 200) .padding(.horizontal, 4) @@ -158,16 +159,14 @@ public struct FilePicker: View { } private func moveSelection(up: Bool, proxy: ScrollViewProxy) { - let files = filteredFiles - guard !files.isEmpty else { return } + guard let files = filteredFiles, !files.isEmpty else { return } let nextId = selectedId + (up ? -1 : 1) selectedId = max(0, min(nextId, files.count - 1)) proxy.scrollTo(selectedId, anchor: .bottom) } private func handleEnter() { - let files = filteredFiles - guard !files.isEmpty && selectedId < files.count else { return } + guard let files = filteredFiles, !files.isEmpty && selectedId < files.count else { return } onSubmit(files[selectedId]) } } @@ -192,9 +191,13 @@ struct FileRowView: View { Text(doc.fileName ?? doc.url.lastPathComponent) .font(.body) .hoverPrimaryForeground(isHovered: selectedId == id) + .lineLimit(1) + .truncationMode(.middle) Text(doc.relativePath ?? doc.url.path) .font(.caption) .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) } Spacer() @@ -206,7 +209,7 @@ struct FileRowView: View { .onHover(perform: { hovering in isHovered = hovering }) - .transition(.move(edge: .bottom)) + .help(doc.relativePath ?? doc.url.path) } } } diff --git a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift index 1c6818d2..e310f5d5 100644 --- a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift +++ b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift @@ -163,18 +163,198 @@ public class GitHubCopilotViewModel: ObservableObject { CopilotModelManager.updateLLMs(models) } } catch let error as GitHubCopilotError { - if case .languageServerError(.timeout) = error { - // TODO figure out how to extend the default timeout on a Chime LSP request - // Until then, reissue request + switch error { + case .languageServerError(.timeout): waitForSignIn() return + case .languageServerError( + .serverError( + code: CLSErrorCode.deviceFlowFailed.rawValue, + message: _, + data: _ + ) + ): + await showSignInFailedAlert(error: error) + waitingForSignIn = false + return + default: + throw error } - throw error } catch { toast(error.localizedDescription, .error) } } } + + private func extractSigninErrorMessage(error: GitHubCopilotError) -> String { + let errorDescription = error.localizedDescription + + // Handle specific EACCES permission denied errors + if errorDescription.contains("EACCES") { + // Look for paths wrapped in single quotes + let pattern = "'([^']+)'" + if let regex = try? NSRegularExpression(pattern: pattern, options: []) { + let range = NSRange(location: 0, length: errorDescription.utf16.count) + if let match = regex.firstMatch(in: errorDescription, options: [], range: range) { + let pathRange = Range(match.range(at: 1), in: errorDescription)! + let path = String(errorDescription[pathRange]) + return path + } + } + } + + return errorDescription + } + + private func getSigninErrorTitle(error: GitHubCopilotError) -> String { + let errorDescription = error.localizedDescription + + if errorDescription.contains("EACCES") { + return "Can't sign you in. The app couldn't create or access files in" + } + + return "Error details:" + } + + private var accessPermissionCommands: String { + """ + sudo mkdir -p ~/.config/github-copilot + sudo chown -R $(whoami):staff ~/.config + chmod -N ~/.config ~/.config/github-copilot + """ + } + + private var containerBackgroundColor: CGColor { + let isDarkMode = NSApp.effectiveAppearance.name == .darkAqua + return isDarkMode + ? NSColor.black.withAlphaComponent(0.85).cgColor + : NSColor.white.withAlphaComponent(0.85).cgColor + } + + // MARK: - Alert Building Functions + + private func showSignInFailedAlert(error: GitHubCopilotError) async { + let alert = NSAlert() + alert.messageText = "GitHub Copilot Sign-in Failed" + alert.alertStyle = .critical + + let accessoryView = createAlertAccessoryView(error: error) + alert.accessoryView = accessoryView + alert.addButton(withTitle: "Copy Commands") + alert.addButton(withTitle: "Cancel") + + let response = await MainActor.run { + alert.runModal() + } + + if response == .alertFirstButtonReturn { + copyCommandsToClipboard() + } + } + + private func createAlertAccessoryView(error: GitHubCopilotError) -> NSView { + let accessoryView = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 142)) + + let detailsHeader = createDetailsHeader(error: error) + accessoryView.addSubview(detailsHeader) + + let errorContainer = createErrorContainer(error: error) + accessoryView.addSubview(errorContainer) + + let terminalHeader = createTerminalHeader() + accessoryView.addSubview(terminalHeader) + + let commandsContainer = createCommandsContainer() + accessoryView.addSubview(commandsContainer) + + return accessoryView + } + + private func createDetailsHeader(error: GitHubCopilotError) -> NSView { + let detailsHeader = NSView(frame: NSRect(x: 16, y: 122, width: 368, height: 20)) + + let warningIcon = NSImageView(frame: NSRect(x: 0, y: 4, width: 16, height: 16)) + warningIcon.image = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Warning") + warningIcon.contentTintColor = NSColor.systemOrange + detailsHeader.addSubview(warningIcon) + + let detailsLabel = NSTextField(wrappingLabelWithString: getSigninErrorTitle(error: error)) + detailsLabel.frame = NSRect(x: 20, y: 0, width: 346, height: 20) + detailsLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) + detailsLabel.textColor = NSColor.labelColor + detailsHeader.addSubview(detailsLabel) + + return detailsHeader + } + + private func createErrorContainer(error: GitHubCopilotError) -> NSView { + let errorContainer = NSView(frame: NSRect(x: 16, y: 96, width: 368, height: 22)) + errorContainer.wantsLayer = true + errorContainer.layer?.backgroundColor = containerBackgroundColor + errorContainer.layer?.borderColor = NSColor.separatorColor.cgColor + errorContainer.layer?.borderWidth = 1 + errorContainer.layer?.cornerRadius = 6 + + let errorMessage = NSTextField(wrappingLabelWithString: extractSigninErrorMessage(error: error)) + errorMessage.frame = NSRect(x: 8, y: 4, width: 368, height: 14) + errorMessage.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + errorMessage.textColor = NSColor.labelColor + errorMessage.backgroundColor = .clear + errorMessage.isBordered = false + errorMessage.isEditable = false + errorMessage.drawsBackground = false + errorMessage.usesSingleLineMode = true + errorContainer.addSubview(errorMessage) + + return errorContainer + } + + private func createTerminalHeader() -> NSView { + let terminalHeader = NSView(frame: NSRect(x: 16, y: 66, width: 368, height: 20)) + + let toolIcon = NSImageView(frame: NSRect(x: 0, y: 4, width: 16, height: 16)) + toolIcon.image = NSImage(systemSymbolName: "terminal.fill", accessibilityDescription: "Terminal") + toolIcon.contentTintColor = NSColor.secondaryLabelColor + terminalHeader.addSubview(toolIcon) + + let terminalLabel = NSTextField(wrappingLabelWithString: "Copy and run the commands below in Terminal, then retry.") + terminalLabel.frame = NSRect(x: 20, y: 0, width: 346, height: 20) + terminalLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) + terminalLabel.textColor = NSColor.labelColor + terminalHeader.addSubview(terminalLabel) + + return terminalHeader + } + + private func createCommandsContainer() -> NSView { + let commandsContainer = NSView(frame: NSRect(x: 16, y: 4, width: 368, height: 58)) + commandsContainer.wantsLayer = true + commandsContainer.layer?.backgroundColor = containerBackgroundColor + commandsContainer.layer?.borderColor = NSColor.separatorColor.cgColor + commandsContainer.layer?.borderWidth = 1 + commandsContainer.layer?.cornerRadius = 6 + + let commandsText = NSTextField(wrappingLabelWithString: accessPermissionCommands) + commandsText.frame = NSRect(x: 8, y: 8, width: 344, height: 42) + commandsText.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + commandsText.textColor = NSColor.labelColor + commandsText.backgroundColor = .clear + commandsText.isBordered = false + commandsText.isEditable = false + commandsText.isSelectable = true + commandsText.drawsBackground = false + commandsContainer.addSubview(commandsText) + + return commandsContainer + } + + private func copyCommandsToClipboard() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString( + self.accessPermissionCommands.replacingOccurrences(of: "\n", with: " && "), + forType: .string + ) + } public func broadcastStatusChange() { DistributedNotificationCenter.default().post( diff --git a/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift b/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift index bcd0adf2..f0a21a57 100644 --- a/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift @@ -1,4 +1,5 @@ import Combine +import Client import SwiftUI import Toast @@ -11,7 +12,8 @@ struct EnterpriseSection: View { SettingsTextField( title: "Auth provider URL", prompt: "https://your-enterprise.ghe.com", - text: DebouncedBinding($gitHubCopilotEnterpriseURI, handler: urlChanged).binding + text: $gitHubCopilotEnterpriseURI, + onDebouncedChange: { url in urlChanged(url)} ) } } @@ -24,15 +26,26 @@ struct EnterpriseSection: View { name: .gitHubCopilotShouldRefreshEditorInformation, object: nil ) + Task { + do { + let service = try getService() + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + } catch { + toast(error.localizedDescription, .error) + } + } } func validateAuthURL(_ url: String) { let maybeURL = URL(string: url) - guard let parsedURl = maybeURL else { + guard let parsedURL = maybeURL else { toast("Invalid URL", .error) return } - if parsedURl.scheme != "https" { + if parsedURL.scheme != "https" { toast("URL scheme must be https://", .error) return } diff --git a/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift b/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift index 9b763ade..b429f581 100644 --- a/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift +++ b/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift @@ -67,8 +67,8 @@ struct GlobalInstructionsView: View { object: nil ) Task { - let service = try getService() do { + let service = try getService() // Notify extension service process to refresh all its CLS subprocesses to apply new configuration try await service.postNotification( name: Notification.Name diff --git a/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift b/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift index 168bdb1f..ab2062c7 100644 --- a/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift @@ -15,37 +15,38 @@ struct ProxySection: View { SettingsTextField( title: "Proxy URL", prompt: "http://host:port", - text: wrapBinding($gitHubCopilotProxyUrl) + text: $gitHubCopilotProxyUrl, + onDebouncedChange: { _ in refreshConfiguration() } ) SettingsTextField( title: "Proxy username", prompt: "username", - text: wrapBinding($gitHubCopilotProxyUsername) + text: $gitHubCopilotProxyUsername, + onDebouncedChange: { _ in refreshConfiguration() } ) - SettingsSecureField( + SettingsTextField( title: "Proxy password", prompt: "password", - text: wrapBinding($gitHubCopilotProxyPassword) + text: $gitHubCopilotProxyPassword, + isSecure: true, + onDebouncedChange: { _ in refreshConfiguration() } ) SettingsToggle( title: "Proxy strict SSL", - isOn: wrapBinding($gitHubCopilotUseStrictSSL) + isOn: $gitHubCopilotUseStrictSSL ) + .onChange(of: gitHubCopilotUseStrictSSL) { _ in refreshConfiguration() } } } - private func wrapBinding(_ b: Binding) -> Binding { - DebouncedBinding(b, handler: refreshConfiguration).binding - } - - func refreshConfiguration(_: Any) { + func refreshConfiguration() { NotificationCenter.default.post( name: .gitHubCopilotShouldRefreshEditorInformation, object: nil ) Task { - let service = try getService() do { + let service = try getService() try await service.postNotification( name: Notification.Name .gitHubCopilotShouldRefreshEditorInformation.rawValue diff --git a/Core/Sources/HostApp/MCPConfigView.swift b/Core/Sources/HostApp/MCPConfigView.swift index 5008cc44..855d4fc4 100644 --- a/Core/Sources/HostApp/MCPConfigView.swift +++ b/Core/Sources/HostApp/MCPConfigView.swift @@ -39,10 +39,6 @@ struct MCPConfigView: View { } } - private func wrapBinding(_ b: Binding) -> Binding { - DebouncedBinding(b, handler: refreshConfiguration).binding - } - private func setupConfigFilePath() { let fileManager = FileManager.default @@ -162,8 +158,8 @@ struct MCPConfigView: View { } Task { - let service = try getService() do { + let service = try getService() try await service.postNotification( name: Notification.Name .gitHubCopilotShouldRefreshEditorInformation.rawValue diff --git a/Core/Sources/HostApp/SharedComponents/DebouncedBinding.swift b/Core/Sources/HostApp/SharedComponents/DebouncedBinding.swift deleted file mode 100644 index 6b4224b2..00000000 --- a/Core/Sources/HostApp/SharedComponents/DebouncedBinding.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Combine -import SwiftUI - -class DebouncedBinding { - private let subject = PassthroughSubject() - private let cancellable: AnyCancellable - private let wrappedBinding: Binding - - init(_ binding: Binding, handler: @escaping (T) -> Void) { - self.wrappedBinding = binding - self.cancellable = subject - .debounce(for: .seconds(1.0), scheduler: RunLoop.main) - .sink { handler($0) } - } - - var binding: Binding { - return Binding( - get: { self.wrappedBinding.wrappedValue }, - set: { - self.wrappedBinding.wrappedValue = $0 - self.subject.send($0) - } - ) - } -} diff --git a/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift b/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift index 580ef886..ae135ee5 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift @@ -4,31 +4,47 @@ struct SettingsTextField: View { let title: String let prompt: String @Binding var text: String - - var body: some View { - Form { - TextField(text: $text, prompt: Text(prompt)) { - Text(title) - } - .textFieldStyle(PlainTextFieldStyle()) - .multilineTextAlignment(.trailing) - } - .padding(10) + let isSecure: Bool + + @State private var localText: String = "" + @State private var debounceTimer: Timer? + + var onDebouncedChange: ((String) -> Void)? + + init(title: String, prompt: String, text: Binding, isSecure: Bool = false, onDebouncedChange: ((String) -> Void)? = nil) { + self.title = title + self.prompt = prompt + self._text = text + self.isSecure = isSecure + self.onDebouncedChange = onDebouncedChange + self._localText = State(initialValue: text.wrappedValue) } -} - -struct SettingsSecureField: View { - let title: String - let prompt: String - @Binding var text: String var body: some View { Form { - SecureField(text: $text, prompt: Text(prompt)) { - Text(title) + Group { + if isSecure { + SecureField(text: $localText, prompt: Text(prompt)) { + Text(title) + } + } else { + TextField(text: $localText, prompt: Text(prompt)) { + Text(title) + } + } } .textFieldStyle(.plain) .multilineTextAlignment(.trailing) + .onChange(of: localText) { newValue in + text = newValue + debounceTimer?.invalidate() + debounceTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in + onDebouncedChange?(newValue) + } + } + .onAppear { + localText = text + } } .padding(10) } @@ -42,10 +58,11 @@ struct SettingsSecureField: View { text: .constant("") ) Divider() - SettingsSecureField( + SettingsTextField( title: "Password", prompt: "pass", - text: .constant("") + text: .constant(""), + isSecure: true ) } .padding(.vertical, 10) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 68bdeb5a..45800b9f 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -40,8 +40,8 @@ struct ChatWindowView: View { ChatLoginView(viewModel: GitHubCopilotViewModel.shared) case .notAuthorized: ChatNoSubscriptionView(viewModel: GitHubCopilotViewModel.shared) - default: - ChatLoadingView() + case .unknown: + ChatLoginView(viewModel: GitHubCopilotViewModel.shared) } } } diff --git a/Server/package-lock.json b/Server/package-lock.json index d31c6de2..28967547 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.338.0", + "@github/copilot-language-server": "^1.341.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,10 +36,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.338.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.338.0.tgz", - "integrity": "sha512-QPg4Gn/IWON6J+fqeEHvFMxaHOi+AmBq3jc8ySat2isQ8gL5cWvd/mThXvqeJ9XeHLAsojWvS6YitFCntZviSg==", - "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", + "version": "1.341.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.341.0.tgz", + "integrity": "sha512-u0RfW9A68+RM7evQSCICH/uK/03p9bzp/8+2+zg6GDC/u3O2F8V+G1RkvlqfrckXrQZd1rImO41ch7ns3A4zMQ==", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" }, diff --git a/Server/package.json b/Server/package.json index 73223fa6..7fd1269b 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.338.0", + "@github/copilot-language-server": "^1.341.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index e60ea435..a46ddf32 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -93,10 +93,6 @@ } }, { - "skippedTests" : [ - "FileChangeWatcherServiceTests\/testProjectMonitoringDetectsAddedProjects()", - "FileChangeWatcherServiceTests\/testProjectMonitoringDetectsRemovedProjects()" - ], "target" : { "containerPath" : "container:Tool", "identifier" : "WorkspaceTests", diff --git a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift index 6b68117f..281b534d 100644 --- a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift +++ b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift @@ -3,17 +3,15 @@ import Combine import Workspace import XcodeInspector import Foundation +import ConversationServiceProvider public protocol WatchedFilesHandler { - var onWatchedFiles: PassthroughSubject<(WatchedFilesRequest, (AnyJSONRPCResponse) -> Void), Never> { get } func handleWatchedFiles(_ request: WatchedFilesRequest, workspaceURL: URL, completion: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?) } public final class WatchedFilesHandlerImpl: WatchedFilesHandler { public static let shared = WatchedFilesHandlerImpl() - - public let onWatchedFiles: PassthroughSubject<(WatchedFilesRequest, (AnyJSONRPCResponse) -> Void), Never> = .init() - + public func handleWatchedFiles(_ request: WatchedFilesRequest, workspaceURL: URL, completion: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?) { guard let params = request.params, params.workspaceFolder.uri != "/" else { return } @@ -24,20 +22,23 @@ public final class WatchedFilesHandlerImpl: WatchedFilesHandler { projectURL: projectURL, excludeGitIgnoredFiles: params.excludeGitignoredFiles, excludeIDEIgnoredFiles: params.excludeIDEIgnoredFiles - ).prefix(10000) // Set max number of indexing file to 10000 + ) + WorkspaceFileIndex.shared.setFiles(files, for: workspaceURL) + + let fileUris = files.prefix(10000).map { $0.url.absoluteString } // Set max number of indexing file to 10000 let batchSize = BatchingFileChangeWatcher.maxEventPublishSize /// only `batchSize`(100) files to complete this event for setup watching workspace in CLS side - let jsonResult: JSONValue = .array(files.prefix(batchSize).map { .hash(["uri": .string($0)]) }) + let jsonResult: JSONValue = .array(fileUris.prefix(batchSize).map { .hash(["uri": .string($0)]) }) let jsonValue: JSONValue = .hash(["files": jsonResult]) completion(AnyJSONRPCResponse(id: request.id, result: jsonValue)) Task { - if files.count > batchSize { - for startIndex in stride(from: batchSize, to: files.count, by: batchSize) { - let endIndex = min(startIndex + batchSize, files.count) - let batch = Array(files[startIndex.. batchSize { + for startIndex in stride(from: batchSize, to: fileUris.count, by: batchSize) { + let endIndex = min(startIndex + batchSize, fileUris.count) + let batch = Array(fileUris[startIndex.. TimeInterval { - return agentMode ? 86400 /* 24h for agent mode timeout */ : 90 + return agentMode ? 86400 /* 24h for agent mode timeout */ : 600 /* ask mode timeout */ } @GitHubCopilotSuggestionActor diff --git a/Tool/Sources/Persist/ConfigPathUtils.swift b/Tool/Sources/Persist/ConfigPathUtils.swift index ec7614ac..603581ba 100644 --- a/Tool/Sources/Persist/ConfigPathUtils.swift +++ b/Tool/Sources/Persist/ConfigPathUtils.swift @@ -62,8 +62,12 @@ struct ConfigPathUtils { if !fileManager.fileExists(atPath: url.path) { do { try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) - } catch { - Logger.client.info("Failed to create directory: \(error)") + } catch let error as NSError { + if error.domain == NSPOSIXErrorDomain && error.code == EACCES { + Logger.client.error("Permission denied when trying to create directory: \(url.path)") + } else { + Logger.client.info("Failed to create directory: \(error)") + } } } } diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift b/Tool/Sources/Workspace/FileChangeWatcher/BatchingFileChangeWatcher.swift similarity index 61% rename from Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift rename to Tool/Sources/Workspace/FileChangeWatcher/BatchingFileChangeWatcher.swift index 9bcc6cf4..c63f0ad1 100644 --- a/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift +++ b/Tool/Sources/Workspace/FileChangeWatcher/BatchingFileChangeWatcher.swift @@ -1,22 +1,9 @@ import Foundation import System import Logger -import CoreServices import LanguageServerProtocol -import XcodeInspector -public typealias PublisherType = (([FileEvent]) -> Void) - -protocol FileChangeWatcher { - func onFileCreated(file: URL) - func onFileChanged(file: URL) - func onFileDeleted(file: URL) - - func addPaths(_ paths: [URL]) - func removePaths(_ paths: [URL]) -} - -public final class BatchingFileChangeWatcher: FileChangeWatcher { +public final class BatchingFileChangeWatcher: DirectoryWatcherProtocol { private var watchedPaths: [URL] private let changePublisher: PublisherType private let publishInterval: TimeInterval @@ -30,9 +17,7 @@ public final class BatchingFileChangeWatcher: FileChangeWatcher { // Dependencies injected for testing private let fsEventProvider: FSEventProvider - - public var paths: [URL] { watchedPaths } - + /// TODO: set a proper value for stdio public static let maxEventPublishSize = 100 @@ -73,7 +58,11 @@ public final class BatchingFileChangeWatcher: FileChangeWatcher { updateWatchedPaths(updatedPaths) } } - + + public func paths() -> [URL] { + return watchedPaths + } + internal func start() { guard !isWatching else { return } @@ -161,7 +150,8 @@ public final class BatchingFileChangeWatcher: FileChangeWatcher { } /// Starts watching for file changes in the project - private func startWatching() -> Bool { + public func startWatching() -> Bool { + isWatching = true var isEventStreamStarted = false var context = FSEventStreamContext() @@ -196,7 +186,7 @@ public final class BatchingFileChangeWatcher: FileChangeWatcher { } /// Stops watching for file changes - internal func stopWatching() { + public func stopWatching() { guard isWatching, let eventStream = eventStream else { return } fsEventProvider.stopStream(eventStream) @@ -263,142 +253,3 @@ extension BatchingFileChangeWatcher { return false } } - -public class FileChangeWatcherService { - internal var watcher: BatchingFileChangeWatcher? - /// for watching projects added or removed - private var timer: Timer? - private var projectWatchingInterval: TimeInterval - - private(set) public var workspaceURL: URL - private(set) public var publisher: PublisherType - - // Dependencies injected for testing - internal let workspaceFileProvider: WorkspaceFileProvider - internal let watcherFactory: ([URL], @escaping PublisherType) -> BatchingFileChangeWatcher - - public init( - _ workspaceURL: URL, - publisher: @escaping PublisherType, - publishInterval: TimeInterval = 3.0, - projectWatchingInterval: TimeInterval = 300.0, - workspaceFileProvider: WorkspaceFileProvider = FileChangeWatcherWorkspaceFileProvider(), - watcherFactory: (([URL], @escaping PublisherType) -> BatchingFileChangeWatcher)? = nil - ) { - self.workspaceURL = workspaceURL - self.publisher = publisher - self.workspaceFileProvider = workspaceFileProvider - self.watcherFactory = watcherFactory ?? { projectURLs, publisher in - BatchingFileChangeWatcher(watchedPaths: projectURLs, changePublisher: publisher, publishInterval: publishInterval) - } - self.projectWatchingInterval = projectWatchingInterval - } - - deinit { - self.watcher = nil - self.timer?.invalidate() - } - - internal func startWatchingProject() { - guard timer == nil else { return } - - Task { @MainActor [weak self] in - guard let self else { return } - - self.timer = Timer.scheduledTimer(withTimeInterval: self.projectWatchingInterval, repeats: true) { [weak self] _ in - guard let self, let watcher = self.watcher else { return } - - let watchingProjects = Set(watcher.paths) - let projects = Set(self.workspaceFileProvider.getProjects(by: self.workspaceURL)) - - /// find added projects - let addedProjects = projects.subtracting(watchingProjects) - self.onProjectAdded(Array(addedProjects)) - - /// find removed projects - let removedProjects = watchingProjects.subtracting(projects) - self.onProjectRemoved(Array(removedProjects)) - } - } - } - - public func startWatching() { - guard workspaceURL.path != "/" else { return } - - guard watcher == nil else { return } - - let projects = workspaceFileProvider.getProjects(by: workspaceURL) - guard projects.count > 0 else { return } - - watcher = watcherFactory(projects, publisher) - Logger.client.info("Started watching for file changes in \(projects)") - - startWatchingProject() - } - - internal func onProjectAdded(_ projectURLs: [URL]) { - guard let watcher = watcher, projectURLs.count > 0 else { return } - - watcher.addPaths(projectURLs) - - Logger.client.info("Started watching for file changes in \(projectURLs)") - - /// sync all the files as created in the project when added - for projectURL in projectURLs { - let files = workspaceFileProvider.getFilesInActiveWorkspace( - workspaceURL: projectURL, - workspaceRootURL: projectURL - ) - publisher(files.map { .init(uri: $0.url.absoluteString, type: .created) }) - } - } - - internal func onProjectRemoved(_ projectURLs: [URL]) { - guard let watcher = watcher, projectURLs.count > 0 else { return } - - watcher.removePaths(projectURLs) - - Logger.client.info("Stopped watching for file changes in \(projectURLs)") - - /// sync all the files as deleted in the project when removed - for projectURL in projectURLs { - let files = workspaceFileProvider.getFilesInActiveWorkspace(workspaceURL: projectURL, workspaceRootURL: projectURL) - publisher(files.map { .init(uri: $0.url.absoluteString, type: .deleted) }) - } - } -} - -@globalActor -public enum PoolActor: GlobalActor { - public actor Actor {} - public static let shared = Actor() -} - -public class FileChangeWatcherServicePool { - - public static let shared = FileChangeWatcherServicePool() - private var servicePool: [URL: FileChangeWatcherService] = [:] - - private init() {} - - @PoolActor - public func watch(for workspaceURL: URL, publisher: @escaping PublisherType) { - guard workspaceURL.path != "/" else { return } - - var validWorkspaceURL: URL? = nil - if WorkspaceFile.isXCWorkspace(workspaceURL) { - validWorkspaceURL = workspaceURL - } else if WorkspaceFile.isXCProject(workspaceURL) { - validWorkspaceURL = WorkspaceFile.getWorkspaceByProject(workspaceURL) - } - - guard let validWorkspaceURL else { return } - - guard servicePool[workspaceURL] == nil else { return } - - let watcherService = FileChangeWatcherService(validWorkspaceURL, publisher: publisher) - watcherService.startWatching() - - servicePool[workspaceURL] = watcherService - } -} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/DefaultFileWatcherFactory.swift b/Tool/Sources/Workspace/FileChangeWatcher/DefaultFileWatcherFactory.swift new file mode 100644 index 00000000..eecbebbc --- /dev/null +++ b/Tool/Sources/Workspace/FileChangeWatcher/DefaultFileWatcherFactory.swift @@ -0,0 +1,24 @@ +import Foundation + +public class DefaultFileWatcherFactory: FileWatcherFactory { + public init() {} + + public func createFileWatcher(fileURL: URL, dispatchQueue: DispatchQueue?, + onFileModified: (() -> Void)? = nil, onFileDeleted: (() -> Void)? = nil, onFileRenamed: (() -> Void)? = nil) -> FileWatcherProtocol { + return SingleFileWatcher(fileURL: fileURL, + dispatchQueue: dispatchQueue, + onFileModified: onFileModified, + onFileDeleted: onFileDeleted, + onFileRenamed: onFileRenamed + ) + } + + public func createDirectoryWatcher(watchedPaths: [URL], changePublisher: @escaping PublisherType, + publishInterval: TimeInterval) -> DirectoryWatcherProtocol { + return BatchingFileChangeWatcher(watchedPaths: watchedPaths, + changePublisher: changePublisher, + publishInterval: publishInterval, + fsEventProvider: FileChangeWatcherFSEventProvider() + ) + } +} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FSEventProvider.swift b/Tool/Sources/Workspace/FileChangeWatcher/FSEventProvider.swift index 8057b106..3a15c016 100644 --- a/Tool/Sources/Workspace/FileChangeWatcher/FSEventProvider.swift +++ b/Tool/Sources/Workspace/FileChangeWatcher/FSEventProvider.swift @@ -1,6 +1,6 @@ import Foundation -protocol FSEventProvider { +public protocol FSEventProvider { func createEventStream( paths: CFArray, latency: CFTimeInterval, diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcherService.swift b/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcherService.swift new file mode 100644 index 00000000..2bd28eee --- /dev/null +++ b/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcherService.swift @@ -0,0 +1,206 @@ +import Foundation +import System +import Logger +import CoreServices +import LanguageServerProtocol +import XcodeInspector + +public class FileChangeWatcherService { + internal var watcher: DirectoryWatcherProtocol? + + private(set) public var workspaceURL: URL + private(set) public var publisher: PublisherType + private(set) public var publishInterval: TimeInterval + + // Dependencies injected for testing + internal let workspaceFileProvider: WorkspaceFileProvider + internal let watcherFactory: FileWatcherFactory + + // Watching workspace metadata file + private var workspaceConfigFileWatcher: FileWatcherProtocol? + private var isMonitoringWorkspaceConfigFile = false + private let monitoringQueue = DispatchQueue(label: "com.github.copilot.workspaceMonitor", qos: .utility) + private let configFileEventQueue = DispatchQueue(label: "com.github.copilot.workspaceEventMonitor", qos: .utility) + + public init( + _ workspaceURL: URL, + publisher: @escaping PublisherType, + publishInterval: TimeInterval = 3.0, + workspaceFileProvider: WorkspaceFileProvider = FileChangeWatcherWorkspaceFileProvider(), + watcherFactory: FileWatcherFactory? = nil + ) { + self.workspaceURL = workspaceURL + self.publisher = publisher + self.publishInterval = publishInterval + self.workspaceFileProvider = workspaceFileProvider + self.watcherFactory = watcherFactory ?? DefaultFileWatcherFactory() + } + + deinit { + stopWorkspaceConfigFileMonitoring() + self.watcher = nil + } + + public func startWatching() { + guard workspaceURL.path != "/" else { return } + + guard watcher == nil else { return } + + let projects = workspaceFileProvider.getProjects(by: workspaceURL) + guard projects.count > 0 else { return } + + watcher = watcherFactory.createDirectoryWatcher(watchedPaths: projects, changePublisher: publisher, publishInterval: publishInterval) + Logger.client.info("Started watching for file changes in \(projects)") + + startWatchingProject() + } + + internal func startWatchingProject() { + if self.workspaceFileProvider.isXCWorkspace(self.workspaceURL) { + guard !isMonitoringWorkspaceConfigFile else { return } + isMonitoringWorkspaceConfigFile = true + recreateConfigFileMonitor() + } + } + + private func recreateConfigFileMonitor() { + let workspaceDataFile = workspaceURL.appendingPathComponent("contents.xcworkspacedata") + + // Clean up existing monitor first + cleanupCurrentMonitor() + + guard self.workspaceFileProvider.fileExists(atPath: workspaceDataFile.path) else { + Logger.client.info("[FileWatcher] contents.xcworkspacedata file not found at \(workspaceDataFile.path).") + return + } + + // Create SingleFileWatcher for the workspace file + workspaceConfigFileWatcher = self.watcherFactory.createFileWatcher( + fileURL: workspaceDataFile, + dispatchQueue: configFileEventQueue, + onFileModified: { [weak self] in + self?.handleWorkspaceConfigFileChange() + self?.scheduleMonitorRecreation(delay: 1.0) + }, + onFileDeleted: { [weak self] in + self?.handleWorkspaceConfigFileChange() + self?.scheduleMonitorRecreation(delay: 1.0) + }, + onFileRenamed: nil + ) + + let _ = workspaceConfigFileWatcher?.startWatching() + } + + private func handleWorkspaceConfigFileChange() { + guard let watcher = self.watcher else { + return + } + + let workspaceDataFile = workspaceURL.appendingPathComponent("contents.xcworkspacedata") + // Check if file still exists + let fileExists = self.workspaceFileProvider.fileExists(atPath: workspaceDataFile.path) + if fileExists { + // File was modified, check for project changes + let watchingProjects = Set(watcher.paths()) + let projects = Set(self.workspaceFileProvider.getProjects(by: self.workspaceURL)) + + /// find added projects + let addedProjects = projects.subtracting(watchingProjects) + if !addedProjects.isEmpty { + self.onProjectAdded(Array(addedProjects)) + } + + /// find removed projects + let removedProjects = watchingProjects.subtracting(projects) + if !removedProjects.isEmpty { + self.onProjectRemoved(Array(removedProjects)) + } + } else { + Logger.client.info("[FileWatcher] contents.xcworkspacedata file was deleted") + } + } + + private func scheduleMonitorRecreation(delay: TimeInterval) { + monitoringQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self, self.isMonitoringWorkspaceConfigFile else { return } + self.recreateConfigFileMonitor() + } + } + + private func cleanupCurrentMonitor() { + workspaceConfigFileWatcher?.stopWatching() + workspaceConfigFileWatcher = nil + } + + private func stopWorkspaceConfigFileMonitoring() { + isMonitoringWorkspaceConfigFile = false + cleanupCurrentMonitor() + } + + internal func onProjectAdded(_ projectURLs: [URL]) { + guard let watcher = watcher, projectURLs.count > 0 else { return } + + watcher.addPaths(projectURLs) + + Logger.client.info("Started watching for file changes in \(projectURLs)") + + /// sync all the files as created in the project when added + for projectURL in projectURLs { + let files = workspaceFileProvider.getFilesInActiveWorkspace( + workspaceURL: projectURL, + workspaceRootURL: projectURL + ) + publisher(files.map { .init(uri: $0.url.absoluteString, type: .created) }) + } + } + + internal func onProjectRemoved(_ projectURLs: [URL]) { + guard let watcher = watcher, projectURLs.count > 0 else { return } + + watcher.removePaths(projectURLs) + + Logger.client.info("Stopped watching for file changes in \(projectURLs)") + + /// sync all the files as deleted in the project when removed + for projectURL in projectURLs { + let files = workspaceFileProvider.getFilesInActiveWorkspace(workspaceURL: projectURL, workspaceRootURL: projectURL) + publisher(files.map { .init(uri: $0.url.absoluteString, type: .deleted) }) + } + } +} + +@globalActor +public enum PoolActor: GlobalActor { + public actor Actor {} + public static let shared = Actor() +} + +public class FileChangeWatcherServicePool { + + public static let shared = FileChangeWatcherServicePool() + private var servicePool: [URL: FileChangeWatcherService] = [:] + + private init() {} + + @PoolActor + public func watch(for workspaceURL: URL, publisher: @escaping PublisherType) { + guard workspaceURL.path != "/" else { return } + + var validWorkspaceURL: URL? = nil + if WorkspaceFile.isXCWorkspace(workspaceURL) { + validWorkspaceURL = workspaceURL + } else if WorkspaceFile.isXCProject(workspaceURL) { + validWorkspaceURL = WorkspaceFile.getWorkspaceByProject(workspaceURL) + } + + guard let validWorkspaceURL else { return } + + guard servicePool[workspaceURL] == nil else { return } + + let watcherService = FileChangeWatcherService(validWorkspaceURL, publisher: publisher) + watcherService.startWatching() + + servicePool[workspaceURL] = watcherService + } +} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FileWatcherProtocol.swift b/Tool/Sources/Workspace/FileChangeWatcher/FileWatcherProtocol.swift new file mode 100644 index 00000000..7252d613 --- /dev/null +++ b/Tool/Sources/Workspace/FileChangeWatcher/FileWatcherProtocol.swift @@ -0,0 +1,31 @@ +import Foundation +import LanguageServerProtocol + +public protocol FileWatcherProtocol { + func startWatching() -> Bool + func stopWatching() +} + +public typealias PublisherType = (([FileEvent]) -> Void) + +public protocol DirectoryWatcherProtocol: FileWatcherProtocol { + func addPaths(_ paths: [URL]) + func removePaths(_ paths: [URL]) + func paths() -> [URL] +} + +public protocol FileWatcherFactory { + func createFileWatcher( + fileURL: URL, + dispatchQueue: DispatchQueue?, + onFileModified: (() -> Void)?, + onFileDeleted: (() -> Void)?, + onFileRenamed: (() -> Void)? + ) -> FileWatcherProtocol + + func createDirectoryWatcher( + watchedPaths: [URL], + changePublisher: @escaping PublisherType, + publishInterval: TimeInterval + ) -> DirectoryWatcherProtocol +} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/SingleFileWatcher.swift b/Tool/Sources/Workspace/FileChangeWatcher/SingleFileWatcher.swift new file mode 100644 index 00000000..612e402d --- /dev/null +++ b/Tool/Sources/Workspace/FileChangeWatcher/SingleFileWatcher.swift @@ -0,0 +1,81 @@ +import Foundation +import Logger + +class SingleFileWatcher: FileWatcherProtocol { + private var fileDescriptor: CInt = -1 + private var dispatchSource: DispatchSourceFileSystemObject? + private let fileURL: URL + private let dispatchQueue: DispatchQueue? + + // Callbacks for file events + private let onFileModified: (() -> Void)? + private let onFileDeleted: (() -> Void)? + private let onFileRenamed: (() -> Void)? + + init( + fileURL: URL, + dispatchQueue: DispatchQueue? = nil, + onFileModified: (() -> Void)? = nil, + onFileDeleted: (() -> Void)? = nil, + onFileRenamed: (() -> Void)? = nil + ) { + self.fileURL = fileURL + self.dispatchQueue = dispatchQueue + self.onFileModified = onFileModified + self.onFileDeleted = onFileDeleted + self.onFileRenamed = onFileRenamed + } + + func startWatching() -> Bool { + // Open the file for event-only monitoring + fileDescriptor = open(fileURL.path, O_EVTONLY) + guard fileDescriptor != -1 else { + Logger.client.info("[FileWatcher] Failed to open file \(fileURL.path).") + return false + } + + // Create DispatchSource to monitor the file descriptor + dispatchSource = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: [.write, .delete, .rename], + queue: self.dispatchQueue ?? DispatchQueue.global() + ) + + dispatchSource?.setEventHandler { [weak self] in + guard let self = self else { return } + + let flags = self.dispatchSource?.data ?? [] + + if flags.contains(.write) { + self.onFileModified?() + } + if flags.contains(.delete) { + self.onFileDeleted?() + self.stopWatching() + } + if flags.contains(.rename) { + self.onFileRenamed?() + self.stopWatching() + } + } + + dispatchSource?.setCancelHandler { [weak self] in + guard let self = self else { return } + close(self.fileDescriptor) + self.fileDescriptor = -1 + } + + dispatchSource?.resume() + Logger.client.info("[FileWatcher] Started watching file: \(fileURL.path)") + return true + } + + func stopWatching() { + dispatchSource?.cancel() + dispatchSource = nil + } + + deinit { + stopWatching() + } +} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift b/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift index 76a1a00f..2a5d464a 100644 --- a/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift +++ b/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift @@ -7,6 +7,7 @@ public protocol WorkspaceFileProvider { func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference] func isXCProject(_ url: URL) -> Bool func isXCWorkspace(_ url: URL) -> Bool + func fileExists(atPath: String) -> Bool } public class FileChangeWatcherWorkspaceFileProvider: WorkspaceFileProvider { @@ -15,7 +16,8 @@ public class FileChangeWatcherWorkspaceFileProvider: WorkspaceFileProvider { public func getProjects(by workspaceURL: URL) -> [URL] { guard let workspaceInfo = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) else { return [] } - return WorkspaceFile.getProjects(workspace: workspaceInfo).map { URL(fileURLWithPath: $0.uri) } + + return WorkspaceFile.getProjects(workspace: workspaceInfo).compactMap { URL(string: $0.uri) } } public func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference] { @@ -29,4 +31,8 @@ public class FileChangeWatcherWorkspaceFileProvider: WorkspaceFileProvider { public func isXCWorkspace(_ url: URL) -> Bool { return WorkspaceFile.isXCWorkspace(url) } + + public func fileExists(atPath: String) -> Bool { + return FileManager.default.fileExists(atPath: atPath) + } } diff --git a/Tool/Sources/Workspace/WorkspaceFile.swift b/Tool/Sources/Workspace/WorkspaceFile.swift index c653220e..449469cd 100644 --- a/Tool/Sources/Workspace/WorkspaceFile.swift +++ b/Tool/Sources/Workspace/WorkspaceFile.swift @@ -277,7 +277,7 @@ public struct WorkspaceFile { projectURL: URL, excludeGitIgnoredFiles: Bool, excludeIDEIgnoredFiles: Bool - ) -> [String] { + ) -> [FileReference] { // Directly return for invalid workspace guard workspaceURL.path != "/" else { return [] } @@ -290,6 +290,6 @@ public struct WorkspaceFile { shouldExcludeFile: shouldExcludeFile ) - return files.map { $0.url.absoluteString } + return files } } diff --git a/Tool/Sources/Workspace/WorkspaceFileIndex.swift b/Tool/Sources/Workspace/WorkspaceFileIndex.swift new file mode 100644 index 00000000..f1e29819 --- /dev/null +++ b/Tool/Sources/Workspace/WorkspaceFileIndex.swift @@ -0,0 +1,60 @@ +import Foundation +import ConversationServiceProvider + +public class WorkspaceFileIndex { + public static let shared = WorkspaceFileIndex() + /// Maximum number of files allowed per workspace + public static let maxFilesPerWorkspace = 1_000_000 + + private var workspaceIndex: [URL: [FileReference]] = [:] + private let queue = DispatchQueue(label: "com.copilot.workspace-file-index") + + /// Reset files for a specific workspace URL + public func setFiles(_ files: [FileReference], for workspaceURL: URL) { + queue.sync { + // Enforce the file limit when setting files + if files.count > Self.maxFilesPerWorkspace { + self.workspaceIndex[workspaceURL] = Array(files.prefix(Self.maxFilesPerWorkspace)) + } else { + self.workspaceIndex[workspaceURL] = files + } + } + } + + /// Get all files for a specific workspace URL + public func getFiles(for workspaceURL: URL) -> [FileReference]? { + return workspaceIndex[workspaceURL] + } + + /// Add a file to the workspace index + /// - Returns: true if the file was added successfully, false if the workspace has reached the maximum file limit + @discardableResult + public func addFile(_ file: FileReference, to workspaceURL: URL) -> Bool { + return queue.sync { + if self.workspaceIndex[workspaceURL] == nil { + self.workspaceIndex[workspaceURL] = [] + } + + // Check if we've reached the maximum file limit + let currentFileCount = self.workspaceIndex[workspaceURL]!.count + if currentFileCount >= Self.maxFilesPerWorkspace { + return false + } + + // Avoid duplicates by checking if file already exists + if !self.workspaceIndex[workspaceURL]!.contains(file) { + self.workspaceIndex[workspaceURL]!.append(file) + return true + } + + return true // File already exists, so we consider this a successful "add" + } + } + + /// Remove a file from the workspace index + public func removeFile(_ file: FileReference, from workspaceURL: URL) { + queue.sync { + self.workspaceIndex[workspaceURL]?.removeAll { $0 == file } + } + } +} diff --git a/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift b/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift index fd5ed987..02d35acd 100644 --- a/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift +++ b/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift @@ -75,6 +75,56 @@ class MockWorkspaceFileProvider: WorkspaceFileProvider { func isXCWorkspace(_ url: URL) -> Bool { return xcWorkspacePaths.contains(url.path) } + + func fileExists(atPath: String) -> Bool { + return true + } +} + +class MockFileWatcher: FileWatcherProtocol { + var fileURL: URL + var dispatchQueue: DispatchQueue? + var onFileModified: (() -> Void)? + var onFileDeleted: (() -> Void)? + var onFileRenamed: (() -> Void)? + + static var watchers = [URL: MockFileWatcher]() + + init(fileURL: URL, dispatchQueue: DispatchQueue? = nil, onFileModified: (() -> Void)? = nil, onFileDeleted: (() -> Void)? = nil, onFileRenamed: (() -> Void)? = nil) { + self.fileURL = fileURL + self.dispatchQueue = dispatchQueue + self.onFileModified = onFileModified + self.onFileDeleted = onFileDeleted + self.onFileRenamed = onFileRenamed + MockFileWatcher.watchers[fileURL] = self + } + + func startWatching() -> Bool { + return true + } + + func stopWatching() { + MockFileWatcher.watchers[fileURL] = nil + } + + static func triggerFileDelete(for fileURL: URL) { + guard let watcher = watchers[fileURL] else { return } + watcher.onFileDeleted?() + } +} + +class MockFileWatcherFactory: FileWatcherFactory { + func createFileWatcher(fileURL: URL, dispatchQueue: DispatchQueue?, onFileModified: (() -> Void)?, onFileDeleted: (() -> Void)?, onFileRenamed: (() -> Void)?) -> FileWatcherProtocol { + return MockFileWatcher(fileURL: fileURL, dispatchQueue: dispatchQueue, onFileModified: onFileModified, onFileDeleted: onFileDeleted, onFileRenamed: onFileRenamed) + } + + func createDirectoryWatcher(watchedPaths: [URL], changePublisher: @escaping PublisherType, publishInterval: TimeInterval) -> DirectoryWatcherProtocol { + return BatchingFileChangeWatcher( + watchedPaths: watchedPaths, + changePublisher: changePublisher, + fsEventProvider: MockFSEventProvider() + ) + } } // MARK: - Tests for BatchingFileChangeWatcher @@ -193,13 +243,11 @@ extension BatchingFileChangeWatcherTests { final class FileChangeWatcherServiceTests: XCTestCase { var mockWorkspaceFileProvider: MockWorkspaceFileProvider! var publishedEvents: [[FileEvent]] = [] - var createdWatchers: [[URL]: BatchingFileChangeWatcher] = [:] override func setUp() { super.setUp() mockWorkspaceFileProvider = MockWorkspaceFileProvider() publishedEvents = [] - createdWatchers = [:] } func createService(workspaceURL: URL = URL(fileURLWithPath: "/test/workspace")) -> FileChangeWatcherService { @@ -209,17 +257,8 @@ final class FileChangeWatcherServiceTests: XCTestCase { self?.publishedEvents.append(events) }, publishInterval: 0.1, - projectWatchingInterval: 0.1, workspaceFileProvider: mockWorkspaceFileProvider, - watcherFactory: { projectURLs, publisher in - let watcher = BatchingFileChangeWatcher( - watchedPaths: projectURLs, - changePublisher: publisher, - fsEventProvider: MockFSEventProvider() - ) - self.createdWatchers[projectURLs] = watcher - return watcher - } + watcherFactory: MockFileWatcherFactory() ) } @@ -231,26 +270,28 @@ final class FileChangeWatcherServiceTests: XCTestCase { let service = createService() service.startWatching() - XCTAssertEqual(createdWatchers.count, 1) - XCTAssertNotNil(createdWatchers[[project1, project2]]) + XCTAssertNotNil(service.watcher) + XCTAssertEqual(service.watcher?.paths().count, 2) + XCTAssertEqual(service.watcher?.paths(), [project1, project2]) } func testStartWatchingDoesNotCreateWatcherForRootDirectory() { let service = createService(workspaceURL: URL(fileURLWithPath: "/")) service.startWatching() - XCTAssertTrue(createdWatchers.isEmpty) + XCTAssertNil(service.watcher) } func testProjectMonitoringDetectsAddedProjects() { let workspace = URL(fileURLWithPath: "/test/workspace") let project1 = URL(fileURLWithPath: "/test/workspace/project1") mockWorkspaceFileProvider.subprojects = [project1] + mockWorkspaceFileProvider.xcWorkspacePaths = [workspace.path] let service = createService(workspaceURL: workspace) service.startWatching() - XCTAssertEqual(createdWatchers.count, 1) + XCTAssertNotNil(service.watcher) // Simulate adding a new project let project2 = URL(fileURLWithPath: "/test/workspace/project2") @@ -271,9 +312,9 @@ final class FileChangeWatcherServiceTests: XCTestCase { ) mockWorkspaceFileProvider.filesInWorkspace = [file1, file2] - XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout") + MockFileWatcher.triggerFileDelete(for: workspace.appendingPathComponent("contents.xcworkspacedata")) - XCTAssertEqual(createdWatchers.count, 1) + XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout") guard !publishedEvents.isEmpty else { return } @@ -290,11 +331,12 @@ final class FileChangeWatcherServiceTests: XCTestCase { let project1 = URL(fileURLWithPath: "/test/workspace/project1") let project2 = URL(fileURLWithPath: "/test/workspace/project2") mockWorkspaceFileProvider.subprojects = [project1, project2] + mockWorkspaceFileProvider.xcWorkspacePaths = [workspace.path] let service = createService(workspaceURL: workspace) service.startWatching() - XCTAssertEqual(createdWatchers.count, 1) + XCTAssertNotNil(service.watcher) // Simulate removing a project mockWorkspaceFileProvider.subprojects = [project1] @@ -316,14 +358,13 @@ final class FileChangeWatcherServiceTests: XCTestCase { // Clear published events from setup publishedEvents = [] + + MockFileWatcher.triggerFileDelete(for: workspace.appendingPathComponent("contents.xcworkspacedata")) XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout") guard !publishedEvents.isEmpty else { return } - // Verify the watcher was removed - XCTAssertEqual(createdWatchers.count, 1) - // Verify file events were published XCTAssertEqual(publishedEvents[0].count, 2) From c862f9239778da1d6643a10d0e15605c24f41d8e Mon Sep 17 00:00:00 2001 From: smoku8282 <101044492+smoku8282@users.noreply.github.com> Date: Tue, 22 Jul 2025 21:36:30 +0200 Subject: [PATCH 13/26] Create swift.yml --- .github/workflows/swift.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/swift.yml diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 00000000..21ae770f --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,22 @@ +# This workflow will build a Swift project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift + +name: Swift + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + - name: Build + run: swift build -v + - name: Run tests + run: swift test -v From afbbdadadbc6d3154089713a898360d3cf3d57ee Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 23 Jul 2025 08:09:50 +0000 Subject: [PATCH 14/26] Release 0.39.0 --- CHANGELOG.md | 7 +++++++ ReleaseNotes.md | 16 +++++----------- Server/package-lock.json | 9 +++++---- Server/package.json | 2 +- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bafeb44f..414c8e6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.39.0 - July 23, 2025 +### Fixed +- Performance: Fixed a freezing issue in 'Add Context' view when opening large projects. +- Login failed due to insufficient permissions on the .config folder. +- Fixed an issue that setting changes like proxy config did not take effect. +- Increased the timeout for ask mode to prevent response failures due to timeout. + ## 0.38.0 - June 30, 2025 ### Added - Support for Claude 4 in Chat. diff --git a/ReleaseNotes.md b/ReleaseNotes.md index a44299a2..1b6907ec 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,17 +1,11 @@ -### GitHub Copilot for Xcode 0.38.0 +### GitHub Copilot for Xcode 0.39.0 **🚀 Highlights** -* Support for Claude 4 in Chat. -* Support for Copilot Vision (image attachments). -* Support for remote MCP servers. - -**💪 Improvements** -* Automatically suggests a title for conversations created in agent mode. -* Improved restoration of MCP tool status after Copilot restarts. -* Reduced duplication of MCP server instances. +* Performance: Fixed a freezing issue in 'Add Context' view when opening large projects. **🛠️ Bug Fixes** -* Switching accounts now correctly refreshes the auth token and models. -* Fixed file create/edit issues in agent mode. +* Login failed due to insufficient permissions on the .config folder. +* Fixed an issue that setting changes like proxy config did not take effect. +* Increased the timeout for ask mode to prevent response failures due to timeout. diff --git a/Server/package-lock.json b/Server/package-lock.json index 28967547..f8500448 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.341.0", + "@github/copilot-language-server": "^1.347.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,9 +36,10 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.341.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.341.0.tgz", - "integrity": "sha512-u0RfW9A68+RM7evQSCICH/uK/03p9bzp/8+2+zg6GDC/u3O2F8V+G1RkvlqfrckXrQZd1rImO41ch7ns3A4zMQ==", + "version": "1.347.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.347.0.tgz", + "integrity": "sha512-ygDQhnRkoKD+9jIUNTRrB9F0hP6N6jJUy+TSFtSsge5lNC2P/ntWyCFkEcrVnXcvewG7dHj8U9RRAExEeg8FgQ==", + "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" }, diff --git a/Server/package.json b/Server/package.json index 7fd1269b..46892e24 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.341.0", + "@github/copilot-language-server": "^1.347.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" From 9d1d42f00bcfda05922974f063a84b2f4a59bf03 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 24 Jul 2025 08:08:31 +0000 Subject: [PATCH 15/26] Release 0.40.0 --- CHANGELOG.md | 4 + .../ModelPicker/ChatModePicker.swift | 106 ++++++++++++------ Core/Sources/HostApp/TabContainer.swift | 38 ++++++- Core/Sources/Service/XPCService.swift | 9 ++ ReleaseNotes.md | 3 +- Server/package-lock.json | 8 +- Server/package.json | 2 +- .../Services/FeatureFlagNotifier.swift | 21 +++- .../XPCShared/XPCExtensionService.swift | 20 ++++ .../XPCShared/XPCServiceProtocol.swift | 2 + 10 files changed, 164 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 414c8e6d..cce07a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.40.0 - July 24, 2025 +### Added +- Support disabling Agent mode when it's disabled by policy. + ## 0.39.0 - July 23, 2025 ### Fixed - Performance: Fixed a freezing issue in 'Add Context' view when opening large projects. diff --git a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift b/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift index 5e61b4c0..559a6d9d 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift @@ -1,63 +1,95 @@ import SwiftUI import Persist import ConversationServiceProvider +import GitHubCopilotService +import Combine public extension Notification.Name { static let gitHubCopilotChatModeDidChange = Notification .Name("com.github.CopilotForXcode.ChatModeDidChange") } +public enum ChatMode: String { + case Ask = "Ask" + case Agent = "Agent" +} + public struct ChatModePicker: View { @Binding var chatMode: String @Environment(\.colorScheme) var colorScheme + @State var isAgentModeFFEnabled: Bool + @State private var cancellables = Set() var onScopeChange: (PromptTemplateScope) -> Void public init(chatMode: Binding, onScopeChange: @escaping (PromptTemplateScope) -> Void = { _ in }) { self._chatMode = chatMode self.onScopeChange = onScopeChange + self.isAgentModeFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.agent_mode != false + } + + private func setChatMode(mode: ChatMode) { + chatMode = mode.rawValue + AppState.shared.setSelectedChatMode(mode.rawValue) + onScopeChange(mode == .Ask ? .chatPanel : .agentPanel) + NotificationCenter.default.post( + name: .gitHubCopilotChatModeDidChange, + object: nil + ) + } + + private func subscribeToFeatureFlagsDidChangeEvent() { + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { (featureFlags) in + isAgentModeFFEnabled = featureFlags.agent_mode ?? true + }) + .store(in: &cancellables) } public var body: some View { - HStack(spacing: -1) { - ModeButton( - title: "Ask", - isSelected: chatMode == "Ask", - activeBackground: colorScheme == .dark ? Color.white.opacity(0.25) : Color.white, - activeTextColor: Color.primary, - inactiveTextColor: Color.primary.opacity(0.5), - action: { - chatMode = "Ask" - AppState.shared.setSelectedChatMode("Ask") - onScopeChange(.chatPanel) - NotificationCenter.default.post( - name: .gitHubCopilotChatModeDidChange, - object: nil + VStack { + if isAgentModeFFEnabled { + HStack(spacing: -1) { + ModeButton( + title: "Ask", + isSelected: chatMode == "Ask", + activeBackground: colorScheme == .dark ? Color.white.opacity(0.25) : Color.white, + activeTextColor: Color.primary, + inactiveTextColor: Color.primary.opacity(0.5), + action: { + setChatMode(mode: .Ask) + } ) - } - ) - - ModeButton( - title: "Agent", - isSelected: chatMode == "Agent", - activeBackground: Color.blue, - activeTextColor: Color.white, - inactiveTextColor: Color.primary.opacity(0.5), - action: { - chatMode = "Agent" - AppState.shared.setSelectedChatMode("Agent") - onScopeChange(.agentPanel) - NotificationCenter.default.post( - name: .gitHubCopilotChatModeDidChange, - object: nil + + ModeButton( + title: "Agent", + isSelected: chatMode == "Agent", + activeBackground: Color.blue, + activeTextColor: Color.white, + inactiveTextColor: Color.primary.opacity(0.5), + action: { + setChatMode(mode: .Agent) + } ) } - ) + .padding(1) + .frame(height: 20, alignment: .topLeading) + .background(.primary.opacity(0.1)) + .cornerRadius(5) + .padding(4) + .help("Set Mode") + } else { + EmptyView() + } + } + .task { + subscribeToFeatureFlagsDidChangeEvent() + if !isAgentModeFFEnabled { + setChatMode(mode: .Ask) + } + } + .onChange(of: isAgentModeFFEnabled) { newAgentModeFFEnabled in + if !newAgentModeFFEnabled { + setChatMode(mode: .Ask) + } } - .padding(1) - .frame(height: 20, alignment: .topLeading) - .background(.primary.opacity(0.1)) - .cornerRadius(5) - .padding(4) - .help("Set Mode") } } diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 3a4bb494..546b0d0a 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -5,6 +5,9 @@ import LaunchAgentManager import SwiftUI import Toast import UpdateChecker +import Client +import Logger +import Combine @MainActor public let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() }) @@ -13,6 +16,7 @@ public struct TabContainer: View { let store: StoreOf @ObservedObject var toastController: ToastController @State private var tabBarItems = [TabBarItem]() + @State private var isAgentModeFFEnabled = true @Binding var tag: Int public init() { @@ -32,6 +36,19 @@ public struct TabContainer: View { set: { store.send(.setActiveTab($0)) } ) } + + private func updateAgentModeFeatureFlag() async { + do { + let service = try getService() + let featureFlags = try await service.getCopilotFeatureFlags() + isAgentModeFFEnabled = featureFlags?.agent_mode ?? true + if hostAppStore.activeTabIndex == 2 && !isAgentModeFFEnabled { + hostAppStore.send(.setActiveTab(0)) + } + } catch { + Logger.client.error("Failed to get copilot feature flags: \(error)") + } + } public var body: some View { WithPerceptionTracking { @@ -51,11 +68,13 @@ public struct TabContainer: View { title: "Advanced", image: "gearshape.2.fill" ) - MCPConfigView().tabBarItem( - tag: 2, - title: "MCP", - image: "wrench.and.screwdriver.fill" - ) + if isAgentModeFFEnabled { + MCPConfigView().tabBarItem( + tag: 2, + title: "MCP", + image: "wrench.and.screwdriver.fill" + ) + } } .environment(\.tabBarTabTag, tag) .frame(minHeight: 400) @@ -70,7 +89,16 @@ public struct TabContainer: View { } .onAppear { store.send(.appear) + Task { + await updateAgentModeFeatureFlag() + } } + .onReceive(DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in + Task { + await updateAgentModeFeatureFlag() + } + } } } } diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 84ce30e5..0297224a 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -308,6 +308,15 @@ public class XPCService: NSObject, XPCServiceProtocol { } } + // MARK: - FeatureFlags + public func getCopilotFeatureFlags( + withReply reply: @escaping (Data?) -> Void + ) { + let featureFlags = FeatureFlagNotifierImpl.shared.featureFlags + let data = try? JSONEncoder().encode(featureFlags) + reply(data) + } + // MARK: - Auth public func signOutAllGitHubCopilotService() { Task { @MainActor in diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 1b6907ec..75211dae 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,8 +1,9 @@ -### GitHub Copilot for Xcode 0.39.0 +### GitHub Copilot for Xcode 0.40.0 **🚀 Highlights** * Performance: Fixed a freezing issue in 'Add Context' view when opening large projects. +* Support disabling Agent mode when it's disabled by policy. **🛠️ Bug Fixes** diff --git a/Server/package-lock.json b/Server/package-lock.json index f8500448..99e43b7c 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.347.0", + "@github/copilot-language-server": "^1.348.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,9 +36,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.347.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.347.0.tgz", - "integrity": "sha512-ygDQhnRkoKD+9jIUNTRrB9F0hP6N6jJUy+TSFtSsge5lNC2P/ntWyCFkEcrVnXcvewG7dHj8U9RRAExEeg8FgQ==", + "version": "1.348.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.348.0.tgz", + "integrity": "sha512-CV1+hU9I29GXrZKwdRj2x7ur47IAoqa56FWwnkI/Cvs0BdTTrLigJlOseeFCQ1bglnIyr6ZLFCduBahDtqR1AQ==", "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" diff --git a/Server/package.json b/Server/package.json index 46892e24..4c37672f 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.347.0", + "@github/copilot-language-server": "^1.348.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift index 2f0949c1..3061a48e 100644 --- a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift +++ b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift @@ -1,11 +1,29 @@ import Combine import SwiftUI +public extension Notification.Name { + static let gitHubCopilotFeatureFlagsDidChange = Notification + .Name("com.github.CopilotForXcode.CopilotFeatureFlagsDidChange") +} + +public enum ExperimentValue: Hashable, Codable { + case string(String) + case number(Double) + case boolean(Bool) + case stringArray([String]) +} + +public typealias ActiveExperimentForFeatureFlags = [String: ExperimentValue] + public struct FeatureFlags: Hashable, Codable { public var rt: Bool public var sn: Bool public var chat: Bool + public var ic: Bool + public var pc: Bool public var xc: Bool? + public var ae: ActiveExperimentForFeatureFlags + public var agent_mode: Bool? } public protocol FeatureFlagNotifier { @@ -19,7 +37,7 @@ public class FeatureFlagNotifierImpl: FeatureFlagNotifier { public static let shared = FeatureFlagNotifierImpl() public var featureFlagsDidChange: PassthroughSubject - init(featureFlags: FeatureFlags = FeatureFlags(rt: false, sn: false, chat: true), + init(featureFlags: FeatureFlags = FeatureFlags(rt: false, sn: false, chat: true, ic: true, pc: true, ae: [:]), featureFlagsDidChange: PassthroughSubject = PassthroughSubject()) { self.featureFlags = featureFlags self.featureFlagsDidChange = featureFlagsDidChange @@ -31,6 +49,7 @@ public class FeatureFlagNotifierImpl: FeatureFlagNotifier { DispatchQueue.main.async { [weak self] in guard let self else { return } self.featureFlagsDidChange.send(self.featureFlags) + DistributedNotificationCenter.default().post(name: .gitHubCopilotFeatureFlagsDidChange, object: nil) } } } diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index bcf82c19..5b1d7953 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -392,6 +392,26 @@ extension XPCExtensionService { } } + @XPCServiceActor + public func getCopilotFeatureFlags() async throws -> FeatureFlags? { + return try await withXPCServiceConnected { + service, continuation in + service.getCopilotFeatureFlags { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let tools = try JSONDecoder().decode(FeatureFlags.self, from: data) + continuation.resume(tools) + } catch { + continuation.reject(error) + } + } + } + } + @XPCServiceActor public func signOutAllGitHubCopilotService() async throws { return try await withXPCServiceConnected { diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift index dbc64f4d..5552ea38 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -24,6 +24,8 @@ public protocol XPCServiceProtocol { func getXcodeInspectorData(withReply reply: @escaping (Data?, Error?) -> Void) func getAvailableMCPServerToolsCollections(withReply reply: @escaping (Data?) -> Void) func updateMCPServerToolsStatus(tools: Data) + + func getCopilotFeatureFlags(withReply reply: @escaping (Data?) -> Void) func signOutAllGitHubCopilotService() func getXPCServiceAuthStatus(withReply reply: @escaping (Data?) -> Void) From 0517f3b580fa57aadafcd79312b89732a4071e16 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 1 Aug 2025 01:38:27 +0000 Subject: [PATCH 16/26] Pre-release 0.40.132 --- .../xcshareddata/swiftpm/Package.resolved | 74 +-- Copilot for Xcode/Credits.rtf | 37 +- Core/Package.swift | 9 +- Core/Sources/ChatService/ChatService.swift | 4 + .../ToolCalls/CopilotToolRegistry.swift | 1 + .../ToolCalls/FetchWebPageTool.swift | 46 ++ .../ChatService/ToolCalls/ICopilotTool.swift | 52 +- .../ToolCalls/InsertEditIntoFileTool.swift | 18 +- .../ModelPicker/ChatModePicker.swift | 6 +- .../ModelPicker/ModelPicker.swift | 50 +- .../ConversationTab/ViewExtension.swift | 25 +- Core/Sources/HostApp/MCPConfigView.swift | 37 +- .../HostApp/MCPSettings/MCPIntroView.swift | 103 +++- .../BorderedProminentWhiteButtonStyle.swift | 3 +- .../SharedComponents/CardGroupBoxStyle.swift | 6 +- Core/Sources/HostApp/TabContainer.swift | 2 +- Core/Sources/Service/Helpers.swift | 3 + .../ChatWindow/ChatHistoryView.swift | 16 +- .../SuggestionWidget/ChatWindowView.swift | 36 +- Server/package-lock.json | 8 +- Server/package.json | 2 +- Tool/Package.swift | 12 +- ...ExtensionConversationServiceProvider.swift | 14 + .../ConversationServiceProvider.swift | 2 + .../LSPTypes.swift | 10 +- .../ToolNames.swift | 1 + .../LanguageServer/ClientToolRegistry.swift | 21 + .../CopilotLocalProcessServer.swift | 464 +++++++----------- .../CustomJSONRPCServerConnection.swift | 378 ++++++++++++++ .../LanguageServer/CustomStdioTransport.swift | 30 -- .../LanguageServer/GitHubCopilotRequest.swift | 65 ++- .../LanguageServer/GitHubCopilotService.swift | 192 +++++--- .../GithubCopilotRequest+Message.swift | 19 - .../SafeInitializingServer.swift | 62 +++ .../ServerNotificationHandler.swift | 9 +- .../LanguageServer/ServerRequestHandler.swift | 37 +- .../Services/FeatureFlagNotifier.swift | 72 ++- .../GitHubCopilotConversationService.swift | 4 + Tool/Sources/Logger/FileLogger.swift | 50 +- Tool/Sources/Logger/Logger.swift | 25 +- Tool/Sources/Logger/MCPRuntimeLogger.swift | 53 ++ .../HTMLToMarkdownConverter.swift | 217 ++++++++ .../WebContentExtractor.swift | 227 +++++++++ .../FetchSuggestionsTests.swift | 10 + 44 files changed, 1905 insertions(+), 607 deletions(-) create mode 100644 Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift create mode 100644 Tool/Sources/GitHubCopilotService/LanguageServer/CustomJSONRPCServerConnection.swift delete mode 100644 Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift create mode 100644 Tool/Sources/GitHubCopilotService/LanguageServer/SafeInitializingServer.swift create mode 100644 Tool/Sources/Logger/MCPRuntimeLogger.swift create mode 100644 Tool/Sources/WebContentExtractor/HTMLToMarkdownConverter.swift create mode 100644 Tool/Sources/WebContentExtractor/WebContentExtractor.swift diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3a57f6e9..3db257ec 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -41,17 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Frizlab/FSEventsWrapper", "state" : { - "revision" : "e0c59a2ce2775e5f6642da6d19207445f10112d0", - "version" : "1.0.2" - } - }, - { - "identity" : "glob", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Bouke/Glob", - "state" : { - "revision" : "deda6e163d2ff2a8d7e138e2c3326dbd71157faf", - "version" : "1.0.5" + "revision" : "70bbea4b108221fcabfce8dbced8502831c0ae04", + "version" : "2.1.0" } }, { @@ -68,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/JSONRPC", "state" : { - "revision" : "5da978702aece6ba5c7879b0d253c180d61e4ef3", - "version" : "0.6.0" + "revision" : "c6ec759d41a76ac88fe7327c41a77d9033943374", + "version" : "0.9.0" } }, { @@ -86,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/LanguageClient", "state" : { - "revision" : "f0198ee0a102d266078f7d9c28f086f2989f988a", - "version" : "0.3.1" + "revision" : "4f28cc3cad7512470275f65ca2048359553a86f5", + "version" : "0.8.2" } }, { @@ -95,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/LanguageServerProtocol", "state" : { - "revision" : "6e97f943dc024307c5524a80bd33cdbd1cc621de", - "version" : "0.8.0" + "revision" : "d51412945ae88ffcab65ec339ca89aed9c9f0b8a", + "version" : "0.13.3" } }, { @@ -109,21 +100,30 @@ } }, { - "identity" : "operationplus", + "identity" : "processenv", "kind" : "remoteSourceControl", - "location" : "https://github.com/ChimeHQ/OperationPlus", + "location" : "https://github.com/ChimeHQ/ProcessEnv", "state" : { - "revision" : "1340f95dce3e93d742497d88db18f8676f4badf4", - "version" : "1.6.0" + "revision" : "552f611479a4f28243a1ef2a7376a216d6899f42", + "version" : "1.0.1" } }, { - "identity" : "processenv", + "identity" : "queue", "kind" : "remoteSourceControl", - "location" : "https://github.com/ChimeHQ/ProcessEnv", + "location" : "https://github.com/mattmassicotte/Queue", + "state" : { + "revision" : "9f941ae35f146ccadd2689b9ab8d5aebb1f5d584", + "version" : "0.2.1" + } + }, + { + "identity" : "semaphore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/Semaphore", "state" : { - "revision" : "29487b6581bb785c372c611c943541ef4309d051", - "version" : "0.3.1" + "revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2", + "version" : "0.1.0" } }, { @@ -225,6 +225,15 @@ "version" : "1.3.0" } }, + { + "identity" : "swift-glob", + "kind" : "remoteSourceControl", + "location" : "https://github.com/davbeck/swift-glob", + "state" : { + "revision" : "07ba6f47d903a0b1b59f12ca70d6de9949b975d6", + "version" : "0.2.0" + } + }, { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", @@ -264,10 +273,19 @@ { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", + "version" : "510.0.3" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", "state" : { - "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", - "version" : "509.0.2" + "revision" : "dee225a3da7b68d34936abc4dc8f34f2264db647", + "version" : "2.9.6" } }, { diff --git a/Copilot for Xcode/Credits.rtf b/Copilot for Xcode/Credits.rtf index d282374b..13a16781 100644 --- a/Copilot for Xcode/Credits.rtf +++ b/Copilot for Xcode/Credits.rtf @@ -163,7 +163,7 @@ SOFTWARE.\ \ \ Dependency: github.com/apple/swift-syntax\ -Version: 509.0.2\ +Version: 510.0.3\ License Content:\ Apache License\ Version 2.0, January 2004\ @@ -1761,7 +1761,7 @@ License Content:\ \ \ Dependency: github.com/ChimeHQ/JSONRPC\ -Version: 0.6.0\ +Version: 0.9.0\ License Content:\ BSD 3-Clause License\ \ @@ -1795,7 +1795,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\ \ \ Dependency: github.com/ChimeHQ/LanguageServerProtocol\ -Version: 0.8.0\ +Version: 0.13.3\ License Content:\ BSD 3-Clause License\ \ @@ -2611,7 +2611,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI \ \ Dependency: github.com/ChimeHQ/LanguageClient\ -Version: 0.3.1\ +Version: 0.8.2\ License Content:\ BSD 3-Clause License\ \ @@ -2645,7 +2645,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\ \ \ Dependency: github.com/ChimeHQ/ProcessEnv\ -Version: 0.3.1\ +Version: 1.0.1\ License Content:\ BSD 3-Clause License\ \ @@ -3322,4 +3322,31 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\ THE SOFTWARE.\ \ \ +Dependency: https://github.com/scinfu/SwiftSoup\ +Version: 2.9.6\ +License Content:\ +The MIT License\ +\ +Copyright (c) 2009-2025 Jonathan Hedley \ +Swift port copyright (c) 2016-2025 Nabil Chatbi\ +\ +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/Core/Package.swift b/Core/Package.swift index 1508eead..966dcaab 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -53,8 +53,7 @@ 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") + .package(url: "https://github.com/globulus/swiftui-flow-layout", from: "1.0.5") ], targets: [ // MARK: - Main @@ -182,7 +181,8 @@ let package = Package( .product(name: "Workspace", package: "Tool"), .product(name: "Terminal", package: "Tool"), .product(name: "SystemUtils", package: "Tool"), - .product(name: "AppKitExtension", package: "Tool") + .product(name: "AppKitExtension", package: "Tool"), + .product(name: "WebContentExtractor", package: "Tool") ]), .testTarget( name: "ChatServiceTests", @@ -202,8 +202,7 @@ let package = Package( .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout"), - .product(name: "Persist", package: "Tool"), - .product(name: "Terminal", package: "Tool") + .product(name: "Persist", package: "Tool") ] ), diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index c420afe1..a693aaa6 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -217,6 +217,10 @@ public final class ChatService: ChatServiceType, ObservableObject { } } + public func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws { + try await conversationProvider?.notifyChangeTextDocument(fileURL: fileURL, content: content, version: version, workspaceURL: getWorkspaceURL()) + } + public static func service(for chatTabInfo: ChatTabInfo) -> ChatService { let provider = BuiltinExtensionConversationServiceProvider( extension: GitHubCopilotExtension.self diff --git a/Core/Sources/ChatService/ToolCalls/CopilotToolRegistry.swift b/Core/Sources/ChatService/ToolCalls/CopilotToolRegistry.swift index 50408824..f03d2fe5 100644 --- a/Core/Sources/ChatService/ToolCalls/CopilotToolRegistry.swift +++ b/Core/Sources/ChatService/ToolCalls/CopilotToolRegistry.swift @@ -10,6 +10,7 @@ public class CopilotToolRegistry { tools[ToolName.getErrors.rawValue] = GetErrorsTool() tools[ToolName.insertEditIntoFile.rawValue] = InsertEditIntoFileTool() tools[ToolName.createFile.rawValue] = CreateFileTool() + tools[ToolName.fetchWebPage.rawValue] = FetchWebPageTool() } public func getTool(name: String) -> ICopilotTool? { diff --git a/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift new file mode 100644 index 00000000..5ff5f6b9 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift @@ -0,0 +1,46 @@ +import AppKit +import AXExtension +import AXHelper +import ConversationServiceProvider +import Foundation +import JSONRPC +import Logger +import WebKit +import WebContentExtractor + +public class FetchWebPageTool: ICopilotTool { + public static let name = ToolName.fetchWebPage + + public func invokeTool( + _ request: InvokeClientToolRequest, + completion: @escaping (AnyJSONRPCResponse) -> Void, + chatHistoryUpdater: ChatHistoryUpdater?, + contextProvider: (any ToolContextProvider)? + ) -> Bool { + guard let params = request.params, + let input = params.input, + let urls = input["urls"]?.value as? [String] + else { + completeResponse(request, status: .error, response: "Invalid parameters", completion: completion) + return true + } + + guard !urls.isEmpty else { + completeResponse(request, status: .error, response: "No valid URLs provided", completion: completion) + return true + } + + // Use the improved WebContentFetcher to fetch content from all URLs + Task { + let results = await WebContentFetcher.fetchMultipleContentAsync(from: urls) + + completeResponses( + request, + responses: results, + completion: completion + ) + } + + return true + } +} diff --git a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift index 479e93b1..cbe9e2ec 100644 --- a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift +++ b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift @@ -1,15 +1,13 @@ +import ChatTab import ConversationServiceProvider +import Foundation import JSONRPC -import ChatTab - -enum ToolInvocationStatus: String { - case success, error, cancelled -} public protocol ToolContextProvider { // MARK: insert_edit_into_file var chatTabInfo: ChatTabInfo { get } func updateFileEdits(by fileEdit: FileEdit) -> Void + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws } public typealias ChatHistoryUpdater = (String, [AgentRound]) -> Void @@ -48,14 +46,42 @@ extension ICopilotTool { response: String = "", completion: @escaping (AnyJSONRPCResponse) -> Void ) { - let result: JSONValue = .array([ - .hash([ - "status": .string(status.rawValue), - "content": .array([.hash(["value": .string(response)])]) - ]), - .null - ]) - completion(AnyJSONRPCResponse(id: request.id, result: result)) + completeResponses( + request, + status: status, + responses: [response], + completion: completion + ) + } + + /// + /// Completes a tool response with multiple data entries. + /// - Parameters: + /// - request: The original tool invocation request. + /// - status: The completion status of the tool execution (success, error, or cancelled). + /// - responses: Array of string values to include in the response content. + /// - completion: The completion handler to call with the response. + /// + func completeResponses( + _ request: InvokeClientToolRequest, + status: ToolInvocationStatus = .success, + responses: [String], + completion: @escaping (AnyJSONRPCResponse) -> Void + ) { + let toolResult = LanguageModelToolResult(status: status, content: responses.map { response in + LanguageModelToolResult.Content(value: response) + }) + let jsonResult = try? JSONEncoder().encode(toolResult) + let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null + completion( + AnyJSONRPCResponse( + id: request.id, + result: JSONValue.array([ + jsonValue, + JSONValue.null, + ]) + ) + ) } } diff --git a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift index 22700a9a..935b81bc 100644 --- a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift @@ -86,9 +86,9 @@ public class InsertEditIntoFileTool: ICopilotTool { } public static func applyEdit( - for fileURL: URL, - content: String, - contextProvider: any ToolContextProvider, + for fileURL: URL, + content: String, + contextProvider: any ToolContextProvider, xcodeInstance: AppInstanceInspector ) throws -> String { // Get the focused element directly from the app (like XcodeInspector does) @@ -166,7 +166,7 @@ public class InsertEditIntoFileTool: ICopilotTool { } private static func findSourceEditorElement( - from element: AXUIElement, + from element: AXUIElement, xcodeInstance: AppInstanceInspector, shouldRetry: Bool = true ) throws -> AXUIElement { @@ -215,8 +215,8 @@ public class InsertEditIntoFileTool: ICopilotTool { } public static func applyEdit( - for fileURL: URL, - content: String, + for fileURL: URL, + content: String, contextProvider: any ToolContextProvider, completion: ((String?, Error?) -> Void)? = nil ) { @@ -242,7 +242,11 @@ public class InsertEditIntoFileTool: ICopilotTool { xcodeInstance: appInstanceInspector ) - if let completion = completion { completion(newContent, nil) } + Task { + // Force to notify the CLS about the new change within the document before edit_file completion. + try? await contextProvider.notifyChangeTextDocument(fileURL: fileURL, content: newContent, version: 0) + if let completion = completion { completion(newContent, nil) } + } } catch { if let completion = completion { completion(nil, error) } Logger.client.info("Failed to apply edit for file at \(fileURL), \(error)") diff --git a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift b/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift index 559a6d9d..94cd8051 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift @@ -24,7 +24,7 @@ public struct ChatModePicker: View { public init(chatMode: Binding, onScopeChange: @escaping (PromptTemplateScope) -> Void = { _ in }) { self._chatMode = chatMode self.onScopeChange = onScopeChange - self.isAgentModeFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.agent_mode != false + self.isAgentModeFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.agentMode } private func setChatMode(mode: ChatMode) { @@ -38,8 +38,8 @@ public struct ChatModePicker: View { } private func subscribeToFeatureFlagsDidChangeEvent() { - FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { (featureFlags) in - isAgentModeFFEnabled = featureFlags.agent_mode ?? true + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in + isAgentModeFFEnabled = featureFlags.agentMode }) .store(in: &cancellables) } diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift index 7dd48183..0f76adea 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift @@ -203,6 +203,9 @@ struct ModelPicker: View { // Separate caches for both scopes @State private var askScopeCache: ScopeCache = ScopeCache() @State private var agentScopeCache: ScopeCache = ScopeCache() + + @State var isMCPFFEnabled: Bool + @State private var cancellables = Set() let minimumPadding: Int = 48 let attributes: [NSAttributedString.Key: NSFont] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] @@ -218,8 +221,16 @@ struct ModelPicker: View { init() { let initialModel = AppState.shared.getSelectedModelName() ?? CopilotModelManager.getDefaultChatModel()?.modelName ?? "" self._selectedModel = State(initialValue: initialModel) + self.isMCPFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.mcp updateAgentPicker() } + + private func subscribeToFeatureFlagsDidChangeEvent() { + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in + isMCPFFEnabled = featureFlags.mcp + }) + .store(in: &cancellables) + } var models: [LLMModel] { AppState.shared.isAgentModeEnabled() ? modelManager.availableAgentModels : modelManager.availableChatModels @@ -371,22 +382,34 @@ struct ModelPicker: View { } private var mcpButton: some View { - Button(action: { - try? launchHostAppMCPSettings() - }) { - Image(systemName: "wrench.and.screwdriver") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .padding(4) - .foregroundColor(.primary.opacity(0.85)) - .font(Font.system(size: 11, weight: .semibold)) + Group { + if isMCPFFEnabled { + Button(action: { + try? launchHostAppMCPSettings() + }) { + mcpIcon.foregroundColor(.primary.opacity(0.85)) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .help("Configure your MCP server") + } else { + // Non-interactive view that looks like a button but only shows tooltip + mcpIcon.foregroundColor(Color(nsColor: .tertiaryLabelColor)) + .padding(0) + .help("MCP servers are disabled by org policy. Contact your admin.") + } } - .buttonStyle(HoverButtonStyle(padding: 0)) - .help("Configure your MCP server") .cornerRadius(6) } + private var mcpIcon: some View { + Image(systemName: "wrench.and.screwdriver") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .padding(4) + .font(Font.system(size: 11, weight: .semibold)) + } + // Main view body var body: some View { WithPerceptionTracking { @@ -436,6 +459,9 @@ struct ModelPicker: View { .onReceive(NotificationCenter.default.publisher(for: .gitHubCopilotSelectedModelDidChange)) { _ in updateCurrentModel() } + .task { + subscribeToFeatureFlagsDidChangeEvent() + } } } diff --git a/Core/Sources/ConversationTab/ViewExtension.swift b/Core/Sources/ConversationTab/ViewExtension.swift index e619f5a4..912c9687 100644 --- a/Core/Sources/ConversationTab/ViewExtension.swift +++ b/Core/Sources/ConversationTab/ViewExtension.swift @@ -25,13 +25,14 @@ struct HoverRadiusBackgroundModifier: ViewModifier { RoundedRectangle(cornerRadius: cornerRadius) .fill(isHovered ? hoverColor ?? ITEM_SELECTED_COLOR : Color.clear) ) + .clipShape( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + ) .overlay( - Group { - if isHovered && showBorder { - RoundedRectangle(cornerRadius: cornerRadius) - .stroke(borderColor, lineWidth: borderWidth) - } - } + (isHovered && showBorder) ? + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(borderColor, lineWidth: borderWidth) : + nil ) } } @@ -58,8 +59,16 @@ extension View { self.modifier(HoverRadiusBackgroundModifier(isHovered: isHovered, hoverColor: hoverColor, cornerRadius: cornerRadius)) } - public func hoverRadiusBackground(isHovered: Bool, hoverColor: Color?, cornerRadius: CGFloat, showBorder: Bool) -> some View { - self.modifier(HoverRadiusBackgroundModifier(isHovered: isHovered, hoverColor: hoverColor, cornerRadius: cornerRadius, showBorder: showBorder)) + public func hoverRadiusBackground(isHovered: Bool, hoverColor: Color?, cornerRadius: CGFloat, showBorder: Bool, borderColor: Color = .white.opacity(0.07)) -> some View { + self.modifier( + HoverRadiusBackgroundModifier( + isHovered: isHovered, + hoverColor: hoverColor, + cornerRadius: cornerRadius, + showBorder: true, + borderColor: borderColor + ) + ) } public func hoverForeground(isHovered: Bool, defaultColor: Color) -> some View { diff --git a/Core/Sources/HostApp/MCPConfigView.swift b/Core/Sources/HostApp/MCPConfigView.swift index 855d4fc4..df80423a 100644 --- a/Core/Sources/HostApp/MCPConfigView.swift +++ b/Core/Sources/HostApp/MCPConfigView.swift @@ -15,6 +15,7 @@ struct MCPConfigView: View { @State private var isMonitoring: Bool = false @State private var lastModificationDate: Date? = nil @State private var fileMonitorTask: Task? = nil + @State private var isMCPFFEnabled = false @Environment(\.colorScheme) var colorScheme private static var lastSyncTimestamp: Date? = nil @@ -23,19 +24,47 @@ struct MCPConfigView: View { WithPerceptionTracking { ScrollView { VStack(alignment: .leading, spacing: 8) { - MCPIntroView() - MCPToolsListView() + MCPIntroView(isMCPFFEnabled: $isMCPFFEnabled) + if isMCPFFEnabled { + MCPToolsListView() + } } .padding(20) .onAppear { setupConfigFilePath() - startMonitoringConfigFile() - refreshConfiguration(()) + Task { + await updateMCPFeatureFlag() + } } .onDisappear { stopMonitoringConfigFile() } + .onChange(of: isMCPFFEnabled) { newMCPFFEnabled in + if newMCPFFEnabled { + startMonitoringConfigFile() + refreshConfiguration(()) + } else { + stopMonitoringConfigFile() + } + } + .onReceive(DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in + Task { + await updateMCPFeatureFlag() + } + } + } + } + } + + private func updateMCPFeatureFlag() async { + do { + let service = try getService() + if let featureFlags = try await service.getCopilotFeatureFlags() { + isMCPFFEnabled = featureFlags.mcp } + } catch { + Logger.client.error("Failed to get copilot feature flags: \(error)") } } diff --git a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift b/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift index 98327a96..98e92c76 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift +++ b/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift @@ -3,7 +3,6 @@ import Foundation import Logger import SharedUIComponents import SwiftUI -import Toast struct MCPIntroView: View { var exampleConfig: String { @@ -24,9 +23,33 @@ struct MCPIntroView: View { } @State private var isExpanded = true + @Binding private var isMCPFFEnabled: Bool + + public init(isExpanded: Bool = true, isMCPFFEnabled: Binding) { + self.isExpanded = isExpanded + self._isMCPFFEnabled = isMCPFFEnabled + } var body: some View { VStack(alignment: .leading, spacing: 8) { + if !isMCPFFEnabled { + GroupBox { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "info.circle.fill") + .font(.body) + .foregroundColor(.gray) + Text( + "MCP servers are disabled by your organization’s policy. To enable them, please contact your administrator. [Get More Info about Copilot policies](https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-organization/manage-policies)" + ) + } + } + .groupBoxStyle( + CardGroupBoxStyle( + backgroundColor: Color(nsColor: .textBackgroundColor) + ) + ) + } + GroupBox( label: Text("Model Context Protocol (MCP) Configuration") .fontWeight(.bold) @@ -36,30 +59,51 @@ struct MCPIntroView: View { ) }.groupBoxStyle(CardGroupBoxStyle()) - DisclosureGroup(isExpanded: $isExpanded) { - exampleConfigView() - } label: { - sectionHeader() - } - .padding(.horizontal, 0) - .padding(.vertical, 10) - - Button { - openConfigFile() - } label: { - HStack(spacing: 0) { - Image(systemName: "square.and.pencil") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12, height: 12, alignment: .center) - .padding(4) - Text("Edit Config") + if isMCPFFEnabled { + DisclosureGroup(isExpanded: $isExpanded) { + exampleConfigView() + } label: { + sectionHeader() + } + .padding(.horizontal, 0) + .padding(.vertical, 10) + + HStack(spacing: 8) { + Button { + openConfigFile() + } label: { + HStack(spacing: 0) { + Image(systemName: "square.and.pencil") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Edit Config") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.borderedProminent) + .help("Configure your MCP server") + + Button { + openMCPRunTimeLogFolder() + } label: { + HStack(spacing: 0) { + Image(systemName: "folder") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Open MCP Log Folder") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.borderedProminentWhite) + .help("Open MCP Runtime Log Folder") } - .conditionalFontWeight(.semibold) } - .buttonStyle(.borderedProminentWhite) - .help("Configure your MCP server") } + } @ViewBuilder @@ -104,9 +148,22 @@ struct MCPIntroView: View { let url = URL(fileURLWithPath: mcpConfigFilePath) NSWorkspace.shared.open(url) } + + private func openMCPRunTimeLogFolder() { + let url = URL( + fileURLWithPath: FileLoggingLocation.mcpRuntimeLogsPath.description, + isDirectory: true + ) + NSWorkspace.shared.open(url) + } +} + +#Preview { + MCPIntroView(isExpanded: true, isMCPFFEnabled: .constant(true)) + .frame(width: 800) } #Preview { - MCPIntroView() + MCPIntroView(isExpanded: true, isMCPFFEnabled: .constant(false)) .frame(width: 800) } diff --git a/Core/Sources/HostApp/SharedComponents/BorderedProminentWhiteButtonStyle.swift b/Core/Sources/HostApp/SharedComponents/BorderedProminentWhiteButtonStyle.swift index c4af1cc5..7cc5db2a 100644 --- a/Core/Sources/HostApp/SharedComponents/BorderedProminentWhiteButtonStyle.swift +++ b/Core/Sources/HostApp/SharedComponents/BorderedProminentWhiteButtonStyle.swift @@ -23,7 +23,6 @@ public struct BorderedProminentWhiteButtonStyle: ButtonStyle { .overlay( RoundedRectangle(cornerRadius: 5).stroke(.clear, lineWidth: 1) ) - .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0) - .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5) } } + diff --git a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift index 35b9fe6a..85205d04 100644 --- a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift +++ b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift @@ -1,6 +1,10 @@ import SwiftUI public struct CardGroupBoxStyle: GroupBoxStyle { + public var backgroundColor: Color + public init(backgroundColor: Color = Color("GroupBoxBackgroundColor")) { + self.backgroundColor = backgroundColor + } public func makeBody(configuration: Configuration) -> some View { VStack(alignment: .leading, spacing: 11) { configuration.label.foregroundColor(.primary) @@ -8,7 +12,7 @@ public struct CardGroupBoxStyle: GroupBoxStyle { } .padding(8) .frame(maxWidth: .infinity, alignment: .topLeading) - .background(Color("GroupBoxBackgroundColor")) + .background(backgroundColor) .cornerRadius(4) .overlay( RoundedRectangle(cornerRadius: 4) diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 546b0d0a..0aa3b008 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -41,7 +41,7 @@ public struct TabContainer: View { do { let service = try getService() let featureFlags = try await service.getCopilotFeatureFlags() - isAgentModeFFEnabled = featureFlags?.agent_mode ?? true + isAgentModeFFEnabled = featureFlags?.agentMode ?? true if hostAppStore.activeTabIndex == 2 && !isAgentModeFFEnabled { hostAppStore.send(.setActiveTab(0)) } diff --git a/Core/Sources/Service/Helpers.swift b/Core/Sources/Service/Helpers.swift index 0dfede82..90ac6344 100644 --- a/Core/Sources/Service/Helpers.swift +++ b/Core/Sources/Service/Helpers.swift @@ -1,4 +1,5 @@ import Foundation +import GitHubCopilotService import LanguageServerProtocol extension NSError { @@ -34,6 +35,8 @@ extension NSError { message = "Invalid request: \(error?.localizedDescription ?? "Unknown")." case .timeout: message = "Timeout." + case .unknownError: + message = "Unknown error: \(error.localizedDescription)." } return NSError(domain: "com.github.CopilotForXcode", code: -1, userInfo: [ NSLocalizedDescriptionKey: message, diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift index 64a1c28a..3817c812 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift @@ -170,9 +170,9 @@ struct ChatHistoryItemView: View { // directly get title from chat tab info Text(previewInfo.title ?? "New Chat") .frame(alignment: .leading) - .font(.system(size: 14, weight: .regular)) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) .lineLimit(1) - .hoverPrimaryForeground(isHovered: isHovered) if isTabSelected() { Text("Current") @@ -185,7 +185,8 @@ struct ChatHistoryItemView: View { HStack(spacing: 0) { Text(formatDate(previewInfo.updatedAt)) .frame(alignment: .leading) - .font(.system(size: 13, weight: .thin)) + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.secondary) .lineLimit(1) Spacer() @@ -202,6 +203,7 @@ struct ChatHistoryItemView: View { } }) { Image(systemName: "trash") + .foregroundColor(.primary) .opacity(isHovered ? 1 : 0) } .buttonStyle(HoverButtonStyle()) @@ -215,7 +217,13 @@ struct ChatHistoryItemView: View { .onHover(perform: { isHovered = $0 }) - .hoverRadiusBackground(isHovered: isHovered, cornerRadius: 4) + .hoverRadiusBackground( + isHovered: isHovered, + hoverColor: Color(nsColor: .textBackgroundColor.withAlphaComponent(0.55)), + cornerRadius: 4, + showBorder: isHovered, + borderColor: Color(nsColor: .separatorColor) + ) .onTapGesture { Task { @MainActor in await store.send(.chatHistoryItemClicked(id: previewInfo.id)).finish() diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 45800b9f..cc4a82a8 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -453,20 +453,28 @@ struct ChatTabContainer: View { tabInfoArray: IdentifiedArray, selectedTabId: String ) -> some View { - ZStack { - ForEach(tabInfoArray) { tabInfo in - if let tab = chatTabPool.getTab(of: tabInfo.id) { - let isActive = tab.id == selectedTabId - tab.body - .opacity(isActive ? 1 : 0) - .disabled(!isActive) - .allowsHitTesting(isActive) - .frame(maxWidth: .infinity, maxHeight: .infinity) - // Inactive tabs are rotated out of view - .rotationEffect( - isActive ? .zero : .degrees(90), - anchor: .topLeading - ) + GeometryReader { geometry in + ZStack { + ForEach(tabInfoArray) { tabInfo in + if let tab = chatTabPool.getTab(of: tabInfo.id) { + let isActive = tab.id == selectedTabId + + if isActive { + // Only render the active tab with full layout + tab.body + .frame( + width: geometry.size.width, + height: geometry.size.height + ) + } else { + // Render inactive tabs with minimal footprint to avoid layout conflicts + tab.body + .frame(width: 1, height: 1) + .opacity(0) + .allowsHitTesting(false) + .clipped() + } + } } } } diff --git a/Server/package-lock.json b/Server/package-lock.json index 99e43b7c..e2d8b63e 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.348.0", + "@github/copilot-language-server": "^1.351.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,9 +36,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.348.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.348.0.tgz", - "integrity": "sha512-CV1+hU9I29GXrZKwdRj2x7ur47IAoqa56FWwnkI/Cvs0BdTTrLigJlOseeFCQ1bglnIyr6ZLFCduBahDtqR1AQ==", + "version": "1.351.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.351.0.tgz", + "integrity": "sha512-Owpl/cOTMQwXYArYuB1KCZGYkAScSb4B1TxPrKxAM10nIBeCtyHuEc1NQ0Pw05asMAHnoHWHVGQDrJINjlA8Ww==", "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" diff --git a/Server/package.json b/Server/package.json index 4c37672f..9bd5a961 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.348.0", + "@github/copilot-language-server": "^1.351.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/Tool/Package.swift b/Tool/Package.swift index 1e040128..e7c4e9f3 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -22,6 +22,7 @@ let package = Package( .library(name: "Persist", targets: ["Persist"]), .library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]), .library(name: "Workspace", targets: ["Workspace", "WorkspaceSuggestionService"]), + .library(name: "WebContentExtractor", targets: ["WebContentExtractor"]), .library( name: "SuggestionProvider", targets: ["SuggestionProvider"] @@ -67,11 +68,11 @@ let package = Package( ], dependencies: [ // TODO: Update LanguageClient some day. - .package(url: "https://github.com/ChimeHQ/LanguageClient", exact: "0.3.1"), - .package(url: "https://github.com/ChimeHQ/LanguageServerProtocol", exact: "0.8.0"), + .package(url: "https://github.com/ChimeHQ/LanguageClient", exact: "0.8.2"), + .package(url: "https://github.com/ChimeHQ/LanguageServerProtocol", exact: "0.13.3"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), - .package(url: "https://github.com/ChimeHQ/JSONRPC", exact: "0.6.0"), + .package(url: "https://github.com/ChimeHQ/JSONRPC", exact: "0.9.0"), .package(url: "https://github.com/devm33/Highlightr", branch: "master"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", @@ -80,7 +81,8 @@ let package = Package( .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), // TODO: remove CopilotForXcodeKit dependency once extension provider logic is removed. .package(url: "https://github.com/devm33/CopilotForXcodeKit", branch: "main"), - .package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.15.3") + .package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.15.3"), + .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.9.6") ], targets: [ // MARK: - Helpers @@ -92,6 +94,8 @@ let package = Package( .target(name: "Preferences", dependencies: ["Configs"]), .target(name: "Terminal", dependencies: ["Logger", "SystemUtils"]), + + .target(name: "WebContentExtractor", dependencies: ["Logger", "SwiftSoup", "Preferences"]), .target(name: "Logger"), diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift index 355ca323..3d4be7c1 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -8,6 +8,20 @@ import Workspace public final class BuiltinExtensionConversationServiceProvider< T: BuiltinExtension >: ConversationServiceProvider { + public func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspaceURL: URL?) async throws { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return + } + + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { + Logger.service.error("Could not get active workspace info") + return + } + + try? await conversationService.notifyChangeTextDocument(fileURL: fileURL, content: content, version: version, workspace: workspaceInfo) + } + private let extensionManager: BuiltinExtensionManager diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index 1c4a2407..913c5cf7 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -13,6 +13,7 @@ public protocol ConversationServiceType { func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]? func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws func agents(workspace: WorkspaceInfo) async throws -> [ChatAgent]? + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspace: WorkspaceInfo) async throws } public protocol ConversationServiceProvider { @@ -25,6 +26,7 @@ public protocol ConversationServiceProvider { func models() async throws -> [CopilotModel]? func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws func agents() async throws -> [ChatAgent]? + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspaceURL: URL?) async throws } public struct FileReference: Hashable, Codable, Equatable { diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift index 636d1e0b..63d44b32 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift @@ -247,6 +247,12 @@ public struct AnyCodable: Codable, Equatable { public typealias InvokeClientToolRequest = JSONRPCRequest +public enum ToolInvocationStatus: String, Codable { + case success + case error + case cancelled +} + public struct LanguageModelToolResult: Codable, Equatable { public struct Content: Codable, Equatable { public let value: AnyCodable @@ -256,9 +262,11 @@ public struct LanguageModelToolResult: Codable, Equatable { } } + public let status: ToolInvocationStatus public let content: [Content] - public init(content: [Content]) { + public init(status: ToolInvocationStatus = .success, content: [Content]) { + self.status = status self.content = content } } diff --git a/Tool/Sources/ConversationServiceProvider/ToolNames.swift b/Tool/Sources/ConversationServiceProvider/ToolNames.swift index 4bc31857..7b9d12c9 100644 --- a/Tool/Sources/ConversationServiceProvider/ToolNames.swift +++ b/Tool/Sources/ConversationServiceProvider/ToolNames.swift @@ -5,4 +5,5 @@ public enum ToolName: String { case getErrors = "get_errors" case insertEditIntoFile = "insert_edit_into_file" case createFile = "create_file" + case fetchWebPage = "fetch_webpage" } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift index ec0d5add..e78c9cde 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift @@ -93,12 +93,33 @@ func registerClientTools(server: GitHubCopilotConversationServiceType) async { required: ["filePath", "code", "explanation"] ) ) + + let fetchWebPageTool: LanguageModelToolInformation = .init( + name: ToolName.fetchWebPage.rawValue, + description: "Fetches the main content from a web page. This tool is useful for summarizing or analyzing the content of a webpage.", + inputSchema: .init( + type: "object", + properties: [ + "urls": .init( + type: "array", + description: "An array of web page URLs to fetch content from.", + items: .init(type: "string") + ), + ], + required: ["urls"] + ), + confirmationMessages: LanguageModelToolConfirmationMessages( + title: "Fetch Web Page", + message: "Web content may contain malicious code or attempt prompt injection attacks." + ) + ) tools.append(runInTerminalTool) tools.append(getTerminalOutputTool) tools.append(getErrorsTool) tools.append(insertEditIntoFileTool) tools.append(createFileTool) + tools.append(fetchWebPageTool) if !tools.isEmpty { try? await server.registerTools(tools: tools) diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index 65a972d6..29e33d35 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -7,18 +7,51 @@ import Logger import ProcessEnv import Status +public enum ServerError: LocalizedError { + case handlerUnavailable(String) + case unhandledMethod(String) + case notificationDispatchFailed(Error) + case requestDispatchFailed(Error) + case clientDataUnavailable(Error) + case serverUnavailable + case missingExpectedParameter + case missingExpectedResult + case unableToDecodeRequest(Error) + case unableToSendRequest(Error) + case unableToSendNotification(Error) + case serverError(code: Int, message: String, data: Codable?) + case invalidRequest(Error?) + case timeout + case unknownError(Error) + + static func responseError(_ error: AnyJSONRPCResponseError) -> ServerError { + return ServerError.serverError(code: error.code, + message: error.message, + data: error.data) + } + + static func convertToServerError(error: any Error) -> ServerError { + if let serverError = error as? ServerError { + return serverError + } else if let jsonRPCError = error as? AnyJSONRPCResponseError { + return responseError(jsonRPCError) + } + + return .unknownError(error) + } +} + +public typealias LSPResponse = Decodable & Sendable + /// A clone of the `LocalProcessServer`. /// 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 - private let process: Process - private var wrappedServer: CustomJSONRPCLanguageServer? + private var process: Process? + private var wrappedServer: CustomJSONRPCServerConnection? + private var cancellables = Set() - var terminationHandler: (() -> Void)? @MainActor var ongoingCompletionRequestIDs: [JSONId] = [] @MainActor var ongoingConversationRequestIDs = [String: JSONId]() @@ -37,238 +70,91 @@ class CopilotLocalProcessServer { } init(executionParameters parameters: Process.ExecutionParameters) { - transport = StdioDataTransport() - let framing = SeperatedHTTPHeaderMessageFraming() - let messageTransport = MessageTransport( - dataTransport: transport, - messageProtocol: framing - ) - customTransport = CustomDataTransport(nextTransport: messageTransport) - wrappedServer = CustomJSONRPCLanguageServer(dataTransport: customTransport) - - process = Process() - - // Because the implementation of LanguageClient is so closed, - // we need to get the request IDs from a custom transport before the data - // is written to the language server. - customTransport.onWriteRequest = { [weak self] request in - if request.method == "getCompletionsCycling" { - Task { @MainActor [weak self] in - self?.ongoingCompletionRequestIDs.append(request.id) - } - } else if request.method == "conversation/create" { - Task { @MainActor [weak self] in - if let paramsData = try? JSONEncoder().encode(request.params) { - do { - let params = try JSONDecoder().decode(ConversationCreateParams.self, from: paramsData) - self?.ongoingConversationRequestIDs[params.workDoneToken] = request.id - } catch { - // Handle decoding error - print("Error decoding ConversationCreateParams: \(error)") - Logger.gitHubCopilot.error("Error decoding ConversationCreateParams: \(error)") - } - } - } - } else if request.method == "conversation/turn" { - Task { @MainActor [weak self] in - if let paramsData = try? JSONEncoder().encode(request.params) { - do { - let params = try JSONDecoder().decode(TurnCreateParams.self, from: paramsData) - self?.ongoingConversationRequestIDs[params.workDoneToken] = request.id - } catch { - // Handle decoding error - print("Error decoding TurnCreateParams: \(error)") - Logger.gitHubCopilot.error("Error decoding TurnCreateParams: \(error)") - } - } - } - } + do { + let channel: DataChannel = try startLocalProcess(parameters: parameters, terminationHandler: processTerminated) + let noop: @Sendable (Data) async -> Void = { _ in } + let newChannel = DataChannel.tap(channel: channel.withMessageFraming(), onRead: noop, onWrite: onWriteRequest) + + self.wrappedServer = CustomJSONRPCServerConnection(dataChannel: newChannel, notificationHandler: handleNotification) + } catch { + Logger.gitHubCopilot.error("Failed to start local CLS process: \(error)") } - - 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 - process.standardError = transport.stderrPipe - - process.parameters = parameters - - process.terminationHandler = { [unowned self] task in - self.processTerminated(task) - } - - process.launch() } - + deinit { - process.terminationHandler = nil - process.terminate() - transport.close() - } - - private func processTerminated(_: Process) { - transport.close() - - // releasing the server here will short-circuit any pending requests, - // which might otherwise take a while to time out, if ever. - wrappedServer = nil - terminationHandler?() - } - - var logMessages: Bool { - get { return wrappedServer?.logMessages ?? false } - set { wrappedServer?.logMessages = newValue } - } -} - -extension CopilotLocalProcessServer: LanguageServerProtocol.Server { - public var requestHandler: RequestHandler? { - get { return wrappedServer?.requestHandler } - set { wrappedServer?.requestHandler = newValue } - } - - public var notificationHandler: NotificationHandler? { - get { wrappedServer?.notificationHandler } - set { wrappedServer?.notificationHandler = newValue } + self.process?.terminate() } - public func sendNotification( - _ notif: ClientNotification, - completionHandler: @escaping (ServerError?) -> Void - ) { - guard let server = wrappedServer, process.isRunning else { - completionHandler(.serverUnavailable) - return - } - - server.sendNotification(notif, completionHandler: completionHandler) - } - - /// send copilot specific notification - public func sendCopilotNotification( - _ notif: CopilotClientNotification, - completionHandler: @escaping (ServerError?) -> Void - ) { - guard let server = wrappedServer, process.isRunning else { - completionHandler(.serverUnavailable) - return + private func startLocalProcess(parameters: Process.ExecutionParameters, + terminationHandler: @escaping @Sendable () -> Void) throws -> DataChannel { + let (channel, process) = try DataChannel.localProcessChannel(parameters: parameters, terminationHandler: terminationHandler) + + // Create a serial queue to synchronize writes + let writeQueue = DispatchQueue(label: "DataChannel.writeQueue") + let stdinPipe: Pipe = process.standardInput as! Pipe + self.process = process + let handler: DataChannel.WriteHandler = { data in + try writeQueue.sync { + // write is not thread-safe, so we need to use queue to ensure it thread-safe + try stdinPipe.fileHandleForWriting.write(contentsOf: data) + } } - server.sendCopilotNotification(notif, completionHandler: completionHandler) - } + let wrappedChannel = DataChannel( + writeHandler: handler, + dataSequence: channel.dataSequence + ) - /// Cancel ongoing completion requests. - public func cancelOngoingTasks() async { - let task = Task { @MainActor in - for id in ongoingCompletionRequestIDs { - await cancelTask(id) - } - self.ongoingCompletionRequestIDs = [] - } - await task.value - } - - public func cancelOngoingTask(_ workDoneToken: String) async { - let task = Task { @MainActor in - guard let id = ongoingConversationRequestIDs[workDoneToken] else { return } - await cancelTask(id) - } - await task.value + return wrappedChannel } - public func cancelTask(_ id: JSONId) async { - guard let server = wrappedServer, process.isRunning else { - return - } - - switch id { - case let .numericId(id): - try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) - case let .stringId(id): - try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) - } - } - - public func sendRequest( - _ request: ClientRequest, - completionHandler: @escaping (ServerResult) -> Void - ) { - guard let server = wrappedServer, process.isRunning else { - completionHandler(.failure(.serverUnavailable)) + @Sendable + private func onWriteRequest(data: Data) { + guard let request = try? JSONDecoder().decode(JSONRPCRequest.self, from: data) else { return } - server.sendRequest(request, completionHandler: completionHandler) - } -} - -protocol CopilotNotificationJSONRPCLanguageServer { - func sendCopilotNotification(_ notif: CopilotClientNotification, completionHandler: @escaping (ServerError?) -> Void) -} - -final class CustomJSONRPCLanguageServer: Server { - let internalServer: JSONRPCLanguageServer - - typealias ProtocolResponse = ProtocolTransport.ResponseResult - - private let protocolTransport: ProtocolTransport - - 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? - - init(protocolTransport: ProtocolTransport) { - self.protocolTransport = protocolTransport - internalServer = JSONRPCLanguageServer(protocolTransport: protocolTransport) - - let previouseRequestHandler = protocolTransport.requestHandler - let previouseNotificationHandler = protocolTransport.notificationHandler - - protocolTransport - .requestHandler = { [weak self] in - guard let self else { return } - if !self.handleRequest($0, data: $1, callback: $2) { - previouseRequestHandler?($0, $1, $2) + if request.method == "getCompletionsCycling" { + Task { @MainActor [weak self] in + self?.ongoingCompletionRequestIDs.append(request.id) + } + } else if request.method == "conversation/create" { + Task { @MainActor [weak self] in + if let paramsData = try? JSONEncoder().encode(request.params) { + do { + let params = try JSONDecoder().decode(ConversationCreateParams.self, from: paramsData) + self?.ongoingConversationRequestIDs[params.workDoneToken] = request.id + } catch { + // Handle decoding error + Logger.gitHubCopilot.error("Error decoding ConversationCreateParams: \(error)") + } } } - protocolTransport - .notificationHandler = { [weak self] in - guard let self else { return } - if !self.handleNotification($0, data: $1, block: $2) { - previouseNotificationHandler?($0, $1, $2) + } else if request.method == "conversation/turn" { + Task { @MainActor [weak self] in + if let paramsData = try? JSONEncoder().encode(request.params) { + do { + let params = try JSONDecoder().decode(TurnCreateParams.self, from: paramsData) + self?.ongoingConversationRequestIDs[params.workDoneToken] = request.id + } catch { + // Handle decoding error + Logger.gitHubCopilot.error("Error decoding TurnCreateParams: \(error)") + } } } + } } - convenience init(dataTransport: DataTransport) { - self.init(protocolTransport: ProtocolTransport(dataTransport: dataTransport)) - } - - deinit { - protocolTransport.requestHandler = nil - protocolTransport.notificationHandler = nil - } - - var logMessages: Bool { - get { return internalServer.logMessages } - set { internalServer.logMessages = newValue } + @Sendable + private func processTerminated() { + // releasing the server here will short-circuit any pending requests, + // which might otherwise take a while to time out, if ever. + wrappedServer = nil } -} -extension CustomJSONRPCLanguageServer { private func handleNotification( _ anyNotification: AnyJSONRPCNotification, - data: Data, - block: @escaping (Error?) -> Void + data: Data ) -> Bool { let methodName = anyNotification.method let debugDescription = encodeJSONParams(params: anyNotification.params) @@ -276,11 +162,9 @@ extension CustomJSONRPCLanguageServer { switch method { case .windowLogMessage: Logger.gitHubCopilot.info("\(anyNotification.method): \(debugDescription)") - block(nil) return true case .protocolProgress: notificationPublisher.send(anyNotification) - block(nil) return true default: return false @@ -289,7 +173,6 @@ extension CustomJSONRPCLanguageServer { switch methodName { case "LogMessage": Logger.gitHubCopilot.info("\(anyNotification.method): \(debugDescription)") - block(nil) return true case "didChangeStatus": Logger.gitHubCopilot.info("\(anyNotification.method): \(debugDescription)") @@ -303,58 +186,110 @@ extension CustomJSONRPCLanguageServer { ) } } - block(nil) return true - case "featureFlagsNotification": + case "copilot/didChangeFeatureFlags": notificationPublisher.send(anyNotification) - block(nil) return true case "copilot/mcpTools": notificationPublisher.send(anyNotification) - block(nil) + return true + case "copilot/mcpRuntimeLogs": + notificationPublisher.send(anyNotification) return true case "conversation/preconditionsNotification", "statusNotification": // Ignore - block(nil) return true default: return false } } } +} - public func sendNotification( - _ notif: ClientNotification, - completionHandler: @escaping (ServerError?) -> Void - ) { - internalServer.sendNotification(notif, completionHandler: completionHandler) +extension CopilotLocalProcessServer: ServerConnection { + var eventSequence: EventSequence { + guard let server = wrappedServer else { + let result = EventSequence.makeStream() + result.continuation.finish() + return result.stream + } + + return server.eventSequence } -} -extension CustomJSONRPCLanguageServer { - private func handleRequest( - _ request: AnyJSONRPCRequest, - data: Data, - callback: @escaping (AnyJSONRPCResponse) -> Void - ) -> Bool { - let methodName = request.method - let debugDescription = encodeJSONParams(params: request.params) - serverRequestPublisher.send((request: request, callback: callback)) + public func sendNotification(_ notif: ClientNotification) async throws { + guard let server = wrappedServer, let process = process, process.isRunning else { + throw ServerError.serverUnavailable + } + + do { + try await server.sendNotification(notif) + } catch { + throw ServerError.unableToSendNotification(error) + } + } + + /// send copilot specific notification + public func sendCopilotNotification(_ notif: CopilotClientNotification) async throws -> Void { + guard let server = wrappedServer, let process = process, process.isRunning else { + throw ServerError.serverUnavailable + } + + let method = notif.method.rawValue + + switch notif { + case .copilotDidChangeWatchedFiles(let params): + do { + try await server.sendNotification(params, method: method) + } catch { + throw ServerError.unableToSendNotification(error) + } + } + } - switch methodName { - case "conversation/invokeClientTool": - return true - case "conversation/invokeClientToolConfirmation": - return true - case "conversation/context": - return true - case "copilot/watchedFiles": - return true - case "window/showMessageRequest": - Logger.gitHubCopilot.info("\(methodName): \(debugDescription)") - return true - default: - return false // delegate the default handling to the server + /// Cancel ongoing completion requests. + public func cancelOngoingTasks() async { + let task = Task { @MainActor in + for id in ongoingCompletionRequestIDs { + await cancelTask(id) + } + self.ongoingCompletionRequestIDs = [] + } + await task.value + } + + public func cancelOngoingTask(_ workDoneToken: String) async { + let task = Task { @MainActor in + guard let id = ongoingConversationRequestIDs[workDoneToken] else { return } + await cancelTask(id) + } + await task.value + } + + public func cancelTask(_ id: JSONId) async { + guard let server = wrappedServer, let process = process, process.isRunning else { + return + } + + switch id { + case let .numericId(id): + try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) + case let .stringId(id): + try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) + } + } + + public func sendRequest( + _ request: ClientRequest + ) async throws -> Response { + guard let server = wrappedServer, let process = process, process.isRunning else { + throw ServerError.serverUnavailable + } + + do { + return try await server.sendRequest(request) + } catch { + throw ServerError.convertToServerError(error: error) } } } @@ -370,19 +305,10 @@ func encodeJSONParams(params: JSONValue?) -> String { return "N/A" } -extension CustomJSONRPCLanguageServer { - public func sendRequest( - _ request: ClientRequest, - completionHandler: @escaping (ServerResult) -> Void - ) { - internalServer.sendRequest(request, completionHandler: completionHandler) - } -} - // MARK: - Copilot custom notification public struct CopilotDidChangeWatchedFilesParams: Codable, Hashable { - /// The CLS need an additional paramter `workspaceUri` for "workspace/didChangeWatchedFiles" event + /// The CLS need an additional parameter `workspaceUri` for "workspace/didChangeWatchedFiles" event public var workspaceUri: String public var changes: [FileEvent] @@ -406,17 +332,3 @@ public enum CopilotClientNotification { } } } - -extension CustomJSONRPCLanguageServer: CopilotNotificationJSONRPCLanguageServer { - public func sendCopilotNotification(_ notif: CopilotClientNotification, completionHandler: @escaping (ServerError?) -> Void) { - let method = notif.method.rawValue - - switch notif { - case .copilotDidChangeWatchedFiles(let params): - // the protocolTransport is not exposed by LSP Server, need to use it directly - protocolTransport.sendNotification(params, method: method) { error in - completionHandler(error.map({ .unableToSendNotification($0) })) - } - } - } -} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CustomJSONRPCServerConnection.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CustomJSONRPCServerConnection.swift new file mode 100644 index 00000000..d65e9c4c --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CustomJSONRPCServerConnection.swift @@ -0,0 +1,378 @@ +import Foundation +import LanguageClient +import JSONRPC +import LanguageServerProtocol + +/// A clone of the `JSONRPCServerConnection`. +/// We need it because the original one does not allow us to handle custom notifications. +public actor CustomJSONRPCServerConnection: ServerConnection { + public let eventSequence: EventSequence + private let eventContinuation: EventSequence.Continuation + + private let session: JSONRPCSession + + /// NOTE: The channel will wrapped with message framing + public init(dataChannel: DataChannel, notificationHandler: ((AnyJSONRPCNotification, Data) -> Bool)? = nil) { + self.notificationHandler = notificationHandler + self.session = JSONRPCSession(channel: dataChannel) + + (self.eventSequence, self.eventContinuation) = EventSequence.makeStream() + + Task { + await startMonitoringSession() + } + } + + deinit { + eventContinuation.finish() + } + + private func startMonitoringSession() async { + let seq = await session.eventSequence + + for await event in seq { + + switch event { + case let .notification(notification, data): + self.handleNotification(notification, data: data) + case let .request(request, handler, data): + self.handleRequest(request, data: data, handler: handler) + case .error: + break // TODO? + } + + } + + eventContinuation.finish() + } + + public func sendNotification(_ notif: ClientNotification) async throws { + let method = notif.method.rawValue + + switch notif { + case .initialized(let params): + try await session.sendNotification(params, method: method) + case .exit: + try await session.sendNotification(method: method) + case .textDocumentDidChange(let params): + try await session.sendNotification(params, method: method) + case .textDocumentDidOpen(let params): + try await session.sendNotification(params, method: method) + case .textDocumentDidClose(let params): + try await session.sendNotification(params, method: method) + case .textDocumentWillSave(let params): + try await session.sendNotification(params, method: method) + case .textDocumentDidSave(let params): + try await session.sendNotification(params, method: method) + case .workspaceDidChangeWatchedFiles(let params): + try await session.sendNotification(params, method: method) + case .protocolCancelRequest(let params): + try await session.sendNotification(params, method: method) + case .protocolSetTrace(let params): + try await session.sendNotification(params, method: method) + case .workspaceDidChangeWorkspaceFolders(let params): + try await session.sendNotification(params, method: method) + case .workspaceDidChangeConfiguration(let params): + try await session.sendNotification(params, method: method) + case .workspaceDidCreateFiles(let params): + try await session.sendNotification(params, method: method) + case .workspaceDidRenameFiles(let params): + try await session.sendNotification(params, method: method) + case .workspaceDidDeleteFiles(let params): + try await session.sendNotification(params, method: method) + case .windowWorkDoneProgressCancel(let params): + try await session.sendNotification(params, method: method) + } + } + + public func sendRequest(_ request: ClientRequest) async throws -> Response + where Response: Decodable & Sendable { + let method = request.method.rawValue + + switch request { + case .initialize(let params, _): + return try await session.response(to: method, params: params) + case .shutdown: + return try await session.response(to: method) + case .workspaceExecuteCommand(let params, _): + return try await session.response(to: method, params: params) + case .workspaceInlayHintRefresh: + return try await session.response(to: method) + case .workspaceWillCreateFiles(let params, _): + return try await session.response(to: method, params: params) + case .workspaceWillRenameFiles(let params, _): + return try await session.response(to: method, params: params) + case .workspaceWillDeleteFiles(let params, _): + return try await session.response(to: method, params: params) + case .workspaceSymbol(let params, _): + return try await session.response(to: method, params: params) + case .workspaceSymbolResolve(let params, _): + return try await session.response(to: method, params: params) + case .textDocumentWillSaveWaitUntil(let params, _): + return try await session.response(to: method, params: params) + case .completion(let params, _): + return try await session.response(to: method, params: params) + case .completionItemResolve(let params, _): + return try await session.response(to: method, params: params) + case .hover(let params, _): + return try await session.response(to: method, params: params) + case .signatureHelp(let params, _): + return try await session.response(to: method, params: params) + case .declaration(let params, _): + return try await session.response(to: method, params: params) + case .definition(let params, _): + return try await session.response(to: method, params: params) + case .typeDefinition(let params, _): + return try await session.response(to: method, params: params) + case .implementation(let params, _): + return try await session.response(to: method, params: params) + case .documentHighlight(let params, _): + return try await session.response(to: method, params: params) + case .documentSymbol(let params, _): + return try await session.response(to: method, params: params) + case .codeAction(let params, _): + return try await session.response(to: method, params: params) + case .codeActionResolve(let params, _): + return try await session.response(to: method, params: params) + case .codeLens(let params, _): + return try await session.response(to: method, params: params) + case .codeLensResolve(let params, _): + return try await session.response(to: method, params: params) + case .selectionRange(let params, _): + return try await session.response(to: method, params: params) + case .linkedEditingRange(let params, _): + return try await session.response(to: method, params: params) + case .prepareCallHierarchy(let params, _): + return try await session.response(to: method, params: params) + case .prepareRename(let params, _): + return try await session.response(to: method, params: params) + case .prepareTypeHierarchy(let params, _): + return try await session.response(to: method, params: params) + case .rename(let params, _): + return try await session.response(to: method, params: params) + case .inlayHint(let params, _): + return try await session.response(to: method, params: params) + case .inlayHintResolve(let params, _): + return try await session.response(to: method, params: params) + case .diagnostics(let params, _): + return try await session.response(to: method, params: params) + case .documentLink(let params, _): + return try await session.response(to: method, params: params) + case .documentLinkResolve(let params, _): + return try await session.response(to: method, params: params) + case .documentColor(let params, _): + return try await session.response(to: method, params: params) + case .colorPresentation(let params, _): + return try await session.response(to: method, params: params) + case .formatting(let params, _): + return try await session.response(to: method, params: params) + case .rangeFormatting(let params, _): + return try await session.response(to: method, params: params) + case .onTypeFormatting(let params, _): + return try await session.response(to: method, params: params) + case .references(let params, _): + return try await session.response(to: method, params: params) + case .foldingRange(let params, _): + return try await session.response(to: method, params: params) + case .moniker(let params, _): + return try await session.response(to: method, params: params) + case .semanticTokensFull(let params, _): + return try await session.response(to: method, params: params) + case .semanticTokensFullDelta(let params, _): + return try await session.response(to: method, params: params) + case .semanticTokensRange(let params, _): + return try await session.response(to: method, params: params) + case .callHierarchyIncomingCalls(let params, _): + return try await session.response(to: method, params: params) + case .callHierarchyOutgoingCalls(let params, _): + return try await session.response(to: method, params: params) + case let .custom(method, params, _): + return try await session.response(to: method, params: params) + } + } + + private func decodeNotificationParams(_ type: Params.Type, from data: Data) throws + -> Params where Params: Decodable + { + let note = try JSONDecoder().decode(JSONRPCNotification.self, from: data) + + guard let params = note.params else { + throw ProtocolError.missingParams + } + + return params + } + + private func yield(_ notification: ServerNotification) { + eventContinuation.yield(.notification(notification)) + } + + private func yield(id: JSONId, request: ServerRequest) { + eventContinuation.yield(.request(id: id, request: request)) + } + + private func handleNotification(_ anyNotification: AnyJSONRPCNotification, data: Data) { + // MARK: Handle custom notifications here. + if let handler = notificationHandler, handler(anyNotification, data) { + return + } + // MARK: End of custom notification handling. + + let methodName = anyNotification.method + + do { + guard let method = ServerNotification.Method(rawValue: methodName) else { + throw ProtocolError.unrecognizedMethod(methodName) + } + + switch method { + case .windowLogMessage: + let params = try decodeNotificationParams(LogMessageParams.self, from: data) + + yield(.windowLogMessage(params)) + case .windowShowMessage: + let params = try decodeNotificationParams(ShowMessageParams.self, from: data) + + yield(.windowShowMessage(params)) + case .textDocumentPublishDiagnostics: + let params = try decodeNotificationParams(PublishDiagnosticsParams.self, from: data) + + yield(.textDocumentPublishDiagnostics(params)) + case .telemetryEvent: + let params = anyNotification.params ?? .null + + yield(.telemetryEvent(params)) + case .protocolCancelRequest: + let params = try decodeNotificationParams(CancelParams.self, from: data) + + yield(.protocolCancelRequest(params)) + case .protocolProgress: + let params = try decodeNotificationParams(ProgressParams.self, from: data) + + yield(.protocolProgress(params)) + case .protocolLogTrace: + let params = try decodeNotificationParams(LogTraceParams.self, from: data) + + yield(.protocolLogTrace(params)) + } + } catch { + // should we backchannel this to the client somehow? + print("failed to relay notification: \(error)") + } + } + + private func decodeRequestParams(_ type: Params.Type, from data: Data) throws -> Params + where Params: Decodable { + let req = try JSONDecoder().decode(JSONRPCRequest.self, from: data) + + guard let params = req.params else { + throw ProtocolError.missingParams + } + + return params + } + + private nonisolated func makeErrorOnlyHandler(_ handler: @escaping JSONRPCEvent.RequestHandler) + -> ServerRequest.ErrorOnlyHandler + { + return { + if let error = $0 { + await handler(.failure(error)) + } else { + await handler(.success(JSONValue.null)) + } + } + } + + private nonisolated func makeHandler(_ handler: @escaping JSONRPCEvent.RequestHandler) + -> ServerRequest.Handler + { + return { + let loweredResult = $0.map({ $0 as Encodable & Sendable }) + + await handler(loweredResult) + } + } + + private func handleRequest( + _ anyRequest: AnyJSONRPCRequest, data: Data, handler: @escaping JSONRPCEvent.RequestHandler + ) { + let methodName = anyRequest.method + let id = anyRequest.id + + do { + + let method = ServerRequest.Method(rawValue: methodName) ?? .custom + switch method { + case .workspaceConfiguration: + let params = try decodeRequestParams(ConfigurationParams.self, from: data) + let reqHandler: ServerRequest.Handler<[LSPAny]> = makeHandler(handler) + + yield(id: id, request: ServerRequest.workspaceConfiguration(params, reqHandler)) + case .workspaceFolders: + let reqHandler: ServerRequest.Handler = makeHandler( + handler) + + yield(id: id, request: ServerRequest.workspaceFolders(reqHandler)) + case .workspaceApplyEdit: + let params = try decodeRequestParams(ApplyWorkspaceEditParams.self, from: data) + let reqHandler: ServerRequest.Handler = makeHandler( + handler) + + yield(id: id, request: ServerRequest.workspaceApplyEdit(params, reqHandler)) + case .clientRegisterCapability: + let params = try decodeRequestParams(RegistrationParams.self, from: data) + let reqHandler = makeErrorOnlyHandler(handler) + + yield(id: id, request: ServerRequest.clientRegisterCapability(params, reqHandler)) + case .clientUnregisterCapability: + let params = try decodeRequestParams(UnregistrationParams.self, from: data) + let reqHandler = makeErrorOnlyHandler(handler) + + yield(id: id, request: ServerRequest.clientUnregisterCapability(params, reqHandler)) + case .workspaceCodeLensRefresh: + let reqHandler = makeErrorOnlyHandler(handler) + + yield(id: id, request: ServerRequest.workspaceCodeLensRefresh(reqHandler)) + case .workspaceSemanticTokenRefresh: + let reqHandler = makeErrorOnlyHandler(handler) + + yield(id: id, request: ServerRequest.workspaceSemanticTokenRefresh(reqHandler)) + case .windowShowMessageRequest: + let params = try decodeRequestParams(ShowMessageRequestParams.self, from: data) + let reqHandler: ServerRequest.Handler = makeHandler( + handler) + + yield(id: id, request: ServerRequest.windowShowMessageRequest(params, reqHandler)) + case .windowShowDocument: + let params = try decodeRequestParams(ShowDocumentParams.self, from: data) + let reqHandler: ServerRequest.Handler = makeHandler(handler) + + yield(id: id, request: ServerRequest.windowShowDocument(params, reqHandler)) + case .windowWorkDoneProgressCreate: + let params = try decodeRequestParams(WorkDoneProgressCreateParams.self, from: data) + let reqHandler = makeErrorOnlyHandler(handler) + + yield( + id: id, request: ServerRequest.windowWorkDoneProgressCreate(params, reqHandler)) + case .custom: + let params = try decodeRequestParams(LSPAny.self, from: data) + let reqHandler: ServerRequest.Handler = makeHandler(handler) + + yield(id: id, request: ServerRequest.custom(methodName, params, reqHandler)) + + } + + } catch { + // should we backchannel this to the client somehow? + print("failed to relay request: \(error)") + } + } + + // MARK: New properties/methods to handle custom copilot notifications + private var notificationHandler: ((AnyJSONRPCNotification, Data) -> Bool)? + + public func sendNotification(_ params: Note, method: String) async throws where Note: Encodable { + try await self.session.sendNotification(params, method: method) + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift deleted file mode 100644 index 82e98544..00000000 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import JSONRPC -import os.log - -public class CustomDataTransport: DataTransport { - let nextTransport: DataTransport - - var onWriteRequest: (JSONRPCRequest) -> Void = { _ in } - - init(nextTransport: DataTransport) { - self.nextTransport = nextTransport - } - - public func write(_ data: Data) { - if let request = try? JSONDecoder().decode(JSONRPCRequest.self, from: data) { - onWriteRequest(request) - } - - nextTransport.write(data) - } - - public func setReaderHandler(_ handler: @escaping ReadHandler) { - nextTransport.setReaderHandler(handler) - } - - public func close() { - nextTransport.close() - } -} - diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index c750f4a8..9453e54f 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -121,7 +121,7 @@ enum GitHubCopilotRequest { } var request: ClientRequest { - .custom("getVersion", .hash([:])) + .custom("getVersion", .hash([:]), ClientRequest.NullHandler) } } @@ -132,7 +132,7 @@ enum GitHubCopilotRequest { } var request: ClientRequest { - .custom("checkStatus", .hash([:])) + .custom("checkStatus", .hash([:]), ClientRequest.NullHandler) } } @@ -140,7 +140,7 @@ enum GitHubCopilotRequest { typealias Response = GitHubCopilotQuotaInfo var request: ClientRequest { - .custom("checkQuota", .hash([:])) + .custom("checkQuota", .hash([:]), ClientRequest.NullHandler) } } @@ -155,7 +155,7 @@ enum GitHubCopilotRequest { } var request: ClientRequest { - .custom("signInInitiate", .hash([:])) + .custom("signInInitiate", .hash([:]), ClientRequest.NullHandler) } } @@ -170,7 +170,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { .custom("signInConfirm", .hash([ "userCode": .string(userCode), - ])) + ]), ClientRequest.NullHandler) } } @@ -180,7 +180,7 @@ enum GitHubCopilotRequest { } var request: ClientRequest { - .custom("signOut", .hash([:])) + .custom("signOut", .hash([:]), ClientRequest.NullHandler) } } @@ -196,7 +196,7 @@ enum GitHubCopilotRequest { let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) return .custom("getCompletions", .hash([ "doc": dict, - ])) + ]), ClientRequest.NullHandler) } } @@ -212,7 +212,7 @@ enum GitHubCopilotRequest { let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) return .custom("getCompletionsCycling", .hash([ "doc": dict, - ])) + ]), ClientRequest.NullHandler) } } @@ -262,7 +262,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(doc)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("textDocument/inlineCompletion", dict) + return .custom("textDocument/inlineCompletion", dict, ClientRequest.NullHandler) } } @@ -278,7 +278,7 @@ enum GitHubCopilotRequest { let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) return .custom("getPanelCompletions", .hash([ "doc": dict, - ])) + ]), ClientRequest.NullHandler) } } @@ -290,7 +290,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { .custom("notifyShown", .hash([ "uuid": .string(completionUUID), - ])) + ]), ClientRequest.NullHandler) } } @@ -309,7 +309,7 @@ enum GitHubCopilotRequest { dict["acceptedLength"] = .number(Double(acceptedLength)) } - return .custom("notifyAccepted", .hash(dict)) + return .custom("notifyAccepted", .hash(dict), ClientRequest.NullHandler) } } @@ -321,7 +321,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { .custom("notifyRejected", .hash([ "uuids": .array(completionUUIDs.map(JSONValue.string)), - ])) + ]), ClientRequest.NullHandler) } } @@ -335,7 +335,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("conversation/create", dict) + return .custom("conversation/create", dict, ClientRequest.NullHandler) } } @@ -349,7 +349,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("conversation/turn", dict) + return .custom("conversation/turn", dict, ClientRequest.NullHandler) } } @@ -363,7 +363,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("conversation/rating", dict) + return .custom("conversation/rating", dict, ClientRequest.NullHandler) } } @@ -373,7 +373,7 @@ enum GitHubCopilotRequest { typealias Response = Array var request: ClientRequest { - .custom("conversation/templates", .hash([:])) + .custom("conversation/templates", .hash([:]), ClientRequest.NullHandler) } } @@ -381,7 +381,7 @@ enum GitHubCopilotRequest { typealias Response = Array var request: ClientRequest { - .custom("copilot/models", .hash([:])) + .custom("copilot/models", .hash([:]), ClientRequest.NullHandler) } } @@ -395,7 +395,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("mcp/updateToolsStatus", dict) + return .custom("mcp/updateToolsStatus", dict, ClientRequest.NullHandler) } } @@ -405,7 +405,7 @@ enum GitHubCopilotRequest { typealias Response = Array var request: ClientRequest { - .custom("conversation/agents", .hash([:])) + .custom("conversation/agents", .hash([:]), ClientRequest.NullHandler) } } @@ -417,7 +417,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("conversation/registerTools", dict) + return .custom("conversation/registerTools", dict, ClientRequest.NullHandler) } } @@ -431,7 +431,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("conversation/copyCode", dict) + return .custom("conversation/copyCode", dict, ClientRequest.NullHandler) } } @@ -445,7 +445,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("telemetry/exception", dict) + return .custom("telemetry/exception", dict, ClientRequest.NullHandler) } } } @@ -484,4 +484,23 @@ public enum GitHubCopilotNotification { } } + + public struct MCPRuntimeNotification: Codable { + public enum MCPRuntimeLogLevel: String, Codable { + case Info = "info" + case Warning = "warning" + case Error = "error" + } + + public var level: MCPRuntimeLogLevel + public var message: String + public var server: String + public var tool: String? + public var time: Double + + public static func decode(fromParams params: JSONValue?) -> MCPRuntimeNotification? { + try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data()) + } + } + } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 44a05e07..4ea5de5c 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -85,8 +85,8 @@ public protocol GitHubCopilotConversationServiceType { } protocol GitHubCopilotLSP { + var eventSequence: ServerConnection.EventSequence { get } func sendRequest(_ endpoint: E) async throws -> E.Response - func sendRequest(_ endpoint: E, timeout: TimeInterval) async throws -> E.Response func sendNotification(_ notif: ClientNotification) async throws } @@ -135,6 +135,8 @@ public enum GitHubCopilotError: Error, LocalizedError { return "Language server error: Invalid request" case .timeout: return "Language server error: Timeout, please try again later" + case .unknownError: + return "Language server error: An unknown error occurred: \(error)" } } } @@ -227,13 +229,8 @@ public class GitHubCopilotBaseService { Logger.gitHubCopilot.info("Running on Xcode \(xcodeVersion), extension version \(versionNumber)") let localServer = CopilotLocalProcessServer(executionParameters: executionParams) - localServer.notificationHandler = { _, respond in - respond(.timeout) - } - let server = InitializingServer(server: localServer) - // TODO: set proper timeout against different request. - server.defaultTimeout = 90 - server.initializeParamsProvider = { + + let initializeParamsProvider = { @Sendable () -> InitializeParams in let capabilities = ClientCapabilities( workspace: .init( applyEdit: false, @@ -270,6 +267,7 @@ public class GitHubCopilotBaseService { "copilotCapabilities": [ /// The editor has support for watching files over LSP "watchedFiles": watchedFiles, + "didChangeFeatureFlags": true ] ], capabilities: capabilities, @@ -280,6 +278,8 @@ public class GitHubCopilotBaseService { )] ) } + + let server = SafeInitializingServer(InitializingServer(server: localServer, initializeParamsProvider: initializeParamsProvider)) return (server, localServer) }() @@ -287,8 +287,6 @@ public class GitHubCopilotBaseService { self.server = server localProcessServer = localServer - let notifications = NotificationCenter.default - .notifications(named: .gitHubCopilotShouldRefreshEditorInformation) Task { [weak self] in if projectRootURL.path != "/" { try? await server.sendNotification( @@ -297,25 +295,35 @@ public class GitHubCopilotBaseService { ) ) } - - let includeMCP = projectRootURL.path != "/" - // Send workspace/didChangeConfiguration once after initalize - _ = try? await server.sendNotification( - .workspaceDidChangeConfiguration( - .init(settings: editorConfiguration(includeMCP: includeMCP)) - ) - ) - for await _ in notifications { - guard self != nil else { return } + + func sendConfigurationUpdate() async { + let includeMCP = projectRootURL.path != "/" && + FeatureFlagNotifierImpl.shared.featureFlags.agentMode && + FeatureFlagNotifierImpl.shared.featureFlags.mcp _ = try? await server.sendNotification( .workspaceDidChangeConfiguration( .init(settings: editorConfiguration(includeMCP: includeMCP)) ) ) } + + // Send initial configuration after initialize + await sendConfigurationUpdate() + + // Combine both notification streams + let combinedNotifications = Publishers.Merge( + NotificationCenter.default.publisher(for: .gitHubCopilotShouldRefreshEditorInformation).map { _ in "editorInfo" }, + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.map { _ in "featureFlags" } + ) + + for await _ in combinedNotifications.values { + guard self != nil else { return } + await sendConfigurationUpdate() + } } } - + + public static func createFoldersIfNeeded() throws -> ( applicationSupportURL: URL, @@ -421,6 +429,7 @@ public final class GitHubCopilotService: private static var services: [GitHubCopilotService] = [] // cache all alive copilot service instances private var isMCPInitialized = false private var unrestoredMcpServers: [String] = [] + private var mcpRuntimeLogFileName: String = "" override init(designatedServer: any GitHubCopilotLSP) { super.init(designatedServer: designatedServer) @@ -439,12 +448,35 @@ public final class GitHubCopilotService: } } } + + if notification.method == "copilot/mcpRuntimeLogs" && projectRootURL.path != "/" { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + Task { @MainActor in + await self.handleMCPRuntimeLogsNotification(notification) + } + } + } self?.serverNotificationHandler.handleNotification(notification) }).store(in: &cancellables) - localProcessServer?.serverRequestPublisher.sink(receiveValue: { [weak self] (request, callback) in - self?.serverRequestHandler.handleRequest(request, workspaceURL: workspaceURL, callback: callback, service: self) - }).store(in: &cancellables) + + Task { + for await event in server.eventSequence { + switch event { + case let .request(id, request): + switch request { + case let .custom(method, params, callback): + self.serverRequestHandler.handleRequest(.init(id: id, method: method, params: params), workspaceURL: workspaceURL, callback: callback, service: self) + default: + break + } + default: + break + } + } + } + updateStatusInBackground() GitHubCopilotService.services.append(self) @@ -619,7 +651,7 @@ public final class GitHubCopilotService: userLanguage: userLanguage) do { _ = try await sendRequest( - GitHubCopilotRequest.CreateConversation(params: params), timeout: conversationRequestTimeout(agentMode)) + GitHubCopilotRequest.CreateConversation(params: params)) } catch { print("Failed to create conversation. Error: \(error)") throw error @@ -659,17 +691,13 @@ public final class GitHubCopilotService: chatMode: agentMode ? "Agent" : nil, needToolCallConfirmation: true) _ = try await sendRequest( - GitHubCopilotRequest.CreateTurn(params: params), timeout: conversationRequestTimeout(agentMode)) + GitHubCopilotRequest.CreateTurn(params: params)) } catch { print("Failed to create turn. Error: \(error)") throw error } } - private func conversationRequestTimeout(_ agentMode: Bool) -> TimeInterval { - return agentMode ? 86400 /* 24h for agent mode timeout */ : 600 /* ask mode timeout */ - } - @GitHubCopilotSuggestionActor public func templates() async throws -> [ChatTemplate] { do { @@ -797,7 +825,7 @@ public final class GitHubCopilotService: let uri = "file://\(fileURL.path)" // Logger.service.debug("Open \(uri), \(content.count)") try await server.sendNotification( - .didOpenTextDocument( + .textDocumentDidOpen( DidOpenTextDocumentParams( textDocument: .init( uri: uri, @@ -819,7 +847,7 @@ public final class GitHubCopilotService: let uri = "file://\(fileURL.path)" // Logger.service.debug("Change \(uri), \(content.count)") try await server.sendNotification( - .didChangeTextDocument( + .textDocumentDidChange( DidChangeTextDocumentParams( uri: uri, version: version, @@ -837,14 +865,14 @@ public final class GitHubCopilotService: public func notifySaveTextDocument(fileURL: URL) async throws { let uri = "file://\(fileURL.path)" // Logger.service.debug("Save \(uri)") - try await server.sendNotification(.didSaveTextDocument(.init(uri: uri))) + try await server.sendNotification(.textDocumentDidSave(.init(uri: uri))) } @GitHubCopilotSuggestionActor public func notifyCloseTextDocument(fileURL: URL) async throws { let uri = "file://\(fileURL.path)" // Logger.service.debug("Close \(uri)") - try await server.sendNotification(.didCloseTextDocument(.init(uri: uri))) + try await server.sendNotification(.textDocumentDidClose(.init(uri: uri))) } @GitHubCopilotSuggestionActor @@ -995,34 +1023,20 @@ public final class GitHubCopilotService: @GitHubCopilotSuggestionActor public func shutdown() async throws { GitHubCopilotService.services.removeAll { $0 === self } - let stream = AsyncThrowingStream { continuation in - if let localProcessServer { - localProcessServer.shutdown() { err in - continuation.finish(throwing: err) - } - } else { - continuation.finish(throwing: GitHubCopilotError.languageServerError(ServerError.serverUnavailable)) - } - } - for try await _ in stream { - return + if let localProcessServer { + try await localProcessServer.shutdown() + } else { + throw GitHubCopilotError.languageServerError(ServerError.serverUnavailable) } } @GitHubCopilotSuggestionActor public func exit() async throws { GitHubCopilotService.services.removeAll { $0 === self } - let stream = AsyncThrowingStream { continuation in - if let localProcessServer { - localProcessServer.exit() { err in - continuation.finish(throwing: err) - } - } else { - continuation.finish(throwing: GitHubCopilotError.languageServerError(ServerError.serverUnavailable)) - } - } - for try await _ in stream { - return + if let localProcessServer { + try await localProcessServer.exit() + } else { + throw GitHubCopilotError.languageServerError(ServerError.serverUnavailable) } } @@ -1053,12 +1067,9 @@ public final class GitHubCopilotService: private func sendRequest(_ endpoint: E, timeout: TimeInterval? = nil) async throws -> E.Response { do { - if let timeout = timeout { - return try await server.sendRequest(endpoint, timeout: timeout) - } else { - return try await server.sendRequest(endpoint) - } - } catch let error as ServerError { + return try await server.sendRequest(endpoint) + } catch { + let error = ServerError.convertToServerError(error: error) if let info = CLSErrorInfo(for: error) { // update the auth status if the error indicates it may have changed, and then rethrow if info.affectsAuthStatus && !(endpoint is GitHubCopilotRequest.CheckStatus) { @@ -1067,7 +1078,7 @@ public final class GitHubCopilotService: } let methodName: String switch endpoint.request { - case .custom(let method, _): + case .custom(let method, _, _): methodName = method default: methodName = endpoint.request.method.rawValue @@ -1193,32 +1204,51 @@ public final class GitHubCopilotService: CopilotMCPToolManager.updateMCPTools(payload.servers) } } + + public func handleMCPRuntimeLogsNotification(_ notification: AnyJSONRPCNotification) async { + let debugDescription = encodeJSONParams(params: notification.params) + Logger.mcp.info("[\(self.projectRootURL.path)] copilot/mcpRuntimeLogs: \(debugDescription)") + + if let payload = GitHubCopilotNotification.MCPRuntimeNotification.decode( + fromParams: notification.params + ) { + if mcpRuntimeLogFileName.isEmpty { + mcpRuntimeLogFileName = mcpLogFileNameFromURL(projectRootURL) + } + Logger + .logMCPRuntime( + logFileName: mcpRuntimeLogFileName, + level: payload.level.rawValue, + message: payload.message, + server: payload.server, + tool: payload.tool, + time: payload.time + ) + } + } + + private func mcpLogFileNameFromURL(_ projectRootURL: URL) -> String { + // Create a unique key from workspace URL that's safe for filesystem + let workspaceName = projectRootURL.lastPathComponent + .replacingOccurrences(of: ".xcworkspace", with: "") + .replacingOccurrences(of: ".xcodeproj", with: "") + .replacingOccurrences(of: ".playground", with: "") + let workspacePath = projectRootURL.path + + // Use a combination of name and hash of path for uniqueness + let pathHash = String(workspacePath.hash.magnitude, radix: 36).prefix(6) + return "\(workspaceName)-\(pathHash)" + } } -extension InitializingServer: GitHubCopilotLSP { +extension SafeInitializingServer: GitHubCopilotLSP { func sendRequest(_ endpoint: E) async throws -> E.Response { try await sendRequest(endpoint.request) } - - func sendRequest(_ endpoint: E, timeout: TimeInterval) async throws -> E.Response { - return try await withCheckedThrowingContinuation { continuation in - self.sendRequest(endpoint.request, timeout: timeout) { result in - continuation.resume(with: result) - } - } - } } extension GitHubCopilotService { func sendCopilotNotification(_ notif: CopilotClientNotification) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - localProcessServer?.sendCopilotNotification(notif) { error in - if let error = error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } + try await localProcessServer?.sendCopilotNotification(notif) } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GithubCopilotRequest+Message.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GithubCopilotRequest+Message.swift index 3ed0fa85..b43ec840 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GithubCopilotRequest+Message.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GithubCopilotRequest+Message.swift @@ -1,23 +1,4 @@ -import Foundation import JSONRPC import LanguageServerProtocol -public struct MessageActionItem: Codable, Hashable { - public var title: String -} - -public struct ShowMessageRequestParams: Codable, Hashable { - public var type: MessageType - public var message: String - public var actions: [MessageActionItem]? -} - -extension ShowMessageRequestParams: CustomStringConvertible { - public var description: String { - return "\(type): \(message)" - } -} - -public typealias ShowMessageRequestResponse = MessageActionItem? - public typealias ShowMessageRequest = JSONRPCRequest diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/SafeInitializingServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/SafeInitializingServer.swift new file mode 100644 index 00000000..49cbbeb8 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/SafeInitializingServer.swift @@ -0,0 +1,62 @@ +import LanguageClient +import LanguageServerProtocol + +public actor SafeInitializingServer { + private let underlying: InitializingServer + private var initTask: Task? = nil + + public init(_ server: InitializingServer) { + self.underlying = server + } + + // Ensure initialize request is sent by once + public func initializeIfNeeded() async throws -> InitializationResponse { + if let task = initTask { + return try await task.value + } + + let task = Task { + try await underlying.initializeIfNeeded() + } + initTask = task + + do { + let result = try await task.value + return result + } catch { + // Retryable failure + initTask = nil + throw error + } + } + + public func shutdownAndExit() async throws { + try await underlying.shutdownAndExit() + } + + public func sendNotification(_ notif: ClientNotification) async throws { + _ = try await initializeIfNeeded() + try await underlying.sendNotification(notif) + } + + public func sendRequest(_ request: ClientRequest) async throws -> Response { + _ = try await initializeIfNeeded() + return try await underlying.sendRequest(request) + } + + public var capabilities: ServerCapabilities? { + get async { + await underlying.capabilities + } + } + + public var serverInfo: ServerInfo? { + get async { + await underlying.serverInfo + } + } + + public nonisolated var eventSequence: ServerConnection.EventSequence { + underlying.eventSequence + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift index 1381747b..39c2c4a5 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift @@ -35,10 +35,13 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { } } else { switch methodName { - case "featureFlagsNotification": + case "copilot/didChangeFeatureFlags": if let data = try? JSONEncoder().encode(notification.params), - let featureFlags = try? JSONDecoder().decode(FeatureFlags.self, from: data) { - featureFlagNotifier.handleFeatureFlagNotification(featureFlags) + let didChangeFeatureFlagsParams = try? JSONDecoder().decode( + DidChangeFeatureFlagsParams.self, + from: data + ) { + featureFlagNotifier.handleFeatureFlagNotification(didChangeFeatureFlagsParams) } break default: diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift index f76031fe..897245f2 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift @@ -6,8 +6,11 @@ import LanguageClient import LanguageServerProtocol import Logger +public typealias ResponseHandler = ServerRequest.Handler +public typealias LegacyResponseHandler = (AnyJSONRPCResponse) -> Void + protocol ServerRequestHandler { - func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?) + func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping ResponseHandler, service: GitHubCopilotService?) } class ServerRequestHandlerImpl : ServerRequestHandler { @@ -15,9 +18,10 @@ class ServerRequestHandlerImpl : ServerRequestHandler { private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared private let showMessageRequestHandler: ShowMessageRequestHandler = ShowMessageRequestHandlerImpl.shared - - func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?) { + + func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping ResponseHandler, service: GitHubCopilotService?) { let methodName = request.method + let legacyResponseHandler = toLegacyResponseHandler(callback) do { switch methodName { case "conversation/context": @@ -25,12 +29,12 @@ class ServerRequestHandlerImpl : ServerRequestHandler { let contextParams = try JSONDecoder().decode(ConversationContextParams.self, from: params) conversationContextHandler.handleConversationContext( ConversationContextRequest(id: request.id, method: request.method, params: contextParams), - completion: callback) + completion: legacyResponseHandler) case "copilot/watchedFiles": let params = try JSONEncoder().encode(request.params) let watchedFilesParams = try JSONDecoder().decode(WatchedFilesParams.self, from: params) - watchedFilesHandler.handleWatchedFiles(WatchedFilesRequest(id: request.id, method: request.method, params: watchedFilesParams), workspaceURL: workspaceURL, completion: callback, service: service) + watchedFilesHandler.handleWatchedFiles(WatchedFilesRequest(id: request.id, method: request.method, params: watchedFilesParams), workspaceURL: workspaceURL, completion: legacyResponseHandler, service: service) case "window/showMessageRequest": let params = try JSONEncoder().encode(request.params) @@ -42,24 +46,24 @@ class ServerRequestHandlerImpl : ServerRequestHandler { method: request.method, params: showMessageRequestParams ), - completion: callback + completion: legacyResponseHandler ) case "conversation/invokeClientTool": let params = try JSONEncoder().encode(request.params) let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: params) - ClientToolHandlerImpl.shared.invokeClientTool(InvokeClientToolRequest(id: request.id, method: request.method, params: invokeParams), completion: callback) + ClientToolHandlerImpl.shared.invokeClientTool(InvokeClientToolRequest(id: request.id, method: request.method, params: invokeParams), completion: legacyResponseHandler) case "conversation/invokeClientToolConfirmation": let params = try JSONEncoder().encode(request.params) let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: params) - ClientToolHandlerImpl.shared.invokeClientToolConfirmation(InvokeClientToolConfirmationRequest(id: request.id, method: request.method, params: invokeParams), completion: callback) + ClientToolHandlerImpl.shared.invokeClientToolConfirmation(InvokeClientToolConfirmationRequest(id: request.id, method: request.method, params: invokeParams), completion: legacyResponseHandler) default: break } } catch { - handleError(request, error: error, callback: callback) + handleError(request, error: error, callback: legacyResponseHandler) } } @@ -77,4 +81,19 @@ class ServerRequestHandlerImpl : ServerRequestHandler { ) Logger.gitHubCopilot.error(error) } + + /// Converts a new Handler to work with old code that expects LegacyResponseHandler + private func toLegacyResponseHandler( + _ newHandler: @escaping ResponseHandler + ) -> LegacyResponseHandler { + return { response in + Task { + if let error = response.error { + await newHandler(.failure(error)) + } else if let result = response.result { + await newHandler(.success(result)) + } + } + } + } } diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift index 3061a48e..2008b33c 100644 --- a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift +++ b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift @@ -1,5 +1,6 @@ import Combine import SwiftUI +import JSONRPC public extension Notification.Name { static let gitHubCopilotFeatureFlagsDidChange = Notification @@ -15,37 +16,80 @@ public enum ExperimentValue: Hashable, Codable { public typealias ActiveExperimentForFeatureFlags = [String: ExperimentValue] +public struct DidChangeFeatureFlagsParams: Hashable, Codable { + let envelope: [String: JSONValue] + let token: [String: String] + let activeExps: ActiveExperimentForFeatureFlags +} + public struct FeatureFlags: Hashable, Codable { - public var rt: Bool - public var sn: Bool + public var restrictedTelemetry: Bool + public var snippy: Bool public var chat: Bool - public var ic: Bool - public var pc: Bool - public var xc: Bool? - public var ae: ActiveExperimentForFeatureFlags - public var agent_mode: Bool? + public var inlineChat: Bool + public var projectContext: Bool + public var agentMode: Bool + public var mcp: Bool + public var activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags + + public init( + restrictedTelemetry: Bool = true, + snippy: Bool = true, + chat: Bool = true, + inlineChat: Bool = true, + projectContext: Bool = true, + agentMode: Bool = true, + mcp: Bool = true, + activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags = [:] + ) { + self.restrictedTelemetry = restrictedTelemetry + self.snippy = snippy + self.chat = chat + self.inlineChat = inlineChat + self.projectContext = projectContext + self.agentMode = agentMode + self.mcp = mcp + self.activeExperimentForFeatureFlags = activeExperimentForFeatureFlags + } } public protocol FeatureFlagNotifier { - var featureFlags: FeatureFlags { get } + var didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams { get } var featureFlagsDidChange: PassthroughSubject { get } - func handleFeatureFlagNotification(_ featureFlags: FeatureFlags) + func handleFeatureFlagNotification(_ didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams) } public class FeatureFlagNotifierImpl: FeatureFlagNotifier { + public var didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams public var featureFlags: FeatureFlags public static let shared = FeatureFlagNotifierImpl() public var featureFlagsDidChange: PassthroughSubject - init(featureFlags: FeatureFlags = FeatureFlags(rt: false, sn: false, chat: true, ic: true, pc: true, ae: [:]), - featureFlagsDidChange: PassthroughSubject = PassthroughSubject()) { + init( + didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams = .init(envelope: [:], token: [:], activeExps: [:]), + featureFlags: FeatureFlags = FeatureFlags(), + featureFlagsDidChange: PassthroughSubject = PassthroughSubject() + ) { + self.didChangeFeatureFlagsParams = didChangeFeatureFlagsParams self.featureFlags = featureFlags self.featureFlagsDidChange = featureFlagsDidChange } + + private func updateFeatureFlags() { + let xcodeChat = self.didChangeFeatureFlagsParams.envelope["xcode_chat"]?.boolValue != false + let chatEnabled = self.didChangeFeatureFlagsParams.envelope["chat_enabled"]?.boolValue != false + self.featureFlags.restrictedTelemetry = self.didChangeFeatureFlagsParams.token["rt"] != "0" + self.featureFlags.snippy = self.didChangeFeatureFlagsParams.token["sn"] != "0" + self.featureFlags.chat = xcodeChat && chatEnabled + self.featureFlags.inlineChat = chatEnabled + self.featureFlags.agentMode = self.didChangeFeatureFlagsParams.token["agent_mode"] != "0" + self.featureFlags.mcp = self.didChangeFeatureFlagsParams.token["mcp"] != "0" + self.featureFlags.activeExperimentForFeatureFlags = self.didChangeFeatureFlagsParams.activeExps + } - public func handleFeatureFlagNotification(_ featureFlags: FeatureFlags) { - self.featureFlags = featureFlags - self.featureFlags.chat = featureFlags.chat == true && featureFlags.xc == true + public func handleFeatureFlagNotification(_ didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams) { + self.didChangeFeatureFlagsParams = didChangeFeatureFlagsParams + updateFeatureFlags() DispatchQueue.main.async { [weak self] in guard let self else { return } self.featureFlagsDidChange.send(self.featureFlags) diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift index cb3f5006..fc86e530 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -6,6 +6,10 @@ import Workspace import LanguageServerProtocol public final class GitHubCopilotConversationService: ConversationServiceType { + public func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspace: WorkspaceInfo) async throws { + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyChangeTextDocument(fileURL: fileURL, content: content, version: version) + } private let serviceLocator: ServiceLocator diff --git a/Tool/Sources/Logger/FileLogger.swift b/Tool/Sources/Logger/FileLogger.swift index 14d9ff8d..92d51161 100644 --- a/Tool/Sources/Logger/FileLogger.swift +++ b/Tool/Sources/Logger/FileLogger.swift @@ -8,6 +8,8 @@ public final class FileLoggingLocation { .appending("Logs") .appending("GitHubCopilot") }() + + public static let mcpRuntimeLogsPath = path.appending("MCPRuntimeLogs") } final class FileLogger { @@ -33,30 +35,56 @@ final class FileLogger { } actor FileLoggerImplementation { + private let baseLogger: BaseFileLoggerImplementation + + public init() { + baseLogger = BaseFileLoggerImplementation( + logDir: FileLoggingLocation.path + ) + } + + public func logToFile(_ log: String) async { + await baseLogger.logToFile(log) + } +} + +// MARK: - Shared Base File Logger +actor BaseFileLoggerImplementation { #if DEBUG private let logBaseName = "github-copilot-for-xcode-dev" #else private let logBaseName = "github-copilot-for-xcode" #endif private let logExtension = "log" - private let maxLogSize = 5_000_000 - private let logOverflowLimit = 5_000_000 * 2 - private let maxLogs = 10 - private let maxLockTime = 3_600 // 1 hour - + private let maxLogSize: Int + private let logOverflowLimit: Int + private let maxLogs: Int + private let maxLockTime: Int + private let logDir: FilePath private let logName: String private let lockFilePath: FilePath private var logStream: OutputStream? private var logHandle: FileHandle? - - public init() { - logDir = FileLoggingLocation.path - logName = "\(logBaseName).\(logExtension)" - lockFilePath = logDir.appending(logName + ".lock") + + init( + logDir: FilePath, + logFileName: String? = nil, + maxLogSize: Int = 5_000_000, + logOverflowLimit: Int? = nil, + maxLogs: Int = 10, + maxLockTime: Int = 3_600 + ) { + self.logDir = logDir + self.logName = (logFileName ?? logBaseName) + "." + logExtension + self.lockFilePath = logDir.appending(logName + ".lock") + self.maxLogSize = maxLogSize + self.logOverflowLimit = logOverflowLimit ?? maxLogSize * 2 + self.maxLogs = maxLogs + self.maxLockTime = maxLockTime } - public func logToFile(_ log: String) { + func logToFile(_ log: String) async { if let stream = logAppender() { let data = [UInt8](log.utf8) stream.write(data, maxLength: data.count) diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index ae52c32b..a23f33b2 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -12,6 +12,7 @@ public final class Logger { private let category: String private let osLog: OSLog private let fileLogger = FileLogger() + private static let mcpRuntimeFileLogger = MCPRuntimeFileLogger() public static let service = Logger(category: "Service") public static let ui = Logger(category: "UI") @@ -24,6 +25,7 @@ public final class Logger { public static let `extension` = Logger(category: "Extension") public static let communicationBridge = Logger(category: "CommunicationBridge") public static let workspacePool = Logger(category: "WorkspacePool") + public static let mcp = Logger(category: "MCP") public static let debug = Logger(category: "Debug") public static var telemetryLogger: TelemetryLoggerProvider? = nil #if DEBUG @@ -57,7 +59,9 @@ public final class Logger { } os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg) - fileLogger.log(level: level, category: category, message: message) + if category != "MCP" { + fileLogger.log(level: level, category: category, message: message) + } if osLogType == .error { if let error = error { @@ -140,6 +144,25 @@ public final class Logger { ) } + public static func logMCPRuntime( + logFileName: String, + level: String, + message: String, + server: String, + tool: String? = nil, + time: Double + ) { + mcpRuntimeFileLogger + .log( + logFileName: logFileName, + level: level, + message: message, + server: server, + tool: tool, + time: time + ) + } + public func signpostBegin( name: StaticString, file: StaticString = #file, diff --git a/Tool/Sources/Logger/MCPRuntimeLogger.swift b/Tool/Sources/Logger/MCPRuntimeLogger.swift new file mode 100644 index 00000000..36527e43 --- /dev/null +++ b/Tool/Sources/Logger/MCPRuntimeLogger.swift @@ -0,0 +1,53 @@ +import Foundation +import System + +public final class MCPRuntimeFileLogger { + private let timestampFormat = Date.ISO8601FormatStyle.iso8601 + .year() + .month() + .day() + .timeZone(separator: .omitted).time(includingFractionalSeconds: true) + private static let implementation = MCPRuntimeFileLoggerImplementation() + + /// Converts a timestamp in milliseconds since the Unix epoch to a formatted date string. + private func timestamp(timeStamp: Double) -> String { + return Date(timeIntervalSince1970: timeStamp/1000).formatted(timestampFormat) + } + + public func log( + logFileName: String, + level: String, + message: String, + server: String, + tool: String? = nil, + time: Double + ) { + let log = "[\(timestamp(timeStamp: time))] [\(level)] [\(server)\(tool == nil ? "" : "-\(tool!))")] \(message)\(message.hasSuffix("\n") ? "" : "\n")" + + Task { + await MCPRuntimeFileLogger.implementation.logToFile(logFileName: logFileName, log: log) + } + } +} + +actor MCPRuntimeFileLoggerImplementation { + private let logDir: FilePath + private var workspaceLoggers: [String: BaseFileLoggerImplementation] = [:] + + public init() { + logDir = FileLoggingLocation.mcpRuntimeLogsPath + } + + public func logToFile(logFileName: String, log: String) async { + if workspaceLoggers[logFileName] == nil { + workspaceLoggers[logFileName] = BaseFileLoggerImplementation( + logDir: logDir, + logFileName: logFileName + ) + } + + if let logger = workspaceLoggers[logFileName] { + await logger.logToFile(log) + } + } +} diff --git a/Tool/Sources/WebContentExtractor/HTMLToMarkdownConverter.swift b/Tool/Sources/WebContentExtractor/HTMLToMarkdownConverter.swift new file mode 100644 index 00000000..56236a0d --- /dev/null +++ b/Tool/Sources/WebContentExtractor/HTMLToMarkdownConverter.swift @@ -0,0 +1,217 @@ +import SwiftSoup +import WebKit + +class HTMLToMarkdownConverter { + + // MARK: - Configuration + private struct Config { + static let unwantedSelectors = "script, style, nav, header, footer, aside, noscript, iframe, .navigation, .sidebar, .ad, .advertisement, .cookie-banner, .popup, .social, .share, .social-share, .related, .comments, .menu, .breadcrumb" + static let mainContentSelectors = [ + "main", + "article", + "div.content", + "div#content", + "div.post-content", + "div.article-body", + "div.main-content", + "section.content", + ".content", + ".main", + ".main-content", + ".article", + ".article-content", + ".post-content", + "#content", + "#main", + ".container .row .col", + "[role='main']" + ] + } + + // MARK: - Main Conversion Method + func convertToMarkdown(from html: String) throws -> String { + let doc = try SwiftSoup.parse(html) + let rawMarkdown = try extractCleanContent(from: doc) + return cleanupExcessiveNewlines(rawMarkdown) + } + + // MARK: - Content Extraction + private func extractCleanContent(from doc: Document) throws -> String { + try removeUnwantedElements(from: doc) + + // Try to find main content areas + for selector in Config.mainContentSelectors { + if let mainElement = try findMainContent(in: doc, using: selector) { + return try convertElementToMarkdown(mainElement) + } + } + + // Fallback: clean body content + return try fallbackContentExtraction(from: doc) + } + + private func removeUnwantedElements(from doc: Document) throws { + try doc.select(Config.unwantedSelectors).remove() + } + + private func findMainContent(in doc: Document, using selector: String) throws -> Element? { + let elements = try doc.select(selector) + guard let mainElement = elements.first() else { return nil } + + // Clean nested unwanted elements + try mainElement.select("nav, aside, .related, .comments, .social-share, .advertisement").remove() + return mainElement + } + + private func fallbackContentExtraction(from doc: Document) throws -> String { + guard let body = doc.body() else { return "" } + try body.select(Config.unwantedSelectors).remove() + return try convertElementToMarkdown(body) + } + + // MARK: - Cleanup Method + private func cleanupExcessiveNewlines(_ markdown: String) -> String { + // Replace 3+ consecutive newlines with just 2 newlines + let cleaned = markdown.replacingOccurrences( + of: #"\n{3,}"#, + with: "\n\n", + options: .regularExpression + ) + return cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Element Processing + private func convertElementToMarkdown(_ element: Element) throws -> String { + let markdown = try convertElement(element) + return markdown + } + + func convertElement(_ element: Element) throws -> String { + var result = "" + + for node in element.getChildNodes() { + if let textNode = node as? TextNode { + result += textNode.text() + } else if let childElement = node as? Element { + result += try convertSpecificElement(childElement) + } + } + + return result + } + + private func convertSpecificElement(_ element: Element) throws -> String { + let tagName = element.tagName().lowercased() + let text = try element.text() + + switch tagName { + case "h1": + return "\n# \(text)\n" + case "h2": + return "\n## \(text)\n" + case "h3": + return "\n### \(text)\n" + case "h4": + return "\n#### \(text)\n" + case "h5": + return "\n##### \(text)\n" + case "h6": + return "\n###### \(text)\n" + case "p": + return "\n\(try convertElement(element))\n" + case "br": + return "\n" + case "strong", "b": + return "**\(text)**" + case "em", "i": + return "*\(text)*" + case "code": + return "`\(text)`" + case "pre": + return "\n```\n\(text)\n```\n" + case "a": + let href = try element.attr("href") + let title = try element.attr("title") + if href.isEmpty { + return text + } + + // Skip non-http/https/file schemes + if let url = URL(string: href), + let scheme = url.scheme?.lowercased(), + !["http", "https", "file"].contains(scheme) { + return text + } + + let titlePart = title.isEmpty ? "" : " \"\(title.replacingOccurrences(of: "\"", with: "\\\""))\"" + return "[\(text)](\(href)\(titlePart))" + case "img": + let src = try element.attr("src") + let alt = try element.attr("alt") + let title = try element.attr("title") + + var finalSrc = src + // Remove data URIs + if src.hasPrefix("data:") { + finalSrc = src.components(separatedBy: ",").first ?? "" + "..." + } + + let titlePart = title.isEmpty ? "" : " \"\(title.replacingOccurrences(of: "\"", with: "\\\""))\"" + return "![\(alt)](\(finalSrc)\(titlePart))" + case "ul": + return try convertList(element, ordered: false) + case "ol": + return try convertList(element, ordered: true) + case "li": + return try convertElement(element) + case "table": + return try convertTable(element) + case "blockquote": + let content = try convertElement(element) + return content.components(separatedBy: .newlines) + .map { "> \($0)" } + .joined(separator: "\n") + default: + return try convertElement(element) + } + } + + private func convertList(_ element: Element, ordered: Bool) throws -> String { + var result = "\n" + let items = try element.select("li") + + for (index, item) in items.enumerated() { + let content = try convertElement(item).trimmingCharacters(in: .whitespacesAndNewlines) + if ordered { + result += "\(index + 1). \(content)\n" + } else { + result += "- \(content)\n" + } + } + + return result + } + + private func convertTable(_ element: Element) throws -> String { + var result = "\n" + let rows = try element.select("tr") + + guard !rows.isEmpty() else { return "" } + + var isFirstRow = true + for row in rows { + let cells = try row.select("td, th") + let cellContents = try cells.map { try $0.text() } + + result += "| " + cellContents.joined(separator: " | ") + " |\n" + + if isFirstRow { + let separator = Array(repeating: "---", count: cellContents.count).joined(separator: " | ") + result += "| \(separator) |\n" + isFirstRow = false + } + } + + return result + } +} diff --git a/Tool/Sources/WebContentExtractor/WebContentExtractor.swift b/Tool/Sources/WebContentExtractor/WebContentExtractor.swift new file mode 100644 index 00000000..aee0d889 --- /dev/null +++ b/Tool/Sources/WebContentExtractor/WebContentExtractor.swift @@ -0,0 +1,227 @@ +import WebKit +import Logger +import Preferences + +public class WebContentFetcher: NSObject, WKNavigationDelegate { + private var webView: WKWebView? + private var loadingTimer: Timer? + private static let converter = HTMLToMarkdownConverter() + private var completion: ((Result) -> Void)? + + private struct Config { + static let timeout: TimeInterval = 30.0 + static let contentLoadDelay: TimeInterval = 2.0 + } + + public enum WebContentError: Error, LocalizedError { + case invalidURL(String) + case timeout + case noContent + case navigationFailed(Error) + case javascriptError(Error) + + public var errorDescription: String? { + switch self { + case .invalidURL(let url): "Invalid URL: \(url)" + case .timeout: "Request timed out" + case .noContent: "No content found" + case .navigationFailed(let error): "Navigation failed: \(error.localizedDescription)" + case .javascriptError(let error): "JavaScript execution error: \(error.localizedDescription)" + } + } + } + + // MARK: - Initialization + public override init() { + super.init() + setupWebView() + } + + deinit { + cleanup() + } + + // MARK: - Public Methods + public func fetchContent(from urlString: String, completion: @escaping (Result) -> Void) { + guard let url = URL(string: urlString) else { + completion(.failure(WebContentError.invalidURL(urlString))) + return + } + + DispatchQueue.main.async { [weak self] in + self?.completion = completion + self?.setupTimeout() + self?.loadContent(from: url) + } + } + + public static func fetchContentAsync(from urlString: String) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let fetcher = WebContentFetcher() + fetcher.fetchContent(from: urlString) { result in + withExtendedLifetime(fetcher) { + continuation.resume(with: result) + } + } + } + } + + public static func fetchMultipleContentAsync(from urls: [String]) async -> [String] { + var results: [String] = [] + + for url in urls { + do { + let content = try await fetchContentAsync(from: url) + results.append("Successfully fetched content from \(url): \(content)") + } catch { + Logger.client.error("Failed to fetch content from \(url): \(error.localizedDescription)") + results.append("Failed to fetch content from \(url) with error: \(error.localizedDescription)") + } + } + + return results + } + + // MARK: - Private Methods + private func setupWebView() { + let configuration = WKWebViewConfiguration() + let dataSource = WKWebsiteDataStore.nonPersistent() + + if #available(macOS 14.0, *) { + configureProxy(for: dataSource) + } + + configuration.websiteDataStore = dataSource + webView = WKWebView(frame: .zero, configuration: configuration) + webView?.navigationDelegate = self + } + + @available(macOS 14.0, *) + private func configureProxy(for dataSource: WKWebsiteDataStore) { + let proxyURL = UserDefaults.shared.value(for: \.gitHubCopilotProxyUrl) + guard let url = URL(string: proxyURL), + let host = url.host, + let port = url.port, + let proxyPort = NWEndpoint.Port(port.description) else { return } + + let tlsOptions = NWProtocolTLS.Options() + let useStrictSSL = UserDefaults.shared.value(for: \.gitHubCopilotUseStrictSSL) + + if !useStrictSSL { + let secOptions = tlsOptions.securityProtocolOptions + sec_protocol_options_set_verify_block(secOptions, { _, _, completion in + completion(true) + }, .main) + } + + let httpProxy = ProxyConfiguration( + httpCONNECTProxy: NWEndpoint.hostPort( + host: NWEndpoint.Host(host), + port: proxyPort + ), + tlsOptions: tlsOptions + ) + + httpProxy.applyCredential( + username: UserDefaults.shared.value(for: \.gitHubCopilotProxyUsername), + password: UserDefaults.shared.value(for: \.gitHubCopilotProxyPassword) + ) + + dataSource.proxyConfigurations = [httpProxy] + } + + private func cleanup() { + loadingTimer?.invalidate() + loadingTimer = nil + webView?.navigationDelegate = nil + webView?.stopLoading() + webView = nil + } + + private func setupTimeout() { + loadingTimer?.invalidate() + loadingTimer = Timer.scheduledTimer(withTimeInterval: Config.timeout, repeats: false) { [weak self] _ in + DispatchQueue.main.async { + Logger.client.error("Request timed out") + self?.completeWithError(WebContentError.timeout) + } + } + } + + private func loadContent(from url: URL) { + if webView == nil { + setupWebView() + } + + guard let webView = webView else { + completeWithError(WebContentError.navigationFailed(NSError(domain: "WebView creation failed", code: -1))) + return + } + + let request = URLRequest( + url: url, + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, + timeoutInterval: Config.timeout + ) + webView.load(request) + } + + private func processHTML(_ html: String) { + do { + let cleanedText = try Self.converter.convertToMarkdown(from: html) + completeWithSuccess(cleanedText) + } catch { + Logger.client.error("SwiftSoup parsing error: \(error.localizedDescription)") + completeWithError(error) + } + } + + private func completeWithSuccess(_ content: String) { + completion?(.success(content)) + completion = nil + } + + private func completeWithError(_ error: Error) { + completion?(.failure(error)) + completion = nil + } + + // MARK: - WKNavigationDelegate + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + loadingTimer?.invalidate() + + DispatchQueue.main.asyncAfter(deadline: .now() + Config.contentLoadDelay) { + webView.evaluateJavaScript("document.body.innerHTML") { [weak self] result, error in + DispatchQueue.main.async { + if let error = error { + Logger.client.error("JavaScript execution error: \(error.localizedDescription)") + self?.completeWithError(WebContentError.javascriptError(error)) + return + } + + if let html = result as? String, !html.isEmpty { + self?.processHTML(html) + } else { + self?.completeWithError(WebContentError.noContent) + } + } + } + } + } + + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + handleNavigationFailure(error) + } + + public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + handleNavigationFailure(error) + } + + private func handleNavigationFailure(_ error: Error) { + loadingTimer?.invalidate() + DispatchQueue.main.async { + Logger.client.error("Navigation failed: \(error.localizedDescription)") + self.completeWithError(WebContentError.navigationFailed(error)) + } + } +} diff --git a/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift b/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift index face1f60..d6cdcbff 100644 --- a/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift +++ b/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift @@ -44,6 +44,11 @@ final class FetchSuggestionTests: XCTestCase { func sendRequest(_: E, timeout: TimeInterval) async throws -> E.Response where E: GitHubCopilotRequestType { return GitHubCopilotRequest.InlineCompletion.Response(items: []) as! E.Response } + var eventSequence: ServerConnection.EventSequence { + let result = ServerConnection.EventSequence.makeStream() + result.continuation.finish() + return result.stream + } } let service = GitHubCopilotSuggestionService(serviceLocator: TestServiceLocator(server: TestServer())) let completions = try await service.getSuggestions( @@ -87,6 +92,11 @@ final class FetchSuggestionTests: XCTestCase { func sendRequest(_ endpoint: E, timeout: TimeInterval) async throws -> E.Response where E : GitHubCopilotRequestType { return GitHubCopilotRequest.InlineCompletion.Response(items: []) as! E.Response } + var eventSequence: ServerConnection.EventSequence { + let result = ServerConnection.EventSequence.makeStream() + result.continuation.finish() + return result.stream + } } let testServer = TestServer() let service = GitHubCopilotSuggestionService(serviceLocator: TestServiceLocator(server: testServer)) From c6e9a07843d4a0a7839fa477f10785a073db950e Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 12 Aug 2025 11:21:53 +0000 Subject: [PATCH 17/26] Pre-release 0.40.133 --- .../xcschemes/ExtensionService.xcscheme | 12 + Core/Package.swift | 4 +- Core/Sources/ChatService/ChatInjector.swift | 144 ++++-- Core/Sources/ChatService/ChatService.swift | 272 ++++++++--- .../CodeReview/CodeReviewProvider.swift | 59 +++ .../CodeReview/CodeReviewService.swift | 48 ++ .../ToolCalls/CreateFileTool.swift | 3 +- .../ToolCalls/InsertEditIntoFileTool.swift | 55 +-- .../Sources/ChatService/ToolCalls/Utils.swift | 28 -- Core/Sources/ConversationTab/Chat.swift | 23 +- Core/Sources/ConversationTab/ChatPanel.swift | 135 +++++- .../ConversationCodeReviewFeature.swift | 90 ++++ Core/Sources/ConversationTab/Styles.swift | 27 +- .../ConversationTab/Views/BotMessage.swift | 11 +- .../CodeReviewRound/CodeReviewMainView.swift | 118 +++++ .../FileSelectionSection.swift | 213 +++++++++ .../ReviewResultsSection.swift | 182 +++++++ .../ReviewSummarySection.swift | 44 ++ .../Views/ConversationAgentProgressView.swift | 1 - .../Views/ThemedMarkdownText.swift | 44 +- .../ConversationTab/Views/UserMessage.swift | 12 +- .../GitHubCopilotViewModel.swift | 11 +- .../CodeReviewPanelView.swift | 448 ++++++++++++++++++ .../FeatureReducers/CodeReviewFeature.swift | 356 ++++++++++++++ .../FeatureReducers/WidgetFeature.swift | 12 + Core/Sources/SuggestionWidget/Styles.swift | 2 + .../WidgetPositionStrategy.swift | 84 ++++ .../WidgetWindowsController.swift | 220 ++++++++- .../Assets.xcassets/Icons/Contents.json | 6 + .../Icons/chevron.down.imageset/Contents.json | 16 + .../chevron.down.imageset/chevron-down.svg | 3 + .../Sparkle.imageset/Contents.json | 22 + .../Sparkle.imageset/sparkle.svg | 1 + .../Sparkle.imageset/sparkle_dark.svg | 3 + .../codeReview.imageset/Contents.json | 25 + .../codeReview.imageset/codeReview 1.svg | 4 + .../codeReview.imageset/codeReview.svg | 4 + .../Contents.json | 38 ++ .../Contents.json | 38 ++ .../Contents.json | 38 ++ .../Contents.json | 38 ++ Server/package-lock.json | 8 +- Server/package.json | 2 +- Tool/Package.swift | 16 +- Tool/Sources/AXExtension/AXUIElement.swift | 63 +++ Tool/Sources/AXHelper/AXHelper.swift | 38 +- .../NSWorkspace+Extension.swift | 29 ++ ...ExtensionConversationServiceProvider.swift | 13 + .../ChatAPIService/Memory/ChatMemory.swift | 64 ++- Tool/Sources/ChatAPIService/Models.swift | 64 +++ .../CodeReview/CodeReviewRound.swift | 154 ++++++ .../ConversationServiceProvider.swift | 2 + .../LSPTypes.swift | 63 +++ Tool/Sources/GitHelper/CurrentChange.swift | 74 +++ Tool/Sources/GitHelper/GitDiff.swift | 114 +++++ Tool/Sources/GitHelper/GitHunk.swift | 105 ++++ Tool/Sources/GitHelper/GitShow.swift | 24 + Tool/Sources/GitHelper/GitStatus.swift | 47 ++ Tool/Sources/GitHelper/types.swift | 23 + .../Conversation/MCPOAuthRequestHandler.swift | 67 +++ .../GitHubCopilotRequest+MCP.swift | 11 + .../LanguageServer/GitHubCopilotRequest.swift | 14 + .../LanguageServer/GitHubCopilotService.swift | 98 ++-- .../LanguageServer/ServerRequestHandler.swift | 13 + .../Services/FeatureFlagNotifier.swift | 4 + .../GitHubCopilotConversationService.swift | 6 + .../SharedUIComponents/Base/Colors.swift | 5 + .../Base/HoverButtunStyle.swift | 2 +- .../CopilotMessageHeader.swift | 32 +- Tool/Sources/Status/Status.swift | 4 + Tool/Sources/Status/StatusObserver.swift | 24 + .../Status/Types/GitHubCopilotQuotaInfo.swift | 2 + Tool/Sources/SystemUtils/SystemUtils.swift | 40 +- Tool/Tests/GitHelperTests/GitHunkTests.swift | 272 +++++++++++ .../SystemUtilsTests/SystemUtilsTests.swift | 29 ++ 75 files changed, 4099 insertions(+), 321 deletions(-) create mode 100644 Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift create mode 100644 Core/Sources/ChatService/CodeReview/CodeReviewService.swift create mode 100644 Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift create mode 100644 Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift create mode 100644 Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift create mode 100644 Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift create mode 100644 Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift create mode 100644 Core/Sources/SuggestionWidget/CodeReviewPanelView.swift create mode 100644 Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift create mode 100644 ExtensionService/Assets.xcassets/Icons/Contents.json create mode 100644 ExtensionService/Assets.xcassets/Icons/chevron.down.imageset/Contents.json create mode 100644 ExtensionService/Assets.xcassets/Icons/chevron.down.imageset/chevron-down.svg create mode 100644 ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json create mode 100644 ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle.svg create mode 100644 ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle_dark.svg create mode 100644 ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json create mode 100644 ExtensionService/Assets.xcassets/codeReview.imageset/codeReview 1.svg create mode 100644 ExtensionService/Assets.xcassets/codeReview.imageset/codeReview.svg create mode 100644 ExtensionService/Assets.xcassets/editor.focusedStackFrameHighlightBackground.colorset/Contents.json create mode 100644 ExtensionService/Assets.xcassets/editorOverviewRuler.inlineChatRemoved.colorset/Contents.json create mode 100644 ExtensionService/Assets.xcassets/gitDecoration.addedResourceForeground.colorset/Contents.json create mode 100644 ExtensionService/Assets.xcassets/gitDecoration.deletedResourceForeground.colorset/Contents.json create mode 100644 Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift create mode 100644 Tool/Sources/GitHelper/CurrentChange.swift create mode 100644 Tool/Sources/GitHelper/GitDiff.swift create mode 100644 Tool/Sources/GitHelper/GitHunk.swift create mode 100644 Tool/Sources/GitHelper/GitShow.swift create mode 100644 Tool/Sources/GitHelper/GitStatus.swift create mode 100644 Tool/Sources/GitHelper/types.swift create mode 100644 Tool/Sources/GitHubCopilotService/Conversation/MCPOAuthRequestHandler.swift create mode 100644 Tool/Sources/SharedUIComponents/Base/Colors.swift create mode 100644 Tool/Tests/GitHelperTests/GitHunkTests.swift diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme index c0e9b79f..f672cd16 100644 --- a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme @@ -50,6 +50,18 @@ reference = "container:Pro/ProTestPlan.xctestplan"> + + + + + + 0 ? targetLine.prefix { $0.isWhitespace } : "" - let indentation = String(leadingWhitespace) + try Self.performInsertion( + content: codeBlock, + range: insertionRange, + lines: &lines, + modifications: &modifications, + focusElement: focusElement + ) - // 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)) + guard range.start.line >= 0, + range.start.line < lines.count, + range.end.line >= 0, + range.end.line < lines.count + else { return } - lines.replaceSubrange((cursorPosition.line)...(cursorPosition.line), with: toBeInsertedLines) + var lines = lines + var modifications: [Modification] = [] - // Join the lines - let newContent = String(lines.joined(separator: "\n")) + if range.isValid { + modifications.append(.deletedSelection(range)) + lines = lines.applying([.deletedSelection(range)]) + } - // 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() - } - + try performInsertion( + content: suggestion, + range: range, + lines: &lines, + modifications: &modifications, + focusElement: focusElement ) } catch { - print("Failed to insert code block: \(error)") + print("Failed to insert suggestion: \(error)") + } + } + + private static func performInsertion( + content: String, + range: CursorRange, + lines: inout [String], + modifications: inout [Modification], + focusElement: AXUIElement + ) throws { + let targetLine = lines[range.start.line] + let leadingWhitespace = range.start.character > 0 ? targetLine.prefix { $0.isWhitespace } : "" + let indentation = String(leadingWhitespace) + + let index = targetLine.index(targetLine.startIndex, offsetBy: min(range.start.character, targetLine.count)) + let before = targetLine[.. String in + return index == 0 ? String(element) : indentation + String(element) + } + + var toBeInsertedLines = [String]() + if contentLines.count > 1 { + toBeInsertedLines.append(String(before) + contentLines.first!) + toBeInsertedLines.append(contentsOf: contentLines.dropFirst().dropLast()) + toBeInsertedLines.append(contentLines.last! + String(after)) + } else { + toBeInsertedLines.append(String(before) + contentLines.first! + String(after)) } + + lines.replaceSubrange((range.start.line)...(range.start.line), with: toBeInsertedLines) + + let newContent = String(lines.joined(separator: "\n")) + let newCursorPosition = CursorPosition( + line: range.start.line + contentLines.count - 1, + character: contentLines.last?.count ?? 0 + ) + + modifications.append(.inserted(range.start.line, toBeInsertedLines)) + + try AXHelper().injectUpdatedCodeWithAccessibilityAPI( + .init( + content: newContent, + newSelection: .cursor(newCursorPosition), + modifications: modifications + ), + focusElement: focusElement, + onSuccess: { + NSWorkspace.activatePreviousActiveXcode() + } + ) } } diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index a693aaa6..c48aa4c5 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -15,6 +15,9 @@ import Workspace import XcodeInspector import OrderedCollections import SystemUtils +import GitHelper +import LanguageServerProtocol +import SuggestionBasic public protocol ChatServiceType { var memory: ContextAwareAutoManagedChatMemory { get set } @@ -66,10 +69,15 @@ public struct FileEdit: Equatable { public final class ChatService: ChatServiceType, ObservableObject { + public enum RequestType: String, Equatable { + case conversation, codeReview + } + public var memory: ContextAwareAutoManagedChatMemory @Published public internal(set) var chatHistory: [ChatMessage] = [] @Published public internal(set) var isReceivingMessage = false @Published public internal(set) var fileEditMap: OrderedDictionary = [:] + public internal(set) var requestType: RequestType? = nil public let chatTabInfo: ChatTabInfo private let conversationProvider: ConversationServiceProvider? private let conversationProgressHandler: ConversationProgressHandler @@ -190,13 +198,8 @@ public final class ChatService: ChatServiceType, ObservableObject { let chatTabId = self.chatTabInfo.id Task { let message = ChatMessage( - id: turnId, + assistantMessageWithId: turnId, chatTabID: chatTabId, - clsTurnID: turnId, - role: .assistant, - content: "", - references: [], - steps: [], editAgentRounds: editAgentRounds ) @@ -360,9 +363,8 @@ public final class ChatService: ChatServiceType, ObservableObject { } var chatMessage = ChatMessage( - id: id, - chatTabID: self.chatTabInfo.id, - role: .user, + userMessageWithId: id, + chatTabId: chatTabInfo.id, content: content, contentImageReferences: finalImageReferences, references: references.toConversationReferences() @@ -381,8 +383,9 @@ public final class ChatService: ChatServiceType, ObservableObject { // For associating error message with user message currentTurnId = UUID().uuidString chatMessage.clsTurnID = currentTurnId - errorMessage = buildErrorMessage( - turnId: currentTurnId!, + errorMessage = ChatMessage( + errorMessageWithId: currentTurnId!, + chatTabID: chatTabInfo.id, errorMessages: [ currentFileReadability.errorMessage( using: CurrentEditorSkill.readabilityErrorMessageProvider @@ -407,12 +410,9 @@ public final class ChatService: ChatServiceType, ObservableObject { // there is no turn id from CLS, just set it as id let clsTurnID = UUID().uuidString let progressMessage = ChatMessage( - id: clsTurnID, - chatTabID: self.chatTabInfo.id, - clsTurnID: clsTurnID, - role: .assistant, - content: whatsNewContent, - references: [] + assistantMessageWithId: clsTurnID, + chatTabID: chatTabInfo.id, + content: whatsNewContent ) await memory.appendMessage(progressMessage) } @@ -625,7 +625,15 @@ public final class ChatService: ChatServiceType, ObservableObject { } return URL(fileURLWithPath: chatTabInfo.workspacePath) } - + + private func getProjectRootURL() async throws -> URL? { + guard let workspaceURL = getWorkspaceURL() else { return nil } + return WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: workspaceURL, + documentURL: nil + ) + } + public func upvote(_ id: String, _ rating: ConversationRating) async { try? await conversationProvider?.rateConversation(turnId: id, rating: rating, workspaceURL: getWorkspaceURL()) } @@ -675,13 +683,7 @@ public final class ChatService: ChatServiceType, ObservableObject { /// Display an initial assistant message immediately after the user sends a message. /// This improves perceived responsiveness, especially in Agent Mode where the first /// ProgressReport may take long time. - let message = ChatMessage( - id: turnId, - chatTabID: self.chatTabInfo.id, - clsTurnID: turnId, - role: .assistant, - content: "" - ) + let message = ChatMessage(assistantMessageWithId: turnId, chatTabID: chatTabInfo.id) // will persist in resetOngoingRequest() await memory.appendMessage(message) @@ -727,10 +729,8 @@ public final class ChatService: ChatServiceType, ObservableObject { Task { let message = ChatMessage( - id: id, - chatTabID: self.chatTabInfo.id, - clsTurnID: id, - role: .assistant, + assistantMessageWithId: id, + chatTabID: chatTabInfo.id, content: messageContent, references: messageReferences, steps: messageSteps, @@ -752,9 +752,11 @@ public final class ChatService: ChatServiceType, ObservableObject { Task { await Status.shared .updateCLSStatus(.warning, busy: false, message: CLSError.message) - let errorMessage = buildErrorMessage( - turnId: progress.turnId, - panelMessages: [.init(type: .error, title: String(CLSError.code ?? 0), message: CLSError.message, location: .Panel)]) + let errorMessage = ChatMessage( + errorMessageWithId: progress.turnId, + chatTabID: chatTabInfo.id, + panelMessages: [.init(type: .error, title: String(CLSError.code ?? 0), message: CLSError.message, location: .Panel)] + ) // will persist in resetongoingRequest() await memory.appendMessage(errorMessage) @@ -779,8 +781,9 @@ public final class ChatService: ChatServiceType, ObservableObject { } } else if CLSError.code == 400 && CLSError.message.contains("model is not supported") { Task { - let errorMessage = buildErrorMessage( - turnId: progress.turnId, + let errorMessage = ChatMessage( + errorMessageWithId: progress.turnId, + chatTabID: chatTabInfo.id, errorMessages: ["Oops, the model is not supported. Please enable it first in [GitHub Copilot settings](https://github.com/settings/copilot)."] ) await memory.appendMessage(errorMessage) @@ -789,7 +792,11 @@ public final class ChatService: ChatServiceType, ObservableObject { } } else { Task { - let errorMessage = buildErrorMessage(turnId: progress.turnId, errorMessages: [CLSError.message]) + let errorMessage = ChatMessage( + errorMessageWithId: progress.turnId, + chatTabID: chatTabInfo.id, + errorMessages: [CLSError.message] + ) // will persist in resetOngoingRequest() await memory.appendMessage(errorMessage) resetOngoingRequest() @@ -800,11 +807,8 @@ public final class ChatService: ChatServiceType, ObservableObject { Task { let message = ChatMessage( - id: progress.turnId, - chatTabID: self.chatTabInfo.id, - clsTurnID: progress.turnId, - role: .assistant, - content: "", + assistantMessageWithId: progress.turnId, + chatTabID: chatTabInfo.id, followUp: followUp, suggestedTitle: progress.suggestedTitle ) @@ -814,25 +818,10 @@ public final class ChatService: ChatServiceType, ObservableObject { } } - private func buildErrorMessage( - turnId: String, - errorMessages: [String] = [], - panelMessages: [CopilotShowMessageParams] = [] - ) -> ChatMessage { - return .init( - id: turnId, - chatTabID: chatTabInfo.id, - clsTurnID: turnId, - role: .assistant, - content: "", - errorMessages: errorMessages, - panelMessages: panelMessages - ) - } - private func resetOngoingRequest() { activeRequestId = nil isReceivingMessage = false + requestType = nil // cancel all pending tool call requests for (_, request) in pendingToolCallRequests { @@ -876,6 +865,15 @@ public final class ChatService: ChatServiceType, ObservableObject { } } } + + if history[lastIndex].codeReviewRound != nil, + ( + history[lastIndex].codeReviewRound!.status == .waitForConfirmation + || history[lastIndex].codeReviewRound!.status == .running + ) + { + history[lastIndex].codeReviewRound!.status = .cancelled + } }) // The message of progress report could change rapidly @@ -890,6 +888,7 @@ public final class ChatService: ChatServiceType, ObservableObject { private func sendConversationRequest(_ request: ConversationRequest) async throws { guard !isReceivingMessage else { throw CancellationError() } isReceivingMessage = true + requestType = .conversation do { if let conversationId = conversationId { @@ -1104,3 +1103,164 @@ extension [ChatMessage] { return content } } + +// MARK: Copilot Code Review + +extension ChatService { + + public func requestCodeReview(_ group: GitDiffGroup) async throws { + guard activeRequestId == nil else { return } + activeRequestId = UUID().uuidString + + guard !isReceivingMessage else { + activeRequestId = nil + throw CancellationError() + } + isReceivingMessage = true + requestType = .codeReview + + do { + await CodeReviewService.shared.resetComments() + + let turnId = UUID().uuidString + + await addCodeReviewUserMessage(id: UUID().uuidString, turnId: turnId, group: group) + + let initialBotMessage = ChatMessage( + assistantMessageWithId: turnId, + chatTabID: chatTabInfo.id + ) + await memory.appendMessage(initialBotMessage) + + guard let projectRootURL = try await getProjectRootURL() + else { + let round = CodeReviewRound.fromError(turnId: turnId, error: "Invalid git repository.") + await appendCodeReviewRound(round) + resetOngoingRequest() + return + } + + let prChanges = await CurrentChangeService.getPRChanges( + projectRootURL, + group: group, + shouldIncludeFile: shouldIncludeFileForReview + ) + guard !prChanges.isEmpty else { + let round = CodeReviewRound.fromError( + turnId: turnId, + error: group == .index ? "No staged changes found to review." : "No unstaged changes found to review." + ) + await appendCodeReviewRound(round) + resetOngoingRequest() + return + } + + let round: CodeReviewRound = .init( + turnId: turnId, + status: .waitForConfirmation, + request: .from(prChanges) + ) + await appendCodeReviewRound(round) + } catch { + resetOngoingRequest() + throw error + } + } + + private func shouldIncludeFileForReview(url: URL) -> Bool { + let codeLanguage = CodeLanguage(fileURL: url) + + if case .builtIn = codeLanguage { + return true + } else { + return false + } + } + + private func appendCodeReviewRound(_ round: CodeReviewRound) async { + let message = ChatMessage( + assistantMessageWithId: round.turnId, chatTabID: chatTabInfo.id, codeReviewRound: round + ) + + await memory.appendMessage(message) + } + + private func getCurrentCodeReviewRound(_ id: String) async -> CodeReviewRound? { + guard let lastBotMessage = await memory.history.last, + lastBotMessage.role == .assistant, + let codeReviewRound = lastBotMessage.codeReviewRound, + codeReviewRound.id == id + else { + return nil + } + + return codeReviewRound + } + + public func acceptCodeReview(_ id: String, selectedFileUris: [DocumentUri]) async { + guard activeRequestId != nil, isReceivingMessage else { return } + + guard var round = await getCurrentCodeReviewRound(id), + var request = round.request, + round.status.canTransitionTo(.accepted) + else { return } + + guard selectedFileUris.count > 0 else { + round = round.withError("No files are selected to review.") + await appendCodeReviewRound(round) + resetOngoingRequest() + return + } + + round.status = .accepted + request.updateSelectedChanges(by: selectedFileUris) + round.request = request + await appendCodeReviewRound(round) + + round.status = .running + await appendCodeReviewRound(round) + + let (fileComments, errorMessage) = await CodeReviewProvider.invoke( + request, + context: CodeReviewServiceProvider(conversationServiceProvider: conversationProvider) + ) + + if let errorMessage = errorMessage { + round = round.withError(errorMessage) + await appendCodeReviewRound(round) + resetOngoingRequest() + return + } + + round = round.withResponse(.init(fileComments: fileComments)) + await CodeReviewService.shared.updateComments(fileComments) + await appendCodeReviewRound(round) + + round.status = .completed + await appendCodeReviewRound(round) + + resetOngoingRequest() + } + + public func cancelCodeReview(_ id: String) async { + guard activeRequestId != nil, isReceivingMessage else { return } + + guard var round = await getCurrentCodeReviewRound(id), + round.status.canTransitionTo(.cancelled) + else { return } + + round.status = .cancelled + await appendCodeReviewRound(round) + + resetOngoingRequest() + } + + private func addCodeReviewUserMessage(id: String, turnId: String, group: GitDiffGroup) async { + let content = group == .index + ? "Code review for staged changes." + : "Code review for unstaged changes." + let chatMessage = ChatMessage(userMessageWithId: id, chatTabId: chatTabInfo.id, content: content) + await memory.appendMessage(chatMessage) + saveChatMessageToStorage(chatMessage) + } +} diff --git a/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift b/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift new file mode 100644 index 00000000..2fddf1b3 --- /dev/null +++ b/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift @@ -0,0 +1,59 @@ +import ChatAPIService +import ConversationServiceProvider +import Foundation +import Logger +import GitHelper + +public struct CodeReviewServiceProvider { + public var conversationServiceProvider: (any ConversationServiceProvider)? +} + +public struct CodeReviewProvider { + public static func invoke( + _ request: CodeReviewRequest, + context: CodeReviewServiceProvider + ) async -> (fileComments: [CodeReviewResponse.FileComment], errorMessage: String?) { + var fileComments: [CodeReviewResponse.FileComment] = [] + var errorMessage: String? + + do { + if let result = try await requestReviewChanges(request.fileChange.selectedChanges, context: context) { + for comment in result.comments { + guard let change = request.fileChange.selectedChanges.first(where: { $0.uri == comment.uri }) else { + continue + } + + if let index = fileComments.firstIndex(where: { $0.uri == comment.uri }) { + var currentFileComments = fileComments[index] + currentFileComments.comments.append(comment) + fileComments[index] = currentFileComments + + } else { + fileComments.append( + .init(uri: change.uri, originalContent: change.originalContent, comments: [comment]) + ) + } + } + } + } catch { + Logger.gitHubCopilot.error("Failed to review change: \(error)") + errorMessage = "Oops, failed to review changes." + } + + return (fileComments, errorMessage) + } + + private static func requestReviewChanges( + _ changes: [PRChange], + context: CodeReviewServiceProvider + ) async throws -> CodeReviewResult? { + return try await context.conversationServiceProvider? + .reviewChanges( + .init( + changes: changes.map { + .init(uri: $0.uri, path: $0.path, baseContent: $0.baseContent, headContent: $0.headContent) + } + ) + ) + } +} diff --git a/Core/Sources/ChatService/CodeReview/CodeReviewService.swift b/Core/Sources/ChatService/CodeReview/CodeReviewService.swift new file mode 100644 index 00000000..4ae308d1 --- /dev/null +++ b/Core/Sources/ChatService/CodeReview/CodeReviewService.swift @@ -0,0 +1,48 @@ +import Collections +import ConversationServiceProvider +import Foundation +import LanguageServerProtocol + +public struct DocumentReview: Equatable { + public var comments: [ReviewComment] + public let originalContent: String +} + +public typealias DocumentReviewsByUri = OrderedDictionary + +@MainActor +public class CodeReviewService: ObservableObject { + @Published public private(set) var documentReviews: DocumentReviewsByUri = [:] + + public static let shared = CodeReviewService() + + private init() {} + + public func updateComments(for uri: DocumentUri, comments: [ReviewComment], originalContent: String) { + if var existing = documentReviews[uri] { + existing.comments.append(contentsOf: comments) + existing.comments = sortedComments(existing.comments) + documentReviews[uri] = existing + } else { + documentReviews[uri] = .init(comments: comments, originalContent: originalContent) + } + } + + public func updateComments(_ fileComments: [CodeReviewResponse.FileComment]) { + for fileComment in fileComments { + updateComments( + for: fileComment.uri, + comments: fileComment.comments, + originalContent: fileComment.originalContent + ) + } + } + + private func sortedComments(_ comments: [ReviewComment]) -> [ReviewComment] { + return comments.sorted { $0.range.end.line < $1.range.end.line } + } + + public func resetComments() { + documentReviews = [:] + } +} diff --git a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift index 08343963..f811901a 100644 --- a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift @@ -1,4 +1,5 @@ import JSONRPC +import AppKit import ConversationServiceProvider import Foundation import Logger @@ -56,7 +57,7 @@ public class CreateFileTool: ICopilotTool { toolName: CreateFileTool.name )) - Utils.openFileInXcode(fileURL: URL(fileURLWithPath: filePath)) { _, error in + NSWorkspace.openFileInXcode(fileURL: URL(fileURLWithPath: filePath)) { _, error in if let error = error { Logger.client.info("Failed to open file at \(filePath), \(error)") } diff --git a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift index 935b81bc..db89c57c 100644 --- a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift @@ -98,7 +98,9 @@ public class InsertEditIntoFileTool: ICopilotTool { } // Find the source editor element using XcodeInspector's logic - let editorElement = try findSourceEditorElement(from: focusedElement, xcodeInstance: xcodeInstance) + guard let editorElement = focusedElement.findSourceEditorElement() else { + throw NSError(domain: "Could not find source editor element", code: 0) + } // Check if element supports kAXValueAttribute before reading var value: String = "" @@ -165,62 +167,13 @@ public class InsertEditIntoFileTool: ICopilotTool { } } - private static func findSourceEditorElement( - from element: AXUIElement, - xcodeInstance: AppInstanceInspector, - shouldRetry: Bool = true - ) throws -> AXUIElement { - // 1. Check if the current element is a source editor - if element.isSourceEditor { - return element - } - - // 2. Search for child that is a source editor - if let sourceEditorChild = element.firstChild(where: \.isSourceEditor) { - return sourceEditorChild - } - - // 3. Search for parent that is a source editor (XcodeInspector's approach) - if let sourceEditorParent = element.firstParent(where: \.isSourceEditor) { - return sourceEditorParent - } - - // 4. Search for parent that is an editor area - if let editorAreaParent = element.firstParent(where: \.isEditorArea) { - // 3.1 Search for child that is a source editor - if let sourceEditorChild = editorAreaParent.firstChild(where: \.isSourceEditor) { - return sourceEditorChild - } - } - - // 5. Search for the workspace window - if let xcodeWorkspaceWindowParent = element.firstParent(where: \.isXcodeWorkspaceWindow) { - // 4.1 Search for child that is an editor area - if let editorAreaChild = xcodeWorkspaceWindowParent.firstChild(where: \.isEditorArea) { - // 4.2 Search for child that is a source editor - if let sourceEditorChild = editorAreaChild.firstChild(where: \.isSourceEditor) { - return sourceEditorChild - } - } - } - - // 6. retry - if shouldRetry { - Thread.sleep(forTimeInterval: 1) - return try findSourceEditorElement(from: element, xcodeInstance: xcodeInstance, shouldRetry: false) - } - - - throw NSError(domain: "Could not find source editor element", code: 0) - } - public static func applyEdit( for fileURL: URL, content: String, contextProvider: any ToolContextProvider, completion: ((String?, Error?) -> Void)? = nil ) { - Utils.openFileInXcode(fileURL: fileURL) { app, error in + NSWorkspace.openFileInXcode(fileURL: fileURL) { app, error in do { if let error = error { throw error } diff --git a/Core/Sources/ChatService/ToolCalls/Utils.swift b/Core/Sources/ChatService/ToolCalls/Utils.swift index e4cfcf0b..507714cf 100644 --- a/Core/Sources/ChatService/ToolCalls/Utils.swift +++ b/Core/Sources/ChatService/ToolCalls/Utils.swift @@ -5,34 +5,6 @@ import Logger import XcodeInspector class Utils { - public static func openFileInXcode( - fileURL: URL, - completion: ((NSRunningApplication?, Error?) -> Void)? = nil - ) { - guard let xcodeBundleURL = NSWorkspace.getXcodeBundleURL() - else { - if let completion = completion { - completion(nil, NSError(domain: "The Xcode app is not found.", code: 0)) - } - return - } - - let configuration = NSWorkspace.OpenConfiguration() - configuration.activates = true - - NSWorkspace.shared.open( - [fileURL], - withApplicationAt: xcodeBundleURL, - configuration: configuration - ) { app, error in - if let completion = completion { - completion(app, error) - } else if let error = error { - Logger.client.error("Failed to open file \(String(describing: error))") - } - } - } - public static func getXcode(by workspacePath: String) -> XcodeAppInstanceInspector? { return XcodeInspector.shared.xcodes.first( where: { diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index 0750d6fe..2d1a68c1 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -10,6 +10,7 @@ import GitHubCopilotService import Logger import OrderedCollections import SwiftUI +import GitHelper public struct DisplayedChatMessage: Equatable { public enum Role: Equatable { @@ -29,6 +30,7 @@ public struct DisplayedChatMessage: Equatable { public var steps: [ConversationProgressStep] = [] public var editAgentRounds: [AgentRound] = [] public var panelMessages: [CopilotShowMessageParams] = [] + public var codeReviewRound: CodeReviewRound? = nil public init( id: String, @@ -41,7 +43,8 @@ public struct DisplayedChatMessage: Equatable { errorMessages: [String] = [], steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], - panelMessages: [CopilotShowMessageParams] = [] + panelMessages: [CopilotShowMessageParams] = [], + codeReviewRound: CodeReviewRound? = nil ) { self.id = id self.role = role @@ -54,6 +57,7 @@ public struct DisplayedChatMessage: Equatable { self.steps = steps self.editAgentRounds = editAgentRounds self.panelMessages = panelMessages + self.codeReviewRound = codeReviewRound } } @@ -73,6 +77,7 @@ struct Chat { var typedMessage = "" var history: [DisplayedChatMessage] = [] var isReceivingMessage = false + var requestType: ChatService.RequestType? = nil var chatMenu = ChatMenu.State() var focusedField: Field? var currentEditor: FileReference? = nil @@ -87,6 +92,8 @@ struct Chat { case textField case fileSearchBar } + + var codeReviewState = ConversationCodeReviewFeature.State() } enum Action: Equatable, BindableAction { @@ -143,6 +150,9 @@ struct Chat { case setDiffViewerController(chat: StoreOf) case agentModeChanged(Bool) + + // Code Review + case codeReview(ConversationCodeReviewFeature.Action) } let service: ChatService @@ -165,6 +175,10 @@ struct Chat { Scope(state: \.chatMenu, action: /Action.chatMenu) { ChatMenu(service: service) } + + Scope(state: \.codeReviewState, action: /Action.codeReview) { + ConversationCodeReviewFeature(service: service) + } Reduce { state, action in switch action { @@ -397,7 +411,8 @@ struct Chat { errorMessages: message.errorMessages, steps: message.steps, editAgentRounds: message.editAgentRounds, - panelMessages: message.panelMessages + panelMessages: message.panelMessages, + codeReviewRound: message.codeReviewRound )) return all @@ -407,6 +422,7 @@ struct Chat { case .isReceivingMessageChanged: state.isReceivingMessage = service.isReceivingMessage + state.requestType = service.requestType return .none case .fileEditChanged: @@ -540,6 +556,9 @@ struct Chat { case let .agentModeChanged(isAgentMode): state.isAgentMode = isAgentMode return .none + + case .codeReview: + return .none } } } diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index 5b11637b..65dd41ed 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -13,6 +13,9 @@ import ChatTab import Workspace import Persist import UniformTypeIdentifiers +import Status +import GitHubCopilotService +import GitHubCopilotViewModel private let r: Double = 4 @@ -385,7 +388,8 @@ struct ChatHistoryItem: View { chat: chat, steps: message.steps, editAgentRounds: message.editAgentRounds, - panelMessages: message.panelMessages + panelMessages: message.panelMessages, + codeReviewRound: message.codeReviewRound ) case .ignored: EmptyView() @@ -509,6 +513,37 @@ struct ChatPanelInputArea: View { @State private var isCurrentEditorContextEnabled: Bool = UserDefaults.shared.value( for: \.enableCurrentEditorContext ) + @ObservedObject private var status: StatusObserver = .shared + @State private var isCCRFFEnabled: Bool + @State private var cancellables = Set() + + init( + chat: StoreOf, + focusedField: FocusState.Binding + ) { + self.chat = chat + self.focusedField = focusedField + self.isCCRFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.ccr + } + + var isRequestingConversation: Bool { + if chat.isReceivingMessage, + let requestType = chat.requestType, + requestType == .conversation { + return true + } + return false + } + + var isRequestingCodeReview: Bool { + if chat.isReceivingMessage, + let requestType = chat.requestType, + requestType == .codeReview { + return true + } + + return false + } var body: some View { WithPerceptionTracking { @@ -587,11 +622,19 @@ struct ChatPanelInputArea: View { Spacer() - Group { - if chat.isReceivingMessage { stopButton } - else { sendButton } + codeReviewButton + .buttonStyle(HoverButtonStyle(padding: 0)) + .disabled(isRequestingConversation) + + ZStack { + sendButton + .opacity(isRequestingConversation ? 0 : 1) + + stopButton + .opacity(isRequestingConversation ? 1 : 0) } .buttonStyle(HoverButtonStyle(padding: 0)) + .disabled(isRequestingCodeReview) } .padding(8) .padding(.top, -4) @@ -601,6 +644,16 @@ struct ChatPanelInputArea: View { } .onAppear() { subscribeToActiveDocumentChangeEvent() + // Check quota for CCR + Task { + if status.quotaInfo == nil, + let service = try? GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() { + _ = try? await service.checkQuota() + } + } + } + .task { + subscribeToFeatureFlagsDidChangeEvent() } .background { RoundedRectangle(cornerRadius: 6) @@ -627,6 +680,7 @@ struct ChatPanelInputArea: View { .keyboardShortcut("l", modifiers: [.command]) .accessibilityHidden(true) } + } } @@ -648,7 +702,78 @@ struct ChatPanelInputArea: View { Image(systemName: "stop.circle") .padding(4) } - .help("Stop") + } + + private var shouldEnableCCR: Bool { + guard let quotaInfo = status.quotaInfo else { return false } + + if quotaInfo.isFreeUser { return false } + + if !isCCRFFEnabled { return false } + + return true + } + + private var ccrDisabledTooltip: String { + guard let quotaInfo = status.quotaInfo else { + return "GitHub Copilot Code Review is not available." + } + + if quotaInfo.isFreeUser { + return "GitHub Copilot Code Review requires a paid subscription." + } + + if !isCCRFFEnabled { + return "GitHub Copilot Code Review is disabled by org policy. Contact your admin." + } + + return "GitHub Copilot Code Review is temporarily unavailable." + } + + var codeReviewIcon: some View { + Image("codeReview") + .padding(6) + } + + private var codeReviewButton: some View { + Group { + if !shouldEnableCCR { + codeReviewIcon + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + .help(ccrDisabledTooltip) + } else { + ZStack { + stopButton + .opacity(isRequestingCodeReview ? 1 : 0) + .help("Stop Code Review") + + Menu { + Button(action: { + chat.send(.codeReview(.request(.index))) + }) { + Text("Review Staged Changes") + } + + Button(action: { + chat.send(.codeReview(.request(.workingTree))) + }) { + Text("Review Unstaged Changes") + } + } label: { + codeReviewIcon + } + .opacity(isRequestingCodeReview ? 0 : 1) + .help("Code Review") + } + .buttonStyle(HoverButtonStyle(padding: 0)) + } + } + } + + private func subscribeToFeatureFlagsDidChangeEvent() { + FeatureFlagNotifierImpl.shared.featureFlagsDidChange + .sink(receiveValue: { isCCRFFEnabled = $0.ccr }) + .store(in: &cancellables) } private var dropdownOverlay: some View { diff --git a/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift new file mode 100644 index 00000000..c85ca212 --- /dev/null +++ b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift @@ -0,0 +1,90 @@ +import ComposableArchitecture +import ChatService +import Foundation +import ConversationServiceProvider +import GitHelper +import LanguageServerProtocol +import Terminal +import Combine + +@MainActor +public class CodeReviewStateService: ObservableObject { + public static let shared = CodeReviewStateService() + + public let fileClickedEvent = PassthroughSubject() + + private init() { } + + func notifyFileClicked() { + fileClickedEvent.send() + } +} + +@Reducer +public struct ConversationCodeReviewFeature { + @ObservableState + public struct State: Equatable { + + public init() { } + } + + public enum Action: Equatable { + case request(GitDiffGroup) + case accept(id: String, selectedFiles: [DocumentUri]) + case cancel(id: String) + + case onFileClicked(URL, Int) + } + + public let service: ChatService + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .request(let group): + + return .run { _ in + try await service.requestCodeReview(group) + } + + case let .accept(id, selectedFileUris): + + return .run { _ in + await service.acceptCodeReview(id, selectedFileUris: selectedFileUris) + } + + case .cancel(let id): + + return .run { _ in + await service.cancelCodeReview(id) + } + + // lineNumber: 0-based + case .onFileClicked(let fileURL, let lineNumber): + + return .run { _ in + if FileManager.default.fileExists(atPath: fileURL.path) { + let terminal = Terminal() + do { + _ = try await terminal.runCommand( + "/bin/bash", + arguments: [ + "-c", + "xed -l \(lineNumber+1) \"\(fileURL.path)\"" + ], + environment: [:] + ) + } catch { + print(error) + } + } + + Task { @MainActor in + CodeReviewStateService.shared.notifyFileClicked() + } + } + + } + } + } +} diff --git a/Core/Sources/ConversationTab/Styles.swift b/Core/Sources/ConversationTab/Styles.swift index 0306e4c7..996593f3 100644 --- a/Core/Sources/ConversationTab/Styles.swift +++ b/Core/Sources/ConversationTab/Styles.swift @@ -52,7 +52,7 @@ extension View { _ configuration: CodeBlockConfiguration, backgroundColor: Color, labelColor: Color, - insertAction: (() -> Void)? = nil + context: MarkdownActionProvider? = nil ) -> some View { background(backgroundColor) .clipShape(RoundedRectangle(cornerRadius: 6)) @@ -71,9 +71,11 @@ extension View { NSPasteboard.general.setString(configuration.content, forType: .string) } - InsertButton { - if let insertAction = insertAction { - insertAction() + if let context = context, context.supportInsert { + InsertButton { + if let onInsert = context.onInsert { + onInsert(configuration.content) + } } } } @@ -187,3 +189,20 @@ extension View { } } +// MARK: - Code Review Background Styles + +struct CodeReviewCardBackground: View { + var body: some View { + RoundedRectangle(cornerRadius: 4) + .stroke(.black.opacity(0.17), lineWidth: 1) + .background(Color.gray.opacity(0.05)) + } +} + +struct CodeReviewHeaderBackground: View { + var body: some View { + RoundedRectangle(cornerRadius: 4) + .stroke(.black.opacity(0.17), lineWidth: 1) + .background(Color.gray.opacity(0.1)) + } +} diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index 2f0bf835..a67dbcc5 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -19,6 +19,7 @@ struct BotMessage: View { let steps: [ConversationProgressStep] let editAgentRounds: [AgentRound] let panelMessages: [CopilotShowMessageParams] + let codeReviewRound: CodeReviewRound? @Environment(\.colorScheme) var colorScheme @AppStorage(\.chatFontSize) var chatFontSize @@ -121,7 +122,6 @@ struct BotMessage: View { HStack { VStack(alignment: .leading, spacing: 8) { CopilotMessageHeader() - .padding(.leading, 6) if !references.isEmpty { WithPerceptionTracking { @@ -153,6 +153,12 @@ struct BotMessage: View { if !text.isEmpty { ThemedMarkdownText(text: text, chat: chat) } + + if let codeReviewRound = codeReviewRound { + CodeReviewMainView( + store: chat, round: codeReviewRound + ) + } if !errorMessages.isEmpty { VStack(spacing: 4) { @@ -339,7 +345,8 @@ struct BotMessage_Previews: PreviewProvider { chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }), steps: steps, editAgentRounds: agentRounds, - panelMessages: [] + panelMessages: [], + codeReviewRound: nil ) .padding() .fixedSize(horizontal: true, vertical: true) diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift new file mode 100644 index 00000000..a7f1b68b --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift @@ -0,0 +1,118 @@ +import ComposableArchitecture +import ConversationServiceProvider +import LanguageServerProtocol +import SwiftUI + +// MARK: - Main View + +struct CodeReviewMainView: View { + let store: StoreOf + let round: CodeReviewRound + @State private var selectedFileUris: [DocumentUri] + @AppStorage(\.chatFontSize) var chatFontSize + + private var changedFileUris: [DocumentUri] { + round.request?.changedFileUris ?? [] + } + + private var hasChangedFiles: Bool { + !changedFileUris.isEmpty + } + + private var hasFileComments: Bool { + guard let fileComments = round.response?.fileComments else { return false } + return !fileComments.isEmpty + } + + static let HelloMessage: String = "Sure, I can help you with that." + + public init(store: StoreOf, round: CodeReviewRound) { + self.store = store + self.round = round + self.selectedFileUris = round.request?.selectedFileUris ?? [] + } + + var helloMessageView: some View { + Text(Self.HelloMessage) + .font(.system(size: chatFontSize)) + } + + var statusIcon: some View { + Group { + switch round.status { + case .running: + ProgressView() + .controlSize(.small) + .frame(width: 16, height: 16) + .scaleEffect(0.7) + case .completed: + Image(systemName: "checkmark") + .foregroundColor(.green) + case .error: + Image(systemName: "xmark.circle") + .foregroundColor(.red) + case .cancelled: + Image(systemName: "slash.circle") + .foregroundColor(.gray) + case .waitForConfirmation: + EmptyView() + case .accepted: + EmptyView() + } + } + } + + var statusView: some View { + Group { + switch round.status { + case .waitForConfirmation, .accepted: + EmptyView() + default: + HStack(spacing: 4) { + statusIcon + .frame(width: 16, height: 16) + + Text("Running Code Review...") + .font(.system(size: chatFontSize)) + .foregroundColor(.secondary) + + Spacer() + } + } + } + } + + var shouldShowHelloMessage: Bool { round.statusHistory.contains(.waitForConfirmation) } + var shouldShowRunningStatus: Bool { round.statusHistory.contains(.running) } + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 8) { + if shouldShowHelloMessage { + helloMessageView + } + + if hasChangedFiles { + FileSelectionSection( + store: store, + round: round, + changedFileUris: changedFileUris, + selectedFileUris: $selectedFileUris + ) + } + + if shouldShowRunningStatus { + statusView + } + + if hasFileComments { + ReviewResultsSection(store: store, round: round) + } + + if round.status == .completed || round.status == .error { + ReviewSummarySection(round: round) + } + } + } + } +} diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift new file mode 100644 index 00000000..76f613a7 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift @@ -0,0 +1,213 @@ +import ComposableArchitecture +import ConversationServiceProvider +import LanguageServerProtocol +import SharedUIComponents +import SwiftUI + +// MARK: - File Selection Section + +struct FileSelectionSection: View { + let store: StoreOf + let round: CodeReviewRound + let changedFileUris: [DocumentUri] + @Binding var selectedFileUris: [DocumentUri] + @AppStorage(\.chatFontSize) private var chatFontSize + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + FileSelectionHeader(fileCount: selectedFileUris.count) + .frame(maxWidth: .infinity, alignment: .leading) + + FileSelectionList( + store: store, + fileUris: changedFileUris, + reviewStatus: round.status, + selectedFileUris: $selectedFileUris + ) + + if round.status == .waitForConfirmation { + FileSelectionActions( + store: store, + roundId: round.id, + selectedFileUris: selectedFileUris + ) + } + } + .padding(12) + .background(CodeReviewCardBackground()) + } +} + +// MARK: - File Selection Components + +private struct FileSelectionHeader: View { + let fileCount: Int + @AppStorage(\.chatFontSize) private var chatFontSize + + var body: some View { + HStack(alignment: .top, spacing: 6) { + Image("Sparkle") + .resizable() + .frame(width: 16, height: 16) + + Text("You’ve selected following \(fileCount) file(s) with code changes. Review them or unselect any files you don't need, then click Continue.") + .font(.system(size: chatFontSize)) + .multilineTextAlignment(.leading) + } + } +} + +private struct FileSelectionActions: View { + let store: StoreOf + let roundId: String + let selectedFileUris: [DocumentUri] + + var body: some View { + HStack(spacing: 4) { + Button("Cancel") { + store.send(.codeReview(.cancel(id: roundId))) + } + .buttonStyle(.bordered) + .controlSize(.large) + + Button("Continue") { + store.send(.codeReview(.accept(id: roundId, selectedFiles: selectedFileUris))) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + } +} + +// MARK: - File Selection List + +private struct FileSelectionList: View { + let store: StoreOf + let fileUris: [DocumentUri] + let reviewStatus: CodeReviewRound.Status + @State private var isExpanded = false + @Binding var selectedFileUris: [DocumentUri] + @AppStorage(\.chatFontSize) private var chatFontSize + + private static let defaultVisibleFileCount = 5 + + private var hasMoreFiles: Bool { + fileUris.count > Self.defaultVisibleFileCount + } + + var body: some View { + let visibleFileUris = Array(fileUris.prefix(Self.defaultVisibleFileCount)) + let additionalFileUris = Array(fileUris.dropFirst(Self.defaultVisibleFileCount)) + + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 4) { + FileToggleList( + fileUris: visibleFileUris, + reviewStatus: reviewStatus, + selectedFileUris: $selectedFileUris + ) + + if hasMoreFiles { + if !isExpanded { + ExpandFilesButton(isExpanded: $isExpanded) + } + + if isExpanded { + FileToggleList( + fileUris: additionalFileUris, + reviewStatus: reviewStatus, + selectedFileUris: $selectedFileUris + ) + } + } + } + } + .frame(alignment: .leading) + } +} + +private struct ExpandFilesButton: View { + @Binding var isExpanded: Bool + @AppStorage(\.chatFontSize) private var chatFontSize + + var body: some View { + HStack(spacing: 2) { + Image("chevron.down") + .resizable() + .frame(width: 16, height: 16) + + Button(action: { isExpanded = true }) { + Text("Show more") + .font(.system(size: chatFontSize)) + .underline() + .lineSpacing(20) + } + .buttonStyle(PlainButtonStyle()) + } + .foregroundColor(.blue) + } +} + +private struct FileToggleList: View { + let fileUris: [DocumentUri] + let reviewStatus: CodeReviewRound.Status + @Binding var selectedFileUris: [DocumentUri] + + var body: some View { + ForEach(fileUris, id: \.self) { fileUri in + FileSelectionRow( + fileUri: fileUri, + reviewStatus: reviewStatus, + isSelected: createSelectionBinding(for: fileUri) + ) + } + } + + private func createSelectionBinding(for fileUri: DocumentUri) -> Binding { + Binding( + get: { selectedFileUris.contains(fileUri) }, + set: { isSelected in + if isSelected { + if !selectedFileUris.contains(fileUri) { + selectedFileUris.append(fileUri) + } + } else { + selectedFileUris.removeAll { $0 == fileUri } + } + } + ) + } +} + +private struct FileSelectionRow: View { + let fileUri: DocumentUri + let reviewStatus: CodeReviewRound.Status + @Binding var isSelected: Bool + + private var fileURL: URL? { + URL(string: fileUri) + } + + private var isInteractionEnabled: Bool { + reviewStatus == .waitForConfirmation + } + + var body: some View { + HStack { + Toggle(isOn: $isSelected) { + HStack(spacing: 8) { + drawFileIcon(fileURL) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + + Text(fileURL?.lastPathComponent ?? fileUri) + .lineLimit(1) + .truncationMode(.middle) + } + } + .toggleStyle(CheckboxToggleStyle()) + .disabled(!isInteractionEnabled) + } + } +} diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift new file mode 100644 index 00000000..d2e74d9d --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift @@ -0,0 +1,182 @@ +import SwiftUI +import ComposableArchitecture +import ConversationServiceProvider +import SharedUIComponents + +// MARK: - Review Results Section + +struct ReviewResultsSection: View { + let store: StoreOf + let round: CodeReviewRound + @State private var isExpanded = false + @AppStorage(\.chatFontSize) private var chatFontSize + + private static let defaultVisibleReviewCount = 5 + + private var fileComments: [CodeReviewResponse.FileComment] { + round.response?.fileComments ?? [] + } + + private var visibleReviewCount: Int { + isExpanded ? fileComments.count : min(fileComments.count, Self.defaultVisibleReviewCount) + } + + private var hasMoreReviews: Bool { + fileComments.count > Self.defaultVisibleReviewCount + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ReviewResultsHeader( + reviewStatus: round.status, + chatFontSize: chatFontSize + ) + .padding(8) + .background(CodeReviewHeaderBackground()) + + if !fileComments.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ReviewResultsList( + store: store, + fileComments: Array(fileComments.prefix(visibleReviewCount)) + ) + } + .padding(.horizontal, 8) + .padding(.bottom, !hasMoreReviews || isExpanded ? 8 : 0) + } + + if hasMoreReviews && !isExpanded { + ExpandReviewsButton(isExpanded: $isExpanded) + } + } + .background(CodeReviewCardBackground()) + } +} + +private struct ReviewResultsHeader: View { + let reviewStatus: CodeReviewRound.Status + let chatFontSize: CGFloat + + var body: some View { + HStack(spacing: 4) { + Text("Reviewed Changes") + .font(.system(size: chatFontSize)) + + Spacer() + } + } +} + + +private struct ExpandReviewsButton: View { + @Binding var isExpanded: Bool + + var body: some View { + HStack { + Spacer() + + Button { + isExpanded = true + } label: { + Image("chevron.down") + .resizable() + .frame(width: 16, height: 16) + } + .buttonStyle(PlainButtonStyle()) + + Spacer() + } + .padding(.vertical, 2) + .background(CodeReviewHeaderBackground()) + } +} + +private struct ReviewResultsList: View { + let store: StoreOf + let fileComments: [CodeReviewResponse.FileComment] + + var body: some View { + ForEach(fileComments, id: \.self) { fileComment in + if let fileURL = fileComment.url { + ReviewResultRow( + store: store, + fileURL: fileURL, + comments: fileComment.comments + ) + } + } + } +} + +private struct ReviewResultRow: View { + let store: StoreOf + let fileURL: URL + let comments: [ReviewComment] + @State private var isExpanded = false + + private var commentCountText: String { + comments.count == 1 ? "1 comment" : "\(comments.count) comments" + } + + private var hasComments: Bool { + !comments.isEmpty + } + + var body: some View { + VStack(alignment: .leading) { + ReviewResultRowContent( + store: store, + fileURL: fileURL, + comments: comments, + commentCountText: commentCountText, + hasComments: hasComments + ) + } + } +} + +private struct ReviewResultRowContent: View { + let store: StoreOf + let fileURL: URL + let comments: [ReviewComment] + let commentCountText: String + let hasComments: Bool + @State private var isHovered: Bool = false + + @AppStorage(\.chatFontSize) private var chatFontSize + + var body: some View { + HStack(spacing: 4) { + drawFileIcon(fileURL) + .resizable() + .frame(width: 16, height: 16) + + Button(action: { + if hasComments { + store.send(.codeReview(.onFileClicked(fileURL, comments[0].range.end.line))) + } + }) { + Text(fileURL.lastPathComponent) + .font(.system(size: chatFontSize)) + .foregroundColor(isHovered ? Color("ItemSelectedColor") : .primary) + } + .buttonStyle(PlainButtonStyle()) + .disabled(!hasComments) + .onHover { hovering in + isHovered = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + Text(commentCountText) + .font(.system(size: chatFontSize - 1)) + .lineSpacing(20) + .foregroundColor(.secondary) + + Spacer() + } + } +} diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift new file mode 100644 index 00000000..e924f1bb --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift @@ -0,0 +1,44 @@ +import SwiftUI +import ConversationServiceProvider + +struct ReviewSummarySection: View { + var round: CodeReviewRound + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + if round.status == .error, let errorMessage = round.error { + Text(errorMessage) + .font(.system(size: chatFontSize)) + } else if round.status == .completed, let request = round.request, let response = round.response { + CompletedSummary(request: request, response: response) + } else { + Text("Oops, failed to review changes.") + .font(.system(size: chatFontSize)) + } + } +} + +struct CompletedSummary: View { + var request: CodeReviewRequest + var response: CodeReviewResponse + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + let changedFileUris = request.changedFileUris + let selectedFileUris = request.selectedFileUris + let allComments = response.allComments + + VStack(alignment: .leading, spacing: 8) { + + Text("Total comments: \(allComments.count)") + + if allComments.count > 0 { + Text("Review complete! We found \(allComments.count) comment(s) in your selected file(s). Click a file name to see details in the editor.") + } else { + Text("Copilot reviewed \(selectedFileUris.count) out of \(changedFileUris.count) changed files, and no comments were found.") + } + + } + .font(.system(size: chatFontSize)) + } +} diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift index 02330454..cf4b8a61 100644 --- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift @@ -1,4 +1,3 @@ - import SwiftUI import ConversationServiceProvider import ComposableArchitecture diff --git a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift index d08d7abc..3af730eb 100644 --- a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift +++ b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift @@ -6,7 +6,17 @@ import ComposableArchitecture import SuggestionBasic import ChatTab -struct ThemedMarkdownText: View { +public struct MarkdownActionProvider { + let supportInsert: Bool + let onInsert: ((String) -> Void)? + + public init(supportInsert: Bool = true, onInsert: ((String) -> Void)? = nil) { + self.supportInsert = supportInsert + self.onInsert = onInsert + } +} + +public struct ThemedMarkdownText: View { @AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight @@ -17,14 +27,22 @@ struct ThemedMarkdownText: View { @Environment(\.colorScheme) var colorScheme let text: String - let chat: StoreOf + let context: MarkdownActionProvider + public init(text: String, context: MarkdownActionProvider) { + self.text = text + self.context = context + } + init(text: String, chat: StoreOf) { self.text = text - self.chat = chat + + self.context = .init(onInsert: { content in + chat.send(.insertCode(content)) + }) } - var body: some View { + public var body: some View { Markdown(text) .textSelection(.enabled) .markdownTheme(.custom( @@ -53,7 +71,7 @@ struct ThemedMarkdownText: View { } return Color.secondary.opacity(0.7) }(), - chat: chat + context: context )) } } @@ -66,7 +84,7 @@ extension MarkdownUI.Theme { codeFont: NSFont, codeBlockBackgroundColor: Color, codeBlockLabelColor: Color, - chat: StoreOf + context: MarkdownActionProvider ) -> MarkdownUI.Theme { .gitHub.text { ForegroundColor(.primary) @@ -79,7 +97,7 @@ extension MarkdownUI.Theme { codeFont: codeFont, codeBlockBackgroundColor: codeBlockBackgroundColor, codeBlockLabelColor: codeBlockLabelColor, - chat: chat + context: context ) } } @@ -90,11 +108,7 @@ struct MarkdownCodeBlockView: View { let codeFont: NSFont let codeBlockBackgroundColor: Color let codeBlockLabelColor: Color - let chat: StoreOf - - func insertCode() { - chat.send(.insertCode(codeBlockConfiguration.content)) - } + let context: MarkdownActionProvider var body: some View { let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock) @@ -110,7 +124,7 @@ struct MarkdownCodeBlockView: View { codeBlockConfiguration, backgroundColor: codeBlockBackgroundColor, labelColor: codeBlockLabelColor, - insertAction: insertCode + context: context ) } else { ScrollView(.horizontal) { @@ -126,7 +140,7 @@ struct MarkdownCodeBlockView: View { codeBlockConfiguration, backgroundColor: codeBlockBackgroundColor, labelColor: codeBlockLabelColor, - insertAction: insertCode + context: context ) } } @@ -143,7 +157,7 @@ struct ThemedMarkdownText_Previews: PreviewProvider { } ``` """, - chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) })) + context: .init(onInsert: {_ in print("Inserted") })) } } diff --git a/Core/Sources/ConversationTab/Views/UserMessage.swift b/Core/Sources/ConversationTab/Views/UserMessage.swift index 19a2ca00..e7dc2d32 100644 --- a/Core/Sources/ConversationTab/Views/UserMessage.swift +++ b/Core/Sources/ConversationTab/Views/UserMessage.swift @@ -10,6 +10,8 @@ import ChatTab import ConversationServiceProvider import SwiftUIFlowLayout +private let MAX_TEXT_LENGTH = 10000 // Maximum characters to prevent crashes + struct UserMessage: View { var r: Double { messageBubbleCornerRadius } let id: String @@ -37,6 +39,14 @@ struct UserMessage: View { } } + // Truncate the displayed user message if it's too long. + private var displayText: String { + if text.count > MAX_TEXT_LENGTH { + return String(text.prefix(MAX_TEXT_LENGTH)) + "\n… (message too long, rest hidden)" + } + return text + } + var body: some View { HStack { VStack(alignment: .leading, spacing: 8) { @@ -50,7 +60,7 @@ struct UserMessage: View { Spacer() } - ThemedMarkdownText(text: text, chat: chat) + ThemedMarkdownText(text: displayText, chat: chat) .frame(maxWidth: .infinity, alignment: .leading) if !imageReferences.isEmpty { diff --git a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift index e310f5d5..bca4079f 100644 --- a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift +++ b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift @@ -17,7 +17,6 @@ public class GitHubCopilotViewModel: ObservableObject { public static let shared = GitHubCopilotViewModel() @Dependency(\.toast) var toast - @Dependency(\.openURL) var openURL @AppStorage("username") var username: String = "" @@ -137,10 +136,8 @@ public class GitHubCopilotViewModel: ObservableObject { pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil) pasteboard.setString(signInResponse.userCode, forType: NSPasteboard.PasteboardType.string) toast("Sign-in code \(signInResponse.userCode) copied", .info) - Task { - await openURL(signInResponse.verificationURL) - waitForSignIn() - } + NSWorkspace.shared.open(signInResponse.verificationURL) + waitForSignIn() } public func waitForSignIn() { @@ -243,9 +240,7 @@ public class GitHubCopilotViewModel: ObservableObject { alert.addButton(withTitle: "Copy Commands") alert.addButton(withTitle: "Cancel") - let response = await MainActor.run { - alert.runModal() - } + let response = alert.runModal() if response == .alertFirstButtonReturn { copyCommandsToClipboard() diff --git a/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift new file mode 100644 index 00000000..1a64a7dc --- /dev/null +++ b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift @@ -0,0 +1,448 @@ +import SwiftUI +import Combine +import XcodeInspector +import ComposableArchitecture +import ConversationServiceProvider +import LanguageServerProtocol +import ChatService +import SharedUIComponents +import ConversationTab + +private typealias CodeReviewPanelViewStore = ViewStore + +private struct ViewState: Equatable { + let reviewComments: [ReviewComment] + let currentSelectedComment: ReviewComment? + let currentIndex: Int + let operatedCommentIds: Set + var hasNextComment: Bool + var hasPreviousComment: Bool + + var commentsCount: Int { reviewComments.count } + + init(state: CodeReviewPanelFeature.State) { + self.reviewComments = state.currentDocumentReview?.comments ?? [] + self.currentSelectedComment = state.currentSelectedComment + self.currentIndex = state.currentIndex + self.operatedCommentIds = state.operatedCommentIds + self.hasNextComment = state.hasNextComment + self.hasPreviousComment = state.hasPreviousComment + } +} + +struct CodeReviewPanelView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: ViewState.init) { viewStore in + WithPerceptionTracking { + VStack(spacing: 0) { + VStack(spacing: 0) { + HeaderView(viewStore: viewStore) + .padding(.bottom, 4) + + Divider() + + ContentView( + comment: viewStore.currentSelectedComment, + viewStore: viewStore + ) + .padding(.top, 16) + } + .padding(.vertical, 10) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: Style.codeReviewPanelHeight, alignment: .top) + .fixedSize(horizontal: false, vertical: true) + .xcodeStyleFrame(cornerRadius: 10) + .onAppear { viewStore.send(.appear) } + + Spacer() + } + } + } + } +} + +// MARK: - Header View +private struct HeaderView: View { + let viewStore: CodeReviewPanelViewStore + + var body: some View { + HStack(alignment: .center, spacing: 8) { + ZStack { + Circle() + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + .frame(width: 24, height: 24) + + Image("CopilotLogo") + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: 12, height: 12) + } + + Text("Code Review Comment") + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + + if viewStore.commentsCount > 0 { + Text("(\(viewStore.currentIndex + 1) of \(viewStore.commentsCount))") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + + Spacer() + + NavigationControls(viewStore: viewStore) + } + .fixedSize(horizontal: false, vertical: true) + } +} + +// MARK: - Navigation Controls +private struct NavigationControls: View { + let viewStore: CodeReviewPanelViewStore + + var body: some View { + HStack(spacing: 4) { + if viewStore.hasPreviousComment { + Button(action: { + viewStore.send(.previous) + }) { + Image(systemName: "arrow.up") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 13, height: 13) + } + .buttonStyle(HoverButtonStyle()) + .buttonStyle(PlainButtonStyle()) + .help("Previous") + } + + if viewStore.hasNextComment { + Button(action: { + viewStore.send(.next) + }) { + Image(systemName: "arrow.down") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 13, height: 13) + } + .buttonStyle(HoverButtonStyle()) + .buttonStyle(PlainButtonStyle()) + .help("Next") + } + + Button(action: { + if let id = viewStore.currentSelectedComment?.id { + viewStore.send(.close(commentId: id)) + } + }) { + Image(systemName: "xmark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 13, height: 13) + } + .buttonStyle(HoverButtonStyle()) + .buttonStyle(PlainButtonStyle()) + .help("Close") + } + } +} + +// MARK: - Content View +private struct ContentView: View { + let comment: ReviewComment? + let viewStore: CodeReviewPanelViewStore + + var body: some View { + if let comment = comment { + CommentDetailView(comment: comment, viewStore: viewStore) + } else { + EmptyView() + } + } +} + +// MARK: - Comment Detail View +private struct CommentDetailView: View { + let comment: ReviewComment + let viewStore: CodeReviewPanelViewStore + @AppStorage(\.chatFontSize) var chatFontSize + + var lineInfoContent: String { + let displayStartLine = comment.range.start.line + 1 + let displayEndLine = comment.range.end.line + 1 + + if displayStartLine == displayEndLine { + return "Line \(displayStartLine)" + } else { + return "Line \(displayStartLine)-\(displayEndLine)" + } + } + + var lineInfoView: some View { + Text(lineInfoContent) + .font(.system(size: chatFontSize)) + } + + var kindView: some View { + Text(comment.kind) + .font(.system(size: chatFontSize)) + .padding(.horizontal, 6) + .frame(maxHeight: 20) + .background( + RoundedRectangle(cornerRadius: 4) + .foregroundColor(.hoverColor) + ) + } + + var messageView: some View { + ScrollView { + ThemedMarkdownText( + text: comment.message, + context: .init(supportInsert: false) + ) + } + } + + var dismissButton: some View { + Button(action: { + viewStore.send(.dismiss(commentId: comment.id)) + }) { + Text("Dismiss") + } + .buttonStyle(.bordered) + .foregroundColor(.primary) + .help("Dismiss") + } + + var acceptButton: some View { + Button(action: { + viewStore.send(.accept(commentId: comment.id)) + }) { + Text("Accept") + } + .buttonStyle(.borderedProminent) + .help("Accept") + } + + private var fileURL: URL? { + URL(string: comment.uri) + } + + var fileNameView: some View { + HStack(spacing: 8) { + drawFileIcon(fileURL) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + + Text(fileURL?.lastPathComponent ?? comment.uri) + .fontWeight(.semibold) + .lineLimit(1) + .truncationMode(.middle) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Compact header with range info and badges in one line + HStack(alignment: .center, spacing: 8) { + fileNameView + + Spacer() + + lineInfoView + + kindView + } + + messageView + .frame(maxHeight: 100) + .fixedSize(horizontal: false, vertical: true) + + // Add suggested change view if suggestion exists + if let suggestion = comment.suggestion, + !suggestion.isEmpty, + let fileUrl = URL(string: comment.uri), + let content = try? String(contentsOf: fileUrl) + { + SuggestedChangeView( + suggestion: suggestion, + content: content, + range: comment.range, + chatFontSize: chatFontSize + ) + + if !viewStore.operatedCommentIds.contains(comment.id) { + HStack(spacing: 9) { + Spacer() + + dismissButton + + acceptButton + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Suggested Change View +private struct SuggestedChangeView: View { + let suggestion: String + let content: String + let range: LSPRange + let chatFontSize: CGFloat + + struct DiffLine { + let content: String + let lineNumber: Int + let type: DiffLineType + } + + enum DiffLineType { + case removed + case added + } + + var diffLines: [DiffLine] { + var lines: [DiffLine] = [] + + // Add removed lines + let contentLines = content.components(separatedBy: .newlines) + if range.start.line >= 0 && range.end.line < contentLines.count { + let removedLines = Array(contentLines[range.start.line...range.end.line]) + for (index, lineContent) in removedLines.enumerated() { + lines.append(DiffLine( + content: lineContent, + lineNumber: range.start.line + index + 1, + type: .removed + )) + } + } + + // Add suggested lines + let suggestionLines = suggestion.components(separatedBy: .newlines) + for (index, lineContent) in suggestionLines.enumerated() { + lines.append(DiffLine( + content: lineContent, + lineNumber: range.start.line + index + 1, + type: .added + )) + } + + return lines + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Suggested change") + .font(.system(size: chatFontSize, weight: .regular)) + .foregroundColor(.secondary) + + Spacer() + } + .padding(.leading, 8) + .padding(.vertical, 6) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(NSColor.separatorColor), lineWidth: 0.5) + ) + + Rectangle() + .fill(.ultraThickMaterial) + .frame(height: 1) + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(diffLines.indices, id: \.self) { index in + DiffLineView( + line: diffLines[index], + chatFontSize: chatFontSize + ) + } + } + } + .frame(maxHeight: 150) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(.ultraThickMaterial) + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } +} + +// MARK: - Diff Line View +private struct DiffLineView: View { + let line: SuggestedChangeView.DiffLine + let chatFontSize: CGFloat + @State private var contentHeight: CGFloat = 0 + + private var backgroundColor: SwiftUICore.Color { + switch line.type { + case .removed: + return Color("editorOverviewRuler.inlineChatRemoved") + case .added: + return Color("editor.focusedStackFrameHighlightBackground") + } + } + + private var lineNumberBackgroundColor: SwiftUICore.Color { + switch line.type { + case .removed: + return Color("gitDecoration.deletedResourceForeground") + case .added: + return Color("gitDecoration.addedResourceForeground") + } + } + + private var prefix: String { + switch line.type { + case .removed: + return "-" + case .added: + return "+" + } + } + + var body: some View { + HStack(spacing: 0) { + HStack(alignment: .top, spacing: 0) { + HStack(spacing: 4) { + Text("\(line.lineNumber)") + Text(prefix) + } + } + .font(.system(size: chatFontSize)) + .foregroundColor(.white) + .frame(width: 60, height: contentHeight) // TODO: dynamic set height by font size + .background(lineNumberBackgroundColor) + + // Content section with text wrapping + VStack(alignment: .leading) { + Text(line.content) + .font(.system(size: chatFontSize)) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .padding(.vertical, 4) + .padding(.leading, 8) + .background(backgroundColor) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { contentHeight = geometry.size.height } + } + ) + } + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift new file mode 100644 index 00000000..ed7b4375 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift @@ -0,0 +1,356 @@ +import ChatService +import ComposableArchitecture +import AppKit +import AXHelper +import ConversationServiceProvider +import Foundation +import LanguageServerProtocol +import Logger +import Terminal +import XcodeInspector +import SuggestionBasic +import ConversationTab + +@Reducer +public struct CodeReviewPanelFeature { + @ObservableState + public struct State: Equatable { + public fileprivate(set) var documentReviews: DocumentReviewsByUri = [:] + public var operatedCommentIds: Set = [] + public var currentIndex: Int = 0 + public var activeDocumentURL: URL? = nil + public var isPanelDisplayed: Bool = false + public var closedByUser: Bool = false + + public var currentDocumentReview: DocumentReview? { + if let url = activeDocumentURL, + let result = documentReviews[url.absoluteString] + { + return result + } + return nil + } + + public var currentSelectedComment: ReviewComment? { + guard let currentDocumentReview = currentDocumentReview else { return nil } + guard currentIndex >= 0 && currentIndex < currentDocumentReview.comments.count + else { return nil } + + return currentDocumentReview.comments[currentIndex] + } + + public var originalContent: String? { currentDocumentReview?.originalContent } + + public var documentUris: [DocumentUri] { Array(documentReviews.keys) } + + public var pendingNavigation: PendingNavigation? = nil + + public func getCommentById(id: String) -> ReviewComment? { + // Check current selected comment first for efficiency + if let currentSelectedComment = currentSelectedComment, + currentSelectedComment.id == id { + return currentSelectedComment + } + + // Search through all document reviews + for documentReview in documentReviews.values { + for comment in documentReview.comments { + if comment.id == id { + return comment + } + } + } + + return nil + } + + public func getOriginalContentByUri(_ uri: DocumentUri) -> String? { + documentReviews[uri]?.originalContent + } + + public var hasNextComment: Bool { hasComment(of: .next) } + public var hasPreviousComment: Bool { hasComment(of: .previous) } + + public init() {} + } + + public struct PendingNavigation: Equatable { + public let url: URL + public let index: Int + + public init(url: URL, index: Int) { + self.url = url + self.index = index + } + } + + public enum Action: Equatable { + case next + case previous + case close(commentId: String) + case dismiss(commentId: String) + case accept(commentId: String) + + case onActiveDocumentURLChanged(URL?) + + case appear + case onCodeReviewResultsChanged(DocumentReviewsByUri) + case observeDocumentReviews + case observeReviewedFileClicked + + case checkDisplay + case reviewedfileClicked + } + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .next: + let nextIndex = state.currentIndex + 1 + if let reviewComments = state.currentDocumentReview?.comments, + reviewComments.count > nextIndex { + state.currentIndex = nextIndex + return .none + } + + if let result = state.getDocumentNavigation(.next) { + state.navigateToDocument(uri: result.documentUri, index: result.commentIndex) + } + + return .none + + case .previous: + let previousIndex = state.currentIndex - 1 + if let reviewComments = state.currentDocumentReview?.comments, + reviewComments.count > previousIndex && previousIndex >= 0 { + state.currentIndex = previousIndex + return .none + } + + if let result = state.getDocumentNavigation(.previous) { + state.navigateToDocument(uri: result.documentUri, index: result.commentIndex) + } + + return .none + + case let .close(id): + state.isPanelDisplayed = false + state.closedByUser = true + + return .none + + case let .dismiss(id): + state.operatedCommentIds.insert(id) + return .run { send in + await send(.checkDisplay) + await send(.next) + } + + case let .accept(id): + guard !state.operatedCommentIds.contains(id), + let comment = state.getCommentById(id: id), + let suggestion = comment.suggestion, + let url = URL(string: comment.uri), + let currentContent = try? String(contentsOf: url), + let originalContent = state.getOriginalContentByUri(comment.uri) + else { return .none } + + let currentLines = currentContent.components(separatedBy: .newlines) + + let currentEndLineNumber = CodeReviewLocationStrategy.calculateCurrentLineNumber( + for: comment.range.end.line, + originalLines: originalContent.components(separatedBy: .newlines), + currentLines: currentLines + ) + + let range: CursorRange = .init( + start: .init( + line: currentEndLineNumber - (comment.range.end.line - comment.range.start.line), + character: comment.range.start.character + ), + end: .init(line: currentEndLineNumber, character: comment.range.end.character) + ) + + ChatInjector.insertSuggestion( + suggestion: suggestion, + range: range, + lines: currentLines + ) + + state.operatedCommentIds.insert(id) + + return .none + + case let .onActiveDocumentURLChanged(url): + if url != state.activeDocumentURL { + if let pendingNavigation = state.pendingNavigation, + pendingNavigation.url == url { + state.activeDocumentURL = url + state.currentIndex = pendingNavigation.index + } else { + state.activeDocumentURL = url + state.currentIndex = 0 + } + } + return .run { send in await send(.checkDisplay) } + + case .appear: + return .run { send in + await send(.observeDocumentReviews) + await send(.observeReviewedFileClicked) + } + + case .observeDocumentReviews: + return .run { send in + for await documentReviews in await CodeReviewService.shared.$documentReviews.values { + await send(.onCodeReviewResultsChanged(documentReviews)) + } + } + + case .observeReviewedFileClicked: + return .run { send in + for await _ in await CodeReviewStateService.shared.fileClickedEvent.values { + await send(.reviewedfileClicked) + } + } + + case let .onCodeReviewResultsChanged(newCodeReviewResults): + state.documentReviews = newCodeReviewResults + + return .run { send in await send(.checkDisplay) } + + case .checkDisplay: + guard !state.closedByUser else { + state.isPanelDisplayed = false + return .none + } + + if let currentDocumentReview = state.currentDocumentReview, + currentDocumentReview.comments.count > 0 { + state.isPanelDisplayed = true + } else { + state.isPanelDisplayed = false + } + + return .none + + case .reviewedfileClicked: + state.isPanelDisplayed = true + state.closedByUser = false + + return .none + } + } + } +} + +enum NavigationDirection { + case previous, next +} + +extension CodeReviewPanelFeature.State { + func getDocumentNavigation(_ direction: NavigationDirection) -> (documentUri: String, commentIndex: Int)? { + let documentUris = documentUris + let documentUrisCount = documentUris.count + + guard documentUrisCount > 1, + let activeDocumentURL = activeDocumentURL, + let documentIndex = documentUris.firstIndex(where: { $0 == activeDocumentURL.absoluteString }) + else { return nil } + + var offSet = 1 + // Iter documentUris to find valid next/previous document and comment + while offSet < documentUrisCount { + let targetDocumentIndex: Int = { + switch direction { + case .previous: (documentIndex - offSet + documentUrisCount) % documentUrisCount + case .next: (documentIndex + offSet) % documentUrisCount + } + }() + + let targetDocumentUri = documentUris[targetDocumentIndex] + if let targetComments = documentReviews[targetDocumentUri]?.comments, + !targetComments.isEmpty { + let targetCommentIndex: Int = { + switch direction { + case .previous: targetComments.count - 1 + case .next: 0 + } + }() + + return (targetDocumentUri, targetCommentIndex) + } + + offSet += 1 + } + + return nil + } + + mutating func navigateToDocument(uri: String, index: Int) { + let url = URL(fileURLWithPath: uri) + let originalContent = documentReviews[uri]!.originalContent + let comment = documentReviews[uri]!.comments[index] + + openFileInXcode(fileURL: url, originalContent: originalContent, range: comment.range) + + pendingNavigation = .init(url: url, index: index) + } + + func hasComment(of direction: NavigationDirection) -> Bool { + // Has next comment against current document + switch direction { + case .next: + if currentDocumentReview?.comments.count ?? 0 > currentIndex + 1 { + return true + } + case .previous: + if currentIndex > 0 { + return true + } + } + + // Has next comment against next document + if getDocumentNavigation(direction) != nil { + return true + } + + return false + } +} + +private func openFileInXcode( + fileURL: URL, + originalContent: String, + range: LSPRange +) { + NSWorkspace.openFileInXcode(fileURL: fileURL) { app, error in + guard error == nil else { + Logger.client.error("Failed to open file in xcode: \(error!.localizedDescription)") + return + } + + guard let app = app else { return } + + let appInstanceInspector = AppInstanceInspector(runningApplication: app) + guard appInstanceInspector.isXcode, + let focusedElement = appInstanceInspector.appElement.focusedElement, + let content = try? String(contentsOf: fileURL) + else { return } + + let currentLineNumber = CodeReviewLocationStrategy.calculateCurrentLineNumber( + for: range.end.line, + originalLines: originalContent.components(separatedBy: .newlines), + currentLines: content.components(separatedBy: .newlines) + ) + + + AXHelper.scrollSourceEditorToLine( + currentLineNumber, + content: content, + focusedElement: focusedElement + ) + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index e0af56cb..83b516d5 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -36,6 +36,10 @@ public struct WidgetFeature { // MARK: ChatPanel public var chatPanelState = ChatPanelFeature.State() + + // MARK: CodeReview + + public var codeReviewPanelState = CodeReviewPanelFeature.State() // MARK: CircularWidget @@ -111,6 +115,7 @@ public struct WidgetFeature { case panel(PanelFeature.Action) case chatPanel(ChatPanelFeature.Action) case circularWidget(CircularWidgetFeature.Action) + case codeReviewPanel(CodeReviewPanelFeature.Action) } var windowsController: WidgetWindowsController? { @@ -138,6 +143,10 @@ public struct WidgetFeature { Scope(state: \._internalCircularWidgetState, action: \.circularWidget) { CircularWidgetFeature() } + + Scope(state: \.codeReviewPanelState, action: \.codeReviewPanel) { + CodeReviewPanelFeature() + } Reduce { state, action in switch action { @@ -399,6 +408,9 @@ public struct WidgetFeature { case .chatPanel: return .none + + case .codeReviewPanel: + return .none } } } diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index 382771cf..6a7ea438 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -14,6 +14,8 @@ enum Style { static let widgetPadding: Double = 4 static let chatWindowTitleBarHeight: Double = 24 static let trafficLightButtonSize: Double = 12 + static let codeReviewPanelWidth: Double = 550 + static let codeReviewPanelHeight: Double = 450 } extension Color { diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index d6e6e60c..6ad035fb 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -1,6 +1,7 @@ import AppKit import Foundation import XcodeInspector +import ConversationServiceProvider public struct WidgetLocation: Equatable { struct PanelLocation: Equatable { @@ -357,3 +358,86 @@ enum UpdateLocationStrategy { } } +public struct CodeReviewLocationStrategy { + static func calculateCurrentLineNumber( + for originalLineNumber: Int, // 1-based + originalLines: [String], + currentLines: [String] + ) -> Int { + let difference = currentLines.difference(from: originalLines) + + let targetIndex = originalLineNumber + var adjustment = 0 + + for change in difference { + switch change { + case .insert(let offset, _, _): + // Inserted at or before target line + if offset <= targetIndex + adjustment { + adjustment += 1 + } + case .remove(let offset, _, _): + // Deleted at or before target line + if offset <= targetIndex + adjustment { + adjustment -= 1 + } + } + } + + return targetIndex + adjustment + } + + static func getCurrentLineFrame( + editor: AXUIElement, + currentContent: String, + comment: ReviewComment, + originalContent: String + ) -> (lineNumber: Int?, lineFrame: CGRect?) { + let originalLines = originalContent.components(separatedBy: .newlines) + let currentLines = currentContent.components(separatedBy: .newlines) + + let originalLineNumber = comment.range.end.line + let currentLineNumber = calculateCurrentLineNumber( + for: originalLineNumber, + originalLines: originalLines, + currentLines: currentLines + ) // 1-based + // Calculate the character position for the start of the target line + var characterPosition = 0 + for i in 0 ..< currentLineNumber { + characterPosition += currentLines[i].count + 1 // +1 for newline character + } + + var range = CFRange(location: characterPosition, length: currentLines[currentLineNumber].count) + let rangeValue = AXValueCreate(AXValueType.cfRange, &range) + + var boundsValue: CFTypeRef? + let result = AXUIElementCopyParameterizedAttributeValue( + editor, + kAXBoundsForRangeParameterizedAttribute as CFString, + rangeValue!, + &boundsValue + ) + + if result == .success, + let bounds = boundsValue + { + var rect = CGRect.zero + let success = AXValueGetValue(bounds as! AXValue, AXValueType.cgRect, &rect) + + if success == true { + return ( + currentLineNumber, + CGRect( + x: rect.minX, + y: rect.minY, + width: rect.width, + height: rect.height + ) + ) + } + } + + return (nil, nil) + } +} diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 9c4feb0f..ca2e52f4 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -7,6 +7,7 @@ import Dependencies import Foundation import SwiftUI import XcodeInspector +import AXHelper actor WidgetWindowsController: NSObject { let userDefaultsObservers = WidgetUserDefaultsObservers() @@ -70,12 +71,44 @@ actor WidgetWindowsController: NSObject { } }.store(in: &cancellable) + xcodeInspector.$activeDocumentURL.sink { [weak self] url in + Task { [weak self] in + await self?.updateCodeReviewWindowLocation(.onActiveDocumentURLChanged) + _ = await MainActor.run { [weak self] in + self?.store.send(.codeReviewPanel(.onActiveDocumentURLChanged(url))) + } + } + }.store(in: &cancellable) + userDefaultsObservers.presentationModeChangeObserver.onChange = { [weak self] in Task { [weak self] in await self?.updateWindowLocation(animated: false, immediately: false) await self?.send(.updateColorScheme) } } + + // Observe state change of code review + setupCodeReviewPanelObservers() + } + + private func setupCodeReviewPanelObservers() { + store.publisher + .map(\.codeReviewPanelState.currentIndex) + .removeDuplicates() + .sink { [weak self] _ in + Task { [weak self] in + await self?.updateCodeReviewWindowLocation(.onCurrentReviewIndexChanged) + } + }.store(in: &cancellable) + + store.publisher + .map(\.codeReviewPanelState.isPanelDisplayed) + .removeDuplicates() + .sink { [weak self] isPanelDisplayed in + Task { [weak self] in + await self?.updateCodeReviewWindowLocation(.onIsPanelDisplayedChanged(isPanelDisplayed)) + } + }.store(in: &cancellable) } } @@ -153,12 +186,14 @@ private extension WidgetWindowsController { await updateWidgetsAndNotifyChangeOfEditor(immediately: false) case .windowMiniaturized, .windowDeminiaturized: await updateWidgets(immediately: false) + await updateCodeReviewWindowLocation(.onXcodeAppNotification(notification)) case .resized, .moved, .windowMoved, .windowResized: await updateWidgets(immediately: false) await updateAttachedChatWindowLocation(notification) + await updateCodeReviewWindowLocation(.onXcodeAppNotification(notification)) case .created, .uiElementDestroyed, .xcodeCompletionPanelChanged, .applicationDeactivated: continue @@ -176,11 +211,14 @@ private extension WidgetWindowsController { .filter { $0.kind == .selectedTextChanged } let scroll = await editor.axNotifications.notifications() .filter { $0.kind == .scrollPositionChanged } + let valueChange = await editor.axNotifications.notifications() + .filter { $0.kind == .valueChanged } if #available(macOS 13.0, *) { for await notification in merge( scroll, - selectionRangeChange.debounce(for: Duration.milliseconds(0)) + selectionRangeChange.debounce(for: Duration.milliseconds(0)), + valueChange.debounce(for: Duration.milliseconds(100)) ) { guard await xcodeInspector.safe.latestActiveXcode != nil else { return } try Task.checkCancellation() @@ -192,9 +230,10 @@ private extension WidgetWindowsController { updateWindowLocation(animated: false, immediately: false) updateWindowOpacity(immediately: false) + await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) } } else { - for await notification in merge(selectionRangeChange, scroll) { + for await notification in merge(selectionRangeChange, scroll, valueChange) { guard await xcodeInspector.safe.latestActiveXcode != nil else { return } try Task.checkCancellation() @@ -205,6 +244,7 @@ private extension WidgetWindowsController { updateWindowLocation(animated: false, immediately: false) updateWindowOpacity(immediately: false) + await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) } } } @@ -242,6 +282,19 @@ extension WidgetWindowsController { send(.panel(.hidePanel)) } + @MainActor + func hideCodeReviewWindow() { + windows.codeReviewPanelWindow.alphaValue = 0 + windows.codeReviewPanelWindow.setIsVisible(false) + } + + @MainActor + func displayCodeReviewWindow() { + windows.codeReviewPanelWindow.setIsVisible(true) + windows.codeReviewPanelWindow.alphaValue = 1 + windows.codeReviewPanelWindow.orderFrontRegardless() + } + func generateWidgetLocation() -> WidgetLocation? { // Default location when no active application/window let defaultLocation = generateDefaultLocation() @@ -636,6 +689,135 @@ extension WidgetWindowsController { } } +// MARK: - Code Review +extension WidgetWindowsController { + + enum CodeReviewLocationTrigger { + case onXcodeAppNotification(XcodeAppInstanceInspector.AXNotification) // resized, moved + case onSourceEditorNotification(SourceEditor.AXNotification) // scroll, valueChange + case onActiveDocumentURLChanged + case onCurrentReviewIndexChanged + case onIsPanelDisplayedChanged(Bool) + + static let relevantXcodeAppNotificationKind: [XcodeAppInstanceInspector.AXNotificationKind] = + [ + .windowMiniaturized, + .windowDeminiaturized, + .resized, + .moved, + .windowMoved, + .windowResized + ] + + static let relevantSourceEditorNotificationKind: [SourceEditor.AXNotificationKind] = + [.scrollPositionChanged, .valueChanged] + + var isRelevant: Bool { + switch self { + case .onActiveDocumentURLChanged, .onCurrentReviewIndexChanged, .onIsPanelDisplayedChanged: return true + case let .onSourceEditorNotification(notif): + return Self.relevantSourceEditorNotificationKind.contains(where: { $0 == notif.kind }) + case let .onXcodeAppNotification(notif): + return Self.relevantXcodeAppNotificationKind.contains(where: { $0 == notif.kind }) + } + } + + var shouldScroll: Bool { + switch self { + case .onCurrentReviewIndexChanged: return true + default: return false + } + } + } + + @MainActor + func updateCodeReviewWindowLocation(_ trigger: CodeReviewLocationTrigger) async { + guard trigger.isRelevant else { return } + if case .onIsPanelDisplayedChanged(let isPanelDisplayed) = trigger, !isPanelDisplayed { + hideCodeReviewWindow() + return + } + + var sourceEditorElement: AXUIElement? + + switch trigger { + case .onXcodeAppNotification(let notif): + sourceEditorElement = notif.element.retrieveSourceEditor() + case .onSourceEditorNotification(_), + .onActiveDocumentURLChanged, + .onCurrentReviewIndexChanged, + .onIsPanelDisplayedChanged: + sourceEditorElement = await xcodeInspector.safe.focusedEditor?.element + } + + guard let sourceEditorElement = sourceEditorElement + else { + hideCodeReviewWindow() + return + } + + await _updateCodeReviewWindowLocation( + sourceEditorElement, + shouldScroll: trigger.shouldScroll + ) + } + + @MainActor + func _updateCodeReviewWindowLocation(_ sourceEditorElement: AXUIElement, shouldScroll: Bool = false) async { + // Get the current index and comment from the store state + let state = store.withState { $0.codeReviewPanelState } + + guard state.isPanelDisplayed, + let comment = state.currentSelectedComment, + await currentXcodeApp?.realtimeDocumentURL?.absoluteString == comment.uri, + let reviewWindowFittingSize = windows.codeReviewPanelWindow.contentView?.fittingSize + else { + hideCodeReviewWindow() + return + } + + guard let originalContent = state.originalContent, + let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), + let scrollViewRect = sourceEditorElement.parent?.rect, + let scrollScreenFrame = sourceEditorElement.parent?.maxIntersectionScreen?.frame, + let currentContent: String = try? sourceEditorElement.copyValue(key: kAXValueAttribute) + else { return } + + let result = CodeReviewLocationStrategy.getCurrentLineFrame( + editor: sourceEditorElement, + currentContent: currentContent, + comment: comment, + originalContent: originalContent) + guard let lineNumber = result.lineNumber, let lineFrame = result.lineFrame + else { return } + + // The line should be visible + guard lineFrame.width > 0, lineFrame.height > 0, + scrollViewRect.contains(lineFrame) + else { + if shouldScroll { + AXHelper + .scrollSourceEditorToLine( + lineNumber, + content: currentContent, + focusedElement: sourceEditorElement + ) + } else { + hideCodeReviewWindow() + } + return + } + + // Position the code review window near the target line + var reviewWindowFrame = windows.codeReviewPanelWindow.frame + reviewWindowFrame.origin.x = scrollViewRect.maxX - reviewWindowFrame.width + reviewWindowFrame.origin.y = screen.frame.maxY - lineFrame.maxY + screen.frame.minY - reviewWindowFrame.height + + windows.codeReviewPanelWindow.setFrame(reviewWindowFrame, display: true, animate: true) + displayCodeReviewWindow() + } +} + // MARK: - NSWindowDelegate extension WidgetWindowsController: NSWindowDelegate { @@ -799,6 +981,39 @@ public final class WidgetWindows { return it }() + @MainActor + lazy var codeReviewPanelWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init( + x: 0, + y: 0, + width: Style.codeReviewPanelWidth, + height: Style.codeReviewPanelHeight + ), + styleMask: .borderless, + backing: .buffered, + defer: true + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.hasShadow = true + it.level = widgetLevel(2) + it.contentView = NSHostingView( + rootView: CodeReviewPanelView( + store: store.scope( + state: \.codeReviewPanelState, + action: \.codeReviewPanel + ) + ) + ) + it.canBecomeKeyChecker = { true } + it.alphaValue = 0 + it.setIsVisible(false) + return it + }() + @MainActor lazy var chatPanelWindow = { let it = ChatPanelWindow( @@ -876,4 +1091,3 @@ func widgetLevel(_ addition: Int) -> NSWindow.Level { minimumWidgetLevel = NSWindow.Level.floating.rawValue return .init(minimumWidgetLevel + addition) } - diff --git a/ExtensionService/Assets.xcassets/Icons/Contents.json b/ExtensionService/Assets.xcassets/Icons/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Icons/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/Icons/chevron.down.imageset/Contents.json b/ExtensionService/Assets.xcassets/Icons/chevron.down.imageset/Contents.json new file mode 100644 index 00000000..d5d75895 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Icons/chevron.down.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "chevron-down.svg", + "idiom" : "mac" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "localizable" : true, + "preserves-vector-representation" : true + } +} diff --git a/ExtensionService/Assets.xcassets/Icons/chevron.down.imageset/chevron-down.svg b/ExtensionService/Assets.xcassets/Icons/chevron.down.imageset/chevron-down.svg new file mode 100644 index 00000000..1547b27d --- /dev/null +++ b/ExtensionService/Assets.xcassets/Icons/chevron.down.imageset/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json b/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json new file mode 100644 index 00000000..db53bbf8 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "sparkle.svg", + "idiom" : "mac" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "sparkle_dark.svg", + "idiom" : "mac" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle.svg b/ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle.svg new file mode 100644 index 00000000..442e6cc3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle_dark.svg b/ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle_dark.svg new file mode 100644 index 00000000..2102024b --- /dev/null +++ b/ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json b/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json new file mode 100644 index 00000000..ddb0a503 --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "codeReview.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "codeReview 1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview 1.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview 1.svg new file mode 100644 index 00000000..44ce60ee --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview 1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview.svg new file mode 100644 index 00000000..6084e72c --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ExtensionService/Assets.xcassets/editor.focusedStackFrameHighlightBackground.colorset/Contents.json b/ExtensionService/Assets.xcassets/editor.focusedStackFrameHighlightBackground.colorset/Contents.json new file mode 100644 index 00000000..e475d8e3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/editor.focusedStackFrameHighlightBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "202", + "green" : "223", + "red" : "203" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "57", + "green" : "77", + "red" : "57" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/editorOverviewRuler.inlineChatRemoved.colorset/Contents.json b/ExtensionService/Assets.xcassets/editorOverviewRuler.inlineChatRemoved.colorset/Contents.json new file mode 100644 index 00000000..abd021c3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/editorOverviewRuler.inlineChatRemoved.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "211", + "green" : "214", + "red" : "242" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "25", + "green" : "25", + "red" : "55" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/gitDecoration.addedResourceForeground.colorset/Contents.json b/ExtensionService/Assets.xcassets/gitDecoration.addedResourceForeground.colorset/Contents.json new file mode 100644 index 00000000..a19edf2b --- /dev/null +++ b/ExtensionService/Assets.xcassets/gitDecoration.addedResourceForeground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "52", + "green" : "138", + "red" : "56" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "52", + "green" : "138", + "red" : "56" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/gitDecoration.deletedResourceForeground.colorset/Contents.json b/ExtensionService/Assets.xcassets/gitDecoration.deletedResourceForeground.colorset/Contents.json new file mode 100644 index 00000000..f8b5d709 --- /dev/null +++ b/ExtensionService/Assets.xcassets/gitDecoration.deletedResourceForeground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "57", + "green" : "78", + "red" : "199" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "57", + "green" : "78", + "red" : "199" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Server/package-lock.json b/Server/package-lock.json index e2d8b63e..bc7fe532 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.351.0", + "@github/copilot-language-server": "^1.355.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,9 +36,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.351.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.351.0.tgz", - "integrity": "sha512-Owpl/cOTMQwXYArYuB1KCZGYkAScSb4B1TxPrKxAM10nIBeCtyHuEc1NQ0Pw05asMAHnoHWHVGQDrJINjlA8Ww==", + "version": "1.355.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.355.0.tgz", + "integrity": "sha512-Utuljxab2sosUPIilHdLDwBkr+A1xKju+KHG+iLoxDJNA8FGWtoalZv9L3QhakmvC9meQtvMciAYcdeeKPbcaQ==", "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" diff --git a/Server/package.json b/Server/package.json index 9bd5a961..d5ccd3f8 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.351.0", + "@github/copilot-language-server": "^1.355.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/Tool/Package.swift b/Tool/Package.swift index e7c4e9f3..c0a2785f 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -64,7 +64,8 @@ let package = Package( .library(name: "Cache", targets: ["Cache"]), .library(name: "StatusBarItemView", targets: ["StatusBarItemView"]), .library(name: "HostAppActivator", targets: ["HostAppActivator"]), - .library(name: "AppKitExtension", targets: ["AppKitExtension"]) + .library(name: "AppKitExtension", targets: ["AppKitExtension"]), + .library(name: "GitHelper", targets: ["GitHelper"]) ], dependencies: [ // TODO: Update LanguageClient some day. @@ -276,6 +277,7 @@ let package = Package( .testTarget(name: "SuggestionProviderTests", dependencies: ["SuggestionProvider"]), .target(name: "ConversationServiceProvider", dependencies: [ + "GitHelper", .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), ]), @@ -360,7 +362,17 @@ let package = Package( // MARK: - AppKitExtension - .target(name: "AppKitExtension") + .target(name: "AppKitExtension"), + + // MARK: - GitHelper + .target( + name: "GitHelper", + dependencies: [ + "Terminal", + .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol") + ] + ), + .testTarget(name: "GitHelperTests", dependencies: ["GitHelper"]) ] ) diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index 1a790e20..b7366398 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -245,6 +245,19 @@ public extension AXUIElement { var verticalScrollBar: AXUIElement? { try? copyValue(key: kAXVerticalScrollBarAttribute) } + + func retrieveSourceEditor() -> AXUIElement? { + if self.isSourceEditor { return self } + + if self.isXcodeWorkspaceWindow { + return self.firstChild(where: \.isSourceEditor) + } + + guard let xcodeWorkspaceWindowElement = self.firstParent(where: \.isXcodeWorkspaceWindow) + else { return nil } + + return xcodeWorkspaceWindowElement.firstChild(where: \.isSourceEditor) + } } public extension AXUIElement { @@ -321,6 +334,56 @@ public extension AXUIElement { } } +// MARK: - Xcode Specific +public extension AXUIElement { + func findSourceEditorElement(shouldRetry: Bool = true) -> AXUIElement? { + + // 1. Check if the current element is a source editor + if isSourceEditor { + return self + } + + // 2. Search for child that is a source editor + if let sourceEditorChild = firstChild(where: \.isSourceEditor) { + return sourceEditorChild + } + + // 3. Search for parent that is a source editor (XcodeInspector's approach) + if let sourceEditorParent = firstParent(where: \.isSourceEditor) { + return sourceEditorParent + } + + // 4. Search for parent that is an editor area + if let editorAreaParent = firstParent(where: \.isEditorArea) { + // 3.1 Search for child that is a source editor + if let sourceEditorChild = editorAreaParent.firstChild(where: \.isSourceEditor) { + return sourceEditorChild + } + } + + // 5. Search for the workspace window + if let xcodeWorkspaceWindowParent = firstParent(where: \.isXcodeWorkspaceWindow) { + // 4.1 Search for child that is an editor area + if let editorAreaChild = xcodeWorkspaceWindowParent.firstChild(where: \.isEditorArea) { + // 4.2 Search for child that is a source editor + if let sourceEditorChild = editorAreaChild.firstChild(where: \.isSourceEditor) { + return sourceEditorChild + } + } + } + + // 6. retry + if shouldRetry { + Thread.sleep(forTimeInterval: 0.5) + return findSourceEditorElement(shouldRetry: false) + } + + + return nil + + } +} + #if hasFeature(RetroactiveAttribute) extension AXError: @retroactive Error {} #else diff --git a/Tool/Sources/AXHelper/AXHelper.swift b/Tool/Sources/AXHelper/AXHelper.swift index c6e7405a..5af9a206 100644 --- a/Tool/Sources/AXHelper/AXHelper.swift +++ b/Tool/Sources/AXHelper/AXHelper.swift @@ -56,15 +56,43 @@ public struct AXHelper { if let oldScrollPosition, let scrollBar = focusElement.parent?.verticalScrollBar { - AXUIElementSetAttributeValue( - scrollBar, - kAXValueAttribute as CFString, - oldScrollPosition as CFTypeRef - ) + Self.setScrollBarValue(scrollBar, value: oldScrollPosition) } if let onSuccess = onSuccess { onSuccess() } } + + /// Helper method to set scroll bar value using Accessibility API + private static func setScrollBarValue(_ scrollBar: AXUIElement, value: Double) { + AXUIElementSetAttributeValue( + scrollBar, + kAXValueAttribute as CFString, + value as CFTypeRef + ) + } + + private static func getScrollPositionForLine(_ lineNumber: Int, content: String) -> Double? { + let lines = content.components(separatedBy: .newlines) + let linesCount = lines.count + + guard lineNumber > 0 && lineNumber <= linesCount + else { return nil } + + // Calculate relative position (0.0 to 1.0) + let relativePosition = Double(lineNumber - 1) / Double(linesCount - 1) + + // Ensure valid range + return (0.0 <= relativePosition && relativePosition <= 1.0) ? relativePosition : nil + } + + public static func scrollSourceEditorToLine(_ lineNumber: Int, content: String, focusedElement: AXUIElement) { + guard focusedElement.isSourceEditor, + let scrollBar = focusedElement.parent?.verticalScrollBar, + let linePosition = Self.getScrollPositionForLine(lineNumber, content: content) + else { return } + + Self.setScrollBarValue(scrollBar, value: linePosition) + } } diff --git a/Tool/Sources/AppKitExtension/NSWorkspace+Extension.swift b/Tool/Sources/AppKitExtension/NSWorkspace+Extension.swift index 9cc54ede..46d1aa98 100644 --- a/Tool/Sources/AppKitExtension/NSWorkspace+Extension.swift +++ b/Tool/Sources/AppKitExtension/NSWorkspace+Extension.swift @@ -1,4 +1,5 @@ import AppKit +import Logger extension NSWorkspace { public static func getXcodeBundleURL() -> URL? { @@ -19,4 +20,32 @@ extension NSWorkspace { return xcodeBundleURL } + + public static func openFileInXcode( + fileURL: URL, + completion: ((NSRunningApplication?, Error?) -> Void)? = nil + ) { + guard let xcodeBundleURL = Self.getXcodeBundleURL() else { + if let completion = completion { + completion(nil, NSError(domain: "The Xcode app is not found.", code: 0)) + } + return + } + + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + configuration.promptsUserIfNeeded = false + + Self.shared.open( + [fileURL], + withApplicationAt: xcodeBundleURL, + configuration: configuration + ) { app, error in + if let completion = completion { + completion(app, error) + } else if let error = error { + Logger.client.error("Failed to open file \(String(describing: error))") + } + } + } } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift index 3d4be7c1..0b62e141 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -171,4 +171,17 @@ public final class BuiltinExtensionConversationServiceProvider< return (try? await conversationService.agents(workspace: workspaceInfo)) } + + public func reviewChanges(_ params: ReviewChangesParams) async throws -> CodeReviewResult? { + 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.reviewChanges(workspace: workspaceInfo, params: params)) + } } diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift index bde4a954..9bcbcf97 100644 --- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift @@ -1,4 +1,5 @@ import Foundation +import ConversationServiceProvider public protocol ChatMemory { /// The message history. @@ -70,38 +71,49 @@ extension ChatMessage { // merge agent steps if !message.editAgentRounds.isEmpty { - var mergedAgentRounds = self.editAgentRounds + let mergedAgentRounds = mergeEditAgentRounds( + oldRounds: self.editAgentRounds, + newRounds: message.editAgentRounds + ) - for newRound in message.editAgentRounds { - if let index = mergedAgentRounds.firstIndex(where: { $0.roundId == newRound.roundId }) { - mergedAgentRounds[index].reply = mergedAgentRounds[index].reply + newRound.reply - - if newRound.toolCalls != nil, !newRound.toolCalls!.isEmpty { - var mergedToolCalls = mergedAgentRounds[index].toolCalls ?? [] - for newToolCall in newRound.toolCalls! { - if let toolCallIndex = mergedToolCalls.firstIndex(where: { $0.id == newToolCall.id }) { - mergedToolCalls[toolCallIndex].status = newToolCall.status - if let progressMessage = newToolCall.progressMessage, !progressMessage.isEmpty { - mergedToolCalls[toolCallIndex].progressMessage = newToolCall.progressMessage - } - if let error = newToolCall.error, !error.isEmpty { - mergedToolCalls[toolCallIndex].error = newToolCall.error - } - if let invokeParams = newToolCall.invokeParams { - mergedToolCalls[toolCallIndex].invokeParams = invokeParams - } - } else { - mergedToolCalls.append(newToolCall) + self.editAgentRounds = mergedAgentRounds + } + + self.codeReviewRound = message.codeReviewRound + } + + private func mergeEditAgentRounds(oldRounds: [AgentRound], newRounds: [AgentRound]) -> [AgentRound] { + var mergedAgentRounds = oldRounds + + for newRound in newRounds { + if let index = mergedAgentRounds.firstIndex(where: { $0.roundId == newRound.roundId }) { + mergedAgentRounds[index].reply = mergedAgentRounds[index].reply + newRound.reply + + if newRound.toolCalls != nil, !newRound.toolCalls!.isEmpty { + var mergedToolCalls = mergedAgentRounds[index].toolCalls ?? [] + for newToolCall in newRound.toolCalls! { + if let toolCallIndex = mergedToolCalls.firstIndex(where: { $0.id == newToolCall.id }) { + mergedToolCalls[toolCallIndex].status = newToolCall.status + if let progressMessage = newToolCall.progressMessage, !progressMessage.isEmpty { + mergedToolCalls[toolCallIndex].progressMessage = newToolCall.progressMessage + } + if let error = newToolCall.error, !error.isEmpty { + mergedToolCalls[toolCallIndex].error = newToolCall.error } + if let invokeParams = newToolCall.invokeParams { + mergedToolCalls[toolCallIndex].invokeParams = invokeParams + } + } else { + mergedToolCalls.append(newToolCall) } - mergedAgentRounds[index].toolCalls = mergedToolCalls } - } else { - mergedAgentRounds.append(newRound) + mergedAgentRounds[index].toolCalls = mergedToolCalls } + } else { + mergedAgentRounds.append(newRound) } - - self.editAgentRounds = mergedAgentRounds } + + return mergedAgentRounds } } diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index 9706a4bd..6fbafba8 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -111,6 +111,8 @@ public struct ChatMessage: Equatable, Codable { public var panelMessages: [CopilotShowMessageParams] + public var codeReviewRound: CodeReviewRound? + /// The timestamp of the message. public var createdAt: Date public var updatedAt: Date @@ -130,6 +132,7 @@ public struct ChatMessage: Equatable, Codable { steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], panelMessages: [CopilotShowMessageParams] = [], + codeReviewRound: CodeReviewRound? = nil, createdAt: Date? = nil, updatedAt: Date? = nil ) { @@ -147,11 +150,72 @@ public struct ChatMessage: Equatable, Codable { self.steps = steps self.editAgentRounds = editAgentRounds self.panelMessages = panelMessages + self.codeReviewRound = codeReviewRound let now = Date.now self.createdAt = createdAt ?? now self.updatedAt = updatedAt ?? now } + + public init( + userMessageWithId id: String, + chatTabId: String, + content: String, + contentImageReferences: [ImageReference] = [], + references: [ConversationReference] = [] + ) { + self.init( + id: id, + chatTabID: chatTabId, + role: .user, + content: content, + contentImageReferences: contentImageReferences, + references: references + ) + } + + public init( + assistantMessageWithId id: String, // TurnId + chatTabID: String, + content: String = "", + references: [ConversationReference] = [], + followUp: ConversationFollowUp? = nil, + suggestedTitle: String? = nil, + steps: [ConversationProgressStep] = [], + editAgentRounds: [AgentRound] = [], + codeReviewRound: CodeReviewRound? = nil + ) { + self.init( + id: id, + chatTabID: chatTabID, + clsTurnID: id, + role: .assistant, + content: content, + references: references, + followUp: followUp, + suggestedTitle: suggestedTitle, + steps: steps, + editAgentRounds: editAgentRounds, + codeReviewRound: codeReviewRound + ) + } + + public init( + errorMessageWithId id: String, // TurnId + chatTabID: String, + errorMessages: [String] = [], + panelMessages: [CopilotShowMessageParams] = [] + ) { + self.init( + id: id, + chatTabID: chatTabID, + clsTurnID: id, + role: .assistant, + content: "", + errorMessages: errorMessages, + panelMessages: panelMessages + ) + } } extension ConversationReference { diff --git a/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift b/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift new file mode 100644 index 00000000..d9e2c7e9 --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift @@ -0,0 +1,154 @@ +import Foundation +import LanguageServerProtocol +import GitHelper + +public struct CodeReviewRequest: Equatable, Codable { + public struct FileChange: Equatable, Codable { + public let changes: [PRChange] + public var selectedChanges: [PRChange] + + public init(changes: [PRChange]) { + self.changes = changes + self.selectedChanges = changes + } + } + + public var fileChange: FileChange + + public var changedFileUris: [DocumentUri] { fileChange.changes.map { $0.uri } } + public var selectedFileUris: [DocumentUri] { fileChange.selectedChanges.map { $0.uri } } + + public init(fileChange: FileChange) { + self.fileChange = fileChange + } + + public static func from(_ changes: [PRChange]) -> CodeReviewRequest { + return .init(fileChange: .init(changes: changes)) + } + + public mutating func updateSelectedChanges(by fileUris: [DocumentUri]) { + fileChange.selectedChanges = fileChange.selectedChanges.filter { fileUris.contains($0.uri) } + } +} + +public struct CodeReviewResponse: Equatable, Codable { + public struct FileComment: Equatable, Codable, Hashable { + public let uri: DocumentUri + public let originalContent: String + public var comments: [ReviewComment] + + public var url: URL? { URL(string: uri) } + + public init(uri: DocumentUri, originalContent: String, comments: [ReviewComment]) { + self.uri = uri + self.originalContent = originalContent + self.comments = comments + } + } + + public var fileComments: [FileComment] + + public var allComments: [ReviewComment] { + fileComments.flatMap { $0.comments } + } + + public init(fileComments: [FileComment]) { + self.fileComments = fileComments + } + + public func merge(with other: CodeReviewResponse) -> CodeReviewResponse { + var mergedResponse = self + + for newFileComment in other.fileComments { + if let index = mergedResponse.fileComments.firstIndex(where: { $0.uri == newFileComment.uri }) { + // Merge comments for existing URI + var mergedComments = mergedResponse.fileComments[index].comments + newFileComment.comments + mergedComments.sortByEndLine() + mergedResponse.fileComments[index].comments = mergedComments + } else { + // Append new URI with sorted comments + var newReview = newFileComment + newReview.comments.sortByEndLine() + mergedResponse.fileComments.append(newReview) + } + } + + return mergedResponse + } +} + +public struct CodeReviewRound: Equatable, Codable { + public enum Status: Equatable, Codable { + case waitForConfirmation, accepted, running, completed, error, cancelled + + public func canTransitionTo(_ newStatus: Status) -> Bool { + switch (self, newStatus) { + case (.waitForConfirmation, .accepted): return true + case (.waitForConfirmation, .cancelled): return true + case (.accepted, .running): return true + case (.accepted, .cancelled): return true + case (.running, .completed): return true + case (.running, .error): return true + case (.running, .cancelled): return true + default: return false + } + } + } + + public let id: String + public let turnId: String + public var status: Status { + didSet { statusHistory.append(status) } + } + public private(set) var statusHistory: [Status] + public var request: CodeReviewRequest? + public var response: CodeReviewResponse? + public var error: String? + + public init( + id: String = UUID().uuidString, + turnId: String, + status: Status, + request: CodeReviewRequest? = nil, + response: CodeReviewResponse? = nil, + error: String? = nil + ) { + self.id = id + self.turnId = turnId + self.status = status + self.request = request + self.response = response + self.error = error + self.statusHistory = [status] + } + + public static func fromError(turnId: String, error: String) -> CodeReviewRound { + .init(turnId: turnId, status: .error, error: error) + } + + public func withResponse(_ response: CodeReviewResponse) -> CodeReviewRound { + var round = self + round.response = response + return round + } + + public func withStatus(_ status: Status) -> CodeReviewRound { + var round = self + round.status = status + return round + } + + public func withError(_ error: String) -> CodeReviewRound { + var round = self + round.error = error + round.status = .error + return round + } +} + +extension Array where Element == ReviewComment { + // Order in asc + public mutating func sortByEndLine() { + self.sort(by: { $0.range.end.line < $1.range.end.line }) + } +} diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index 913c5cf7..12d51564 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -14,6 +14,7 @@ public protocol ConversationServiceType { func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws func agents(workspace: WorkspaceInfo) async throws -> [ChatAgent]? func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspace: WorkspaceInfo) async throws + func reviewChanges(workspace: WorkspaceInfo, params: ReviewChangesParams) async throws -> CodeReviewResult? } public protocol ConversationServiceProvider { @@ -27,6 +28,7 @@ public protocol ConversationServiceProvider { func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws func agents() async throws -> [ChatAgent]? func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspaceURL: URL?) async throws + func reviewChanges(_ params: ReviewChangesParams) async throws -> CodeReviewResult? } public struct FileReference: Hashable, Codable, Equatable { diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift index 63d44b32..a0c109f2 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift @@ -342,3 +342,66 @@ public struct ActionCommand: Codable, Equatable, Hashable { public var commandId: String public var args: LSPAny? } + +// MARK: - Copilot Code Review + +public struct ReviewChangesParams: Codable, Equatable { + public struct Change: Codable, Equatable { + public let uri: DocumentUri + public let path: String + // The original content of the file before changes were made. Will be empty string if the file is new. + public let baseContent: String + // The current content of the file with changes applied. Will be empty string if the file is deleted. + public let headContent: String + + public init(uri: DocumentUri, path: String, baseContent: String, headContent: String) { + self.uri = uri + self.path = path + self.baseContent = baseContent + self.headContent = headContent + } + } + + public let changes: [Change] + + public init(changes: [Change]) { + self.changes = changes + } +} + +public struct ReviewComment: Codable, Equatable, Hashable { + // Self-defined `id` for using in comment operation. Add an init value to bypass decoding + public let id: String = UUID().uuidString + public let uri: DocumentUri + public let range: LSPRange + public let message: String + // enum: bug, performance, consistency, documentation, naming, readability, style, other + public let kind: String + // enum: low, medium, high + public let severity: String + public let suggestion: String? + + public init( + uri: DocumentUri, + range: LSPRange, + message: String, + kind: String, + severity: String, + suggestion: String? + ) { + self.uri = uri + self.range = range + self.message = message + self.kind = kind + self.severity = severity + self.suggestion = suggestion + } +} + +public struct CodeReviewResult: Codable, Equatable { + public let comments: [ReviewComment] + + public init(comments: [ReviewComment]) { + self.comments = comments + } +} diff --git a/Tool/Sources/GitHelper/CurrentChange.swift b/Tool/Sources/GitHelper/CurrentChange.swift new file mode 100644 index 00000000..d7680f25 --- /dev/null +++ b/Tool/Sources/GitHelper/CurrentChange.swift @@ -0,0 +1,74 @@ +import Foundation +import LanguageServerProtocol + +public struct PRChange: Equatable, Codable { + public let uri: DocumentUri + public let path: String + public let baseContent: String + public let headContent: String + + public var originalContent: String { headContent } +} + +public enum CurrentChangeService { + public static func getPRChanges( + _ repositoryURL: URL, + group: GitDiffGroup, + shouldIncludeFile: (URL) -> Bool + ) async -> [PRChange] { + let gitStats = await GitDiff.getDiffFiles(repositoryURL: repositoryURL, group: group) + + var changes: [PRChange] = [] + + for stat in gitStats { + guard shouldIncludeFile(stat.url) else { continue } + + guard let content = try? String(contentsOf: stat.url, encoding: .utf8) + else { continue } + let uri = stat.url.absoluteString + + let relativePath = Self.getRelativePath(fileURL: stat.url, repositoryURL: repositoryURL) + + switch stat.status { + case .untracked, .indexAdded: + changes.append(.init(uri: uri, path: relativePath, baseContent: "", headContent: content)) + + case .modified: + guard let originalContent = GitShow.showHeadContent(of: relativePath, repositoryURL: repositoryURL) else { + continue + } + changes.append(.init(uri: uri, path: relativePath, baseContent: originalContent, headContent: content)) + + case .deleted, .indexRenamed: + continue + } + } + + // Include untracked files + if group == .workingTree { + let untrackedGitStats = GitStatus.getStatus(repositoryURL: repositoryURL, untrackedFilesOption: .all) + for stat in untrackedGitStats { + guard !changes.contains(where: { $0.uri == stat.url.absoluteString }), + let content = try? String(contentsOf: stat.url, encoding: .utf8) + else { continue } + + let relativePath = Self.getRelativePath(fileURL: stat.url, repositoryURL: repositoryURL) + changes.append( + .init(uri: stat.url.absoluteString, path: relativePath, baseContent: "", headContent: content) + ) + } + } + + return changes + } + + // TODO: Handle cases of multi-project and referenced file + private static func getRelativePath(fileURL: URL, repositoryURL: URL) -> String { + var relativePath = fileURL.path.replacingOccurrences(of: repositoryURL.path, with: "") + if relativePath.starts(with: "/") { + relativePath = String(relativePath.dropFirst()) + } + + return relativePath + } +} diff --git a/Tool/Sources/GitHelper/GitDiff.swift b/Tool/Sources/GitHelper/GitDiff.swift new file mode 100644 index 00000000..b8cf4a00 --- /dev/null +++ b/Tool/Sources/GitHelper/GitDiff.swift @@ -0,0 +1,114 @@ +import Foundation +import SystemUtils + +public enum GitDiffGroup { + case index // Staged + case workingTree // Unstaged +} + +public struct GitDiff { + public static func getDiff(of filePath: String, repositoryURL: URL, group: GitDiffGroup) async -> String { + var arguments = ["diff"] + if group == .index { + arguments.append("--cached") + } + arguments.append(contentsOf: ["--", filePath]) + + let result = try? SystemUtils.executeCommand( + inDirectory: repositoryURL.path, + path: GitPath, + arguments: arguments + ) + + return result ?? "" + } + + public static func getDiffFiles(repositoryURL: URL, group: GitDiffGroup) async -> [GitChange] { + var arguments = ["diff", "--name-status", "-z", "--diff-filter=ADMR"] + if group == .index { + arguments.append("--cached") + } + + let result = try? SystemUtils.executeCommand( + inDirectory: repositoryURL.path, + path: GitPath, + arguments: arguments + ) + + return result == nil + ? [] + : Self.parseDiff(repositoryURL: repositoryURL, raw: result!) + } + + private static func parseDiff(repositoryURL: URL, raw: String) -> [GitChange] { + var index = 0 + var result: [GitChange] = [] + let segments = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "\0") + .map(String.init) + .filter { !$0.isEmpty } + + segmentsLoop: while index < segments.count - 1 { + let change = segments[index] + index += 1 + + let resourcePath = segments[index] + index += 1 + + if change.isEmpty || resourcePath.isEmpty { + break + } + + let originalURL: URL + if resourcePath.hasPrefix("/") { + originalURL = URL(fileURLWithPath: resourcePath) + } else { + originalURL = repositoryURL.appendingPathComponent(resourcePath) + } + + var url = originalURL + var status = GitFileStatus.untracked + + // Copy or Rename status comes with a number (ex: 'R100'). + // We don't need the number, we use only first character of the status. + switch change.first { + case "A": + status = .indexAdded + + case "M": + status = .modified + + case "D": + status = .deleted + + // Rename contains two paths, the second one is what the file is renamed/copied to. + case "R": + if index >= segments.count { + break + } + + let newPath = segments[index] + index += 1 + + if newPath.isEmpty { + break + } + + status = .indexRenamed + if newPath.hasPrefix("/") { + url = URL(fileURLWithPath: newPath) + } else { + url = repositoryURL.appendingPathComponent(newPath) + } + + default: + // Unknown status + break segmentsLoop + } + + result.append(.init(url: url, originalURL: originalURL, status: status)) + } + + return result + } +} diff --git a/Tool/Sources/GitHelper/GitHunk.swift b/Tool/Sources/GitHelper/GitHunk.swift new file mode 100644 index 00000000..2939dd99 --- /dev/null +++ b/Tool/Sources/GitHelper/GitHunk.swift @@ -0,0 +1,105 @@ +import Foundation + +public struct GitHunk { + public let startDeletedLine: Int // 1-based + public let deletedLines: Int + public let startAddedLine: Int // 1-based + public let addedLines: Int + public let additions: [(start: Int, length: Int)] + public let diffText: String + + public init( + startDeletedLine: Int, + deletedLines: Int, + startAddedLine: Int, + addedLines: Int, + additions: [(start: Int, length: Int)], + diffText: String + ) { + self.startDeletedLine = startDeletedLine + self.deletedLines = deletedLines + self.startAddedLine = startAddedLine + self.addedLines = addedLines + self.additions = additions + self.diffText = diffText + } +} + +public extension GitHunk { + static func parseDiff(_ diff: String) -> [GitHunk] { + var hunkTexts = diff.components(separatedBy: "\n@@") + + if !hunkTexts.isEmpty, hunkTexts.last?.hasSuffix("\n") == true { + hunkTexts[hunkTexts.count - 1] = String(hunkTexts.last!.dropLast()) + } + + let hunks: [GitHunk] = hunkTexts.compactMap { chunk -> GitHunk? in + let rangePattern = #"-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?"# + let regex = try! NSRegularExpression(pattern: rangePattern) + let nsString = chunk as NSString + + guard let match = regex.firstMatch( + in: chunk, + options: [], + range: NSRange(location: 0, length: nsString.length) + ) + else { return nil } + + var startDeletedLine = Int(nsString.substring(with: match.range(at: 1))) ?? 0 + let deletedLines = match.range(at: 2).location != NSNotFound + ? Int(nsString.substring(with: match.range(at: 2))) ?? 1 + : 1 + var startAddedLine = Int(nsString.substring(with: match.range(at: 3))) ?? 0 + let addedLines = match.range(at: 4).location != NSNotFound + ? Int(nsString.substring(with: match.range(at: 4))) ?? 1 + : 1 + + var additions: [(start: Int, length: Int)] = [] + let lines = Array(chunk.components(separatedBy: "\n").dropFirst()) + var d = 0 + var addStart: Int? + + for line in lines { + let ch = line.first ?? Character(" ") + + if ch == "+" { + if addStart == nil { + addStart = startAddedLine + d + } + d += 1 + } else { + if let start = addStart { + additions.append((start: start, length: startAddedLine + d - start)) + addStart = nil + } + if ch == " " { + d += 1 + } + } + } + + if let start = addStart { + additions.append((start: start, length: startAddedLine + d - start)) + } + + if startDeletedLine == 0 { + startDeletedLine = 1 + } + + if startAddedLine == 0 { + startAddedLine = 1 + } + + return GitHunk( + startDeletedLine: startDeletedLine, + deletedLines: deletedLines, + startAddedLine: startAddedLine, + addedLines: addedLines, + additions: additions, + diffText: lines.joined(separator: "\n") + ) + } + + return hunks + } +} diff --git a/Tool/Sources/GitHelper/GitShow.swift b/Tool/Sources/GitHelper/GitShow.swift new file mode 100644 index 00000000..6eaf858f --- /dev/null +++ b/Tool/Sources/GitHelper/GitShow.swift @@ -0,0 +1,24 @@ +import Foundation +import SystemUtils + +public struct GitShow { + public static func showHeadContent(of filePath: String, repositoryURL: URL) -> String? { + let escapedFilePath = Self.escapePath(filePath) + let arguments = ["show", "HEAD:\(escapedFilePath)"] + + let result = try? SystemUtils.executeCommand( + inDirectory: repositoryURL.path, + path: GitPath, + arguments: arguments + ) + + return result + } + + private static func escapePath(_ string: String) -> String { + let charactersToEscape = CharacterSet(charactersIn: " '\"&()[]{}$`\\|;<>*?~") + return string.unicodeScalars.map { scalar in + charactersToEscape.contains(scalar) ? "\\\(Character(scalar))" : String(Character(scalar)) + }.joined() + } +} diff --git a/Tool/Sources/GitHelper/GitStatus.swift b/Tool/Sources/GitHelper/GitStatus.swift new file mode 100644 index 00000000..eb769403 --- /dev/null +++ b/Tool/Sources/GitHelper/GitStatus.swift @@ -0,0 +1,47 @@ +import Foundation +import SystemUtils + +public enum UntrackedFilesOption: String { + case all, no, normal +} + +public struct GitStatus { + static let unTrackedFilePrefix = "?? " + + public static func getStatus(repositoryURL: URL, untrackedFilesOption: UntrackedFilesOption = .all) -> [GitChange] { + let arguments = ["status", "--porcelain", "--untracked-files=\(untrackedFilesOption.rawValue)"] + + let result = try? SystemUtils.executeCommand( + inDirectory: repositoryURL.path, + path: GitPath, + arguments: arguments + ) + + if let result = result { + return Self.parseStatus(statusOutput: result, repositoryURL: repositoryURL) + } else { + return [] + } + } + + private static func parseStatus(statusOutput: String, repositoryURL: URL) -> [GitChange] { + var changes: [GitChange] = [] + let fileManager = FileManager.default + + let lines = statusOutput.components(separatedBy: .newlines) + for line in lines { + if line.hasPrefix(unTrackedFilePrefix) { + let fileRelativePath = String(line.dropFirst(unTrackedFilePrefix.count)) + let fileURL = repositoryURL.appendingPathComponent(fileRelativePath) + + guard fileManager.fileExists(atPath: fileURL.path) else { continue } + + changes.append( + .init(url: fileURL, originalURL: fileURL, status: .untracked) + ) + } + } + + return changes + } +} diff --git a/Tool/Sources/GitHelper/types.swift b/Tool/Sources/GitHelper/types.swift new file mode 100644 index 00000000..26adcec7 --- /dev/null +++ b/Tool/Sources/GitHelper/types.swift @@ -0,0 +1,23 @@ +import Foundation + +let GitPath = "/usr/bin/git" + +public enum GitFileStatus { + case untracked + case indexAdded + case modified + case deleted + case indexRenamed +} + +public struct GitChange { + public let url: URL + public let originalURL: URL + public let status: GitFileStatus + + public init(url: URL, originalURL: URL, status: GitFileStatus) { + self.url = url + self.originalURL = originalURL + self.status = status + } +} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/MCPOAuthRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/MCPOAuthRequestHandler.swift new file mode 100644 index 00000000..47ea5017 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Conversation/MCPOAuthRequestHandler.swift @@ -0,0 +1,67 @@ +import JSONRPC +import Foundation +import Combine +import Logger +import AppKit + +public protocol MCPOAuthRequestHandler { + func handleShowOAuthMessage( + _ request: MCPOAuthRequest, + completion: @escaping ( + AnyJSONRPCResponse + ) -> Void + ) +} + +public final class MCPOAuthRequestHandlerImpl: MCPOAuthRequestHandler { + public static let shared = MCPOAuthRequestHandlerImpl() + + public func handleShowOAuthMessage(_ request: MCPOAuthRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) { + guard let params = request.params else { return } + Logger.gitHubCopilot.debug("Received MCP OAuth Request: \(params)") + Task { @MainActor in + let confirmResult = showMCPOAuthAlert(params) + let jsonResult = try? JSONEncoder().encode(MCPOAuthResponse(confirm: confirmResult)) + let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null + completion(AnyJSONRPCResponse(id: request.id, result: jsonValue)) + } + } + + @MainActor + func showMCPOAuthAlert(_ params: MCPOAuthRequestParams) -> Bool { + let alert = NSAlert() + let mcpConfigString = UserDefaults.shared.value(for: \.gitHubCopilotMCPConfig) + + var serverName = params.mcpServer // Default fallback + + if let mcpConfigData = mcpConfigString.data(using: .utf8), + let mcpConfig = try? JSONDecoder().decode(JSONValue.self, from: mcpConfigData) { + // Iterate through the servers to find a match for the mcpServer URL + if case .hash(let serversDict) = mcpConfig { + for (userDefinedName, serverConfig) in serversDict { + if let url = serverConfig["url"]?.stringValue { + // Check if the mcpServer URL matches the configured URL + if params.mcpServer.contains(url) || url.contains(params.mcpServer) { + serverName = userDefinedName + break + } + } + } + } + } + + alert.messageText = "GitHub Copilot" + alert.informativeText = "The MCP Server Definition '\(serverName)' wants to authenticate to \(params.authLabel)." + alert.alertStyle = .informational + + alert.addButton(withTitle: "Continue") + alert.addButton(withTitle: "Cancel") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + return true + } else { + return false + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift index 431ca5ed..1ad669a1 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift @@ -159,3 +159,14 @@ public struct UpdateMCPToolsStatusParams: Codable, Hashable { } public typealias CopilotMCPToolsRequest = JSONRPCRequest + +public struct MCPOAuthRequestParams: Codable, Hashable { + public var mcpServer: String + public var authLabel: String +} + +public struct MCPOAuthResponse: Codable, Hashable { + public var confirm: Bool +} + +public typealias MCPOAuthRequest = JSONRPCRequest diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 9453e54f..2a352118 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -408,6 +408,20 @@ enum GitHubCopilotRequest { .custom("conversation/agents", .hash([:]), ClientRequest.NullHandler) } } + + // MARK: - Code Review + + struct ReviewChanges: GitHubCopilotRequestType { + typealias Response = CodeReviewResult + + var params: ReviewChangesParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("copilot/codeReview/reviewChanges", dict, ClientRequest.NullHandler) + } + } struct RegisterTools: GitHubCopilotRequestType { struct Response: Codable {} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 4ea5de5c..139fd42b 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -286,41 +286,6 @@ public class GitHubCopilotBaseService { self.server = server localProcessServer = localServer - - Task { [weak self] in - if projectRootURL.path != "/" { - try? await server.sendNotification( - .workspaceDidChangeWorkspaceFolders( - .init(event: .init(added: [.init(uri: projectRootURL.absoluteString, name: projectRootURL.lastPathComponent)], removed: [])) - ) - ) - } - - func sendConfigurationUpdate() async { - let includeMCP = projectRootURL.path != "/" && - FeatureFlagNotifierImpl.shared.featureFlags.agentMode && - FeatureFlagNotifierImpl.shared.featureFlags.mcp - _ = try? await server.sendNotification( - .workspaceDidChangeConfiguration( - .init(settings: editorConfiguration(includeMCP: includeMCP)) - ) - ) - } - - // Send initial configuration after initialize - await sendConfigurationUpdate() - - // Combine both notification streams - let combinedNotifications = Publishers.Merge( - NotificationCenter.default.publisher(for: .gitHubCopilotShouldRefreshEditorInformation).map { _ in "editorInfo" }, - FeatureFlagNotifierImpl.shared.featureFlagsDidChange.map { _ in "featureFlags" } - ) - - for await _ in combinedNotifications.values { - guard self != nil else { return } - await sendConfigurationUpdate() - } - } } @@ -430,6 +395,7 @@ public final class GitHubCopilotService: private var isMCPInitialized = false private var unrestoredMcpServers: [String] = [] private var mcpRuntimeLogFileName: String = "" + private var lastSentConfiguration: JSONValue? override init(designatedServer: any GitHubCopilotLSP) { super.init(designatedServer: designatedServer) @@ -438,6 +404,8 @@ public final class GitHubCopilotService: override public init(projectRootURL: URL = URL(fileURLWithPath: "/"), workspaceURL: URL = URL(fileURLWithPath: "/")) throws { do { try super.init(projectRootURL: projectRootURL, workspaceURL: workspaceURL) + + self.handleSendWorkspaceDidChangeNotifications() localProcessServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in if notification.method == "copilot/mcpTools" && projectRootURL.path != "/" { @@ -467,6 +435,9 @@ public final class GitHubCopilotService: case let .request(id, request): switch request { case let .custom(method, params, callback): + if method == "copilot/mcpOAuth" && projectRootURL.path == "/" { + continue + } self.serverRequestHandler.handleRequest(.init(id: id, method: method, params: params), workspaceURL: workspaceURL, callback: callback, service: self) default: break @@ -733,6 +704,18 @@ public final class GitHubCopilotService: throw error } } + + @GitHubCopilotSuggestionActor + public func reviewChanges(params: ReviewChangesParams) async throws -> CodeReviewResult { + do { + let response = try await sendRequest( + GitHubCopilotRequest.ReviewChanges(params: params) + ) + return response + } catch { + throw error + } + } @GitHubCopilotSuggestionActor public func registerTools(tools: [LanguageModelToolInformation]) async throws { @@ -1239,6 +1222,51 @@ public final class GitHubCopilotService: let pathHash = String(workspacePath.hash.magnitude, radix: 36).prefix(6) return "\(workspaceName)-\(pathHash)" } + + public func handleSendWorkspaceDidChangeNotifications() { + Task { + if projectRootURL.path != "/" { + try? await self.server.sendNotification( + .workspaceDidChangeWorkspaceFolders( + .init(event: .init(added: [.init(uri: projectRootURL.absoluteString, name: projectRootURL.lastPathComponent)], removed: [])) + ) + ) + } + + // Send initial configuration after initialize + await sendConfigurationUpdate() + + // Combine both notification streams + let combinedNotifications = Publishers.Merge( + NotificationCenter.default.publisher(for: .gitHubCopilotShouldRefreshEditorInformation).map { _ in "editorInfo" }, + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.map { _ in "featureFlags" } + ) + + for await _ in combinedNotifications.values { + await sendConfigurationUpdate() + } + } + } + + private func sendConfigurationUpdate() async { + let includeMCP = projectRootURL.path != "/" && + FeatureFlagNotifierImpl.shared.featureFlags.agentMode && + FeatureFlagNotifierImpl.shared.featureFlags.mcp + + let newConfiguration = editorConfiguration(includeMCP: includeMCP) + + // Only send the notification if the configuration has actually changed + guard self.lastSentConfiguration != newConfiguration else { return } + + _ = try? await self.server.sendNotification( + .workspaceDidChangeConfiguration( + .init(settings: newConfiguration) + ) + ) + + // Cache the sent configuration + self.lastSentConfiguration = newConfiguration + } } extension SafeInitializingServer: GitHubCopilotLSP { diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift index 897245f2..7b28b73b 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift @@ -18,6 +18,7 @@ class ServerRequestHandlerImpl : ServerRequestHandler { private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared private let showMessageRequestHandler: ShowMessageRequestHandler = ShowMessageRequestHandlerImpl.shared + private let mcpOAuthRequestHandler: MCPOAuthRequestHandler = MCPOAuthRequestHandlerImpl.shared func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping ResponseHandler, service: GitHubCopilotService?) { let methodName = request.method @@ -59,6 +60,18 @@ class ServerRequestHandlerImpl : ServerRequestHandler { let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: params) ClientToolHandlerImpl.shared.invokeClientToolConfirmation(InvokeClientToolConfirmationRequest(id: request.id, method: request.method, params: invokeParams), completion: legacyResponseHandler) + case "copilot/mcpOAuth": + let params = try JSONEncoder().encode(request.params) + let mcpOAuthRequestParams = try JSONDecoder().decode(MCPOAuthRequestParams.self, from: params) + mcpOAuthRequestHandler.handleShowOAuthMessage( + MCPOAuthRequest( + id: request.id, + method: request.method, + params: mcpOAuthRequestParams + ), + completion: legacyResponseHandler + ) + default: break } diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift index 2008b33c..a4248e8f 100644 --- a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift +++ b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift @@ -30,6 +30,7 @@ public struct FeatureFlags: Hashable, Codable { public var projectContext: Bool public var agentMode: Bool public var mcp: Bool + public var ccr: Bool // Copilot Code Review public var activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags public init( @@ -40,6 +41,7 @@ public struct FeatureFlags: Hashable, Codable { projectContext: Bool = true, agentMode: Bool = true, mcp: Bool = true, + ccr: Bool = true, activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags = [:] ) { self.restrictedTelemetry = restrictedTelemetry @@ -49,6 +51,7 @@ public struct FeatureFlags: Hashable, Codable { self.projectContext = projectContext self.agentMode = agentMode self.mcp = mcp + self.ccr = ccr self.activeExperimentForFeatureFlags = activeExperimentForFeatureFlags } } @@ -84,6 +87,7 @@ public class FeatureFlagNotifierImpl: FeatureFlagNotifier { self.featureFlags.inlineChat = chatEnabled self.featureFlags.agentMode = self.didChangeFeatureFlagsParams.token["agent_mode"] != "0" self.featureFlags.mcp = self.didChangeFeatureFlagsParams.token["mcp"] != "0" + self.featureFlags.ccr = self.didChangeFeatureFlagsParams.token["ccr"] != "0" self.featureFlags.activeExperimentForFeatureFlags = self.didChangeFeatureFlagsParams.activeExps } diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift index fc86e530..b6f19132 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -115,5 +115,11 @@ public final class GitHubCopilotConversationService: ConversationServiceType { guard let service = await serviceLocator.getService(from: workspace) else { return nil } return try await service.agents() } + + public func reviewChanges(workspace: WorkspaceInfo, params: ReviewChangesParams) async throws -> CodeReviewResult? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } + + return try await service.reviewChanges(params: params) + } } diff --git a/Tool/Sources/SharedUIComponents/Base/Colors.swift b/Tool/Sources/SharedUIComponents/Base/Colors.swift new file mode 100644 index 00000000..2015102a --- /dev/null +++ b/Tool/Sources/SharedUIComponents/Base/Colors.swift @@ -0,0 +1,5 @@ +import SwiftUI + +public extension Color { + static var hoverColor: Color { .gray.opacity(0.1) } +} diff --git a/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift index f8f1116d..e58b5b56 100644 --- a/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift +++ b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift @@ -6,7 +6,7 @@ public struct HoverButtonStyle: ButtonStyle { private var padding: CGFloat private var hoverColor: Color - public init(isHovered: Bool = false, padding: CGFloat = 4, hoverColor: Color = Color.gray.opacity(0.1)) { + public init(isHovered: Bool = false, padding: CGFloat = 4, hoverColor: Color = .hoverColor) { self.isHovered = isHovered self.padding = padding self.hoverColor = hoverColor diff --git a/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift b/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift index afaa6073..922ed55f 100644 --- a/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift +++ b/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift @@ -1,24 +1,30 @@ import SwiftUI public struct CopilotMessageHeader: View { - public init() {} + let spacing: CGFloat + + public init(spacing: CGFloat = 4) { + self.spacing = spacing + } public var body: some View { - 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) - ) + HStack(spacing: spacing) { + ZStack { + Circle() + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + .frame(width: 24, height: 24) + + Image("CopilotLogo") + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: 12, height: 12) + } + Text("GitHub Copilot") .font(.system(size: 13)) .fontWeight(.semibold) - .padding(4) + .padding(.leading, 4) Spacer() } diff --git a/Tool/Sources/Status/Status.swift b/Tool/Sources/Status/Status.swift index be005f5f..62176c94 100644 --- a/Tool/Sources/Status/Status.swift +++ b/Tool/Sources/Status/Status.swift @@ -120,6 +120,10 @@ public final actor Status { public func getCLSStatus() -> CLSStatus { clsStatus } + + public func getQuotaInfo() -> GitHubCopilotQuotaInfo? { + currentUserQuotaInfo + } public func getStatus() -> StatusResponse { let authStatusInfo: AuthStatusInfo = getAuthStatusInfo() diff --git a/Tool/Sources/Status/StatusObserver.swift b/Tool/Sources/Status/StatusObserver.swift index 2bda2b2b..825f41ad 100644 --- a/Tool/Sources/Status/StatusObserver.swift +++ b/Tool/Sources/Status/StatusObserver.swift @@ -6,6 +6,7 @@ public class StatusObserver: ObservableObject { @Published public private(set) var authStatus = AuthStatus(status: .unknown, username: nil, message: nil) @Published public private(set) var clsStatus = CLSStatus(status: .unknown, busy: false, message: "") @Published public private(set) var observedAXStatus = ObservedAXStatus.unknown + @Published public private(set) var quotaInfo: GitHubCopilotQuotaInfo? = nil public static let shared = StatusObserver() @@ -14,6 +15,7 @@ public class StatusObserver: ObservableObject { await observeAuthStatus() await observeCLSStatus() await observeAXStatus() + await observeQuotaInfo() } } @@ -32,6 +34,11 @@ public class StatusObserver: ObservableObject { setupAXStatusNotificationObserver() } + private func observeQuotaInfo() async { + await updateQuotaInfo() + setupQuotaInfoNotificationObserver() + } + private func updateAuthStatus() async { let authStatus = await Status.shared.getAuthStatus() let statusInfo = await Status.shared.getStatus() @@ -54,6 +61,10 @@ public class StatusObserver: ObservableObject { self.observedAXStatus = await Status.shared.getAXStatus() } + private func updateQuotaInfo() async { + self.quotaInfo = await Status.shared.getQuotaInfo() + } + private func setupAuthStatusNotificationObserver() { NotificationCenter.default.addObserver( forName: .serviceStatusDidChange, @@ -103,4 +114,17 @@ public class StatusObserver: ObservableObject { } } } + + private func setupQuotaInfoNotificationObserver() { + NotificationCenter.default.addObserver( + forName: .serviceStatusDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + Task { @MainActor [self] in + await self.updateQuotaInfo() + } + } + } } diff --git a/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift b/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift index 8e4b3d23..50ffc4f3 100644 --- a/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift +++ b/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift @@ -12,4 +12,6 @@ public struct GitHubCopilotQuotaInfo: Codable, Equatable, Hashable { public var premiumInteractions: QuotaSnapshot public var resetDate: String public var copilotPlan: String + + public var isFreeUser: Bool { copilotPlan == "free" } } diff --git a/Tool/Sources/SystemUtils/SystemUtils.swift b/Tool/Sources/SystemUtils/SystemUtils.swift index e5b0c79a..43569b88 100644 --- a/Tool/Sources/SystemUtils/SystemUtils.swift +++ b/Tool/Sources/SystemUtils/SystemUtils.swift @@ -176,16 +176,12 @@ public class SystemUtils { /// Returns the environment of a login shell (to get correct PATH and other variables) public func getLoginShellEnvironment(shellPath: String = "/bin/zsh") -> [String: String]? { - let task = Process() - let pipe = Pipe() - task.executableURL = URL(fileURLWithPath: shellPath) - task.arguments = ["-i", "-l", "-c", "env"] - task.standardOutput = pipe do { - try task.run() - task.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let output = String(data: data, encoding: .utf8) else { return nil } + guard let output = try Self.executeCommand( + path: shellPath, + arguments: ["-i", "-l", "-c", "env"]) + else { return nil } + var env: [String: String] = [:] for line in output.split(separator: "\n") { if let idx = line.firstIndex(of: "=") { @@ -200,6 +196,32 @@ public class SystemUtils { return nil } } + + public static func executeCommand( + inDirectory directory: String = NSHomeDirectory(), + path: String, + arguments: [String] + ) throws -> String? { + let task = Process() + let pipe = Pipe() + + defer { + pipe.fileHandleForReading.closeFile() + if task.isRunning { + task.terminate() + } + } + + task.executableURL = URL(fileURLWithPath: path) + task.arguments = arguments + task.standardOutput = pipe + task.currentDirectoryURL = URL(fileURLWithPath: directory) + + try task.run() + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } public func appendCommonBinPaths(path: String) -> String { let homeDirectory = NSHomeDirectory() diff --git a/Tool/Tests/GitHelperTests/GitHunkTests.swift b/Tool/Tests/GitHelperTests/GitHunkTests.swift new file mode 100644 index 00000000..03e79a2f --- /dev/null +++ b/Tool/Tests/GitHelperTests/GitHunkTests.swift @@ -0,0 +1,272 @@ +import XCTest +import GitHelper + +class GitHunkTests: XCTestCase { + + func testParseDiffSingleHunk() { + let diff = """ + @@ -1,3 +1,4 @@ + line1 + +added line + line2 + line3 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1) + XCTAssertEqual(hunk.deletedLines, 3) + XCTAssertEqual(hunk.startAddedLine, 1) + XCTAssertEqual(hunk.addedLines, 4) + XCTAssertEqual(hunk.additions.count, 1) + XCTAssertEqual(hunk.additions[0].start, 2) + XCTAssertEqual(hunk.additions[0].length, 1) + XCTAssertEqual(hunk.diffText, " line1\n+added line\n line2\n line3") + } + + func testParseDiffMultipleHunks() { + let diff = """ + @@ -1,2 +1,3 @@ + line1 + +added line1 + line2 + @@ -10,2 +11,3 @@ + line10 + +added line10 + line11 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 2) + + // First hunk + let hunk1 = hunks[0] + XCTAssertEqual(hunk1.startDeletedLine, 1) + XCTAssertEqual(hunk1.deletedLines, 2) + XCTAssertEqual(hunk1.startAddedLine, 1) + XCTAssertEqual(hunk1.addedLines, 3) + XCTAssertEqual(hunk1.additions.count, 1) + XCTAssertEqual(hunk1.additions[0].start, 2) + XCTAssertEqual(hunk1.additions[0].length, 1) + + // Second hunk + let hunk2 = hunks[1] + XCTAssertEqual(hunk2.startDeletedLine, 10) + XCTAssertEqual(hunk2.deletedLines, 2) + XCTAssertEqual(hunk2.startAddedLine, 11) + XCTAssertEqual(hunk2.addedLines, 3) + XCTAssertEqual(hunk2.additions.count, 1) + XCTAssertEqual(hunk2.additions[0].start, 12) + XCTAssertEqual(hunk2.additions[0].length, 1) + } + + func testParseDiffMultipleAdditions() { + let diff = """ + @@ -1,5 +1,7 @@ + line1 + +added line1 + +added line2 + line2 + line3 + +added line3 + line4 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.additions.count, 2) + + // First addition block + XCTAssertEqual(hunk.additions[0].start, 2) + XCTAssertEqual(hunk.additions[0].length, 2) + + // Second addition block + XCTAssertEqual(hunk.additions[1].start, 6) + XCTAssertEqual(hunk.additions[1].length, 1) + } + + func testParseDiffWithDeletions() { + let diff = """ + @@ -1,4 +1,2 @@ + line1 + -deleted line1 + -deleted line2 + line2 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1) + XCTAssertEqual(hunk.deletedLines, 4) + XCTAssertEqual(hunk.startAddedLine, 1) + XCTAssertEqual(hunk.addedLines, 2) + XCTAssertEqual(hunk.additions.count, 0) // No additions, only deletions + } + + func testParseDiffNewFile() { + let diff = """ + @@ -0,0 +1,3 @@ + +line1 + +line2 + +line3 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1) // Should be adjusted from 0 to 1 + XCTAssertEqual(hunk.deletedLines, 0) + XCTAssertEqual(hunk.startAddedLine, 1) // Should be adjusted from 0 to 1 + XCTAssertEqual(hunk.addedLines, 3) + XCTAssertEqual(hunk.additions.count, 1) + XCTAssertEqual(hunk.additions[0].start, 1) + XCTAssertEqual(hunk.additions[0].length, 3) + } + + func testParseDiffDeletedFile() { + let diff = """ + @@ -1,3 +0,0 @@ + -line1 + -line2 + -line3 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1) + XCTAssertEqual(hunk.deletedLines, 3) + XCTAssertEqual(hunk.startAddedLine, 1) // Should be adjusted from 0 to 1 + XCTAssertEqual(hunk.addedLines, 0) + XCTAssertEqual(hunk.additions.count, 0) + } + + func testParseDiffSingleLineContext() { + let diff = """ + @@ -1 +1,2 @@ + line1 + +added line + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1) + XCTAssertEqual(hunk.deletedLines, 1) // Default when not specified + XCTAssertEqual(hunk.startAddedLine, 1) + XCTAssertEqual(hunk.addedLines, 2) + XCTAssertEqual(hunk.additions.count, 1) + XCTAssertEqual(hunk.additions[0].start, 2) + XCTAssertEqual(hunk.additions[0].length, 1) + } + + func testParseDiffEmptyString() { + let diff = "" + let hunks = GitHunk.parseDiff(diff) + XCTAssertEqual(hunks.count, 0) + } + + func testParseDiffInvalidFormat() { + let diff = """ + invalid diff format + no hunk headers + """ + + let hunks = GitHunk.parseDiff(diff) + XCTAssertEqual(hunks.count, 0) + } + + func testParseDiffTrailingNewline() { + let diff = """ + @@ -1,2 +1,3 @@ + line1 + +added line + line2 + + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.diffText, " line1\n+added line\n line2") + XCTAssertFalse(hunk.diffText.hasSuffix("\n")) + } + + func testParseDiffConsecutiveAdditions() { + let diff = """ + @@ -1,3 +1,6 @@ + line1 + +added1 + +added2 + +added3 + line2 + line3 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.additions.count, 1) + XCTAssertEqual(hunk.additions[0].start, 2) + XCTAssertEqual(hunk.additions[0].length, 3) + } + + func testParseDiffMixedChanges() { + let diff = """ + @@ -1,6 +1,7 @@ + line1 + -deleted line + +added line1 + +added line2 + line2 + line3 + line4 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1) + XCTAssertEqual(hunk.deletedLines, 6) + XCTAssertEqual(hunk.startAddedLine, 1) + XCTAssertEqual(hunk.addedLines, 7) + XCTAssertEqual(hunk.additions.count, 1) + XCTAssertEqual(hunk.additions[0].start, 2) + XCTAssertEqual(hunk.additions[0].length, 2) + } + + func testParseDiffLargeLineNumbers() { + let diff = """ + @@ -1000,5 +1000,6 @@ + line1000 + +added line + line1001 + line1002 + line1003 + line1004 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1000) + XCTAssertEqual(hunk.startAddedLine, 1000) + XCTAssertEqual(hunk.additions.count, 1) + XCTAssertEqual(hunk.additions[0].start, 1001) + XCTAssertEqual(hunk.additions[0].length, 1) + } +} diff --git a/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift b/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift index 95313c0d..4dae3722 100644 --- a/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift +++ b/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift @@ -66,4 +66,33 @@ final class SystemUtilsTests: XCTestCase { // First component should be the initial path components XCTAssertTrue(appendedExistingPath.hasPrefix(existingCommonPath), "Should preserve original path at the beginning") } + + func test_executeCommand() throws { + // Test with a simple echo command + let testMessage = "Hello, World!" + let output = try SystemUtils.executeCommand(path: "/bin/echo", arguments: [testMessage]) + + XCTAssertNotNil(output, "Output should not be nil for valid command") + XCTAssertEqual( + output?.trimmingCharacters(in: .whitespacesAndNewlines), + testMessage, "Output should match the expected message" + ) + + // Test with a command that returns multiple lines + let multilineOutput = try SystemUtils.executeCommand(path: "/bin/echo", arguments: ["-e", "line1\\nline2"]) + XCTAssertNotNil(multilineOutput, "Output should not be nil for multiline command") + XCTAssertTrue(multilineOutput?.contains("line1") ?? false, "Output should contain 'line1'") + XCTAssertTrue(multilineOutput?.contains("line2") ?? false, "Output should contain 'line2'") + + // Test with a command that has no output + let noOutput = try SystemUtils.executeCommand(path: "/usr/bin/true", arguments: []) + XCTAssertNotNil(noOutput, "Output should not be nil even for commands with no output") + XCTAssertTrue(noOutput?.isEmpty ?? false, "Output should be empty for /usr/bin/true") + + // Test with an invalid command path should throw an error + XCTAssertThrowsError( + try SystemUtils.executeCommand(path: "/nonexistent/command", arguments: []), + "Should throw error for invalid command path" + ) + } } From 3a6713080789ad0696f9e72e09e89c28c86bd551 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 14 Aug 2025 09:55:33 +0000 Subject: [PATCH 18/26] Release 0.41.0 --- CHANGELOG.md | 16 ++++++++++ Core/Sources/ConversationTab/ChatPanel.swift | 32 +++++++------------ .../HostApp/MCPSettings/MCPIntroView.swift | 15 +++++++++ ReleaseNotes.md | 19 +++++++---- Tool/Sources/Status/StatusObserver.swift | 4 +++ 5 files changed, 59 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cce07a7d..2c099b50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.41.0 - August 14, 2025 +### Added +- Code review feature. +- Chat: Support for new model GPT-5. +- Agent mode: Added support for new tool to read web URL contents. +- Support disabling MCP when it's disabled by policy. +- Support for opening MCP logs directly from the MCP settings page. +- OAuth support for remote GitHub MCP server. + +### Changed +- Performance: Improved instant-apply speed for edit_file tool. + +### Fixed +- Chat Agent repeatedly reverts its own changes when editing the same file. +- Performance: Avoid chat panel being stuck when sending a large text for chat. + ## 0.40.0 - July 24, 2025 ### Added - Support disabling Agent mode when it's disabled by policy. diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index 65dd41ed..ce783401 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -704,25 +704,13 @@ struct ChatPanelInputArea: View { } } - private var shouldEnableCCR: Bool { - guard let quotaInfo = status.quotaInfo else { return false } - - if quotaInfo.isFreeUser { return false } - - if !isCCRFFEnabled { return false } - - return true + private var isFreeUser: Bool { + guard let quotaInfo = status.quotaInfo else { return true } + + return quotaInfo.isFreeUser } private var ccrDisabledTooltip: String { - guard let quotaInfo = status.quotaInfo else { - return "GitHub Copilot Code Review is not available." - } - - if quotaInfo.isFreeUser { - return "GitHub Copilot Code Review requires a paid subscription." - } - if !isCCRFFEnabled { return "GitHub Copilot Code Review is disabled by org policy. Contact your admin." } @@ -737,11 +725,9 @@ struct ChatPanelInputArea: View { private var codeReviewButton: some View { Group { - if !shouldEnableCCR { - codeReviewIcon - .foregroundColor(Color(nsColor: .tertiaryLabelColor)) - .help(ccrDisabledTooltip) - } else { + if isFreeUser { + // Show nothing + } else if isCCRFFEnabled { ZStack { stopButton .opacity(isRequestingCodeReview ? 1 : 0) @@ -766,6 +752,10 @@ struct ChatPanelInputArea: View { .help("Code Review") } .buttonStyle(HoverButtonStyle(padding: 0)) + } else { + codeReviewIcon + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + .help(ccrDisabledTooltip) } } } diff --git a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift b/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift index 98e92c76..4ff5fd68 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift +++ b/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift @@ -154,6 +154,21 @@ struct MCPIntroView: View { fileURLWithPath: FileLoggingLocation.mcpRuntimeLogsPath.description, isDirectory: true ) + + // Create directory if it doesn't exist + if !FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.createDirectory( + atPath: url.path, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + Logger.client.error("Failed to create MCP runtime log folder: \(error)") + return + } + } + NSWorkspace.shared.open(url) } } diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 75211dae..00538da1 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,12 +1,19 @@ -### GitHub Copilot for Xcode 0.40.0 +### GitHub Copilot for Xcode 0.41.0 **🚀 Highlights** -* Performance: Fixed a freezing issue in 'Add Context' view when opening large projects. -* Support disabling Agent mode when it's disabled by policy. +* Code review feature. +* Chat: Support for new model `GPT-5`. +* Agent mode: Added support for new tool to read web URL contents. +* Support disabling MCP when it's disabled by policy. +* Support for opening MCP logs directly from the MCP settings page. +* OAuth support for remote GitHub MCP server. + +**💪 Improvements** + +* Performance: Improved instant-apply speed for edit_file tool. **🛠️ Bug Fixes** -* Login failed due to insufficient permissions on the .config folder. -* Fixed an issue that setting changes like proxy config did not take effect. -* Increased the timeout for ask mode to prevent response failures due to timeout. +* Chat Agent repeatedly reverts its own changes when editing the same file. +* Performance: Avoid chat panel being stuck when sending a large text for chat. diff --git a/Tool/Sources/Status/StatusObserver.swift b/Tool/Sources/Status/StatusObserver.swift index 825f41ad..e19e3f70 100644 --- a/Tool/Sources/Status/StatusObserver.swift +++ b/Tool/Sources/Status/StatusObserver.swift @@ -43,6 +43,10 @@ public class StatusObserver: ObservableObject { let authStatus = await Status.shared.getAuthStatus() let statusInfo = await Status.shared.getStatus() + if authStatus.status == .notLoggedIn { + await Status.shared.updateQuotaInfo(nil) + } + self.authStatus = AuthStatus( status: authStatus.status, username: statusInfo.userName, From 1339ef753ab28303694f41aeb14fadd161015579 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 27 Aug 2025 07:03:54 +0000 Subject: [PATCH 19/26] Pre-release 0.41.135 --- BYOK.md | 40 +++ Copilot for Xcode/App.swift | 27 +- .../Contents.json | 38 ++ .../Contents.json | 38 ++ .../Contents.json | 38 ++ .../Contents.json | 38 ++ Core/Sources/ChatService/ChatService.swift | 47 ++- .../Skills/CurrentEditorSkill.swift | 30 +- Core/Sources/ConversationTab/Chat.swift | 72 ++-- .../ConversationTab/ChatExtension.swift | 5 +- Core/Sources/ConversationTab/ChatPanel.swift | 244 +++++++++---- .../ConversationTab/ContextUtils.swift | 24 +- .../ConversationTab/ConversationTab.swift | 9 +- Core/Sources/ConversationTab/FilePicker.swift | 72 ++-- .../ModelPicker/ModelManagerUtils.swift | 253 +++++++++++++ .../ModelPicker/ModelPicker.swift | 293 +++++---------- Core/Sources/ConversationTab/Styles.swift | 11 + .../ConversationTab/Views/BotMessage.swift | 23 +- .../FileSelectionSection.swift | 56 ++- Core/Sources/HostApp/BYOKConfigView.swift | 72 ++++ .../HostApp/BYOKSettings/ApiKeySheet.swift | 162 +++++++++ .../HostApp/BYOKSettings/BYOKObservable.swift | 236 ++++++++++++ .../HostApp/BYOKSettings/ModelRowView.swift | 111 ++++++ .../HostApp/BYOKSettings/ModelSheet.swift | 175 +++++++++ .../BYOKSettings/ProviderConfigView.swift | 338 ++++++++++++++++++ Core/Sources/HostApp/HostApp.swift | 36 +- .../HostApp/MCPSettings/MCPIntroView.swift | 2 +- .../MCPSettings/MCPToolsListView.swift | 97 +---- .../HostApp/SharedComponents/Badge.swift | 50 ++- .../HostApp/SharedComponents/Color.swift | 33 ++ .../HostApp/SharedComponents/SearchBar.swift | 100 ++++++ .../TextFieldsContainer.swift | 25 ++ Core/Sources/HostApp/TabContainer.swift | 60 ++-- Core/Sources/Service/XPCService.swift | 142 ++++++++ Server/package-lock.json | 10 +- Server/package.json | 2 +- Tool/Sources/ChatAPIService/Models.swift | 16 +- .../ConversationServiceProvider.swift | 111 ++++-- .../LSPTypes.swift | 5 + .../Conversation/WatchedFilesHandler.swift | 68 +++- .../LanguageServer/BYOKModelManager.swift | 44 +++ .../GitHubCopilotRequest+BYOK.swift | 161 +++++++++ .../GitHubCopilotRequest+Conversation.swift | 43 ++- .../LanguageServer/GitHubCopilotRequest.swift | 73 ++++ .../LanguageServer/GitHubCopilotService.swift | 120 +++++-- .../GitHubCopilotConversationService.swift | 2 + .../HostAppActivator/HostAppActivator.swift | 22 ++ Tool/Sources/Persist/AppState.swift | 7 + .../SharedUIComponents/Base/FileIcon.swift | 8 + .../MixedStateCheckbox.swift | 70 ++++ .../BatchingFileChangeWatcher.swift | 175 ++++++--- .../DefaultFileWatcherFactory.swift | 18 +- .../FileChangeWatcherService.swift | 24 +- .../FileWatcherProtocol.swift | 3 +- .../WorkspaceFileProvider.swift | 4 +- .../Workspace/WorkspaceDirectory.swift | 104 ++++++ .../Workspace/WorkspaceDirectoryIndex.swift | 75 ++++ Tool/Sources/Workspace/WorkspaceFile.swift | 19 +- .../Workspace/WorkspaceFileIndex.swift | 10 +- .../XPCShared/XPCExtensionService.swift | 160 ++++++++- .../XPCShared/XPCServiceProtocol.swift | 7 + .../XcodeInspector/AppInstanceInspector.swift | 6 +- .../Apps/XcodeAppInstanceInspector.swift | 30 +- Tool/Sources/XcodeInspector/Helpers.swift | 15 + .../Sources/XcodeInspector/SourceEditor.swift | 12 + .../XcodeInspector/XcodeInspector.swift | 17 +- .../FileChangeWatcherTests.swift | 243 ++++++++++++- .../WorkspaceDirectoryTests.swift | 241 +++++++++++++ 68 files changed, 4228 insertions(+), 694 deletions(-) create mode 100644 BYOK.md create mode 100644 Copilot for Xcode/Assets.xcassets/QuaternarySystemFillColor.colorset/Contents.json create mode 100644 Copilot for Xcode/Assets.xcassets/QuinarySystemFillColor.colorset/Contents.json create mode 100644 Copilot for Xcode/Assets.xcassets/SecondarySystemFillColor.colorset/Contents.json create mode 100644 Copilot for Xcode/Assets.xcassets/TertiarySystemFillColor.colorset/Contents.json create mode 100644 Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift create mode 100644 Core/Sources/HostApp/BYOKConfigView.swift create mode 100644 Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift create mode 100644 Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift create mode 100644 Core/Sources/HostApp/BYOKSettings/ModelRowView.swift create mode 100644 Core/Sources/HostApp/BYOKSettings/ModelSheet.swift create mode 100644 Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift create mode 100644 Core/Sources/HostApp/SharedComponents/Color.swift create mode 100644 Core/Sources/HostApp/SharedComponents/SearchBar.swift create mode 100644 Core/Sources/HostApp/SharedComponents/TextFieldsContainer.swift create mode 100644 Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift create mode 100644 Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+BYOK.swift create mode 100644 Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift create mode 100644 Tool/Sources/Workspace/WorkspaceDirectory.swift create mode 100644 Tool/Sources/Workspace/WorkspaceDirectoryIndex.swift create mode 100644 Tool/Tests/WorkspaceTests/WorkspaceDirectoryTests.swift diff --git a/BYOK.md b/BYOK.md new file mode 100644 index 00000000..662a92a1 --- /dev/null +++ b/BYOK.md @@ -0,0 +1,40 @@ +# Adding your API Keys with GitHub Copilot - Bring Your Own Key(BYOK) + + +Copilot for Xcode supports **Bring Your Own Key (BYOK)** integration with multiple model providers. You can bring your own API keys to integrate with your preferred model provider, giving you full control and flexibility. + +Supported providers include: +- Anthropic +- Azure +- Gemini +- Groq +- OpenAI +- OpenRouter + + +## Configuration Steps + + +To configure BYOK in Copilot for Xcode: + +- Open the Copilot chat and select “Manage Models” from the Model picker. +- Choose your preferred AI provider (e.g., Anthropic, OpenAI, and Azure). +- Enter the required provider-specific details, such as the API key and endpoint URL (if applicable). + + +| Model Provider | How to get the API Keys | +|-------------------|------------------------------------------------------------------------------------------------------------| +| Anthropic | Sign in to the [Anthropic Console](https://console.anthropic.com/dashboard) to generate and retrieve your API key. | +| Gemini (Google) | Sign in to the [Google Cloud Console](https://aistudio.google.com/app/apikey) to generate and retrieve your API key. | +| Groq | Sign in to the [Groq Console](https://console.groq.com/keys) to generate and retrieve your API key. | +| OpenAI | Sign in to the [OpenAI’s Platform](https://platform.openai.com/api-keys) to generate and retrieve your API key. | +| OpenRouter | Sign in to the [OpenRouter’s API Key Settings](https://openrouter.ai/settings/keys) to generate your API key. | +| Azure | Sign in to the [Azure AI Foundry](https://ai.azure.com/), go to your [Deployments](https://ai.azure.com/resource/deployments/), and retrieve your API key and Endpoint after the deployment is complete. Ensure the model name you enter matches the one you deployed, as shown on the Details page.| + + +- Click "Add" button to continue. +- Once saved, it will list available AI models in the Models setting page. You can enable the models you intend to use with GitHub Copilot. + +> [!NOTE] +> Please keep your API key confidential and never share it publicly for safety. + diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift index d8ed3cdd..70cb846e 100644 --- a/Copilot for Xcode/App.swift +++ b/Copilot for Xcode/App.swift @@ -21,6 +21,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { case chat case settings case mcp + case byok } func applicationDidFinishLaunching(_ notification: Notification) { @@ -50,6 +51,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { return .settings } else if launchArgs.contains("--mcp") { return .mcp + } else if launchArgs.contains("--byok") { + return .byok } else { return .chat } @@ -61,6 +64,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { openSettings() case .mcp: openMCPSettings() + case .byok: + openBYOKSettings() case .chat: openChat() } @@ -84,7 +89,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func openMCPSettings() { DispatchQueue.main.async { activateAndOpenSettings() - hostAppStore.send(.setActiveTab(2)) + hostAppStore.send(.setActiveTab(.mcp)) + } + } + + private func openBYOKSettings() { + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.byok)) } } @@ -187,7 +199,18 @@ struct CopilotForXcodeApp: App { ) { _ in DispatchQueue.main.async { activateAndOpenSettings() - hostAppStore.send(.setActiveTab(2)) + hostAppStore.send(.setActiveTab(.mcp)) + } + } + + DistributedNotificationCenter.default().addObserver( + forName: .openBYOKSettingsWindowRequest, + object: nil, + queue: .main + ) { _ in + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.byok)) } } } diff --git a/Copilot for Xcode/Assets.xcassets/QuaternarySystemFillColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/QuaternarySystemFillColor.colorset/Contents.json new file mode 100644 index 00000000..df9ac298 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/QuaternarySystemFillColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF7", + "green" : "0xF7", + "red" : "0xF7" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x09", + "green" : "0x09", + "red" : "0x09" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/QuinarySystemFillColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/QuinarySystemFillColor.colorset/Contents.json new file mode 100644 index 00000000..fa0a3215 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/QuinarySystemFillColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFB", + "green" : "0xFB", + "red" : "0xFB" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x07", + "green" : "0x07", + "red" : "0x07" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/SecondarySystemFillColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/SecondarySystemFillColor.colorset/Contents.json new file mode 100644 index 00000000..50c00cb2 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/SecondarySystemFillColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE6", + "green" : "0xE6", + "red" : "0xE6" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x14", + "green" : "0x14", + "red" : "0x14" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/TertiarySystemFillColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/TertiarySystemFillColor.colorset/Contents.json new file mode 100644 index 00000000..731810c3 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/TertiarySystemFillColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF2", + "green" : "0xF2", + "red" : "0xF2" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x0D", + "green" : "0x0D", + "red" : "0x0D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index c48aa4c5..f112563f 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -21,7 +21,19 @@ import SuggestionBasic public protocol ChatServiceType { var memory: ContextAwareAutoManagedChatMemory { get set } - func send(_ id: String, content: String, contentImages: [ChatCompletionContentPartImage], contentImageReferences: [ImageReference], skillSet: [ConversationSkill], references: [FileReference], model: String?, agentMode: Bool, userLanguage: String?, turnId: String?) async throws + func send( + _ id: String, + content: String, + contentImages: [ChatCompletionContentPartImage], + contentImageReferences: [ImageReference], + skillSet: [ConversationSkill], + references: [ConversationAttachedReference], + model: String?, + modelProviderName: String?, + agentMode: Bool, + userLanguage: String?, + turnId: String? + ) async throws func stopReceivingMessage() async func upvote(_ id: String, _ rating: ConversationRating) async func downvote(_ id: String, _ rating: ConversationRating) async @@ -333,8 +345,9 @@ public final class ChatService: ChatServiceType, ObservableObject { contentImages: Array = [], contentImageReferences: Array = [], skillSet: Array, - references: Array, + references: [ConversationAttachedReference], model: String? = nil, + modelProviderName: String? = nil, agentMode: Bool = false, userLanguage: String? = nil, turnId: String? = nil @@ -439,6 +452,7 @@ public final class ChatService: ChatServiceType, ObservableObject { activeDoc: activeDoc, references: references, model: model, + modelProviderName: modelProviderName, agentMode: agentMode, userLanguage: userLanguage, turnId: currentTurnId, @@ -455,8 +469,9 @@ public final class ChatService: ChatServiceType, ObservableObject { content: String, contentImages: [ChatCompletionContentPartImage] = [], activeDoc: Doc?, - references: [FileReference], + references: [ConversationAttachedReference], model: String? = nil, + modelProviderName: String? = nil, agentMode: Bool = false, userLanguage: String? = nil, turnId: String? = nil, @@ -481,6 +496,7 @@ public final class ChatService: ChatServiceType, ObservableObject { ignoredSkills: ignoredSkills, references: references, model: model, + modelProviderName: modelProviderName, agentMode: agentMode, userLanguage: userLanguage, turnId: turnId @@ -527,7 +543,7 @@ public final class ChatService: ChatServiceType, ObservableObject { deleteChatMessageFromStorage(id) } - public func resendMessage(id: String, model: String? = nil) async throws { + public func resendMessage(id: String, model: String? = nil, modelProviderName: String? = nil) async throws { if let _ = (await memory.history).first(where: { $0.id == id }), let lastUserRequest { @@ -540,6 +556,7 @@ public final class ChatService: ChatServiceType, ObservableObject { skillSet: skillSet, references: lastUserRequest.references ?? [], model: model != nil ? model : lastUserRequest.model, + modelProviderName: modelProviderName != nil ? modelProviderName : lastUserRequest.modelProviderName, agentMode: lastUserRequest.agentMode, userLanguage: lastUserRequest.userLanguage, turnId: id @@ -1050,21 +1067,35 @@ func replaceFirstWord(in content: String, from oldWord: String, to newWord: Stri return content } -extension Array where Element == Reference { +extension Array where Element == FileReference { func toConversationReferences() -> [ConversationReference] { return self.map { - .init(uri: $0.uri, status: .included, kind: .reference($0)) + .init(uri: $0.uri, status: .included, kind: .reference($0), referenceType: .file) } } } -extension Array where Element == FileReference { +extension Array where Element == ConversationAttachedReference { func toConversationReferences() -> [ConversationReference] { return self.map { - .init(uri: $0.url.path, status: .included, kind: .fileReference($0)) + switch $0 { + case .file(let fileRef): + .init( + uri: fileRef.url.path, + status: .included, + kind: .fileReference($0), + referenceType: .file) + case .directory(let directoryRef): + .init( + uri: directoryRef.url.path, + status: .included, + kind: .fileReference($0), + referenceType: .directory) + } } } } + extension [ChatMessage] { // transfer chat messages to turns // used to restore chat history for CLS diff --git a/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift index 5800820a..774e4077 100644 --- a/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift +++ b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift @@ -3,17 +3,18 @@ import Foundation import GitHubCopilotService import JSONRPC import SystemUtils +import LanguageServerProtocol public class CurrentEditorSkill: ConversationSkill { public static let ID = "current-editor" - public let currentFile: FileReference + public let currentFile: ConversationFileReference public var id: String { return CurrentEditorSkill.ID } public var currentFilePath: String { currentFile.url.path } public init( - currentFile: FileReference + currentFile: ConversationFileReference ) { self.currentFile = currentFile } @@ -35,12 +36,27 @@ public class CurrentEditorSkill: ConversationSkill { public func resolveSkill(request: ConversationContextRequest, completion: JSONRPCResponseHandler){ let uri: String? = self.currentFile.url.absoluteString + var selection: JSONValue? + + if let fileSelection = currentFile.selection { + let start = fileSelection.start + let end = fileSelection.end + selection = .hash([ + "start": .hash(["line": .number(Double(start.line)), "character": .number(Double(start.character))]), + "end": .hash(["line": .number(Double(end.line)), "character": .number(Double(end.character))]) + ]) + } + completion( - AnyJSONRPCResponse(id: request.id, - result: JSONValue.array([ - JSONValue.hash(["uri" : .string(uri ?? "")]), - JSONValue.null - ])) + AnyJSONRPCResponse( + id: request.id, + result: JSONValue.array([ + JSONValue.hash([ + "uri" : .string(uri ?? ""), + "selection": selection ?? .null + ]), + JSONValue.null + ])) ) } } diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index 2d1a68c1..afce9479 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -80,8 +80,8 @@ struct Chat { var requestType: ChatService.RequestType? = nil var chatMenu = ChatMenu.State() var focusedField: Field? - var currentEditor: FileReference? = nil - var selectedFiles: [FileReference] = [] + var currentEditor: ConversationFileReference? = nil + var conversationAttachedReferences: [ConversationAttachedReference] = [] var attachedImages: [ImageReference] = [] /// Cache the original content var fileEditMap: OrderedDictionary = [:] @@ -130,10 +130,10 @@ struct Chat { case chatMenu(ChatMenu.Action) // File context - case addSelectedFile(FileReference) - case removeSelectedFile(FileReference) case resetCurrentEditor - case setCurrentEditor(FileReference) + case setCurrentEditor(ConversationFileReference) + case addReference(ConversationAttachedReference) + case removeReference(ConversationAttachedReference) // Image context case addSelectedImage(ImageReference) @@ -211,13 +211,18 @@ struct Chat { ) state.typedMessage = "" - let selectedFiles = state.selectedFiles - let selectedModelFamily = AppState.shared.getSelectedModelFamily() ?? CopilotModelManager.getDefaultChatModel(scope: AppState.shared.modelScope())?.modelFamily + let selectedModel = AppState.shared.getSelectedModel() + let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel( + scope: AppState.shared.modelScope() + )?.modelFamily let agentMode = AppState.shared.isAgentModeEnabled() - - let shouldAttachImages = AppState.shared.isSelectedModelSupportVision() ?? CopilotModelManager.getDefaultChatModel(scope: AppState.shared.modelScope())?.supportVision ?? false + let shouldAttachImages = selectedModel?.supportVision ?? CopilotModelManager.getDefaultChatModel( + scope: AppState.shared.modelScope() + )?.supportVision ?? false let attachedImages: [ImageReference] = shouldAttachImages ? state.attachedImages : [] state.attachedImages = [] + + let references = state.conversationAttachedReferences return .run { _ in try await service .send( @@ -225,8 +230,9 @@ struct Chat { content: message, contentImageReferences: attachedImages, skillSet: skillSet, - references: selectedFiles, + references: references, model: selectedModelFamily, + modelProviderName: selectedModel?.providerName, agentMode: agentMode, userLanguage: chatResponseLocale ) @@ -254,11 +260,25 @@ struct Chat { isCurrentEditorContextEnabled: enableCurrentEditorContext ) - let selectedFiles = state.selectedFiles - let selectedModelFamily = AppState.shared.getSelectedModelFamily() ?? CopilotModelManager.getDefaultChatModel(scope: AppState.shared.modelScope())?.modelFamily + let selectedModel = AppState.shared.getSelectedModel() + let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel( + scope: AppState.shared.modelScope() + )?.modelFamily + let references = state.conversationAttachedReferences + let agentMode = AppState.shared.isAgentModeEnabled() return .run { _ in - try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles, model: selectedModelFamily, userLanguage: chatResponseLocale) + try await service + .send( + id, + content: message, + skillSet: skillSet, + references: references, + model: selectedModelFamily, + modelProviderName: selectedModel?.providerName, + agentMode: agentMode, + userLanguage: chatResponseLocale + ) }.cancellable(id: CancelID.sendMessage(self.id)) case .returnButtonTapped: @@ -403,7 +423,8 @@ struct Chat { .init( uri: $0.uri, status: $0.status, - kind: $0.kind + kind: $0.kind, + referenceType: $0.referenceType ) }, followUp: message.followUp, @@ -483,21 +504,26 @@ struct Chat { ChatInjector().insertCodeBlock(codeBlock: code) return .none - // MARK: - File Context - 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 + // MARK: - Context case .resetCurrentEditor: state.currentEditor = nil return .none case let .setCurrentEditor(fileReference): state.currentEditor = fileReference return .none + case let .addReference(ref): + guard !state.conversationAttachedReferences.contains(ref) else { + return .none + } + state.conversationAttachedReferences.append(ref) + return .none + + case let .removeReference(ref): + guard let index = state.conversationAttachedReferences.firstIndex(of: ref) else { + return .none + } + state.conversationAttachedReferences.remove(at: index) + return .none // MARK: - Image Context case let .addSelectedImage(imageReference): diff --git a/Core/Sources/ConversationTab/ChatExtension.swift b/Core/Sources/ConversationTab/ChatExtension.swift index 27220a96..8949b882 100644 --- a/Core/Sources/ConversationTab/ChatExtension.swift +++ b/Core/Sources/ConversationTab/ChatExtension.swift @@ -6,11 +6,12 @@ extension Chat.State { guard let currentFile = self.currentEditor, isCurrentEditorContextEnabled else { return [] } - let fileReference = FileReference( + let fileReference = ConversationFileReference( url: currentFile.url, relativePath: currentFile.relativePath, fileName: currentFile.fileName, - isCurrentEditor: currentFile.isCurrentEditor + isCurrentEditor: currentFile.isCurrentEditor, + selection: currentFile.selection ) return [CurrentEditorSkill(currentFile: fileReference), ProblemsInActiveDocumentSkill()] } diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index ce783401..9f03cb4d 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -16,6 +16,7 @@ import UniformTypeIdentifiers import Status import GitHubCopilotService import GitHubCopilotViewModel +import LanguageServerProtocol private let r: Double = 4 @@ -68,6 +69,8 @@ public struct ChatPanel: View { } private func onFileDrop(_ providers: [NSItemProvider]) -> Bool { + let fileManager = FileManager.default + for provider in providers { if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { item, error in @@ -81,16 +84,22 @@ public struct ChatPanel: View { }() guard let url else { return } + + var isDirectory: ObjCBool = false if let isValidFile = try? WorkspaceFile.isValidFile(url), isValidFile { DispatchQueue.main.async { - let fileReference = FileReference(url: url, isCurrentEditor: false) - chat.send(.addSelectedFile(fileReference)) + let fileReference = ConversationFileReference(url: url, isCurrentEditor: false) + chat.send(.addReference(.file(fileReference))) } } else if let data = try? Data(contentsOf: url), ["png", "jpeg", "jpg", "bmp", "gif", "tiff", "tif", "webp"].contains(url.pathExtension.lowercased()) { DispatchQueue.main.async { chat.send(.addSelectedImage(ImageReference(data: data, fileUrl: url))) } + } else if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue { + DispatchQueue.main.async { + chat.send(.addReference(.directory(.init(url: url)))) + } } } } @@ -503,7 +512,7 @@ struct ChatPanelInputArea: View { var focusedField: FocusState.Binding @State var cancellable = Set() @State private var isFilePickerPresented = false - @State private var allFiles: [FileReference]? = nil + @State private var allFiles: [ConversationAttachedReference]? = nil @State private var filteredTemplates: [ChatTemplate] = [] @State private var filteredAgent: [ChatAgent] = [] @State private var showingTemplates = false @@ -554,8 +563,8 @@ struct ChatPanelInputArea: View { FilePicker( allFiles: $allFiles, workspaceURL: chat.workspaceURL, - onSubmit: { file in - chat.send(.addSelectedFile(file)) + onSubmit: { ref in + chat.send(.addReference(ref)) }, onExit: { isFilePickerPresented = false @@ -801,13 +810,13 @@ struct ChatPanelInputArea: View { private var chatContextView: some View { let buttonItems: [ChatContextButtonType] = [.contextAttach, .imageAttach] - let currentEditorItem: [FileReference] = [chat.state.currentEditor].compactMap { + let currentEditorItem: [ConversationFileReference] = [chat.state.currentEditor].compactMap { $0 } - let selectedFileItems = chat.state.selectedFiles + let references = chat.state.conversationAttachedReferences let chatContextItems: [Any] = buttonItems.map { $0 as ChatContextButtonType - } + currentEditorItem + selectedFileItems + } + currentEditorItem + references return FlowLayout(mode: .scrollable, items: chatContextItems, itemSpacing: 4) { item in if let buttonType = item as? ChatContextButtonType { if buttonType == .imageAttach { @@ -834,59 +843,90 @@ struct ChatPanelInputArea: View { .help("Add Context") .cornerRadius(6) } - } else if let select = item as? FileReference { - HStack(spacing: 0) { - drawFileIcon(select.url) - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundColor(.primary.opacity(0.85)) - .padding(4) - .opacity(select.isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) - - Text(select.url.lastPathComponent) - .lineLimit(1) - .truncationMode(.middle) - .foregroundColor( - select.isCurrentEditor && !isCurrentEditorContextEnabled - ? .secondary - : .primary.opacity(0.85) - ) - .font(.body) - .opacity(select.isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) - .help(select.getPathRelativeToHome()) - - if select.isCurrentEditor { - Toggle("", isOn: $isCurrentEditorContextEnabled) - .toggleStyle(SwitchToggleStyle(tint: .blue)) - .controlSize(.mini) - .padding(.trailing, 4) - .onChange(of: isCurrentEditorContextEnabled) { newValue in - enableCurrentEditorContext = newValue - } + } else if let select = item as? ConversationFileReference, select.isCurrentEditor { + makeCurrentEditorView(select) + } else if let select = item as? ConversationAttachedReference { + makeReferenceItemView(select) + } + } + .padding(.horizontal, 8) + .padding(.top, 8) + } + + @ViewBuilder + func makeCurrentEditorView(_ ref: ConversationFileReference) -> some View { + HStack(spacing: 0) { + makeContextFileNameView(url: ref.url, isCurrentEditor: true, selection: ref.selection) + + Toggle("", isOn: $isCurrentEditorContextEnabled) + .toggleStyle(SwitchToggleStyle(tint: .blue)) + .controlSize(.mini) + .padding(.trailing, 4) + .onChange(of: isCurrentEditorContextEnabled) { newValue in + enableCurrentEditorContext = newValue + } + } + .chatContextReferenceStyle(isCurrentEditor: true, r: r) + } + + @ViewBuilder + func makeReferenceItemView(_ ref: ConversationAttachedReference) -> some View { + HStack(spacing: 0) { + makeContextFileNameView(url: ref.url, isCurrentEditor: false, isDirectory: ref.isDirectory) + + Button(action: { chat.send(.removeReference(ref)) }) { + Image(systemName: "xmark") + .resizable() + .frame(width: 8, height: 8) + .foregroundColor(.primary.opacity(0.85)) + .padding(4) + } + .buttonStyle(HoverButtonStyle()) + } + .chatContextReferenceStyle(isCurrentEditor: false, r: r) + } + + @ViewBuilder + func makeContextFileNameView( + url: URL, + isCurrentEditor: Bool, + isDirectory: Bool = false, + selection: LSPRange? = nil + ) -> some View { + drawFileIcon(url, isDirectory: isDirectory) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .foregroundColor(.primary.opacity(0.85)) + .padding(4) + .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) + + HStack(spacing: 0) { + Text(url.lastPathComponent) + + Group { + if isCurrentEditor, let selection { + let startLine = selection.start.line + let endLine = selection.end.line + if startLine == endLine { + Text(String(format: ":%d", selection.start.line + 1)) } else { - Button(action: { chat.send(.removeSelectedFile(select)) }) { - Image(systemName: "xmark") - .resizable() - .frame(width: 8, height: 8) - .foregroundColor(.primary.opacity(0.85)) - .padding(4) - } - .buttonStyle(HoverButtonStyle()) + Text(String(format: ":%d-%d", selection.start.line + 1, selection.end.line + 1)) } } - .background( - Color(nsColor: .windowBackgroundColor).opacity(0.5) - ) - .cornerRadius(select.isCurrentEditor ? 99 : r) - .overlay( - RoundedRectangle(cornerRadius: select.isCurrentEditor ? 99 : r) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) } + .foregroundColor(.secondary) } - .padding(.horizontal, 8) - .padding(.top, 8) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor( + isCurrentEditor && !isCurrentEditorContextEnabled + ? .secondary + : .primary.opacity(0.85) + ) + .font(.body) + .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) + .help(url.getPathRelativeToHome()) } func chatTemplateCompletion(text: String) async -> [ChatTemplate] { @@ -932,27 +972,72 @@ struct ChatPanelInputArea: View { } func subscribeToActiveDocumentChangeEvent() { - Publishers.CombineLatest( + var task: Task? + var currentFocusedEditor: SourceEditor? + + Publishers.CombineLatest3( XcodeInspector.shared.$latestActiveXcode, XcodeInspector.shared.$activeDocumentURL + .removeDuplicates(), + XcodeInspector.shared.$focusedEditor .removeDuplicates() - ) - .receive(on: DispatchQueue.main) - .sink { newXcode, newDocURL in - // First check for realtimeWorkspaceURL if activeWorkspaceURL is nil - if let realtimeURL = newXcode?.realtimeDocumentURL, newDocURL == nil { - if supportedFileExtensions.contains(realtimeURL.pathExtension) { - let currentEditor = FileReference(url: realtimeURL, isCurrentEditor: true) - chat.send(.setCurrentEditor(currentEditor)) - } - } else { - if supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") { - let currentEditor = FileReference(url: newDocURL!, isCurrentEditor: true) - chat.send(.setCurrentEditor(currentEditor)) + ) + .receive(on: DispatchQueue.main) + .sink { newXcode, newDocURL, newFocusedEditor in + var currentEditor: ConversationFileReference? + + // First check for realtimeWorkspaceURL if activeWorkspaceURL is nil + if let realtimeURL = newXcode?.realtimeDocumentURL, newDocURL == nil { + if supportedFileExtensions.contains(realtimeURL.pathExtension) { + currentEditor = ConversationFileReference(url: realtimeURL, isCurrentEditor: true) + } + } else if let docURL = newDocURL, supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") { + currentEditor = ConversationFileReference(url: docURL, isCurrentEditor: true) + } + + if var currentEditor = currentEditor { + if let selection = newFocusedEditor?.getContent().selections.first, + selection.start != selection.end { + currentEditor.selection = .init(start: selection.start, end: selection.end) + } + + chat.send(.setCurrentEditor(currentEditor)) + } + + if currentFocusedEditor != newFocusedEditor { + task?.cancel() + task = nil + currentFocusedEditor = newFocusedEditor + + if let editor = currentFocusedEditor { + task = Task { @MainActor in + for await _ in await editor.axNotifications.notifications() + .filter({ $0.kind == .selectedTextChanged }) { + handleSourceEditorSelectionChanged(editor) + } } } } - .store(in: &cancellable) + } + .store(in: &cancellable) + } + + private func handleSourceEditorSelectionChanged(_ sourceEditor: SourceEditor) { + guard let fileURL = sourceEditor.realtimeDocumentURL, + let currentEditorURL = chat.currentEditor?.url, + fileURL == currentEditorURL + else { + return + } + + var currentEditor: ConversationFileReference = .init(url: fileURL, isCurrentEditor: true) + + if let selection = sourceEditor.getContent().selections.first, + selection.start != selection.end { + currentEditor.selection = .init(start: selection.start, end: selection.end) + } + + chat.send(.setCurrentEditor(currentEditor)) } func submitChatMessage() { @@ -960,6 +1045,20 @@ struct ChatPanelInputArea: View { } } } + +extension URL { + func getPathRelativeToHome() -> String { + let filePath = self.path + guard !filePath.isEmpty else { return "" } + + let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path + if !homeDirectory.isEmpty { + return filePath.replacingOccurrences(of: homeDirectory, with: "~") + } + + return filePath + } +} // MARK: - Previews struct ChatPanel_Preview: PreviewProvider { @@ -983,7 +1082,8 @@ struct ChatPanel_Preview: PreviewProvider { .init( uri: "Hi Hi Hi Hi", status: .included, - kind: .class + kind: .class, + referenceType: .file ), ] ), diff --git a/Core/Sources/ConversationTab/ContextUtils.swift b/Core/Sources/ConversationTab/ContextUtils.swift index 5e05927a..6a646248 100644 --- a/Core/Sources/ConversationTab/ContextUtils.swift +++ b/Core/Sources/ConversationTab/ContextUtils.swift @@ -7,12 +7,28 @@ import SystemUtils public struct ContextUtils { - public static func getFilesFromWorkspaceIndex(workspaceURL: URL?) -> [FileReference]? { - guard let workspaceURL = workspaceURL else { return [] } - return WorkspaceFileIndex.shared.getFiles(for: workspaceURL) + public static func getFilesFromWorkspaceIndex(workspaceURL: URL?) -> [ConversationAttachedReference]? { + guard let workspaceURL = workspaceURL else { return nil } + + var references: [ConversationAttachedReference]? + + if let directories = WorkspaceDirectoryIndex.shared.getDirectories(for: workspaceURL) { + references = directories + .sorted { $0.url.lastPathComponent < $1.url.lastPathComponent } + .map { .directory($0) } + } + + if let files = WorkspaceFileIndex.shared.getFiles(for: workspaceURL) { + references = (references ?? []) + files + .sorted { $0.url.lastPathComponent < $1.url.lastPathComponent } + .map { .file($0) } + } + + + return references } - public static func getFilesInActiveWorkspace(workspaceURL: URL?) -> [FileReference] { + public static func getFilesInActiveWorkspace(workspaceURL: URL?) -> [ConversationFileReference] { if let workspaceURL = workspaceURL, let info = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) { return WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: info.workspaceURL, workspaceRootURL: info.projectURL) } diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift index 50ebe68f..36f62a23 100644 --- a/Core/Sources/ConversationTab/ConversationTab.swift +++ b/Core/Sources/ConversationTab/ConversationTab.swift @@ -249,10 +249,15 @@ public class ConversationTab: ChatTab { let pasteboard = NSPasteboard.general if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], !urls.isEmpty { for url in urls { + // Check if it's a remote URL (http/https) + if url.scheme == "http" || url.scheme == "https" { + return false + } + if let isValidFile = try? WorkspaceFile.isValidFile(url), isValidFile { DispatchQueue.main.async { - let fileReference = FileReference(url: url, isCurrentEditor: false) - self.chat.send(.addSelectedFile(fileReference)) + let fileReference = ConversationFileReference(url: url, isCurrentEditor: false) + self.chat.send(.addReference(.file(fileReference))) } } else if let data = try? Data(contentsOf: url), ["png", "jpeg", "jpg", "bmp", "gif", "tiff", "tif", "webp"].contains(url.pathExtension.lowercased()) { diff --git a/Core/Sources/ConversationTab/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift index 8ae83e10..eb0b0cc9 100644 --- a/Core/Sources/ConversationTab/FilePicker.swift +++ b/Core/Sources/ConversationTab/FilePicker.swift @@ -5,22 +5,41 @@ import SwiftUI import SystemUtils public struct FilePicker: View { - @Binding var allFiles: [FileReference]? + @Binding var allFiles: [ConversationAttachedReference]? let workspaceURL: URL? - var onSubmit: (_ file: FileReference) -> Void + var onSubmit: (_ file: ConversationAttachedReference) -> Void var onExit: () -> Void @FocusState private var isSearchBarFocused: Bool @State private var searchText = "" @State private var selectedId: Int = 0 @State private var localMonitor: Any? = nil - - private var filteredFiles: [FileReference]? { + + // Only showup direct sub directories + private var defaultReferencesForDisplay: [ConversationAttachedReference]? { + guard let allFiles else { return nil } + + let directories = allFiles + .filter { $0.isDirectory } + .filter { + guard case let .directory(directory) = $0 else { + return false + } + + return directory.depth == 1 + } + + let files = allFiles.filter { !$0.isDirectory } + + return directories + files + } + + private var filteredReferences: [ConversationAttachedReference]? { if searchText.isEmpty { - return allFiles + return defaultReferencesForDisplay } - - return allFiles?.filter { doc in - (doc.fileName ?? doc.url.lastPathComponent) .localizedCaseInsensitiveContains(searchText) + + return allFiles?.filter { ref in + ref.url.lastPathComponent.localizedCaseInsensitiveContains(searchText) } } @@ -90,17 +109,17 @@ public struct FilePicker: View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 4) { - if allFiles == nil || filteredFiles?.isEmpty == true { + if allFiles == nil || filteredReferences?.isEmpty == true { emptyStateView .foregroundColor(.secondary) .padding(.leading, 4) .padding(.vertical, 4) } else { - ForEach(Array((filteredFiles ?? []).enumerated()), id: \.element) { index, doc in - FileRowView(doc: doc, id: index, selectedId: $selectedId) + ForEach(Array((filteredReferences ?? []).enumerated()), id: \.element) { index, ref in + FileRowView(ref: ref, id: index, selectedId: $selectedId) .contentShape(Rectangle()) .onTapGesture { - onSubmit(doc) + onSubmit(ref) selectedId = index isSearchBarFocused = true } @@ -108,7 +127,7 @@ public struct FilePicker: View { } } } - .id(filteredFiles?.hashValue) + .id(filteredReferences?.hashValue) } .frame(maxHeight: 200) .padding(.horizontal, 4) @@ -159,45 +178,52 @@ public struct FilePicker: View { } private func moveSelection(up: Bool, proxy: ScrollViewProxy) { - guard let files = filteredFiles, !files.isEmpty else { return } + guard let refs = filteredReferences, !refs.isEmpty else { return } let nextId = selectedId + (up ? -1 : 1) - selectedId = max(0, min(nextId, files.count - 1)) + selectedId = max(0, min(nextId, refs.count - 1)) proxy.scrollTo(selectedId, anchor: .bottom) } private func handleEnter() { - guard let files = filteredFiles, !files.isEmpty && selectedId < files.count else { return } - onSubmit(files[selectedId]) + guard let refs = filteredReferences, !refs.isEmpty && selectedId < refs.count else { + return + } + + onSubmit(refs[selectedId]) } } struct FileRowView: View { @State private var isHovered = false - let doc: FileReference + let ref: ConversationAttachedReference let id: Int @Binding var selectedId: Int var body: some View { WithPerceptionTracking { HStack { - drawFileIcon(doc.url) + drawFileIcon(ref.url, isDirectory: ref.isDirectory) .resizable() .scaledToFit() .frame(width: 16, height: 16) .foregroundColor(.secondary) .padding(.leading, 4) - VStack(alignment: .leading) { - Text(doc.fileName ?? doc.url.lastPathComponent) + HStack(spacing: 4) { + Text(ref.displayName) .font(.body) .hoverPrimaryForeground(isHovered: selectedId == id) .lineLimit(1) .truncationMode(.middle) - Text(doc.relativePath ?? doc.url.path) + .layoutPriority(1) + + Text(ref.relativePath) .font(.caption) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) + // Ensure relative path remains visible even when display name is very long + .frame(minWidth: 80, alignment: .leading) } Spacer() @@ -209,7 +235,7 @@ struct FileRowView: View { .onHover(perform: { hovering in isHovered = hovering }) - .help(doc.relativePath ?? doc.url.path) + .help(ref.url.path) } } } diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift b/Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift new file mode 100644 index 00000000..92af4af9 --- /dev/null +++ b/Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift @@ -0,0 +1,253 @@ +import Foundation +import Combine +import Persist +import GitHubCopilotService +import ConversationServiceProvider + +public let SELECTED_LLM_KEY = "selectedLLM" +public let SELECTED_CHATMODE_KEY = "selectedChatMode" + +public extension Notification.Name { + static let gitHubCopilotSelectedModelDidChange = Notification.Name("com.github.CopilotForXcode.SelectedModelDidChange") +} + +public extension AppState { + func isSelectedModelSupportVision() -> Bool? { + if let savedModel = get(key: SELECTED_LLM_KEY) { + return savedModel["supportVision"]?.boolValue + } + return nil + } + + func getSelectedModel() -> LLMModel? { + guard let savedModel = get(key: SELECTED_LLM_KEY) else { + return nil + } + + guard let modelName = savedModel["modelName"]?.stringValue, + let modelFamily = savedModel["modelFamily"]?.stringValue else { + return nil + } + + let displayName = savedModel["displayName"]?.stringValue + let providerName = savedModel["providerName"]?.stringValue + let supportVision = savedModel["supportVision"]?.boolValue ?? false + + // Try to reconstruct billing info if available + var billing: CopilotModelBilling? + if let isPremium = savedModel["billing"]?["isPremium"]?.boolValue, + let multiplier = savedModel["billing"]?["multiplier"]?.numberValue { + billing = CopilotModelBilling( + isPremium: isPremium, + multiplier: Float(multiplier) + ) + } + + return LLMModel( + displayName: displayName, + modelName: modelName, + modelFamily: modelFamily, + billing: billing, + providerName: providerName, + supportVision: supportVision + ) + } + + func setSelectedModel(_ model: LLMModel) { + update(key: SELECTED_LLM_KEY, value: model) + NotificationCenter.default.post(name: .gitHubCopilotSelectedModelDidChange, object: nil) + } + + func modelScope() -> PromptTemplateScope { + return isAgentModeEnabled() ? .agentPanel : .chatPanel + } + + func getSelectedChatMode() -> String { + if let savedMode = get(key: SELECTED_CHATMODE_KEY), + let modeName = savedMode.stringValue { + return convertChatMode(modeName) + } + + // Default to "Agent" + return "Agent" + } + + func setSelectedChatMode(_ mode: String) { + update(key: SELECTED_CHATMODE_KEY, value: mode) + } + + func isAgentModeEnabled() -> Bool { + return getSelectedChatMode() == "Agent" + } + + private func convertChatMode(_ mode: String) -> String { + switch mode { + case "Ask": + return "Ask" + default: + return "Agent" + } + } +} + +public class CopilotModelManagerObservable: ObservableObject { + static let shared = CopilotModelManagerObservable() + + @Published var availableChatModels: [LLMModel] = [] + @Published var availableAgentModels: [LLMModel] = [] + @Published var defaultChatModel: LLMModel? + @Published var defaultAgentModel: LLMModel? + @Published var availableChatBYOKModels: [LLMModel] = [] + @Published var availableAgentBYOKModels: [LLMModel] = [] + private var cancellables = Set() + + private init() { + // Initial load + availableChatModels = CopilotModelManager.getAvailableChatLLMs(scope: .chatPanel) + availableAgentModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel) + defaultChatModel = CopilotModelManager.getDefaultChatModel(scope: .chatPanel) + defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel) + availableChatBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .chatPanel) + availableAgentBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel) + + // Setup notification to update when models change + NotificationCenter.default.publisher(for: .gitHubCopilotModelsDidChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.availableChatModels = CopilotModelManager.getAvailableChatLLMs(scope: .chatPanel) + self?.availableAgentModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel) + self?.defaultChatModel = CopilotModelManager.getDefaultChatModel(scope: .chatPanel) + self?.defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel) + self?.availableChatBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .chatPanel) + self?.availableAgentBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel) + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .gitHubCopilotShouldSwitchFallbackModel) + .receive(on: DispatchQueue.main) + .sink { _ in + if let fallbackModel = CopilotModelManager.getFallbackLLM( + scope: AppState.shared + .isAgentModeEnabled() ? .agentPanel : .chatPanel + ) { + AppState.shared.setSelectedModel( + .init( + modelName: fallbackModel.modelName, + modelFamily: fallbackModel.id, + billing: fallbackModel.billing, + supportVision: fallbackModel.capabilities.supports.vision + ) + ) + } + } + .store(in: &cancellables) + } +} + +// MARK: - Copilot Model Manager +public extension CopilotModelManager { + static func getAvailableChatLLMs(scope: PromptTemplateScope = .chatPanel) -> [LLMModel] { + let LLMs = CopilotModelManager.getAvailableLLMs() + return LLMs.filter( + { $0.scopes.contains(scope) } + ).map { + return LLMModel( + modelName: $0.modelName, + modelFamily: $0.isChatFallback ? $0.id : $0.modelFamily, + billing: $0.billing, + supportVision: $0.capabilities.supports.vision + ) + } + } + + static func getDefaultChatModel(scope: PromptTemplateScope = .chatPanel) -> LLMModel? { + let LLMs = CopilotModelManager.getAvailableLLMs() + let LLMsInScope = LLMs.filter({ $0.scopes.contains(scope) }) + let defaultModel = LLMsInScope.first(where: { $0.isChatDefault }) + // If a default model is found, return it + if let defaultModel = defaultModel { + return LLMModel( + modelName: defaultModel.modelName, + modelFamily: defaultModel.modelFamily, + billing: defaultModel.billing, + supportVision: defaultModel.capabilities.supports.vision + ) + } + + // Fallback to gpt-4.1 if available + let gpt4_1 = LLMsInScope.first(where: { $0.modelFamily == "gpt-4.1" }) + if let gpt4_1 = gpt4_1 { + return LLMModel( + modelName: gpt4_1.modelName, + modelFamily: gpt4_1.modelFamily, + billing: gpt4_1.billing, + supportVision: gpt4_1.capabilities.supports.vision + ) + } + + // If no default model is found, fallback to the first available model + if let firstModel = LLMsInScope.first { + return LLMModel( + modelName: firstModel.modelName, + modelFamily: firstModel.modelFamily, + billing: firstModel.billing, + supportVision: firstModel.capabilities.supports.vision + ) + } + + return nil + } +} + +// MARK: - BYOK Model Manager +public extension BYOKModelManager { + static func getAvailableChatLLMs(scope: PromptTemplateScope = .chatPanel) -> [LLMModel] { + var BYOKModels = BYOKModelManager.getRegisteredBYOKModels() + if scope == .agentPanel { + BYOKModels = BYOKModels.filter( + { $0.modelCapabilities?.toolCalling == true } + ) + } + return BYOKModels.map { + return LLMModel( + displayName: $0.modelCapabilities?.name, + modelName: $0.modelId, + modelFamily: $0.modelId, + billing: nil, + providerName: $0.providerName.rawValue, + supportVision: $0.modelCapabilities?.vision ?? false + ) + } + } +} + +public struct LLMModel: Codable, Hashable, Equatable { + let displayName: String? + let modelName: String + let modelFamily: String + let billing: CopilotModelBilling? + let providerName: String? + let supportVision: Bool + + public init( + displayName: String? = nil, + modelName: String, + modelFamily: String, + billing: CopilotModelBilling?, + providerName: String? = nil, + supportVision: Bool + ) { + self.displayName = displayName + self.modelName = modelName + self.modelFamily = modelFamily + self.billing = billing + self.providerName = providerName + self.supportVision = supportVision + } +} + +public struct ScopeCache { + var modelMultiplierCache: [String: String] = [:] + var cachedMaxWidth: CGFloat = 0 + var lastModelsHash: Int = 0 +} diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift index 0f76adea..396f9a26 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift @@ -8,190 +8,8 @@ import HostAppActivator import SharedUIComponents import ConversationServiceProvider -public let SELECTED_LLM_KEY = "selectedLLM" -public let SELECTED_CHATMODE_KEY = "selectedChatMode" - -extension Notification.Name { - static let gitHubCopilotSelectedModelDidChange = Notification.Name("com.github.CopilotForXcode.SelectedModelDidChange") -} - -extension AppState { - func getSelectedModelFamily() -> String? { - if let savedModel = get(key: SELECTED_LLM_KEY), - let modelFamily = savedModel["modelFamily"]?.stringValue { - return modelFamily - } - return nil - } - - func getSelectedModelName() -> String? { - if let savedModel = get(key: SELECTED_LLM_KEY), - let modelName = savedModel["modelName"]?.stringValue { - return modelName - } - return nil - } - - func isSelectedModelSupportVision() -> Bool? { - if let savedModel = get(key: SELECTED_LLM_KEY) { - return savedModel["supportVision"]?.boolValue - } - return nil - } - - func setSelectedModel(_ model: LLMModel) { - update(key: SELECTED_LLM_KEY, value: model) - NotificationCenter.default.post(name: .gitHubCopilotSelectedModelDidChange, object: nil) - } - - func modelScope() -> PromptTemplateScope { - return isAgentModeEnabled() ? .agentPanel : .chatPanel - } - - func getSelectedChatMode() -> String { - if let savedMode = get(key: SELECTED_CHATMODE_KEY), - let modeName = savedMode.stringValue { - return convertChatMode(modeName) - } - return "Ask" - } - - func setSelectedChatMode(_ mode: String) { - update(key: SELECTED_CHATMODE_KEY, value: mode) - } - - func isAgentModeEnabled() -> Bool { - return getSelectedChatMode() == "Agent" - } - - private func convertChatMode(_ mode: String) -> String { - switch mode { - case "Agent": - return "Agent" - default: - return "Ask" - } - } -} - -class CopilotModelManagerObservable: ObservableObject { - static let shared = CopilotModelManagerObservable() - - @Published var availableChatModels: [LLMModel] = [] - @Published var availableAgentModels: [LLMModel] = [] - @Published var defaultChatModel: LLMModel? - @Published var defaultAgentModel: LLMModel? - private var cancellables = Set() - - private init() { - // Initial load - availableChatModels = CopilotModelManager.getAvailableChatLLMs(scope: .chatPanel) - availableAgentModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel) - defaultChatModel = CopilotModelManager.getDefaultChatModel(scope: .chatPanel) - defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel) - - - // Setup notification to update when models change - NotificationCenter.default.publisher(for: .gitHubCopilotModelsDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.availableChatModels = CopilotModelManager.getAvailableChatLLMs(scope: .chatPanel) - self?.availableAgentModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel) - self?.defaultChatModel = CopilotModelManager.getDefaultChatModel(scope: .chatPanel) - self?.defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel) - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: .gitHubCopilotShouldSwitchFallbackModel) - .receive(on: DispatchQueue.main) - .sink { _ in - if let fallbackModel = CopilotModelManager.getFallbackLLM( - scope: AppState.shared - .isAgentModeEnabled() ? .agentPanel : .chatPanel - ) { - AppState.shared.setSelectedModel( - .init( - modelName: fallbackModel.modelName, - modelFamily: fallbackModel.id, - billing: fallbackModel.billing, - supportVision: fallbackModel.capabilities.supports.vision - ) - ) - } - } - .store(in: &cancellables) - } -} - -extension CopilotModelManager { - static func getAvailableChatLLMs(scope: PromptTemplateScope = .chatPanel) -> [LLMModel] { - let LLMs = CopilotModelManager.getAvailableLLMs() - return LLMs.filter( - { $0.scopes.contains(scope) } - ).map { - return LLMModel( - modelName: $0.modelName, - modelFamily: $0.isChatFallback ? $0.id : $0.modelFamily, - billing: $0.billing, - supportVision: $0.capabilities.supports.vision - ) - } - } - - static func getDefaultChatModel(scope: PromptTemplateScope = .chatPanel) -> LLMModel? { - let LLMs = CopilotModelManager.getAvailableLLMs() - let LLMsInScope = LLMs.filter({ $0.scopes.contains(scope) }) - let defaultModel = LLMsInScope.first(where: { $0.isChatDefault }) - // If a default model is found, return it - if let defaultModel = defaultModel { - return LLMModel( - modelName: defaultModel.modelName, - modelFamily: defaultModel.modelFamily, - billing: defaultModel.billing, - supportVision: defaultModel.capabilities.supports.vision - ) - } - - // Fallback to gpt-4.1 if available - let gpt4_1 = LLMsInScope.first(where: { $0.modelFamily == "gpt-4.1" }) - if let gpt4_1 = gpt4_1 { - return LLMModel( - modelName: gpt4_1.modelName, - modelFamily: gpt4_1.modelFamily, - billing: gpt4_1.billing, - supportVision: gpt4_1.capabilities.supports.vision - ) - } - - // If no default model is found, fallback to the first available model - if let firstModel = LLMsInScope.first { - return LLMModel( - modelName: firstModel.modelName, - modelFamily: firstModel.modelFamily, - billing: firstModel.billing, - supportVision: firstModel.capabilities.supports.vision - ) - } - - return nil - } -} - -struct LLMModel: Codable, Hashable { - let modelName: String - let modelFamily: String - let billing: CopilotModelBilling? - let supportVision: Bool -} - -struct ScopeCache { - var modelMultiplierCache: [String: String] = [:] - var cachedMaxWidth: CGFloat = 0 - var lastModelsHash: Int = 0 -} - struct ModelPicker: View { - @State private var selectedModel = "" + @State private var selectedModel: LLMModel? @State private var isHovered = false @State private var isPressed = false @ObservedObject private var modelManager = CopilotModelManagerObservable.shared @@ -219,7 +37,8 @@ struct ModelPicker: View { } init() { - let initialModel = AppState.shared.getSelectedModelName() ?? CopilotModelManager.getDefaultChatModel()?.modelName ?? "" + let initialModel = AppState.shared.getSelectedModel() ?? + CopilotModelManager.getDefaultChatModel() self._selectedModel = State(initialValue: initialModel) self.isMCPFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.mcp updateAgentPicker() @@ -232,8 +51,14 @@ struct ModelPicker: View { .store(in: &cancellables) } - var models: [LLMModel] { - AppState.shared.isAgentModeEnabled() ? modelManager.availableAgentModels : modelManager.availableChatModels + var copilotModels: [LLMModel] { + AppState.shared.isAgentModeEnabled() ? + modelManager.availableAgentModels : modelManager.availableChatModels + } + + var byokModels: [LLMModel] { + AppState.shared.isAgentModeEnabled() ? + modelManager.availableAgentBYOKModels : modelManager.availableChatBYOKModels } var defaultModel: LLMModel? { @@ -262,7 +87,9 @@ struct ModelPicker: View { // Update cache for specific scope only if models changed func updateModelCacheIfNeeded(for scope: PromptTemplateScope) { - let currentModels = scope == .agentPanel ? modelManager.availableAgentModels : modelManager.availableChatModels + let currentModels = scope == .agentPanel ? + modelManager.availableAgentModels + modelManager.availableAgentBYOKModels : + modelManager.availableChatModels + modelManager.availableChatBYOKModels let modelsHash = currentModels.hashValue if scope == .agentPanel { @@ -280,18 +107,24 @@ struct ModelPicker: View { var maxWidth: CGFloat = 0 for model in models { - let multiplierText = formatMultiplierText(for: model.billing) - newCache[model.modelName] = multiplierText + var multiplierText = "" + if model.billing != nil { + multiplierText = formatMultiplierText(for: model.billing) + } else if let providerName = model.providerName, !providerName.isEmpty { + // For BYOK models, show the provider name + multiplierText = providerName + } + newCache[model.modelName.appending(model.providerName ?? "")] = multiplierText - let displayName = "✓ \(model.modelName)" + let displayName = "✓ \(model.displayName ?? model.modelName)" let displayNameWidth = displayName.size(withAttributes: attributes).width let multiplierWidth = multiplierText.isEmpty ? 0 : multiplierText.size(withAttributes: attributes).width let totalWidth = displayNameWidth + minimumPaddingWidth + multiplierWidth maxWidth = max(maxWidth, totalWidth) } - if maxWidth == 0 { - maxWidth = selectedModel.size(withAttributes: attributes).width + if maxWidth == 0, let selectedModel = selectedModel { + maxWidth = (selectedModel.displayName ?? selectedModel.modelName).size(withAttributes: attributes).width } return ScopeCache( @@ -302,7 +135,29 @@ struct ModelPicker: View { } func updateCurrentModel() { - selectedModel = AppState.shared.getSelectedModelName() ?? defaultModel?.modelName ?? "" + let currentModel = AppState.shared.getSelectedModel() + let allAvailableModels = copilotModels + byokModels + + // Check if current model exists in available models for current scope using model comparison + let modelExists = allAvailableModels.contains { model in + model == currentModel + } + + if !modelExists && currentModel != nil { + // Switch to default model if current model is not available + if let fallbackModel = defaultModel { + AppState.shared.setSelectedModel(fallbackModel) + selectedModel = fallbackModel + } else if let firstAvailable = allAvailableModels.first { + // If no default model, use first available + AppState.shared.setSelectedModel(firstAvailable) + selectedModel = firstAvailable + } else { + selectedModel = nil + } + } else { + selectedModel = currentModel ?? defaultModel + } } func updateAgentPicker() { @@ -310,10 +165,12 @@ struct ModelPicker: View { } func switchModelsForScope(_ scope: PromptTemplateScope) { - let newModeModels = CopilotModelManager.getAvailableChatLLMs(scope: scope) + let newModeModels = CopilotModelManager.getAvailableChatLLMs( + scope: scope + ) + BYOKModelManager.getAvailableChatLLMs(scope: scope) - if let currentModel = AppState.shared.getSelectedModelName() { - if !newModeModels.isEmpty && !newModeModels.contains(where: { $0.modelName == currentModel }) { + if let currentModel = AppState.shared.getSelectedModel() { + if !newModeModels.isEmpty && !newModeModels.contains(where: { $0 == currentModel }) { let defaultModel = CopilotModelManager.getDefaultChatModel(scope: scope) if let defaultModel = defaultModel { AppState.shared.setSelectedModel(defaultModel) @@ -329,10 +186,14 @@ struct ModelPicker: View { // Model picker menu component private var modelPickerMenu: some View { - Menu(selectedModel) { + Menu(selectedModel?.displayName ?? selectedModel?.modelName ?? "") { // Group models by premium status - let premiumModels = models.filter { $0.billing?.isPremium == true } - let standardModels = models.filter { $0.billing?.isPremium == false || $0.billing == nil } + let premiumModels = copilotModels.filter { + $0.billing?.isPremium == true + } + let standardModels = copilotModels.filter { + $0.billing?.isPremium == false || $0.billing == nil + } // Display standard models section if available modelSection(title: "Standard Models", models: standardModels) @@ -340,6 +201,13 @@ struct ModelPicker: View { // Display premium models section if available modelSection(title: "Premium Models", models: premiumModels) + // Display byok models section if available + modelSection(title: "Other Models", models: byokModels) + + Button("Manage Models...") { + try? launchHostAppBYOKSettings() + } + if standardModels.isEmpty { Link("Add Premium Models", destination: URL(string: "https://aka.ms/github-copilot-upgrade-plan")!) } @@ -374,9 +242,9 @@ struct ModelPicker: View { AppState.shared.setSelectedModel(model) } label: { Text(createModelMenuItemAttributedString( - modelName: model.modelName, - isSelected: selectedModel == model.modelName, - cachedMultiplierText: currentCache.modelMultiplierCache[model.modelName] ?? "" + modelName: model.displayName ?? model.modelName, + isSelected: selectedModel == model, + cachedMultiplierText: currentCache.modelMultiplierCache[model.modelName.appending(model.providerName ?? "")] ?? "" )) } } @@ -426,7 +294,7 @@ struct ModelPicker: View { // Model Picker Group { - if !models.isEmpty && !selectedModel.isEmpty { + if !copilotModels.isEmpty && selectedModel != nil { modelPickerMenu } else { EmptyView() @@ -453,6 +321,14 @@ struct ModelPicker: View { updateCurrentModel() updateModelCacheIfNeeded(for: .agentPanel) } + .onChange(of: modelManager.availableChatBYOKModels) { _ in + updateCurrentModel() + updateModelCacheIfNeeded(for: .chatPanel) + } + .onChange(of: modelManager.availableAgentBYOKModels) { _ in + updateCurrentModel() + updateModelCacheIfNeeded(for: .agentPanel) + } .onChange(of: chatMode) { _ in updateCurrentModel() } @@ -466,7 +342,11 @@ struct ModelPicker: View { } func labelWidth() -> CGFloat { - let width = selectedModel.size(withAttributes: attributes).width + guard let selectedModel = selectedModel else { return 100 } + let displayName = selectedModel.displayName ?? selectedModel.modelName + let width = displayName.size( + withAttributes: attributes + ).width return CGFloat(width + 20) } @@ -506,7 +386,10 @@ struct ModelPicker: View { attributedString = AttributedString(fullString) - if let range = attributedString.range(of: cachedMultiplierText) { + if let range = attributedString.range( + of: cachedMultiplierText, + options: .backwards + ) { attributedString[range].foregroundColor = .secondary } } diff --git a/Core/Sources/ConversationTab/Styles.swift b/Core/Sources/ConversationTab/Styles.swift index 996593f3..a0366ccd 100644 --- a/Core/Sources/ConversationTab/Styles.swift +++ b/Core/Sources/ConversationTab/Styles.swift @@ -187,6 +187,17 @@ extension View { // semibold -> 600 font(.system(size: 13, weight: .semibold)) } + + func chatContextReferenceStyle(isCurrentEditor: Bool, r: Double) -> some View { + background( + Color(nsColor: .windowBackgroundColor).opacity(0.5) + ) + .cornerRadius(isCurrentEditor ? 99 : r) + .overlay( + RoundedRectangle(cornerRadius: isCurrentEditor ? 99 : r) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } } // MARK: - Code Review Background Styles diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index a67dbcc5..70dcd3f2 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -253,7 +253,7 @@ struct ReferenceList: View { chat.send(.referenceClicked(reference)) }) { HStack(spacing: 8) { - drawFileIcon(reference.url) + drawFileIcon(reference.url, isDirectory: reference.isDirectory) .resizable() .scaledToFit() .frame(width: 16, height: 16) @@ -338,7 +338,8 @@ struct BotMessage_Previews: PreviewProvider { references: .init(repeating: .init( uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", status: .included, - kind: .class + kind: .class, + referenceType: .file ), count: 2), followUp: ConversationFollowUp(message: "followup question", id: "id", type: "type"), errorMessages: ["Sorry, an error occurred while generating a response."], @@ -360,32 +361,38 @@ struct ReferenceList_Previews: PreviewProvider { .init( uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", status: .included, - kind: .class + kind: .class, + referenceType: .file ), .init( uri: "/Core/Sources/ConversationTab/Views", status: .included, - kind: .struct + kind: .struct, + referenceType: .file ), .init( uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", status: .included, - kind: .function + kind: .function, + referenceType: .file ), .init( uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", status: .included, - kind: .case + kind: .case, + referenceType: .file ), .init( uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", status: .included, - kind: .extension + kind: .extension, + referenceType: .file ), .init( uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", status: .included, - kind: .webpage + kind: .webpage, + referenceType: .file ), ], chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) })) } diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift index 76f613a7..abfb3478 100644 --- a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift @@ -86,6 +86,7 @@ private struct FileSelectionList: View { let fileUris: [DocumentUri] let reviewStatus: CodeReviewRound.Status @State private var isExpanded = false + @State private var checkboxMixedState: CheckboxMixedState = .off @Binding var selectedFileUris: [DocumentUri] @AppStorage(\.chatFontSize) private var chatFontSize @@ -101,11 +102,17 @@ private struct FileSelectionList: View { WithPerceptionTracking { VStack(alignment: .leading, spacing: 4) { + // Select All checkbox for all files + selectedAllCheckbox + .disabled(reviewStatus != .waitForConfirmation) + FileToggleList( fileUris: visibleFileUris, reviewStatus: reviewStatus, - selectedFileUris: $selectedFileUris + selectedFileUris: $selectedFileUris, + onSelectionChange: updateMixedState ) + .padding(.leading, 16) if hasMoreFiles { if !isExpanded { @@ -116,13 +123,53 @@ private struct FileSelectionList: View { FileToggleList( fileUris: additionalFileUris, reviewStatus: reviewStatus, - selectedFileUris: $selectedFileUris + selectedFileUris: $selectedFileUris, + onSelectionChange: updateMixedState ) + .padding(.leading, 16) } } } } .frame(alignment: .leading) + .onAppear { + updateMixedState() + } + } + + private var selectedAllCheckbox: some View { + let selectedCount = selectedFileUris.count + let totalCount = fileUris.count + let title = "All (\(selectedCount)/\(totalCount))" + + return MixedStateCheckbox( + title: title, + state: $checkboxMixedState + ) { + switch checkboxMixedState { + case .off, .mixed: + // Select all files + selectedFileUris = fileUris + case .on: + // Deselect all files + selectedFileUris = [] + } + updateMixedState() + } + } + + private func updateMixedState() { + let selectedSet = Set(selectedFileUris) + let selectedCount = fileUris.filter { selectedSet.contains($0) }.count + let totalCount = fileUris.count + + if selectedCount == 0 { + checkboxMixedState = .off + } else if selectedCount == totalCount { + checkboxMixedState = .on + } else { + checkboxMixedState = .mixed + } } } @@ -152,6 +199,7 @@ private struct FileToggleList: View { let fileUris: [DocumentUri] let reviewStatus: CodeReviewRound.Status @Binding var selectedFileUris: [DocumentUri] + let onSelectionChange: () -> Void var body: some View { ForEach(fileUris, id: \.self) { fileUri in @@ -174,6 +222,8 @@ private struct FileToggleList: View { } else { selectedFileUris.removeAll { $0 == fileUri } } + + onSelectionChange() } ) } @@ -208,6 +258,8 @@ private struct FileSelectionRow: View { } .toggleStyle(CheckboxToggleStyle()) .disabled(!isInteractionEnabled) + + Spacer() } } } diff --git a/Core/Sources/HostApp/BYOKConfigView.swift b/Core/Sources/HostApp/BYOKConfigView.swift new file mode 100644 index 00000000..a55b995a --- /dev/null +++ b/Core/Sources/HostApp/BYOKConfigView.swift @@ -0,0 +1,72 @@ +import Client +import GitHubCopilotService +import SwiftUI + +public struct BYOKConfigView: View { + @StateObject private var dataManager = BYOKModelManagerObservable() + @State private var activeSheet: BYOKSheetType? + @State private var expansionStates: [BYOKProvider: Bool] = [:] + + private let providers: [BYOKProvider] = [ + .Azure, + .OpenAI, + .Anthropic, + .Gemini, + .Groq, + .OpenRouter, + ] + + private var expansionHash: Int { + expansionStates.values.map { $0 ? 1 : 0 }.reduce(0, +) + } + + private func expansionBinding(for provider: BYOKProvider) -> Binding { + Binding( + get: { expansionStates[provider] ?? true }, + set: { expansionStates[provider] = $0 } + ) + } + + public var body: some View { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(providers, id: \.self) { provider in + BYOKProviderConfigView( + provider: provider, + dataManager: dataManager, + onSheetRequested: presentSheet, + isExpanded: expansionBinding(for: provider) + ) + } + } + .padding(16) + } + .animation(.easeInOut(duration: 0.3), value: expansionHash) + .onAppear { + Task { + await dataManager.refreshData() + } + } + .sheet(item: $activeSheet) { sheetType in + createSheetContent(for: sheetType) + } + } + + // MARK: - Sheet Management + + /// Presents the requested sheet type + private func presentSheet(_ sheetType: BYOKSheetType) { + activeSheet = sheetType + } + + /// Creates the appropriate sheet content based on the sheet type + @ViewBuilder + private func createSheetContent(for sheetType: BYOKSheetType) -> some View { + switch sheetType { + case let .apiKey(provider): + ApiKeySheet(dataManager: dataManager, provider: provider) + case let .model(provider, model): + ModelSheet(dataManager: dataManager, provider: provider, existingModel: model) + } + } +} diff --git a/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift new file mode 100644 index 00000000..d957e2ac --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift @@ -0,0 +1,162 @@ +import GitHubCopilotService +import SwiftUI + +struct ApiKeySheet: View { + @ObservedObject var dataManager: BYOKModelManagerObservable + @Environment(\.dismiss) private var dismiss + + @State private var apiKey = "" + @State private var showDeleteConfirmation = false + @State private var showPopOver = false + @State private var keepCustomModels = true + let provider: BYOKProvider + + private var hasExistingApiKey: Bool { + dataManager.hasApiKey(for: provider) + } + + private var isFormInvalid: Bool { + apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + Form { + VStack(alignment: .center, spacing: 20) { + HStack(alignment: .center) { + Spacer() + Text("\(provider.title)").font(.headline) + Spacer() + if #available(macOS 14.0, *) { + HelpLink(action: openHelpLink).controlSize(.small) + } else { + Button(action: openHelpLink) { + Image(systemName: "questionmark") + } + .controlSize(.small) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0) + .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5) + } + } + + VStack(alignment: .leading, spacing: 4) { + TextFieldsContainer { + SecureField("API Key", text: $apiKey) + } + + if hasExistingApiKey { + HStack(spacing: 8) { + Toggle("Keep Custom Models", isOn: $keepCustomModels) + .toggleStyle(CheckboxToggleStyle()) + + Button(action: {}) { + Image(systemName: "questionmark.circle") + } + .buttonStyle(.borderless) + .foregroundStyle(.primary) + .onHover { hovering in + showPopOver = hovering + } + .popover(isPresented: $showPopOver, arrowEdge: .bottom) { + Text("Retains custom models \nafter API key updates.") + .multilineTextAlignment(.leading) + .padding(4) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + } + + HStack(spacing: 8) { + if hasExistingApiKey { + Button("Delete", role: .destructive) { + showDeleteConfirmation = true + } + .confirmationDialog( + "Delete \(provider.title) API Key?", + isPresented: $showDeleteConfirmation + ) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { deleteApiKey() } + } message: { + Text("This will remove all linked models and configurations. Still want to delete it?") + } + } + + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button(hasExistingApiKey ? "Update" : "Add") { updateApiKey() } + .buttonStyle(.borderedProminent) + .disabled(isFormInvalid) + } + } + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + .padding(20) + } + .onAppear { + loadExistingApiKey() + } + } + + private func loadExistingApiKey() { + apiKey = dataManager.filteredApiKeys(for: provider).first?.apiKey ?? "" + } + + private func updateApiKey() { + Task { + do { + let trimmedApiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + + var savedCustomModels: [BYOKModelInfo] = [] + + // If updating an existing API key and keeping custom models, save them first + if hasExistingApiKey && keepCustomModels { + savedCustomModels = dataManager.filteredModels(for: provider) + .filter { $0.isCustomModel } + } + + // For updates, delete the original API key first + if hasExistingApiKey { + try await dataManager.deleteApiKey(providerName: provider) + } + + // Save the new API key + try await dataManager.saveApiKey(trimmedApiKey, providerName: provider) + + // If we saved custom models and should keep them, restore them + if hasExistingApiKey && keepCustomModels && !savedCustomModels.isEmpty { + for customModel in savedCustomModels { + // Restore the custom model with the same properties + try await dataManager.saveModel(customModel) + } + } + + dismiss() + + // Fetch default models from the provider + await dataManager.listModelsWithFetch(providerName: provider) + } catch { + // Error is already handled in dataManager methods + // The error message will be displayed in the provider view + } + } + } + + private func deleteApiKey() { + Task { + do { + try await dataManager.deleteApiKey(providerName: provider) + dismiss() + } catch { + // Error handling could be improved here, but keeping it simple for now + // The error will be reflected in the UI when the sheet dismisses + } + } + } + + private func openHelpLink() { + NSWorkspace.shared.open(URL(string: BYOKHelpLink)!) + } +} diff --git a/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift b/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift new file mode 100644 index 00000000..838ff4b7 --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift @@ -0,0 +1,236 @@ +import Client +import GitHubCopilotService +import Logger +import SwiftUI +import XPCShared + +actor BYOKServiceActor { + private let service: XPCExtensionService + + // MARK: - Write Serialization + // Chains write operations so only one mutating request is in-flight at a time. + private var writeQueue: Task? = nil + + /// Enqueue a mutating operation ensuring strict sequential execution. + private func enqueueWrite(_ op: @escaping () async throws -> Void) async throws { + return try await withCheckedThrowingContinuation { continuation in + let previousQueue = writeQueue + writeQueue = Task { + // Wait for all previous operations to complete + await previousQueue?.value + + // Now execute this operation + do { + try await op() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + init(serviceFactory: () throws -> XPCExtensionService) rethrows { + self.service = try serviceFactory() + } + + // MARK: - Listing (reads can stay concurrent) + func listApiKeys() async throws -> [BYOKApiKeyInfo] { + let resp = try await service.listBYOKApiKey(BYOKListApiKeysParams()) + return resp?.apiKeys ?? [] + } + + func listModels(providerName: BYOKProviderName? = nil, + enableFetchUrl: Bool? = nil) async throws -> [BYOKModelInfo] { + let params = BYOKListModelsParams(providerName: providerName, + enableFetchUrl: enableFetchUrl) + let resp = try await service.listBYOKModels(params) + return resp?.models ?? [] + } + + // MARK: - Mutations (serialized) + func saveModel(_ model: BYOKModelInfo) async throws { + try await enqueueWrite { [service] in + _ = try await service.saveBYOKModel(model) + } + } + + func deleteModel(providerName: BYOKProviderName, modelId: String) async throws { + try await enqueueWrite { [service] in + let params = BYOKDeleteModelParams(providerName: providerName, modelId: modelId) + _ = try await service.deleteBYOKModel(params) + } + } + + func saveApiKey(_ apiKey: String, providerName: BYOKProviderName) async throws { + try await enqueueWrite { [service] in + let params = BYOKSaveApiKeyParams(providerName: providerName, apiKey: apiKey) + _ = try await service.saveBYOKApiKey(params) + } + } + + func deleteApiKey(providerName: BYOKProviderName) async throws { + try await enqueueWrite { [service] in + let params = BYOKDeleteApiKeyParams(providerName: providerName) + _ = try await service.deleteBYOKApiKey(params) + } + } +} + +@MainActor +class BYOKModelManagerObservable: ObservableObject { + @Published var availableBYOKApiKeys: [BYOKApiKeyInfo] = [] + @Published var availableBYOKModels: [BYOKModelInfo] = [] + @Published var errorMessages: [BYOKProviderName: String] = [:] + @Published var providerLoadingStates: [BYOKProviderName: Bool] = [:] + + private let serviceActor: BYOKServiceActor + + init() { + self.serviceActor = try! BYOKServiceActor { + try getService() // existing factory + } + } + + func refreshData() async { + do { + // Serialized by actor (even though we still parallelize logically, calls run one by one) + async let apiKeys = serviceActor.listApiKeys() + async let models = serviceActor.listModels() + + availableBYOKApiKeys = try await apiKeys + availableBYOKModels = try await models.sorted() + } catch { + Logger.client.error("Failed to refresh BYOK data: \(error)") + } + } + + func deleteModel(_ model: BYOKModelInfo) async throws { + try await serviceActor.deleteModel(providerName: model.providerName, modelId: model.modelId) + await refreshData() + } + + func saveModel(_ modelInfo: BYOKModelInfo) async throws { + try await serviceActor.saveModel(modelInfo) + await refreshData() + } + + func saveApiKey(_ apiKey: String, providerName: BYOKProviderName) async throws { + try await serviceActor.saveApiKey(apiKey, providerName: providerName) + await refreshData() + } + + func deleteApiKey(providerName: BYOKProviderName) async throws { + try await serviceActor.deleteApiKey(providerName: providerName) + errorMessages[providerName] = nil + await refreshData() + } + + func listModelsWithFetch(providerName: BYOKProviderName) async { + providerLoadingStates[providerName] = true + errorMessages[providerName] = nil + defer { providerLoadingStates[providerName] = false } + do { + _ = try await serviceActor.listModels(providerName: providerName, enableFetchUrl: true) + await refreshData() + } catch { + errorMessages[providerName] = error.localizedDescription + } + } + + func updateAllModels(providerName: BYOKProviderName, isRegistered: Bool) async throws { + let current = availableBYOKModels.filter { $0.providerName == providerName && $0.isRegistered != isRegistered } + guard !current.isEmpty else { return } + for model in current { + var updated = model + updated.isRegistered = isRegistered + try await serviceActor.saveModel(updated) + } + await refreshData() + } +} + +// MARK: - Provider-specific Data Filtering + +extension BYOKModelManagerObservable { + func filteredApiKeys(for provider: BYOKProviderName, modelId: String? = nil) -> [BYOKApiKeyInfo] { + availableBYOKApiKeys.filter { apiKey in + apiKey.providerName == provider && (modelId == nil || apiKey.modelId == modelId) + } + } + + func filteredModels(for provider: BYOKProviderName) -> [BYOKModelInfo] { + availableBYOKModels.filter { $0.providerName == provider } + } + + func hasApiKey(for provider: BYOKProviderName) -> Bool { + !filteredApiKeys(for: provider).isEmpty + } + + func hasModels(for provider: BYOKProviderName) -> Bool { + !filteredModels(for: provider).isEmpty + } + + func isLoadingProvider(_ provider: BYOKProviderName) -> Bool { + providerLoadingStates[provider] ?? false + } +} + +public let BYOKHelpLink = "https://github.com/github/CopilotForXcode/blob/main/BYOK.md" + +enum BYOKSheetType: Identifiable { + case apiKey(BYOKProviderName) + case model(BYOKProviderName, BYOKModelInfo? = nil) + + var id: String { + switch self { + case let .apiKey(provider): + return "apiKey_\(provider.rawValue)" + case let .model(provider, model): + if let model = model { + return "editModel_\(provider.rawValue)_\(model.modelId)" + } else { + return "model_\(provider.rawValue)" + } + } + } +} + +enum BYOKAuthType { + case GlobalApiKey + case PerModelDeployment + + var helpText: String { + switch self { + case .GlobalApiKey: + return "Requires a single API key for all models" + case .PerModelDeployment: + return "Requires both deployment URL and API key per model" + } + } +} + +extension BYOKProviderName { + var title: String { + switch self { + case .Azure: return "Azure" + case .Anthropic: return "Anthropic" + case .Gemini: return "Gemini" + case .Groq: return "Groq" + case .OpenAI: return "OpenAI" + case .OpenRouter: return "OpenRouter" + } + } + + // MARK: - Configuration Type + + /// The configuration approach used by this provider + var authType: BYOKAuthType { + switch self { + case .Anthropic, .Gemini, .Groq, .OpenAI, .OpenRouter: return .GlobalApiKey + case .Azure: return .PerModelDeployment + } + } +} + +typealias BYOKProvider = BYOKProviderName diff --git a/Core/Sources/HostApp/BYOKSettings/ModelRowView.swift b/Core/Sources/HostApp/BYOKSettings/ModelRowView.swift new file mode 100644 index 00000000..d8487d23 --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/ModelRowView.swift @@ -0,0 +1,111 @@ +import GitHubCopilotService +import Logger +import SharedUIComponents +import SwiftUI + +struct ModelRowView: View { + var model: BYOKModelInfo + @ObservedObject var dataManager: BYOKModelManagerObservable + let isSelected: Bool + let onSelection: () -> Void + let onEditRequested: ((BYOKModelInfo) -> Void)? // New callback for edit action + @State private var isHovered: Bool = false + + // Extract foreground colors to computed properties + private var primaryForegroundColor: Color { + isSelected ? Color(nsColor: .white) : .primary + } + + private var secondaryForegroundColor: Color { + isSelected ? Color(nsColor: .white) : .secondary + } + + var body: some View { + HStack(alignment: .center, spacing: 4) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 4) { + Text(model.modelCapabilities?.name ?? model.modelId) + .foregroundColor(primaryForegroundColor) + + Text(model.modelCapabilities?.name != nil ? model.modelId : "") + .foregroundColor(secondaryForegroundColor) + .font(.callout) + + if model.isCustomModel { + Badge( + text: "Custom Model", + level: .info, + isSelected: isSelected + ) + } + } + + Group { + if let modelCapabilities = model.modelCapabilities, + modelCapabilities.toolCalling || modelCapabilities.vision { + HStack(spacing: 0) { + if modelCapabilities.toolCalling { + Text("Tools").help("Support Tool Calling") + } + if modelCapabilities.vision { + Text("・") + Text("Vision").help("Support Vision") + } + } + } else { + EmptyView() + } + } + .foregroundColor(secondaryForegroundColor) + } + + Spacer() + + // Show edit icon for custom model when selected or hovered + if model.isCustomModel { + Button(action: { + onEditRequested?(model) + }) { + Image(systemName: "gearshape") + } + .buttonStyle(HoverButtonStyle( + hoverColor: isSelected ? .white.opacity(0.1) : .hoverColor + )) + .foregroundColor(primaryForegroundColor) + .opacity((isSelected || isHovered) ? 1.0 : 0.0) + .padding(.horizontal, 12) + } + + Toggle(" ", isOn: Binding( + // Space in toggle label ensures proper checkbox centering alignment + get: { model.isRegistered }, + set: { newValue in + // Only save when user directly toggles the checkbox + Task { + do { + var newModelInfo = model + newModelInfo.isRegistered = newValue + try await dataManager.saveModel(newModelInfo) + } catch { + Logger.client.error("Failed to update model: \(error.localizedDescription)") + } + } + } + )) + .toggleStyle(.checkbox) + .labelStyle(.iconOnly) + .padding(.vertical, 4) + } + .padding(.leading, 36) + .padding(.trailing, 16) + .padding(.vertical, 4) + .contentShape(Rectangle()) + .background( + isSelected ? Color(nsColor: .controlAccentColor) : Color.clear + ) + .onTapGesture { onSelection() } + .onHover { hovering in + isHovered = hovering + } + } +} diff --git a/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift new file mode 100644 index 00000000..e67e4f3b --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift @@ -0,0 +1,175 @@ +import GitHubCopilotService +import SwiftUI + +struct ModelSheet: View { + @ObservedObject var dataManager: BYOKModelManagerObservable + @Environment(\.dismiss) private var dismiss + + @State private var modelId = "" + @State private var deploymentUrl = "" + @State private var apiKey = "" + @State private var customModelName = "" + @State private var supportToolCalling: Bool = true + @State private var supportVision: Bool = true + + let provider: BYOKProvider + let existingModel: BYOKModelInfo? + + // Computed property to determine if this is a per-model deployment provider + private var isPerModelDeployment: Bool { + provider.authType == .PerModelDeployment + } + + // Computed property to determine if we're editing vs adding + private var isEditing: Bool { + existingModel != nil + } + + var body: some View { + Form { + VStack(alignment: .center, spacing: 20) { + HStack(alignment: .center) { + Spacer() + Text("\(provider.title)").font(.headline) + Spacer() + if #available(macOS 14.0, *) { + HelpLink(action: openHelpLink).controlSize(.small) + } else { + Button(action: openHelpLink) { + Image(systemName: "questionmark") + } + .controlSize(.small) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0) + .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5) + } + } + + VStack(alignment: .leading, spacing: 8) { + // Deployment/Model Name Section + TextFieldsContainer { + TextField(isPerModelDeployment ? "Deployment Name" : "Model ID", text: $modelId) + } + + // Endpoint Section (only for per-model deployment) + if isPerModelDeployment { + VStack(alignment: .leading, spacing: 4) { + Text("Endpoint") + .foregroundStyle(.secondary) + .font(.callout) + .padding(.horizontal, 8) + + TextFieldsContainer { + TextField("Target URI", text: $deploymentUrl) + + Divider() + + SecureField("API Key", text: $apiKey) + } + } + } + + // Optional Section + VStack(alignment: .leading, spacing: 4) { + Text("Optional") + .foregroundStyle(.secondary) + .font(.callout) + .padding(.horizontal, 8) + + TextFieldsContainer { + TextField("Display Name", text: $customModelName) + } + + HStack(spacing: 16) { + Toggle("Support Tool Calling", isOn: $supportToolCalling) + .toggleStyle(CheckboxToggleStyle()) + Toggle("Support Vision", isOn: $supportVision) + .toggleStyle(CheckboxToggleStyle()) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + } + + HStack(spacing: 8) { + Spacer() + Button("Cancel") { dismiss() }.buttonStyle(.bordered) + Button(isEditing ? "Save" : "Add") { saveModel() } + .buttonStyle(.borderedProminent) + .disabled(isFormInvalid) + } + } + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + .padding(20) + } + .onAppear { + loadModelData() + } + } + + private var isFormInvalid: Bool { + let modelIdEmpty = modelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + + if isPerModelDeployment { + let deploymentUrlEmpty = deploymentUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let apiKeyEmpty = apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return modelIdEmpty || deploymentUrlEmpty || apiKeyEmpty + } else { + return modelIdEmpty + } + } + + private func loadModelData() { + guard let model = existingModel else { return } + + modelId = model.modelId + customModelName = model.modelCapabilities?.name ?? "" + supportToolCalling = model.modelCapabilities?.toolCalling ?? true + supportVision = model.modelCapabilities?.vision ?? true + + if isPerModelDeployment { + deploymentUrl = model.deploymentUrl ?? "" + apiKey = dataManager + .filteredApiKeys( + for: provider, + modelId: modelId + ).first?.apiKey ?? "" + } + } + + private func saveModel() { + Task { + do { + // Trim whitespace and newlines from all input fields + let trimmedModelId = modelId.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedDeploymentUrl = deploymentUrl.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedApiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedCustomModelName = customModelName.trimmingCharacters(in: .whitespacesAndNewlines) + + let modelParams = BYOKModelInfo( + providerName: provider, + modelId: trimmedModelId, + isRegistered: existingModel?.isRegistered ?? true, + isCustomModel: true, + deploymentUrl: isPerModelDeployment ? trimmedDeploymentUrl : nil, + apiKey: isPerModelDeployment ? trimmedApiKey : nil, + modelCapabilities: BYOKModelCapabilities( + name: trimmedCustomModelName.isEmpty ? trimmedModelId : trimmedCustomModelName, + toolCalling: supportToolCalling, + vision: supportVision + ) + ) + + try await dataManager.saveModel(modelParams) + dismiss() + } catch { + dataManager.errorMessages[provider] = "Failed to \(isEditing ? "update" : "add") model: \(error.localizedDescription)" + } + } + } + + private func openHelpLink() { + NSWorkspace.shared.open(URL(string: BYOKHelpLink)!) + } +} diff --git a/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift new file mode 100644 index 00000000..5ea63b54 --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift @@ -0,0 +1,338 @@ +import Client +import GitHubCopilotService +import Logger +import SharedUIComponents +import SwiftUI + +struct ModelConfig: Identifiable { + let id = UUID() + var name: String + var isSelected: Bool +} + +struct BYOKProviderConfigView: View { + let provider: BYOKProvider + @ObservedObject var dataManager: BYOKModelManagerObservable + let onSheetRequested: (BYOKSheetType) -> Void + @Binding var isExpanded: Bool + + @State private var selectedModelId: String? = nil + @State private var isSelectedCustomModel: Bool = false + @State private var showDeleteConfirmation: Bool = false + @State private var isSearchBarVisible: Bool = false + @State private var searchText: String = "" + + @Environment(\.colorScheme) var colorScheme + + private var hasApiKey: Bool { dataManager.hasApiKey(for: provider) } + private var hasModels: Bool { dataManager.hasModels(for: provider) } + private var allModels: [BYOKModelInfo] { dataManager.filteredModels(for: provider) } + private var filteredModels: [BYOKModelInfo] { + let base = allModels + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return base } + return base.filter { model in + let modelIdMatch = model.modelId.lowercased().contains(trimmed) + let nameMatch = (model.modelCapabilities?.name ?? "").lowercased().contains(trimmed) + return modelIdMatch || nameMatch + } + } + + private var isProviderEnabled: Bool { allModels.contains { $0.isRegistered } } + private var errorMessage: String? { dataManager.errorMessages[provider] } + private var deleteModelTooltip: String { + if let selectedModelId = selectedModelId { + if isSelectedCustomModel { + return "Delete this model from the list." + } else { + return "\(allModels.first(where: { $0.modelId == selectedModelId })?.modelCapabilities?.name ?? selectedModelId) is the default model from \(provider.title) and can’t be removed." + } + } + return "Select a model to delete." + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ProviderHeaderRowView + + if hasApiKey && isExpanded { + Group { + if !filteredModels.isEmpty { + ModelsListSection + } else if !allModels.isEmpty && !searchText.isEmpty { + VStack(spacing: 0) { + Divider() + Text("No models match \"\(searchText)\"") + .foregroundColor(.secondary) + .padding(.vertical, 8) + } + } + } + .padding(.vertical, 0) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + + FooterToolBar + } + } + .onChange(of: searchText) { _ in + // Clear selection if filtered out + if let selected = selectedModelId, + !filteredModels.contains(where: { $0.modelId == selected }) { + selectedModelId = nil + isSelectedCustomModel = false + } + } + .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .inset(by: 0.5) + .stroke(SecondarySystemFillColor, lineWidth: 1) + .animation(.easeInOut(duration: 0.3), value: isExpanded) + ) + .animation(.easeInOut(duration: 0.3), value: isExpanded) + } + + // MARK: - UI Components + + private var ProviderLabelView: some View { + HStack(spacing: 8) { + Image(systemName: "chevron.right").font(.footnote.bold()) + .foregroundColor(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .animation(.easeInOut(duration: 0.3), value: isExpanded) + .buttonStyle(.borderless) + .opacity(hasApiKey ? 1 : 0) + .allowsHitTesting(hasApiKey) + + HStack(spacing: 8) { + Text(provider.title) + .foregroundColor( + hasApiKey ? .primary : Color( + nsColor: colorScheme == .light ? .tertiaryLabelColor : .secondaryLabelColor + ) + ) + .bold() + + Text(hasModels ? " (\(allModels.filter { $0.isRegistered }.count) of \(allModels.count) Enabled)" : "") + .foregroundColor(.primary) + } + .padding(.vertical, 4) + } + } + + private var ProviderHeaderRowView: some View { + HStack(alignment: .center, spacing: 16) { + ProviderLabelView + + Spacer() + + if let errorMessage = errorMessage { + Badge(text: "Can't connect. Check your API key or network.", level: .danger, icon: "xmark.circle.fill") + .help("Unable to connect to \(provider.title). \(errorMessage) Refresh or recheck your key setup.") + } + + if hasApiKey { + if dataManager.isLoadingProvider(provider) { + ProgressView().controlSize(.small) + } else { + ConfiguredProviderActions + } + } else { + UnconfiguredProviderAction + } + } + .padding(.leading, 20) + .padding(.trailing, 24) + .padding(.vertical, 8) + .background(QuaternarySystemFillColor.opacity(0.75)) + .contentShape(Rectangle()) + .onTapGesture { + guard hasApiKey else { return } + let wasExpanded = isExpanded + withAnimation(.easeInOut) { + isExpanded.toggle() + } + // If we just collapsed, and the search bar was open, reset it. + if wasExpanded && !isExpanded && isSearchBarVisible { + searchText = "" + withAnimation(.easeInOut) { + isSearchBarVisible = false + } + } + } + .accessibilityAddTraits(.isButton) + .accessibilityLabel("\(provider.title) \(isExpanded ? "collapse" : "expand")") + } + + @ViewBuilder + private var ConfiguredProviderActions: some View { + HStack(spacing: 8) { + if provider.authType == .GlobalApiKey && isExpanded { + SearchBar(isVisible: $isSearchBarVisible, text: $searchText) + + Button(action: { Task { + await dataManager.listModelsWithFetch(providerName: provider) + }}) { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(HoverButtonStyle()) + + Button(action: openAddApiKeySheetType) { + Image(systemName: "key") + } + .buttonStyle(HoverButtonStyle()) + + Button(action: { showDeleteConfirmation = true }) { + Image(systemName: "trash") + } + .confirmationDialog( + "Delete \(provider.title) API Key?", + isPresented: $showDeleteConfirmation + + ) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { deleteApiKey() } + } message: { + Text("This will remove all linked models and configurations. Still want to delete it?") + } + .buttonStyle(HoverButtonStyle()) + } + + Toggle("", isOn: Binding( + get: { isProviderEnabled }, + set: { newValue in updateAllModels(isRegistered: newValue) } + )) + .toggleStyle(.switch) + .controlSize(.mini) + } + } + + private var UnconfiguredProviderAction: some View { + Button( + provider.authType == .PerModelDeployment ? "Add Model" : "Add", + systemImage: "plus" + ) { + openAddApiKeySheetType() + } + } + + private var ModelsListSection: some View { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(filteredModels, id: \.modelId) { model in + Divider() + ModelRowView( + model: model, + dataManager: dataManager, + isSelected: selectedModelId == model.modelId, + onSelection: { + selectedModelId = selectedModelId == model.modelId ? nil : model.modelId + isSelectedCustomModel = selectedModelId != nil && model.isCustomModel + }, + onEditRequested: { model in + openEditModelSheet(for: model) + } + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var FooterToolBar: some View { + VStack(spacing: 0) { + Divider() + HStack(spacing: 8) { + Button(action: openAddModelSheet) { + Image(systemName: "plus") + } + .foregroundColor(.primary) + .font(.title2) + .buttonStyle(.borderless) + + Divider() + + Group { + if isSelectedCustomModel { + Button(action: deleteSelectedModel) { + Image(systemName: "minus") + } + .buttonStyle(.borderless) + } else { + Image(systemName: "minus") + } + } + .font(.title2) + .foregroundColor( + isSelectedCustomModel ? .primary : Color( + nsColor: .quaternaryLabelColor + ) + ) + .help(deleteModelTooltip) + + Spacer() + } + .frame(height: 20) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(TertiarySystemFillColor) + } + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + + // MARK: - Actions + + private func openAddApiKeySheetType() { + switch provider.authType { + case .GlobalApiKey: + onSheetRequested(.apiKey(provider)) + case .PerModelDeployment: + onSheetRequested(.model(provider)) + } + } + + private func openAddModelSheet() { + onSheetRequested(.model(provider, nil)) // nil for adding new model + } + + private func openEditModelSheet(for model: BYOKModelInfo) { + onSheetRequested(.model(provider, model)) // pass model for editing + } + + private func deleteApiKey() { + Task { + do { + try await dataManager.deleteApiKey(providerName: provider) + } catch { + Logger.client.error("Failed to delete API key for \(provider.title): \(error)") + } + } + } + + private func deleteSelectedModel() { + guard let selectedModelId = selectedModelId, + let selectedModel = allModels.first(where: { $0.modelId == selectedModelId }) else { + return + } + + self.selectedModelId = nil + isSelectedCustomModel = false + + Task { + do { + try await dataManager.deleteModel(selectedModel) + } catch { + Logger.client.error("Failed to delete model for \(provider.title): \(error)") + } + } + } + + private func updateAllModels(isRegistered: Bool) { + Task { + do { + try await dataManager.updateAllModels(providerName: provider, isRegistered: isRegistered) + } catch { + Logger.client.error("Failed to register models for \(provider.title): \(error)") + } + } + } +} diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index 93c8725a..b9f39791 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -7,18 +7,50 @@ extension KeyboardShortcuts.Name { static let showHideWidget = Self("ShowHideWidget") } +public enum TabIndex: Int, CaseIterable { + case general = 0 + case advanced = 1 + case mcp = 2 + case byok = 3 + + var title: String { + switch self { + case .general: return "General" + case .advanced: return "Advanced" + case .mcp: return "MCP" + case .byok: return "Models" + } + } + + var image: String { + switch self { + case .general: return "CopilotLogo" + case .advanced: return "gearshape.2.fill" + case .mcp: return "wrench.and.screwdriver.fill" + case .byok: return "cube" + } + } + + var isSystemImage: Bool { + switch self { + case .general: return false + default: return true + } + } +} + @Reducer public struct HostApp { @ObservableState public struct State: Equatable { var general = General.State() - public var activeTabIndex: Int = 0 + public var activeTabIndex: TabIndex = .general } public enum Action: Equatable { case appear case general(General.Action) - case setActiveTab(Int) + case setActiveTab(TabIndex) } @Dependency(\.toast) var toast diff --git a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift b/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift index 4ff5fd68..3785dfee 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift +++ b/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift @@ -98,7 +98,7 @@ struct MCPIntroView: View { } .conditionalFontWeight(.semibold) } - .buttonStyle(.borderedProminentWhite) + .buttonStyle(.bordered) .help("Open MCP Runtime Log Folder") } } diff --git a/Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift b/Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift index c4f0f0f2..3b0cbe42 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift +++ b/Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift @@ -1,73 +1,24 @@ -import SwiftUI import Combine import GitHubCopilotService import Persist +import SwiftUI struct MCPToolsListView: View { @ObservedObject private var mcpToolManager = CopilotMCPToolManagerObservable.shared @State private var serverToggleStates: [String: Bool] = [:] @State private var isSearchBarVisible: Bool = false @State private var searchText: String = "" - @FocusState private var isSearchFieldFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 8) { GroupBox( label: - HStack(alignment: .center) { - Text("Available MCP Tools").fontWeight(.bold) - Spacer() - if isSearchBarVisible { - HStack(spacing: 5) { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - - TextField("Search tools...", text: $searchText) - .accessibilityIdentifier("searchTextField") - .accessibilityLabel("Search MCP tools") - .textFieldStyle(PlainTextFieldStyle()) - .focused($isSearchFieldFocused) - - if !searchText.isEmpty { - Button(action: { searchText = "" }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.secondary) - } - .buttonStyle(PlainButtonStyle()) - } - } - .padding(.leading, 7) - .padding(.trailing, 3) - .padding(.vertical, 3) - .background( - RoundedRectangle(cornerRadius: 5) - .fill(Color(.textBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 5) - .stroke(isSearchFieldFocused ? - Color(red: 0, green: 0.48, blue: 1).opacity(0.5) : - Color.gray.opacity(0.4), lineWidth: isSearchFieldFocused ? 3 : 1 - ) - ) - .cornerRadius(5) - .frame(width: 212, height: 20, alignment: .leading) - .shadow(color: Color(red: 0, green: 0.48, blue: 1).opacity(0.5), radius: isSearchFieldFocused ? 1.25 : 0, x: 0, y: 0) - .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0) - .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5) - .padding(2) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } else { - Button(action: { withAnimation(.easeInOut) { isSearchBarVisible = true } }) { - Image(systemName: "magnifyingglass") - .padding(.trailing, 2) - } - .buttonStyle(PlainButtonStyle()) - .frame(height: 24) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } - .clipped() + HStack(alignment: .center) { + Text("Available MCP Tools").fontWeight(.bold) + Spacer() + SearchBar(isVisible: $isSearchBarVisible, text: $searchText) + } + .clipped() ) { let filteredServerTools = filteredMCPServerTools() if filteredServerTools.isEmpty { @@ -83,37 +34,15 @@ struct MCPToolsListView: View { } .groupBoxStyle(CardGroupBoxStyle()) } - .contentShape(Rectangle()) // Allow the VStack to receive taps for dismissing focus - .onTapGesture { - if isSearchFieldFocused { // Only dismiss focus if the search field is currently focused - isSearchFieldFocused = false - } - } .onAppear(perform: updateServerToggleStates) - .onChange(of: mcpToolManager.availableMCPServerTools) { _ in + .onChange(of: mcpToolManager.availableMCPServerTools) { _ in updateServerToggleStates() } - .onChange(of: isSearchFieldFocused) { focused in - if !focused && searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - withAnimation(.easeInOut) { - isSearchBarVisible = false - } - } - } - .onChange(of: isSearchBarVisible) { newIsVisible in - if newIsVisible { - // When isSearchBarVisible becomes true, schedule focusing the TextField. - // The delay helps ensure the TextField is rendered and ready. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isSearchFieldFocused = true - } - } - } } private func updateServerToggleStates() { serverToggleStates = mcpToolManager.availableMCPServerTools.reduce(into: [:]) { result, server in - result[server.name] = !server.tools.isEmpty && !server.tools.allSatisfy{ $0._status != .enabled } + result[server.name] = !server.tools.isEmpty && !server.tools.allSatisfy { $0._status != .enabled } } } @@ -121,6 +50,12 @@ struct MCPToolsListView: View { let key = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !key.isEmpty else { return mcpToolManager.availableMCPServerTools } return mcpToolManager.availableMCPServerTools.compactMap { server in + // If server name contains the search key, return the entire server with all tools + if server.name.lowercased().contains(key) { + return server + } + + // Otherwise, filter tools by name and description let filteredTools = server.tools.filter { tool in tool.name.lowercased().contains(key) || (tool.description?.lowercased().contains(key) ?? false) } @@ -149,7 +84,7 @@ private struct EmptyStateView: View { } // Private components now defined in separate files: -// MCPToolsListContainerView - in MCPToolsListContainerView.swift +// MCPToolsListContainerView - in MCPToolsListContainerView.swift // MCPServerToolsSection - in MCPServerToolsSection.swift // MCPToolRow - in MCPToolRowView.swift diff --git a/Core/Sources/HostApp/SharedComponents/Badge.swift b/Core/Sources/HostApp/SharedComponents/Badge.swift index d3a9dd6e..615d03e9 100644 --- a/Core/Sources/HostApp/SharedComponents/Badge.swift +++ b/Core/Sources/HostApp/SharedComponents/Badge.swift @@ -4,15 +4,19 @@ struct BadgeItem { enum Level: String, Equatable { case warning = "Warning" case danger = "Danger" + case info = "Info" } + let text: String let level: Level let icon: String? - - init(text: String, level: Level, icon: String? = nil) { + let isSelected: Bool + + init(text: String, level: Level, icon: String? = nil, isSelected: Bool = false) { self.text = text self.level = level self.icon = icon + self.isSelected = isSelected } } @@ -20,39 +24,43 @@ struct Badge: View { let text: String let level: BadgeItem.Level let icon: String? - + let isSelected: Bool + init(badgeItem: BadgeItem) { - self.text = badgeItem.text - self.level = badgeItem.level - self.icon = badgeItem.icon + text = badgeItem.text + level = badgeItem.level + icon = badgeItem.icon + isSelected = badgeItem.isSelected } - - init(text: String, level: BadgeItem.Level, icon: String? = nil) { + + init(text: String, level: BadgeItem.Level, icon: String? = nil, isSelected: Bool = false) { self.text = text self.level = level self.icon = icon + self.isSelected = isSelected } - + var body: some View { - HStack(spacing: 4) { + HStack(alignment: .center, spacing: 2) { if let icon = icon { Image(systemName: icon) - .resizable() - .scaledToFit() - .frame(width: 11, height: 11) + .font(.caption2) + .padding(.vertical, 1) } Text(text) .fontWeight(.semibold) - .font(.system(size: 11)) + .font(.caption2) .lineLimit(1) } - .padding(.vertical, 2) - .padding(.horizontal, 4) + .padding(.vertical, 1) + .padding(.horizontal, 3) .foregroundColor( - Color("\(level.rawValue)ForegroundColor") + level == .info ? Color(nsColor: isSelected ? .white : .secondaryLabelColor) + : Color("\(level.rawValue)ForegroundColor") ) .background( - Color("\(level.rawValue)BackgroundColor"), + level == .info ? Color(nsColor: .clear) + : Color("\(level.rawValue)BackgroundColor"), in: RoundedRectangle( cornerRadius: 9999, style: .circular @@ -63,7 +71,11 @@ struct Badge: View { cornerRadius: 9999, style: .circular ) - .stroke(Color("\(level.rawValue)StrokeColor"), lineWidth: 1) + .stroke( + level == .info ? Color(nsColor: isSelected ? .white : .tertiaryLabelColor) + : Color("\(level.rawValue)StrokeColor"), + lineWidth: 1 + ) ) } } diff --git a/Core/Sources/HostApp/SharedComponents/Color.swift b/Core/Sources/HostApp/SharedComponents/Color.swift new file mode 100644 index 00000000..2d5a7682 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/Color.swift @@ -0,0 +1,33 @@ +import SwiftUI + +public var QuinarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .quinarySystemFill) + } else { + return Color("QuinarySystemFillColor") + } +} + +public var QuaternarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .quaternarySystemFill) + } else { + return Color("QuaternarySystemFillColor") + } +} + +public var TertiarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .tertiarySystemFill) + } else { + return Color("TertiarySystemFillColor") + } +} + +public var SecondarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .secondarySystemFill) + } else { + return Color("SecondarySystemFillColor") + } +} diff --git a/Core/Sources/HostApp/SharedComponents/SearchBar.swift b/Core/Sources/HostApp/SharedComponents/SearchBar.swift new file mode 100644 index 00000000..5104d29c --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/SearchBar.swift @@ -0,0 +1,100 @@ +import SharedUIComponents +import SwiftUI + +/// Reusable search control with a toggleable magnifying glass button that expands +/// into a styled search field with clear button, focus handling, and auto-hide +/// when focus is lost and the text is empty. +/// +/// Usage: +/// SearchBar(isVisible: $isSearchBarVisible, text: $searchText) +struct SearchBar: View { + @Binding var isVisible: Bool + @Binding var text: String + + @FocusState private var isFocused: Bool + + var placeholder: String = "Search..." + var accessibilityIdentifier: String = "searchTextField" + + var body: some View { + Group { + if isVisible { + HStack(spacing: 5) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .onTapGesture { withAnimation(.easeInOut) { + isVisible = false + } } + + TextField(placeholder, text: $text) + .accessibilityIdentifier(accessibilityIdentifier) + .textFieldStyle(PlainTextFieldStyle()) + .focused($isFocused) + + if !text.isEmpty { + Button(action: { text = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + .help("Clear search") + } + } + .padding(.leading, 7) + .padding(.trailing, 3) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(Color(.textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke( + isFocused + ? Color(red: 0, green: 0.48, blue: 1).opacity(0.5) + : Color.gray.opacity(0.4), + lineWidth: isFocused ? 3 : 1 + ) + ) + .cornerRadius(5) + .frame(width: 212, height: 20, alignment: .leading) + .shadow(color: Color(red: 0, green: 0.48, blue: 1).opacity(0.5), radius: isFocused ? 1.25 : 0, x: 0, y: 0) + .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0) + .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5) + .padding(2) + // Removed the move(edge: .trailing) to prevent overlap; keep a clean fade instead + .transition(.asymmetric(insertion: .opacity, removal: .opacity)) + .onChange(of: isFocused) { focused in + if !focused && text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + withAnimation(.easeInOut) { + isVisible = false + } + } + } + .onChange(of: isVisible) { newValue in + if newValue { + // Delay to ensure the field is mounted before requesting focus. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isFocused = true + } + } + } + } else { + Button(action: { + withAnimation(.easeInOut) { + isVisible = true + } + }) { + Image(systemName: "magnifyingglass") + .padding(.trailing, 2) + } + .buttonStyle(HoverButtonStyle()) + .frame(height: 24) + .transition(.opacity) + .help("Show search") + } + } + .contentShape(Rectangle()) + .onTapGesture { if isFocused { isFocused = false } } + } +} diff --git a/Core/Sources/HostApp/SharedComponents/TextFieldsContainer.swift b/Core/Sources/HostApp/SharedComponents/TextFieldsContainer.swift new file mode 100644 index 00000000..6cf592f3 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/TextFieldsContainer.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct TextFieldsContainer: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + VStack(spacing: 8) { + content + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(QuaternarySystemFillColor.opacity(0.75)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .inset(by: 0.5) + .stroke(SecondarySystemFillColor, lineWidth: 1) + ) + } +} diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 0aa3b008..c9a4c55c 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -17,7 +17,7 @@ public struct TabContainer: View { @ObservedObject var toastController: ToastController @State private var tabBarItems = [TabBarItem]() @State private var isAgentModeFFEnabled = true - @Binding var tag: Int + @Binding var tag: TabIndex public init() { toastController = ToastControllerDependencyKey.liveValue @@ -42,8 +42,8 @@ public struct TabContainer: View { let service = try getService() let featureFlags = try await service.getCopilotFeatureFlags() isAgentModeFFEnabled = featureFlags?.agentMode ?? true - if hostAppStore.activeTabIndex == 2 && !isAgentModeFFEnabled { - hostAppStore.send(.setActiveTab(0)) + if hostAppStore.state.activeTabIndex == .mcp && !isAgentModeFFEnabled { + hostAppStore.send(.setActiveTab(.general)) } } catch { Logger.client.error("Failed to get copilot feature flags: \(error)") @@ -56,25 +56,12 @@ public struct TabContainer: View { TabBar(tag: $tag, tabBarItems: tabBarItems) .padding(.bottom, 8) ZStack(alignment: .center) { - GeneralView(store: store.scope(state: \.general, action: \.general)) - .tabBarItem( - tag: 0, - title: "General", - image: "CopilotLogo", - isSystemImage: false - ) - AdvancedSettings().tabBarItem( - tag: 1, - title: "Advanced", - image: "gearshape.2.fill" - ) + GeneralView(store: store.scope(state: \.general, action: \.general)).tabBarItem(for: .general) + AdvancedSettings().tabBarItem(for: .advanced) if isAgentModeFFEnabled { - MCPConfigView().tabBarItem( - tag: 2, - title: "MCP", - image: "wrench.and.screwdriver.fill" - ) + MCPConfigView().tabBarItem(for: .mcp) } + BYOKConfigView().tabBarItem(for: .byok) } .environment(\.tabBarTabTag, tag) .frame(minHeight: 400) @@ -82,7 +69,7 @@ public struct TabContainer: View { .focusable(false) .padding(.top, 8) .background(.ultraThinMaterial.opacity(0.01)) - .background(Color(nsColor: .controlBackgroundColor).opacity(0.4)) + .background(Color(nsColor: .controlBackgroundColor)) .handleToast() .onPreferenceChange(TabBarItemPreferenceKey.self) { items in tabBarItems = items @@ -104,7 +91,7 @@ public struct TabContainer: View { } struct TabBar: View { - @Binding var tag: Int + @Binding var tag: TabIndex fileprivate var tabBarItems: [TabBarItem] var body: some View { @@ -123,9 +110,9 @@ struct TabBar: View { } struct TabBarButton: View { - @Binding var currentTag: Int + @Binding var currentTag: TabIndex @State var isHovered = false - var tag: Int + var tag: TabIndex var title: String var image: String var isSystemImage: Bool = true @@ -156,7 +143,7 @@ struct TabBarButton: View { .padding(.vertical, 4) .padding(.top, 4) .background( - tag == currentTag + isSelected ? Color(nsColor: .textColor).opacity(0.1) : Color.clear, in: RoundedRectangle(cornerRadius: 8) @@ -177,7 +164,7 @@ struct TabBarButton: View { private struct TabBarTabViewWrapper: View { @Environment(\.tabBarTabTag) var tabBarTabTag - var tag: Int + var tag: TabIndex var title: String var image: String var isSystemImage: Bool = true @@ -199,25 +186,20 @@ private struct TabBarTabViewWrapper: View { } private extension View { - func tabBarItem( - tag: Int, - title: String, - image: String, - isSystemImage: Bool = true - ) -> some View { + func tabBarItem(for tag: TabIndex) -> some View { TabBarTabViewWrapper( tag: tag, - title: title, - image: image, - isSystemImage: isSystemImage, + title: tag.title, + image: tag.image, + isSystemImage: tag.isSystemImage, content: { self } ) } } private struct TabBarItem: Identifiable, Equatable { - var id: Int { tag } - var tag: Int + var id: TabIndex { tag } + var tag: TabIndex var title: String var image: String var isSystemImage: Bool = true @@ -231,11 +213,11 @@ private struct TabBarItemPreferenceKey: PreferenceKey { } private struct TabBarTabTagKey: EnvironmentKey { - static var defaultValue: Int = 0 + static var defaultValue: TabIndex = .general } private extension EnvironmentValues { - var tabBarTabTag: Int { + var tabBarTabTag: TabIndex { get { self[TabBarTabTagKey.self] } set { self[TabBarTabTagKey.self] = newValue } } diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 0297224a..ad7849fa 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -337,6 +337,148 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(data) } } + + // MARK: - BYOK + public func saveBYOKApiKey(_ params: Data, withReply reply: @escaping (Data?) -> Void) { + let decoder = JSONDecoder() + var saveApiKeyParams: BYOKSaveApiKeyParams? = nil + do { + saveApiKeyParams = try decoder.decode(BYOKSaveApiKeyParams.self, from: params) + if saveApiKeyParams == nil { + return + } + } catch { + Logger.service.error("Failed to save BYOK API Key: \(error)") + return + } + + Task { @MainActor in + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.saveBYOKApiKey(saveApiKeyParams!) + let data = try? JSONEncoder().encode(response) + reply(data) + } + } + + public func listBYOKApiKeys(_ params: Data, withReply reply: @escaping (Data?) -> Void) { + let decoder = JSONDecoder() + var listApiKeysParams: BYOKListApiKeysParams? = nil + do { + listApiKeysParams = try decoder.decode(BYOKListApiKeysParams.self, from: params) + if listApiKeysParams == nil { + return + } + } catch { + Logger.service.error("Failed to list BYOK API keys: \(error)") + return + } + + Task { @MainActor in + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.listBYOKApiKeys(listApiKeysParams!) + if !response.apiKeys.isEmpty { + BYOKModelManager.updateApiKeys(apiKeys: response.apiKeys) + } + let data = try? JSONEncoder().encode(response) + reply(data) + } + } + + public func deleteBYOKApiKey(_ params: Data, withReply reply: @escaping (Data?) -> Void) { + let decoder = JSONDecoder() + var deleteApiKeyParams: BYOKDeleteApiKeyParams? = nil + do { + deleteApiKeyParams = try decoder.decode(BYOKDeleteApiKeyParams.self, from: params) + if deleteApiKeyParams == nil { + return + } + } catch { + Logger.service.error("Failed to delete BYOK API Key: \(error)") + return + } + + Task { @MainActor in + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.deleteBYOKApiKey(deleteApiKeyParams!) + let data = try? JSONEncoder().encode(response) + reply(data) + } + } + + public func saveBYOKModel(_ params: Data, withReply reply: @escaping (Data?) -> Void) { + let decoder = JSONDecoder() + var saveModelParams: BYOKSaveModelParams? = nil + do { + saveModelParams = try decoder.decode(BYOKSaveModelParams.self, from: params) + if saveModelParams == nil { + return + } + } catch { + Logger.service.error("Failed to save BYOK model: \(error)") + return + } + + Task { @MainActor in + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.saveBYOKModel(saveModelParams!) + let data = try? JSONEncoder().encode(response) + reply(data) + } + } + + public func listBYOKModels(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) { + let decoder = JSONDecoder() + var listModelsParams: BYOKListModelsParams? = nil + do { + listModelsParams = try decoder.decode(BYOKListModelsParams.self, from: params) + if listModelsParams == nil { + return + } + } catch { + Logger.service.error("Failed to list BYOK models: \(error)") + return + } + + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.listBYOKModels(listModelsParams!) + if !response.models.isEmpty && listModelsParams?.enableFetchUrl == true { + for model in response.models { + _ = try await service.saveBYOKModel(model) + } + } + let fullModelResponse = try await service.listBYOKModels(BYOKListModelsParams()) + BYOKModelManager.updateBYOKModels(BYOKModels: fullModelResponse.models) + let data = try? JSONEncoder().encode(response) + reply(data, nil) + } catch { + Logger.service.error("Failed to list BYOK models: \(error)") + reply(nil, NSError.from(error)) + } + } + } + + public func deleteBYOKModel(_ params: Data, withReply reply: @escaping (Data?) -> Void) { + let decoder = JSONDecoder() + var deleteModelParams: BYOKDeleteModelParams? = nil + do { + deleteModelParams = try decoder.decode(BYOKDeleteModelParams.self, from: params) + if deleteModelParams == nil { + return + } + } catch { + Logger.service.error("Failed to delete BYOK model: \(error)") + return + } + + Task { @MainActor in + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.deleteBYOKModel(deleteModelParams!) + let data = try? JSONEncoder().encode(response) + reply(data) + } + } } struct NoAccessToAccessibilityAPIError: Error, LocalizedError { diff --git a/Server/package-lock.json b/Server/package-lock.json index bc7fe532..a823aae8 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.355.0", + "@github/copilot-language-server": "^1.362.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,10 +36,10 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.355.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.355.0.tgz", - "integrity": "sha512-Utuljxab2sosUPIilHdLDwBkr+A1xKju+KHG+iLoxDJNA8FGWtoalZv9L3QhakmvC9meQtvMciAYcdeeKPbcaQ==", - "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", + "version": "1.362.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.362.0.tgz", + "integrity": "sha512-4lBt/nVRGCiOZx1Btuvp043ZhGqb3/o8aZzG564nOTOgy3MtQfsNCwzbci+7hZXEuRIPM4bZjBqIzTZ2S2nu9Q==", + "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" }, diff --git a/Server/package.json b/Server/package.json index d5ccd3f8..b434a35f 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.355.0", + "@github/copilot-language-server": "^1.362.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index 6fbafba8..e5896c04 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -21,18 +21,23 @@ public struct ConversationReference: Codable, Equatable, Hashable { case webpage case other // reference for turn - request - case fileReference(FileReference) + case fileReference(ConversationAttachedReference) // reference from turn - response - case reference(Reference) + case reference(FileReference) } public enum Status: String, Codable { case included, blocked, notfound, empty } + + public enum ReferenceType: String, Codable { + case file, directory + } public var uri: String public var status: Status? public var kind: Kind + public var referenceType: ReferenceType public var ext: String { return url?.pathExtension ?? "" @@ -49,16 +54,19 @@ public struct ConversationReference: Codable, Equatable, Hashable { public var url: URL? { return URL(string: uri) } + + public var isDirectory: Bool { referenceType == .directory } public init( uri: String, status: Status?, - kind: Kind + kind: Kind, + referenceType: ReferenceType ) { self.uri = uri self.status = status self.kind = kind - + self.referenceType = referenceType } } diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index 12d51564..f97260c4 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -31,47 +31,113 @@ public protocol ConversationServiceProvider { func reviewChanges(_ params: ReviewChangesParams) async throws -> CodeReviewResult? } -public struct FileReference: Hashable, Codable, Equatable { +public struct ConversationFileReference: Hashable, Codable, Equatable { 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) { + public var selection: LSPRange? + + public init( + url: URL, + relativePath: String? = nil, + fileName: String? = nil, + isCurrentEditor: Bool = false, + selection: LSPRange? = nil + ) { 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 + self.selection = selection } public func hash(into hasher: inout Hasher) { hasher.combine(url) hasher.combine(isCurrentEditor) + hasher.combine(selection) } - public static func == (lhs: FileReference, rhs: FileReference) -> Bool { + public static func == (lhs: ConversationFileReference, rhs: ConversationFileReference) -> 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 "" } +public struct ConversationDirectoryReference: Hashable, Codable { + public let url: URL + // The project URL that this directory belongs to. + // When directly dragging a directory into the chat, this can be nil. + public let projectURL: URL? + + public var depth: Int { + guard let projectURL else { + return -1 + } - let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path - if !homeDirectory.isEmpty { - return filePath.replacingOccurrences(of: homeDirectory, with: "~") + let directoryPathComponents = url.pathComponents + let projectPathComponents = projectURL.pathComponents + if directoryPathComponents.count <= projectPathComponents.count { + return 0 + } + return directoryPathComponents.count - projectPathComponents.count + } + + public var relativePath: String { + guard let projectURL else { + return url.path } - return filePath + return url.path.replacingOccurrences(of: projectURL.path, with: "") + } + + public var displayName: String { url.lastPathComponent } + + public init(url: URL, projectURL: URL? = nil) { + self.url = url + self.projectURL = projectURL + } +} + +extension ConversationDirectoryReference: Equatable { + public static func == (lhs: ConversationDirectoryReference, rhs: ConversationDirectoryReference) -> Bool { + lhs.url.path == rhs.url.path && lhs.projectURL == rhs.projectURL + } +} + +public enum ConversationAttachedReference: Hashable, Codable, Equatable { + case file(ConversationFileReference) + case directory(ConversationDirectoryReference) + + public var url: URL { + switch self { + case .directory(let ref): + return ref.url + case .file(let ref): + return ref.url + } + } + + public var isDirectory: Bool { + switch self { + case .directory: true + case .file: false + } + } + + public var relativePath: String { + switch self { + case .directory(let dir): dir.relativePath + case .file(let file): + file.relativePath ?? file.url.lastPathComponent + } + } + + public var displayName: String { + switch self { + case .directory(let dir): dir.displayName + case .file(let file): + file.fileName ?? file.url.lastPathComponent + } } } @@ -268,8 +334,9 @@ public struct ConversationRequest { public var activeDoc: Doc? public var skills: [String] public var ignoredSkills: [String]? - public var references: [FileReference]? + public var references: [ConversationAttachedReference]? public var model: String? + public var modelProviderName: String? public var turns: [TurnSchema] public var agentMode: Bool = false public var userLanguage: String? = nil @@ -283,8 +350,9 @@ public struct ConversationRequest { activeDoc: Doc? = nil, skills: [String], ignoredSkills: [String]? = nil, - references: [FileReference]? = nil, + references: [ConversationAttachedReference]? = nil, model: String? = nil, + modelProviderName: String? = nil, turns: [TurnSchema] = [], agentMode: Bool = false, userLanguage: String?, @@ -299,6 +367,7 @@ public struct ConversationRequest { self.ignoredSkills = ignoredSkills self.references = references self.model = model + self.modelProviderName = modelProviderName self.turns = turns self.agentMode = agentMode self.userLanguage = userLanguage diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift index a0c109f2..a0ea9920 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift @@ -63,6 +63,11 @@ public struct CopilotModelCapabilitiesSupports: Codable, Equatable { public struct CopilotModelBilling: Codable, Equatable, Hashable { public let isPremium: Bool public let multiplier: Float + + public init(isPremium: Bool, multiplier: Float) { + self.isPremium = isPremium + self.multiplier = multiplier + } } // MARK: Conversation Agents diff --git a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift index 281b534d..f450935e 100644 --- a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift +++ b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift @@ -4,6 +4,7 @@ import Workspace import XcodeInspector import Foundation import ConversationServiceProvider +import LanguageServerProtocol public protocol WatchedFilesHandler { func handleWatchedFiles(_ request: WatchedFilesRequest, workspaceURL: URL, completion: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?) @@ -54,26 +55,57 @@ public final class WatchedFilesHandlerImpl: WatchedFilesHandler { func startFileChangeWatcher(workspaceURL: URL, projectURL: URL, service: GitHubCopilotService?) { Task { + WorkspaceDirectoryIndex.shared.initIndexFor(workspaceURL, projectURL: projectURL) + await FileChangeWatcherServicePool.shared.watch( - for: workspaceURL - ) { fileEvents in - // Update the local file index with file events - fileEvents.forEach { event in - let fileURL = URL(string: event.uri)! - let relativePath = fileURL.path.replacingOccurrences(of: projectURL.path, with: "") - let fileName = fileURL.lastPathComponent - let file = FileReference(url: fileURL, relativePath: relativePath, fileName: fileName) - if event.type == .deleted { - WorkspaceFileIndex.shared.removeFile(file, from: workspaceURL) - } else { - WorkspaceFileIndex.shared.addFile(file, to: workspaceURL) - } - } - - Task { - try? await service?.notifyDidChangeWatchedFiles( - .init(workspaceUri: projectURL.path, changes: fileEvents)) + for: workspaceURL, + publisher: { fileEvents in + self.onFileEvents( + fileEvents: fileEvents, + workspaceURL: workspaceURL, + projectURL: projectURL, + service: service) + }, + directoryChangePublisher: { directoryEvents in + self.onDirectoryEvent( + directoryEvents: directoryEvents, + workspaceURL: workspaceURL, + projectURL: projectURL) } + ) + } + } + + private func onFileEvents(fileEvents: [FileEvent], workspaceURL: URL, projectURL: URL, service: GitHubCopilotService?) { + // Update the local file index with file events + fileEvents.forEach { event in + let fileURL = URL(string: event.uri)! + let relativePath = fileURL.path.replacingOccurrences(of: projectURL.path, with: "") + let fileName = fileURL.lastPathComponent + let file = ConversationFileReference(url: fileURL, relativePath: relativePath, fileName: fileName) + if event.type == .deleted { + WorkspaceFileIndex.shared.removeFile(file, from: workspaceURL) + } else { + WorkspaceFileIndex.shared.addFile(file, to: workspaceURL) + } + } + + Task { + try? await service?.notifyDidChangeWatchedFiles( + .init(workspaceUri: projectURL.path, changes: fileEvents)) + } + } + + private func onDirectoryEvent(directoryEvents: [FileEvent], workspaceURL: URL, projectURL: URL) { + directoryEvents.forEach { event in + guard let directoryURL = URL(string: event.uri) else { + return + } + let directory = ConversationDirectoryReference(url: directoryURL, projectURL: projectURL) + if event.type == .deleted { + WorkspaceDirectoryIndex.shared.removeDirectory(directory, from: workspaceURL) + } else { + WorkspaceDirectoryIndex.shared.addDirectory(directory, to: workspaceURL) } } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift new file mode 100644 index 00000000..1168d954 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift @@ -0,0 +1,44 @@ +import Foundation + +public class BYOKModelManager { + private static var availableApiKeys: [BYOKApiKeyInfo] = [] + private static var availableBYOKModels: [BYOKModelInfo] = [] + + public static func updateBYOKModels(BYOKModels: [BYOKModelInfo]) { + let sortedModels = BYOKModels.sorted() + guard sortedModels != availableBYOKModels else { return } + availableBYOKModels = sortedModels + NotificationCenter.default.post(name: .gitHubCopilotModelsDidChange, object: nil) + } + + public static func hasBYOKModels(providerName: BYOKProviderName? = nil) -> Bool { + if let providerName = providerName { + return availableBYOKModels.contains { $0.providerName == providerName } + } + return !availableBYOKModels.isEmpty + } + + public static func getRegisteredBYOKModels() -> [BYOKModelInfo] { + let fullRegisteredBYOKModels = availableBYOKModels.filter({ $0.isRegistered }) + return fullRegisteredBYOKModels + } + + public static func clearBYOKModels() { + availableBYOKModels = [] + } + + public static func updateApiKeys(apiKeys: [BYOKApiKeyInfo]) { + availableApiKeys = apiKeys + } + + public static func hasApiKey(providerName: BYOKProviderName? = nil) -> Bool { + if let providerName = providerName { + return availableApiKeys.contains { $0.providerName == providerName } + } + return !availableApiKeys.isEmpty + } + + public static func clearApiKeys() { + availableApiKeys = [] + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+BYOK.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+BYOK.swift new file mode 100644 index 00000000..060eab88 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+BYOK.swift @@ -0,0 +1,161 @@ +import Foundation + +public enum BYOKProviderName: String, Codable, Equatable, Hashable, Comparable, CaseIterable { + case Azure + case Anthropic + case Gemini + case Groq + case OpenAI + case OpenRouter + + public static func < (lhs: BYOKProviderName, rhs: BYOKProviderName) -> Bool { + return lhs.rawValue < rhs.rawValue + } +} + +public struct BYOKModelCapabilities: Codable, Equatable, Hashable { + public var name: String + public var maxInputTokens: Int? + public var maxOutputTokens: Int? + public var toolCalling: Bool + public var vision: Bool + + public init( + name: String, + maxInputTokens: Int? = nil, + maxOutputTokens: Int? = nil, + toolCalling: Bool, + vision: Bool + ) { + self.name = name + self.maxInputTokens = maxInputTokens + self.maxOutputTokens = maxOutputTokens + self.toolCalling = toolCalling + self.vision = vision + } +} + +public struct BYOKModelInfo: Codable, Equatable, Hashable, Comparable { + public let providerName: BYOKProviderName + public let modelId: String + public var isRegistered: Bool + public let isCustomModel: Bool + public let deploymentUrl: String? + public let apiKey: String? + public var modelCapabilities: BYOKModelCapabilities? + + public init( + providerName: BYOKProviderName, + modelId: String, + isRegistered: Bool, + isCustomModel: Bool, + deploymentUrl: String?, + apiKey: String?, + modelCapabilities: BYOKModelCapabilities? + ) { + self.providerName = providerName + self.modelId = modelId + self.isRegistered = isRegistered + self.isCustomModel = isCustomModel + self.deploymentUrl = deploymentUrl + self.apiKey = apiKey + self.modelCapabilities = modelCapabilities + } + + public static func < (lhs: BYOKModelInfo, rhs: BYOKModelInfo) -> Bool { + if lhs.providerName != rhs.providerName { + return lhs.providerName < rhs.providerName + } + let lhsId = lhs.modelId.lowercased() + let rhsId = rhs.modelId.lowercased() + if lhsId != rhsId { + return lhsId < rhsId + } + // Fallback to preserve deterministic ordering when only case differs + return lhs.modelId < rhs.modelId + } +} + +public typealias BYOKSaveModelParams = BYOKModelInfo + +public struct BYOKSaveModelResponse: Codable, Equatable, Hashable { + public let success: Bool + public let message: String +} + +public struct BYOKDeleteModelParams: Codable, Equatable, Hashable { + public let providerName: BYOKProviderName + public let modelId: String + + public init(providerName: BYOKProviderName, modelId: String) { + self.providerName = providerName + self.modelId = modelId + } +} + +public typealias BYOKDeleteModelResponse = BYOKSaveModelResponse + +public struct BYOKListModelsParams: Codable, Equatable, Hashable { + public let providerName: BYOKProviderName? + public let enableFetchUrl: Bool? + + public init( + providerName: BYOKProviderName? = nil, + enableFetchUrl: Bool? = nil + ) { + self.providerName = providerName + self.enableFetchUrl = enableFetchUrl + } +} + +public struct BYOKListModelsResponse: Codable, Equatable, Hashable { + public let models: [BYOKModelInfo] +} + +public struct BYOKSaveApiKeyParams: Codable, Equatable, Hashable { + public let providerName: BYOKProviderName + public let apiKey: String + public let modelId: String? + + public init( + providerName: BYOKProviderName, + apiKey: String, + modelId: String? = nil + ) { + self.providerName = providerName + self.apiKey = apiKey + self.modelId = modelId + } +} + +public typealias BYOKSaveApiKeyResponse = BYOKSaveModelResponse + +public struct BYOKDeleteApiKeyParams: Codable, Equatable, Hashable { + public let providerName: BYOKProviderName + + public init(providerName: BYOKProviderName) { + self.providerName = providerName + } +} + +public typealias BYOKDeleteApiKeyResponse = BYOKSaveModelResponse + +public struct BYOKListApiKeysParams: Codable, Equatable, Hashable { + public let providerName: BYOKProviderName? + public let modelId: String? + + public init(providerName: BYOKProviderName? = nil, modelId: String? = nil) { + self.providerName = providerName + self.modelId = modelId + } +} + +public struct BYOKApiKeyInfo: Codable, Equatable, Hashable { + public let providerName: BYOKProviderName + public let modelId: String? + public let apiKey: String? +} + +public struct BYOKListApiKeysResponse: Codable, Equatable, Hashable { + public let apiKeys: [BYOKApiKeyInfo] +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift index 4c1ca9e7..e2401935 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift @@ -10,7 +10,7 @@ enum ConversationSource: String, Codable { case panel, inline } -public struct Reference: Codable, Equatable, Hashable { +public struct FileReference: Codable, Equatable, Hashable { public var type: String = "file" public let uri: String public let position: Position? @@ -20,6 +20,43 @@ public struct Reference: Codable, Equatable, Hashable { public let activeAt: String? } +public struct DirectoryReference: Codable, Equatable, Hashable { + public var type: String = "directory" + public let uri: String +} + +public enum Reference: Codable, Equatable, Hashable { + case file(FileReference) + case directory(DirectoryReference) + + public func encode(to encoder: Encoder) throws { + switch self { + case .file(let fileRef): + try fileRef.encode(to: encoder) + case .directory(let directoryRef): + try directoryRef.encode(to: encoder) + } + } + + public static func from(_ ref: ConversationAttachedReference) -> Reference { + switch ref { + case .file(let fileRef): + return .file( + .init( + uri: fileRef.url.absoluteString, + position: nil, + visibleRange: nil, + selection: nil, + openedAt: nil, + activeAt: nil + ) + ) + case .directory(let directoryRef): + return .directory(.init(uri: directoryRef.url.absoluteString)) + } + } +} + struct ConversationCreateParams: Codable { var workDoneToken: String var turns: [TurnSchema] @@ -32,6 +69,7 @@ struct ConversationCreateParams: Codable { var workspaceFolders: [WorkspaceFolder]? var ignoredSkills: [String]? var model: String? + var modelProviderName: String? var chatMode: String? var needToolCallConfirmation: Bool? var userLanguage: String? @@ -66,7 +104,7 @@ public struct ConversationProgressReport: BaseConversationProgress { public let conversationId: String public let turnId: String public let reply: String? - public let references: [Reference]? + public let references: [FileReference]? public let steps: [ConversationProgressStep]? public let editAgentRounds: [AgentRound]? } @@ -131,6 +169,7 @@ struct TurnCreateParams: Codable { var ignoredSkills: [String]? var references: [Reference]? var model: String? + var modelProviderName: String? var workspaceFolder: String? var workspaceFolders: [WorkspaceFolder]? var chatMode: String? diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 2a352118..33ab0701 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -462,6 +462,79 @@ enum GitHubCopilotRequest { return .custom("telemetry/exception", dict, ClientRequest.NullHandler) } } + + // MARK: BYOK + struct BYOKSaveModel: GitHubCopilotRequestType { + typealias Response = BYOKSaveModelResponse + + var params: BYOKSaveModelParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("copilot/byok/saveModel", dict, ClientRequest.NullHandler) + } + } + + struct BYOKDeleteModel: GitHubCopilotRequestType { + typealias Response = BYOKDeleteModelResponse + + var params: BYOKDeleteModelParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("copilot/byok/deleteModel", dict, ClientRequest.NullHandler) + } + } + + struct BYOKListModels: GitHubCopilotRequestType { + typealias Response = BYOKListModelsResponse + + var params: BYOKListModelsParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("copilot/byok/listModels", dict, ClientRequest.NullHandler) + } + } + + struct BYOKSaveApiKey: GitHubCopilotRequestType { + typealias Response = BYOKSaveApiKeyResponse + + var params: BYOKSaveApiKeyParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("copilot/byok/saveApiKey", dict, ClientRequest.NullHandler) + } + } + + struct BYOKDeleteApiKey: GitHubCopilotRequestType { + typealias Response = BYOKDeleteApiKeyResponse + + var params: BYOKDeleteApiKeyParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("copilot/byok/deleteApiKey", dict, ClientRequest.NullHandler) + } + } + + struct BYOKListApiKeys: GitHubCopilotRequestType { + typealias Response = BYOKListApiKeysResponse + + var params: BYOKListApiKeysParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("copilot/byok/listApiKeys", dict, ClientRequest.NullHandler) + } + } } // MARK: Notifications diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 139fd42b..8e3eaa0d 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -60,8 +60,9 @@ public protocol GitHubCopilotConversationServiceType { activeDoc: Doc?, skills: [String], ignoredSkills: [String]?, - references: [FileReference], + references: [ConversationAttachedReference], model: String?, + modelProviderName: String?, turns: [TurnSchema], agentMode: Bool, userLanguage: String?) async throws @@ -71,8 +72,9 @@ public protocol GitHubCopilotConversationServiceType { turnId: String?, activeDoc: Doc?, ignoredSkills: [String]?, - references: [FileReference], + references: [ConversationAttachedReference], model: String?, + modelProviderName: String?, workspaceFolder: String, workspaceFolders: [WorkspaceFolder]?, agentMode: Bool) async throws @@ -578,8 +580,9 @@ public final class GitHubCopilotService: activeDoc: Doc?, skills: [String], ignoredSkills: [String]?, - references: [FileReference], + references: [ConversationAttachedReference], model: String?, + modelProviderName: String?, turns: [TurnSchema], agentMode: Bool, userLanguage: String?) async throws { @@ -604,19 +607,13 @@ public final class GitHubCopilotService: skills: skills, allSkills: false), textDocument: activeDoc, - references: references.map { - Reference(uri: $0.url.absoluteString, - position: nil, - visibleRange: nil, - selection: nil, - openedAt: nil, - activeAt: nil) - }, + references: references.map { Reference.from($0) }, source: .panel, workspaceFolder: workspaceFolder, workspaceFolders: workspaceFolders, ignoredSkills: ignoredSkills, model: model, + modelProviderName: modelProviderName, chatMode: agentMode ? "Agent" : nil, needToolCallConfirmation: true, userLanguage: userLanguage) @@ -636,8 +633,9 @@ public final class GitHubCopilotService: turnId: String?, activeDoc: Doc?, ignoredSkills: [String]?, - references: [FileReference], + references: [ConversationAttachedReference], model: String?, + modelProviderName: String?, workspaceFolder: String, workspaceFolders: [WorkspaceFolder]? = nil, agentMode: Bool) async throws { @@ -648,15 +646,9 @@ public final class GitHubCopilotService: message: message, textDocument: activeDoc, ignoredSkills: ignoredSkills, - references: references.map { - Reference(uri: $0.url.absoluteString, - position: nil, - visibleRange: nil, - selection: nil, - openedAt: nil, - activeAt: nil) - }, + references: references.map { Reference.from($0) }, model: model, + modelProviderName: modelProviderName, workspaceFolder: workspaceFolder, workspaceFolders: workspaceFolders, chatMode: agentMode ? "Agent" : nil, @@ -912,6 +904,28 @@ public final class GitHubCopilotService: CopilotModelManager.updateLLMs(models) } } + + if !BYOKModelManager.hasApiKey() { + Logger.gitHubCopilot.info("No BYOK API keys found, fetching BYOK API keys...") + let byokApiKeys = try? await listBYOKApiKeys( + .init(providerName: nil, modelId: nil) + ) + if let byokApiKeys = byokApiKeys, !byokApiKeys.apiKeys.isEmpty { + BYOKModelManager + .updateApiKeys(apiKeys: byokApiKeys.apiKeys) + } + } + + if !BYOKModelManager.hasBYOKModels() { + Logger.gitHubCopilot.info("No BYOK models found, fetching BYOK models...") + let byokModels = try? await listBYOKModels( + .init(providerName: nil, enableFetchUrl: nil) + ) + if let byokModels = byokModels, !byokModels.models.isEmpty { + BYOKModelManager + .updateBYOKModels(BYOKModels: byokModels.models) + } + } await unwatchAuthStatus() } else if status.status == .notAuthorized { await Status.shared @@ -1267,6 +1281,72 @@ public final class GitHubCopilotService: // Cache the sent configuration self.lastSentConfiguration = newConfiguration } + + public func saveBYOKApiKey(_ params: BYOKSaveApiKeyParams) async throws -> BYOKSaveApiKeyResponse { + do { + let response = try await sendRequest( + GitHubCopilotRequest.BYOKSaveApiKey(params: params) + ) + return response + } catch { + throw error + } + } + + public func listBYOKApiKeys(_ params: BYOKListApiKeysParams) async throws -> BYOKListApiKeysResponse { + do { + let response = try await sendRequest( + GitHubCopilotRequest.BYOKListApiKeys(params: params) + ) + return response + } catch { + throw error + } + } + + public func deleteBYOKApiKey(_ params: BYOKDeleteApiKeyParams) async throws -> BYOKDeleteApiKeyResponse { + do { + let response = try await sendRequest( + GitHubCopilotRequest.BYOKDeleteApiKey(params: params) + ) + return response + } catch { + throw error + } + } + + public func saveBYOKModel(_ params: BYOKSaveModelParams) async throws -> BYOKSaveModelResponse { + do { + let response = try await sendRequest( + GitHubCopilotRequest.BYOKSaveModel(params: params) + ) + return response + } catch { + throw error + } + } + + public func listBYOKModels(_ params: BYOKListModelsParams) async throws -> BYOKListModelsResponse { + do { + let response = try await sendRequest( + GitHubCopilotRequest.BYOKListModels(params: params) + ) + return response + } catch { + throw error + } + } + + public func deleteBYOKModel(_ params: BYOKDeleteModelParams) async throws -> BYOKDeleteModelResponse { + do { + let response = try await sendRequest( + GitHubCopilotRequest.BYOKDeleteModel(params: params) + ) + return response + } catch { + throw error + } + } } extension SafeInitializingServer: GitHubCopilotLSP { diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift index b6f19132..2acd130c 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -54,6 +54,7 @@ public final class GitHubCopilotConversationService: ConversationServiceType { ignoredSkills: request.ignoredSkills, references: request.references ?? [], model: request.model, + modelProviderName: request.modelProviderName, turns: request.turns, agentMode: request.agentMode, userLanguage: request.userLanguage) @@ -72,6 +73,7 @@ public final class GitHubCopilotConversationService: ConversationServiceType { ignoredSkills: request.ignoredSkills, references: request.references ?? [], model: request.model, + modelProviderName: request.modelProviderName, workspaceFolder: workspace.projectURL.absoluteString, workspaceFolders: getWorkspaceFolders(workspace: workspace), agentMode: request.agentMode) diff --git a/Tool/Sources/HostAppActivator/HostAppActivator.swift b/Tool/Sources/HostAppActivator/HostAppActivator.swift index 81658337..ca7f7016 100644 --- a/Tool/Sources/HostAppActivator/HostAppActivator.swift +++ b/Tool/Sources/HostAppActivator/HostAppActivator.swift @@ -9,6 +9,8 @@ public extension Notification.Name { .Name("com.github.CopilotForXcode.OpenSettingsWindowRequest") static let openMCPSettingsWindowRequest = Notification .Name("com.github.CopilotForXcode.OpenMCPSettingsWindowRequest") + static let openBYOKSettingsWindowRequest = Notification + .Name("com.github.CopilotForXcode.OpenBYOKSettingsWindowRequest") } public enum GitHubCopilotForXcodeSettingsLaunchError: Error, LocalizedError { @@ -74,6 +76,26 @@ public func launchHostAppMCPSettings() throws { } } +public func launchHostAppBYOKSettings() throws { + // Try the AppleScript approach first, but only if app is already running + if let hostApp = getRunningHostApp() { + let activated = hostApp.activate(options: [.activateIgnoringOtherApps]) + Logger.ui.info("\(hostAppName()) activated: \(activated)") + + _ = tryLaunchWithAppleScript() + + DistributedNotificationCenter.default().postNotificationName( + .openBYOKSettingsWindowRequest, + object: nil + ) + Logger.ui.info("\(hostAppName()) BYOK settings notification sent after activation") + return + } else { + // If app is not running, launch it with the settings flag + try launchHostAppWithArgs(args: ["--byok"]) + } +} + private func tryLaunchWithAppleScript() -> Bool { // Try to launch settings using AppleScript let script = """ diff --git a/Tool/Sources/Persist/AppState.swift b/Tool/Sources/Persist/AppState.swift index 3b7f8cc2..3a2834fb 100644 --- a/Tool/Sources/Persist/AppState.swift +++ b/Tool/Sources/Persist/AppState.swift @@ -25,6 +25,13 @@ public extension JSONValue { } return nil } + + var numberValue: Double? { + if case .number(let value) = self { + return value + } + return nil + } static func convertToJSONValue(_ object: T) -> JSONValue? { do { diff --git a/Tool/Sources/SharedUIComponents/Base/FileIcon.swift b/Tool/Sources/SharedUIComponents/Base/FileIcon.swift index 039a4925..92384e17 100644 --- a/Tool/Sources/SharedUIComponents/Base/FileIcon.swift +++ b/Tool/Sources/SharedUIComponents/Base/FileIcon.swift @@ -16,3 +16,11 @@ public func drawFileIcon(_ file: URL?) -> Image { return defaultImage } + +public func drawFileIcon(_ file: URL?, isDirectory: Bool = false) -> Image { + if isDirectory { + return Image(systemName: "folder") + } else { + return drawFileIcon(file) + } +} diff --git a/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift b/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift new file mode 100644 index 00000000..c5c1e12d --- /dev/null +++ b/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift @@ -0,0 +1,70 @@ +import SwiftUI +import AppKit + +public enum CheckboxMixedState { + case off, mixed, on +} + +public struct MixedStateCheckbox: View { + let title: String + let action: () -> Void + + @Binding var state: CheckboxMixedState + + public init(title: String, state: Binding, action: @escaping () -> Void) { + self.title = title + self.action = action + self._state = state + } + + public var body: some View { + MixedStateCheckboxView(title: title, state: state, action: action) + } +} + +private struct MixedStateCheckboxView: NSViewRepresentable { + let title: String + let state: CheckboxMixedState + let action: () -> Void + + func makeNSView(context: Context) -> NSButton { + let button = NSButton() + button.setButtonType(.switch) + button.allowsMixedState = true + button.title = title + button.target = context.coordinator + button.action = #selector(Coordinator.onButtonClicked) + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + return button + } + + func makeCoordinator() -> Coordinator { + Coordinator(action: action) + } + + class Coordinator: NSObject { + let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + @objc func onButtonClicked() { + action() + } + } + + func updateNSView(_ nsView: NSButton, context: Context) { + nsView.title = title + + switch state { + case .off: + nsView.state = .off + case .mixed: + nsView.state = .mixed + case .on: + nsView.state = .on + } + } +} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/BatchingFileChangeWatcher.swift b/Tool/Sources/Workspace/FileChangeWatcher/BatchingFileChangeWatcher.swift index c63f0ad1..4273bac7 100644 --- a/Tool/Sources/Workspace/FileChangeWatcher/BatchingFileChangeWatcher.swift +++ b/Tool/Sources/Workspace/FileChangeWatcher/BatchingFileChangeWatcher.swift @@ -6,11 +6,14 @@ import LanguageServerProtocol public final class BatchingFileChangeWatcher: DirectoryWatcherProtocol { private var watchedPaths: [URL] private let changePublisher: PublisherType + private let directoryChangePublisher: PublisherType? private let publishInterval: TimeInterval private var pendingEvents: [FileEvent] = [] + private var pendingDirectoryEvents: [FileEvent] = [] private var timer: Timer? private let eventQueue: DispatchQueue + private let directoryEventQueue: DispatchQueue private let fsEventQueue: DispatchQueue private var eventStream: FSEventStreamRef? private(set) public var isWatching = false @@ -25,14 +28,17 @@ public final class BatchingFileChangeWatcher: DirectoryWatcherProtocol { watchedPaths: [URL], changePublisher: @escaping PublisherType, publishInterval: TimeInterval = 3.0, - fsEventProvider: FSEventProvider = FileChangeWatcherFSEventProvider() + fsEventProvider: FSEventProvider = FileChangeWatcherFSEventProvider(), + directoryChangePublisher: PublisherType? = nil ) { self.watchedPaths = watchedPaths self.changePublisher = changePublisher self.publishInterval = publishInterval self.fsEventProvider = fsEventProvider - self.eventQueue = DispatchQueue(label: "com.github.copilot.filechangewatcher") + self.eventQueue = DispatchQueue(label: "com.github.copilot.filechangewatcher.file") + self.directoryEventQueue = DispatchQueue(label: "com.github.copilot.filechangewatcher.directory") self.fsEventQueue = DispatchQueue(label: "com.github.copilot.filechangewatcherfseventstream", qos: .utility) + self.directoryChangePublisher = directoryChangePublisher self.start() } @@ -86,6 +92,7 @@ public final class BatchingFileChangeWatcher: DirectoryWatcherProtocol { guard let self else { return } self.timer = Timer.scheduledTimer(withTimeInterval: self.publishInterval, repeats: true) { [weak self] _ in self?.publishChanges() + self?.publishDirectoryChanges() } } } @@ -96,43 +103,38 @@ public final class BatchingFileChangeWatcher: DirectoryWatcherProtocol { } } - public func onFileCreated(file: URL) { - addEvent(file: file, type: .created) - } - - public func onFileChanged(file: URL) { - addEvent(file: file, type: .changed) + internal func addDirectoryEvent(directory: URL, type: FileChangeType) { + guard self.directoryChangePublisher != nil else { + return + } + directoryEventQueue.async { + self.pendingDirectoryEvents.append(FileEvent(uri: directory.absoluteString, type: type)) + } } - public func onFileDeleted(file: URL) { - addEvent(file: file, type: .deleted) + /// When `.deleted`, the `isDirectory` will be `nil` + public func onFsEvent(url: URL, type: FileChangeType, isDirectory: Bool?) { + // Could be file or directory + if type == .deleted, isDirectory == nil { + addEvent(file: url, type: type) + addDirectoryEvent(directory: url, type: type) + return + } + + guard let isDirectory else { return } + + if isDirectory { + addDirectoryEvent(directory: url, type: type) + } else { + addEvent(file: url, type: type) + } } private func publishChanges() { eventQueue.async { guard !self.pendingEvents.isEmpty else { return } - var compressedEvent: [String: FileEvent] = [:] - for event in self.pendingEvents { - let existingEvent = compressedEvent[event.uri] - - guard existingEvent != nil else { - compressedEvent[event.uri] = event - continue - } - - if event.type == .deleted { /// file deleted. Cover created and changed event - compressedEvent[event.uri] = event - } else if event.type == .created { /// file created. Cover deleted and changed event - compressedEvent[event.uri] = event - } else if event.type == .changed { - if existingEvent?.type != .created { /// file changed. Won't cover created event - compressedEvent[event.uri] = event - } - } - } - - let compressedEventArray: [FileEvent] = Array(compressedEvent.values) + let compressedEventArray = self.compressEvents(self.pendingEvents) let changes = Array(compressedEventArray.prefix(BatchingFileChangeWatcher.maxEventPublishSize)) if compressedEventArray.count > BatchingFileChangeWatcher.maxEventPublishSize { @@ -149,6 +151,59 @@ public final class BatchingFileChangeWatcher: DirectoryWatcherProtocol { } } + private func publishDirectoryChanges() { + guard let directoryChangePublisher = self.directoryChangePublisher else { + return + } + directoryEventQueue.async { + guard !self.pendingDirectoryEvents.isEmpty else { + return + } + + let compressedEventArray = self.compressEvents(self.pendingDirectoryEvents) + let changes = Array(compressedEventArray.prefix(Self.maxEventPublishSize)) + if compressedEventArray.count > Self.maxEventPublishSize { + self.pendingDirectoryEvents = Array( + compressedEventArray[Self.maxEventPublishSize.. [FileEvent] { + var compressedEvent: [String: FileEvent] = [:] + for event in events { + let existingEvent = compressedEvent[event.uri] + + guard existingEvent != nil else { + compressedEvent[event.uri] = event + continue + } + + if event.type == .deleted { /// file deleted. Cover created and changed event + compressedEvent[event.uri] = event + } else if event.type == .created { /// file created. Cover deleted and changed event + compressedEvent[event.uri] = event + } else if event.type == .changed { + if existingEvent?.type != .created { /// file changed. Won't cover created event + compressedEvent[event.uri] = event + } + } + } + + let compressedEventArray: [FileEvent] = Array(compressedEvent.values) + + return compressedEventArray + } + /// Starts watching for file changes in the project public func startWatching() -> Bool { isWatching = true @@ -209,47 +264,57 @@ public final class BatchingFileChangeWatcher: DirectoryWatcherProtocol { let url = URL(fileURLWithPath: path) - guard !shouldIgnoreURL(url: url) else { continue } + // Keep this duplicated checking. Will block in advance for corresponding cases + guard !WorkspaceFile.shouldSkipURL(url) else { + continue + } let fileExists = FileManager.default.fileExists(atPath: path) + var isDirectory: Bool? + if fileExists { + guard let resourceValues = try? url.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]), + (resourceValues.isDirectory == true || resourceValues.isRegularFile == true) + else { + continue + } + isDirectory = resourceValues.isDirectory == true + if isDirectory == false, let isValid = try? WorkspaceFile.isValidFile(url), !isValid { + continue + } else if isDirectory == true, !WorkspaceDirectory.isValidDirectory(url) { + continue + } + } /// FileSystem events can have multiple flags set simultaneously, if flags & UInt32(kFSEventStreamEventFlagItemCreated) != 0 { - if fileExists { onFileCreated(file: url) } + if fileExists { + onFsEvent(url: url, type: .created, isDirectory: isDirectory) + } } if flags & UInt32(kFSEventStreamEventFlagItemRemoved) != 0 { - onFileDeleted(file: url) + onFsEvent(url: url, type: .deleted, isDirectory: isDirectory) } /// The fiesystem report "Renamed" event when file content changed. if flags & UInt32(kFSEventStreamEventFlagItemRenamed) != 0 { - if fileExists { onFileChanged(file: url) } - else { onFileDeleted(file: url) } + if fileExists { + onFsEvent(url: url, type: .changed, isDirectory: isDirectory) + } + else { + onFsEvent(url: url, type: .deleted, isDirectory: isDirectory) + } } if flags & UInt32(kFSEventStreamEventFlagItemModified) != 0 { - if fileExists { onFileChanged(file: url) } - else { onFileDeleted(file: url)} + if fileExists { + onFsEvent(url: url, type: .changed, isDirectory: isDirectory) + } + else { + onFsEvent(url: url, type: .deleted, isDirectory: isDirectory) + } } } } } - -extension BatchingFileChangeWatcher { - internal func shouldIgnoreURL(url: URL) -> Bool { - if let resourceValues = try? url.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]), - resourceValues.isDirectory == true { return true } - - if supportedFileExtensions.contains(url.pathExtension.lowercased()) == false { return true } - - if WorkspaceFile.isXCProject(url) || WorkspaceFile.isXCWorkspace(url) { return true } - - if WorkspaceFile.matchesPatterns(url, patterns: skipPatterns) { return true } - - // TODO: check if url is ignored by git / ide - - return false - } -} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/DefaultFileWatcherFactory.swift b/Tool/Sources/Workspace/FileChangeWatcher/DefaultFileWatcherFactory.swift index eecbebbc..05caf3a0 100644 --- a/Tool/Sources/Workspace/FileChangeWatcher/DefaultFileWatcherFactory.swift +++ b/Tool/Sources/Workspace/FileChangeWatcher/DefaultFileWatcherFactory.swift @@ -13,12 +13,18 @@ public class DefaultFileWatcherFactory: FileWatcherFactory { ) } - public func createDirectoryWatcher(watchedPaths: [URL], changePublisher: @escaping PublisherType, - publishInterval: TimeInterval) -> DirectoryWatcherProtocol { - return BatchingFileChangeWatcher(watchedPaths: watchedPaths, - changePublisher: changePublisher, - publishInterval: publishInterval, - fsEventProvider: FileChangeWatcherFSEventProvider() + public func createDirectoryWatcher( + watchedPaths: [URL], + changePublisher: @escaping PublisherType, + publishInterval: TimeInterval, + directoryChangePublisher: PublisherType? = nil + ) -> DirectoryWatcherProtocol { + return BatchingFileChangeWatcher( + watchedPaths: watchedPaths, + changePublisher: changePublisher, + publishInterval: publishInterval, + fsEventProvider: FileChangeWatcherFSEventProvider(), + directoryChangePublisher: directoryChangePublisher ) } } diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcherService.swift b/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcherService.swift index 2bd28eee..ac6f76dd 100644 --- a/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcherService.swift +++ b/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcherService.swift @@ -11,6 +11,7 @@ public class FileChangeWatcherService { private(set) public var workspaceURL: URL private(set) public var publisher: PublisherType private(set) public var publishInterval: TimeInterval + private(set) public var directoryChangePublisher: PublisherType? // Dependencies injected for testing internal let workspaceFileProvider: WorkspaceFileProvider @@ -27,13 +28,15 @@ public class FileChangeWatcherService { publisher: @escaping PublisherType, publishInterval: TimeInterval = 3.0, workspaceFileProvider: WorkspaceFileProvider = FileChangeWatcherWorkspaceFileProvider(), - watcherFactory: FileWatcherFactory? = nil + watcherFactory: FileWatcherFactory? = nil, + directoryChangePublisher: PublisherType? ) { self.workspaceURL = workspaceURL self.publisher = publisher self.publishInterval = publishInterval self.workspaceFileProvider = workspaceFileProvider self.watcherFactory = watcherFactory ?? DefaultFileWatcherFactory() + self.directoryChangePublisher = directoryChangePublisher } deinit { @@ -49,7 +52,12 @@ public class FileChangeWatcherService { let projects = workspaceFileProvider.getProjects(by: workspaceURL) guard projects.count > 0 else { return } - watcher = watcherFactory.createDirectoryWatcher(watchedPaths: projects, changePublisher: publisher, publishInterval: publishInterval) + watcher = watcherFactory.createDirectoryWatcher( + watchedPaths: projects, + changePublisher: publisher, + publishInterval: publishInterval, + directoryChangePublisher: directoryChangePublisher + ) Logger.client.info("Started watching for file changes in \(projects)") startWatchingProject() @@ -184,7 +192,11 @@ public class FileChangeWatcherServicePool { private init() {} @PoolActor - public func watch(for workspaceURL: URL, publisher: @escaping PublisherType) { + public func watch( + for workspaceURL: URL, + publisher: @escaping PublisherType, + directoryChangePublisher: PublisherType? = nil + ) { guard workspaceURL.path != "/" else { return } var validWorkspaceURL: URL? = nil @@ -198,7 +210,11 @@ public class FileChangeWatcherServicePool { guard servicePool[workspaceURL] == nil else { return } - let watcherService = FileChangeWatcherService(validWorkspaceURL, publisher: publisher) + let watcherService = FileChangeWatcherService( + validWorkspaceURL, + publisher: publisher, + directoryChangePublisher: directoryChangePublisher + ) watcherService.startWatching() servicePool[workspaceURL] = watcherService diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FileWatcherProtocol.swift b/Tool/Sources/Workspace/FileChangeWatcher/FileWatcherProtocol.swift index 7252d613..a4d37754 100644 --- a/Tool/Sources/Workspace/FileChangeWatcher/FileWatcherProtocol.swift +++ b/Tool/Sources/Workspace/FileChangeWatcher/FileWatcherProtocol.swift @@ -26,6 +26,7 @@ public protocol FileWatcherFactory { func createDirectoryWatcher( watchedPaths: [URL], changePublisher: @escaping PublisherType, - publishInterval: TimeInterval + publishInterval: TimeInterval, + directoryChangePublisher: PublisherType? ) -> DirectoryWatcherProtocol } diff --git a/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift b/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift index 2a5d464a..151effdb 100644 --- a/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift +++ b/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift @@ -4,7 +4,7 @@ import Foundation public protocol WorkspaceFileProvider { func getProjects(by workspaceURL: URL) -> [URL] - func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference] + func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [ConversationFileReference] func isXCProject(_ url: URL) -> Bool func isXCWorkspace(_ url: URL) -> Bool func fileExists(atPath: String) -> Bool @@ -20,7 +20,7 @@ public class FileChangeWatcherWorkspaceFileProvider: WorkspaceFileProvider { return WorkspaceFile.getProjects(workspace: workspaceInfo).compactMap { URL(string: $0.uri) } } - public func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference] { + public func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [ConversationFileReference] { return WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: workspaceURL, workspaceRootURL: workspaceRootURL) } diff --git a/Tool/Sources/Workspace/WorkspaceDirectory.swift b/Tool/Sources/Workspace/WorkspaceDirectory.swift new file mode 100644 index 00000000..b02fb499 --- /dev/null +++ b/Tool/Sources/Workspace/WorkspaceDirectory.swift @@ -0,0 +1,104 @@ +import Foundation +import Logger +import ConversationServiceProvider + +/// Directory operations in workspace contexts +public struct WorkspaceDirectory { + + /// Determines if a directory should be skipped based on its path + /// - Parameter url: The URL of the directory to check + /// - Returns: `true` if the directory should be skipped, `false` otherwise + public static func shouldSkipDirectory(_ url: URL) -> Bool { + let path = url.path + let normalizedPath = path.hasPrefix("/") ? path: "/" + path + + for skipPattern in skipPatterns { + // Pattern: /skipPattern/ (directory anywhere in path) + if normalizedPath.contains("/\(skipPattern)/") { + return true + } + + // Pattern: /skipPattern (directory at end of path) + if normalizedPath.hasSuffix("/\(skipPattern)") { + return true + } + + // Pattern: skipPattern at root + if normalizedPath == "/\(skipPattern)" { + return true + } + } + + return false + } + + /// Validates if a URL represents a valid directory for workspace operations + /// - Parameter url: The URL to validate + /// - Returns: `true` if the directory is valid for processing, `false` otherwise + public static func isValidDirectory(_ url: URL) -> Bool { + guard !WorkspaceFile.shouldSkipURL(url) else { + return false + } + + guard let resourceValues = try? url.resourceValues(forKeys: [.isDirectoryKey]), + resourceValues.isDirectory == true else { + return false + } + + guard !shouldSkipDirectory(url) else { + return false + } + + return true + } + + /// Retrieves all valid directories within the active workspace + /// - Parameters: + /// - workspaceURL: The URL of the workspace + /// - workspaceRootURL: The root URL of the workspace + /// - Returns: An array of `ConversationDirectoryReference` objects representing valid directories + public static func getDirectoriesInActiveWorkspace( + workspaceURL: URL, + workspaceRootURL: URL + ) -> [ConversationDirectoryReference] { + var directories: [ConversationDirectoryReference] = [] + let fileManager = FileManager.default + var subprojects: [URL] = [] + + if WorkspaceFile.isXCWorkspace(workspaceURL) { + subprojects = WorkspaceFile.getSubprojectURLs(in: workspaceURL) + } else { + subprojects.append(workspaceRootURL) + } + + for subproject in subprojects { + guard FileManager.default.fileExists(atPath: subproject.path) else { + continue + } + + let enumerator = fileManager.enumerator( + at: subproject, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + + while let directoryURL = enumerator?.nextObject() as? URL { + // Skip items matching the specified pattern + if WorkspaceFile.shouldSkipURL(directoryURL) { + enumerator?.skipDescendants() + continue + } + + guard isValidDirectory(directoryURL) else { continue } + + let directory = ConversationDirectoryReference( + url: directoryURL, + projectURL: workspaceRootURL + ) + directories.append(directory) + } + } + + return directories + } +} diff --git a/Tool/Sources/Workspace/WorkspaceDirectoryIndex.swift b/Tool/Sources/Workspace/WorkspaceDirectoryIndex.swift new file mode 100644 index 00000000..f34c9442 --- /dev/null +++ b/Tool/Sources/Workspace/WorkspaceDirectoryIndex.swift @@ -0,0 +1,75 @@ +import Foundation +import ConversationServiceProvider + +public class WorkspaceDirectoryIndex { + public static let shared = WorkspaceDirectoryIndex() + /// Maximum number of directories allowed per workspace + public static let maxDirectoriesPerWorkspace = 100_000 + + private var workspaceIndex: [URL: [ConversationDirectoryReference]] = [:] + private let queue = DispatchQueue(label: "com.copilot.workspace-directory-index") + + /// Reset directories for a specific workspace URL + public func setDirectories(_ directories: [ConversationDirectoryReference], for workspaceURL: URL) { + queue.sync { + // Enforce the directory limit when setting directories + if directories.count > Self.maxDirectoriesPerWorkspace { + self.workspaceIndex[workspaceURL] = Array(directories.prefix(Self.maxDirectoriesPerWorkspace)) + } else { + self.workspaceIndex[workspaceURL] = directories + } + } + } + + /// Get all directories for a specific workspace URL + public func getDirectories(for workspaceURL: URL) -> [ConversationDirectoryReference]? { + return queue.sync { + return workspaceIndex[workspaceURL]?.map { $0 } + } + } + + /// Add a directory to the workspace index + /// - Returns: true if the directory was added successfully, false if the workspace has reached the maximum directory limit + @discardableResult + public func addDirectory(_ directory: ConversationDirectoryReference, to workspaceURL: URL) -> Bool { + return queue.sync { + if self.workspaceIndex[workspaceURL] == nil { + self.workspaceIndex[workspaceURL] = [] + } + + guard var directories = self.workspaceIndex[workspaceURL] else { + return false + } + + // Check if we've reached the maximum directory limit + let currentDirectoryCount = directories.count + if currentDirectoryCount >= Self.maxDirectoriesPerWorkspace { + return false + } + + // Avoid duplicates by checking if directory already exists + if !directories.contains(directory) { + directories.append(directory) + self.workspaceIndex[workspaceURL] = directories + } + + return true // Directory already exists, so we consider this a successful "add" + } + } + + /// Remove a directory from the workspace index + public func removeDirectory(_ directory: ConversationDirectoryReference, from workspaceURL: URL) { + queue.sync { + self.workspaceIndex[workspaceURL]?.removeAll { $0 == directory } + } + } + + /// Init index for workspace + public func initIndexFor(_ workspaceURL: URL, projectURL: URL) { + let directories = WorkspaceDirectory.getDirectoriesInActiveWorkspace( + workspaceURL: workspaceURL, + workspaceRootURL: projectURL + ) + setDirectories(directories, for: workspaceURL) + } +} diff --git a/Tool/Sources/Workspace/WorkspaceFile.swift b/Tool/Sources/Workspace/WorkspaceFile.swift index 449469cd..11c68ce2 100644 --- a/Tool/Sources/Workspace/WorkspaceFile.swift +++ b/Tool/Sources/Workspace/WorkspaceFile.swift @@ -13,7 +13,9 @@ public let skipPatterns: [String] = [ ".DS_Store", "Thumbs.db", "node_modules", - "bower_components" + "bower_components", + "Preview Content", + ".swiftpm" ] public struct ProjectInfo { @@ -190,7 +192,8 @@ public struct WorkspaceFile { return name } - private static func shouldSkipFile(_ url: URL) -> Bool { + // Commom URL skip checking + public static func shouldSkipURL(_ url: URL) -> Bool { return matchesPatterns(url, patterns: skipPatterns) || isXCWorkspace(url) || isXCProject(url) @@ -202,7 +205,7 @@ public struct WorkspaceFile { _ url: URL, shouldExcludeFile: ((URL) -> Bool)? = nil ) throws -> Bool { - if shouldSkipFile(url) { return false } + if shouldSkipURL(url) { return false } let resourceValues = try url.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) @@ -225,8 +228,8 @@ public struct WorkspaceFile { workspaceURL: URL, workspaceRootURL: URL, shouldExcludeFile: ((URL) -> Bool)? = nil - ) -> [FileReference] { - var files: [FileReference] = [] + ) -> [ConversationFileReference] { + var files: [ConversationFileReference] = [] do { let fileManager = FileManager.default var subprojects: [URL] = [] @@ -248,7 +251,7 @@ public struct WorkspaceFile { while let fileURL = enumerator?.nextObject() as? URL { // Skip items matching the specified pattern - if shouldSkipFile(fileURL) { + if shouldSkipURL(fileURL) { enumerator?.skipDescendants() continue } @@ -258,7 +261,7 @@ public struct WorkspaceFile { let relativePath = fileURL.path.replacingOccurrences(of: workspaceRootURL.path, with: "") let fileName = fileURL.lastPathComponent - let file = FileReference(url: fileURL, relativePath: relativePath, fileName: fileName) + let file = ConversationFileReference(url: fileURL, relativePath: relativePath, fileName: fileName) files.append(file) } } @@ -277,7 +280,7 @@ public struct WorkspaceFile { projectURL: URL, excludeGitIgnoredFiles: Bool, excludeIDEIgnoredFiles: Bool - ) -> [FileReference] { + ) -> [ConversationFileReference] { // Directly return for invalid workspace guard workspaceURL.path != "/" else { return [] } diff --git a/Tool/Sources/Workspace/WorkspaceFileIndex.swift b/Tool/Sources/Workspace/WorkspaceFileIndex.swift index f1e29819..ca060504 100644 --- a/Tool/Sources/Workspace/WorkspaceFileIndex.swift +++ b/Tool/Sources/Workspace/WorkspaceFileIndex.swift @@ -6,11 +6,11 @@ public class WorkspaceFileIndex { /// Maximum number of files allowed per workspace public static let maxFilesPerWorkspace = 1_000_000 - private var workspaceIndex: [URL: [FileReference]] = [:] + private var workspaceIndex: [URL: [ConversationFileReference]] = [:] private let queue = DispatchQueue(label: "com.copilot.workspace-file-index") /// Reset files for a specific workspace URL - public func setFiles(_ files: [FileReference], for workspaceURL: URL) { + public func setFiles(_ files: [ConversationFileReference], for workspaceURL: URL) { queue.sync { // Enforce the file limit when setting files if files.count > Self.maxFilesPerWorkspace { @@ -22,14 +22,14 @@ public class WorkspaceFileIndex { } /// Get all files for a specific workspace URL - public func getFiles(for workspaceURL: URL) -> [FileReference]? { + public func getFiles(for workspaceURL: URL) -> [ConversationFileReference]? { return workspaceIndex[workspaceURL] } /// Add a file to the workspace index /// - Returns: true if the file was added successfully, false if the workspace has reached the maximum file limit @discardableResult - public func addFile(_ file: FileReference, to workspaceURL: URL) -> Bool { + public func addFile(_ file: ConversationFileReference, to workspaceURL: URL) -> Bool { return queue.sync { if self.workspaceIndex[workspaceURL] == nil { self.workspaceIndex[workspaceURL] = [] @@ -52,7 +52,7 @@ public class WorkspaceFileIndex { } /// Remove a file from the workspace index - public func removeFile(_ file: FileReference, from workspaceURL: URL) { + public func removeFile(_ file: ConversationFileReference, from workspaceURL: URL) { queue.sync { self.workspaceIndex[workspaceURL]?.removeAll { $0 == file } } diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index 5b1d7953..69b5b0f2 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -403,8 +403,8 @@ extension XPCExtensionService { } do { - let tools = try JSONDecoder().decode(FeatureFlags.self, from: data) - continuation.resume(tools) + let featureFlags = try JSONDecoder().decode(FeatureFlags.self, from: data) + continuation.resume(featureFlags) } catch { continuation.reject(error) } @@ -438,4 +438,160 @@ extension XPCExtensionService { } } } + + // MARK: BYOK + @XPCServiceActor + public func saveBYOKApiKey(_ params: BYOKSaveApiKeyParams) async throws -> BYOKSaveApiKeyResponse? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.saveBYOKApiKey(params) { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(BYOKSaveApiKeyResponse.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func listBYOKApiKey(_ params: BYOKListApiKeysParams) async throws -> BYOKListApiKeysResponse? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.listBYOKApiKeys(params) { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(BYOKListApiKeysResponse.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func deleteBYOKApiKey(_ params: BYOKDeleteApiKeyParams) async throws -> BYOKDeleteApiKeyResponse? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.deleteBYOKApiKey(params) { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(BYOKDeleteApiKeyResponse.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func saveBYOKModel(_ params: BYOKSaveModelParams) async throws -> BYOKSaveModelResponse? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.saveBYOKModel(params) { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(BYOKSaveModelResponse.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func listBYOKModels(_ params: BYOKListModelsParams) async throws -> BYOKListModelsResponse? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.listBYOKModels(params) { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(BYOKListModelsResponse.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func deleteBYOKModel(_ params: BYOKDeleteModelParams) async throws -> BYOKDeleteModelResponse? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.deleteBYOKModel(params) { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(BYOKDeleteModelResponse.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } } diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift index 5552ea38..7670d590 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -30,6 +30,13 @@ public protocol XPCServiceProtocol { func signOutAllGitHubCopilotService() func getXPCServiceAuthStatus(withReply reply: @escaping (Data?) -> Void) + func saveBYOKApiKey(_ params: Data, withReply reply: @escaping (Data?) -> Void) + func listBYOKApiKeys(_ params: Data, withReply reply: @escaping (Data?) -> Void) + func deleteBYOKApiKey(_ params: Data, withReply reply: @escaping (Data?) -> Void) + func saveBYOKModel(_ params: Data, withReply reply: @escaping (Data?) -> Void) + func listBYOKModels(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func deleteBYOKModel(_ params: Data, withReply reply: @escaping (Data?) -> Void) + func postNotification(name: String, withReply reply: @escaping () -> Void) func send(endpoint: String, requestBody: Data, reply: @escaping (Data?, Error?) -> Void) func quit(reply: @escaping () -> Void) diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift index 8c678aec..b1caae3e 100644 --- a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -7,11 +7,7 @@ public class AppInstanceInspector: ObservableObject { public let bundleURL: URL? public let bundleIdentifier: String? - public var appElement: AXUIElement { - let app = AXUIElementCreateApplication(runningApplication.processIdentifier) - app.setMessagingTimeout(2) - return app - } + public var appElement: AXUIElement { .fromRunningApplication(runningApplication) } public var isTerminated: Bool { return runningApplication.isTerminated diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 54865f1d..4b64d287 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -4,6 +4,7 @@ import AXExtension import AXNotificationStream import Combine import Foundation +import Status public final class XcodeAppInstanceInspector: AppInstanceInspector { public struct AXNotification { @@ -77,13 +78,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { public let axNotifications = AsyncPassthroughSubject() - public var realtimeDocumentURL: URL? { - guard let window = appElement.focusedWindow, - window.identifier == "Xcode.WorkspaceWindow" - else { return nil } - - return WorkspaceXcodeWindowInspector.extractDocumentURL(windowElement: window) - } + public var realtimeDocumentURL: URL? { appElement.realtimeDocumentURL } public var realtimeWorkspaceURL: URL? { guard let window = appElement.focusedWindow, @@ -408,6 +403,27 @@ extension XcodeAppInstanceInspector { } } +// MARK: - Focused Element + +extension XcodeAppInstanceInspector { + public func getFocusedElement(shouldRecordStatus: Bool = false) -> AXUIElement? { + do { + let focused: AXUIElement = try self.appElement.copyValue(key: kAXFocusedUIElementAttribute) + if shouldRecordStatus { + Task { await Status.shared.updateAXStatus(.granted) } + } + return focused + } catch AXError.apiDisabled { + if shouldRecordStatus { + Task { await Status.shared.updateAXStatus(.notGranted) } + } + } catch { + // ignore + } + return nil + } +} + public extension AXUIElement { var tabBars: [AXUIElement] { // Searching by traversing with AXUIElement is (Xcode) resource consuming, we should skip diff --git a/Tool/Sources/XcodeInspector/Helpers.swift b/Tool/Sources/XcodeInspector/Helpers.swift index eab2b002..87b2313f 100644 --- a/Tool/Sources/XcodeInspector/Helpers.swift +++ b/Tool/Sources/XcodeInspector/Helpers.swift @@ -16,3 +16,18 @@ public extension FileManager { } } +extension AXUIElement { + var realtimeDocumentURL: URL? { + guard let window = self.focusedWindow, + window.identifier == "Xcode.WorkspaceWindow" + else { return nil } + + return WorkspaceXcodeWindowInspector.extractDocumentURL(windowElement: window) + } + + static func fromRunningApplication(_ runningApplication: NSRunningApplication) -> AXUIElement { + let app = AXUIElementCreateApplication(runningApplication.processIdentifier) + app.setMessagingTimeout(2) + return app + } +} diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 6082a324..8caf9813 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -34,6 +34,12 @@ public class SourceEditor { /// To prevent expensive calculations in ``getContent()``. private let cache = Cache() + public var appElement: AXUIElement { .fromRunningApplication(runningApplication) } + + public var realtimeDocumentURL: URL? { + appElement.realtimeDocumentURL + } + public func getLatestEvaluatedContent() -> Content { let selectionRange = element.selectedTextRange let (content, lines, selections) = cache.latest() @@ -294,3 +300,9 @@ public extension SourceEditor { } } +extension SourceEditor: Equatable { + public static func ==(lhs: SourceEditor, rhs: SourceEditor) -> Bool { + return lhs.runningApplication.processIdentifier == rhs.runningApplication.processIdentifier + && lhs.element == rhs.element + } +} diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 2b2ea1e8..bb5c3cf9 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -290,20 +290,8 @@ public final class XcodeInspector: ObservableObject { let setFocusedElement = { @XcodeInspectorActor [weak self] in guard let self else { return } - func getFocusedElementAndRecordStatus(_ element: AXUIElement) -> AXUIElement? { - do { - let focused: AXUIElement = try element.copyValue(key: kAXFocusedUIElementAttribute) - Task { await Status.shared.updateAXStatus(.granted) } - return focused - } catch AXError.apiDisabled { - Task { await Status.shared.updateAXStatus(.notGranted) } - } catch { - // ignore - } - return nil - } - - focusedElement = getFocusedElementAndRecordStatus(xcode.appElement) + focusedElement = xcode.getFocusedElement(shouldRecordStatus: true) + if let editorElement = focusedElement, editorElement.isSourceEditor { focusedEditor = .init( runningApplication: xcode.runningApplication, @@ -319,6 +307,7 @@ public final class XcodeInspector: ObservableObject { } else { focusedEditor = nil } + } setFocusedElement() diff --git a/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift b/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift index 02d35acd..36883d28 100644 --- a/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift +++ b/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift @@ -56,7 +56,7 @@ class MockFSEventProvider: FSEventProvider { class MockWorkspaceFileProvider: WorkspaceFileProvider { var subprojects: [URL] = [] - var filesInWorkspace: [FileReference] = [] + var filesInWorkspace: [ConversationFileReference] = [] var xcProjectPaths: Set = [] var xcWorkspacePaths: Set = [] @@ -64,7 +64,7 @@ class MockWorkspaceFileProvider: WorkspaceFileProvider { return subprojects } - func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference] { + func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [ConversationFileReference] { return filesInWorkspace } @@ -118,11 +118,13 @@ class MockFileWatcherFactory: FileWatcherFactory { return MockFileWatcher(fileURL: fileURL, dispatchQueue: dispatchQueue, onFileModified: onFileModified, onFileDeleted: onFileDeleted, onFileRenamed: onFileRenamed) } - func createDirectoryWatcher(watchedPaths: [URL], changePublisher: @escaping PublisherType, publishInterval: TimeInterval) -> DirectoryWatcherProtocol { + func createDirectoryWatcher(watchedPaths: [URL], changePublisher: @escaping PublisherType, publishInterval: TimeInterval, directoryChangePublisher: PublisherType?) -> DirectoryWatcherProtocol { return BatchingFileChangeWatcher( watchedPaths: watchedPaths, changePublisher: changePublisher, - fsEventProvider: MockFSEventProvider() + publishInterval: publishInterval, + fsEventProvider: MockFSEventProvider(), + directoryChangePublisher: directoryChangePublisher ) } } @@ -180,7 +182,7 @@ final class BatchingFileChangeWatcherTests: XCTestCase { let watcher = createWatcher() let fileURL = URL(fileURLWithPath: "/test/project/file.swift") - watcher.onFileCreated(file: fileURL) + watcher.onFsEvent(url: fileURL, type: .created, isDirectory: false) // No events should be published yet XCTAssertTrue(publishedEvents.isEmpty) @@ -199,8 +201,8 @@ final class BatchingFileChangeWatcherTests: XCTestCase { let watcher = createWatcher() let fileURL = URL(fileURLWithPath: "/test/project/file.swift") - // Test file creation - directly call methods instead of simulating FS events - watcher.onFileCreated(file: fileURL) + // Test file creation - directly call onFsEvent instead of removed methods + watcher.onFsEvent(url: fileURL, type: .created, isDirectory: false) XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout") guard !publishedEvents.isEmpty else { return } @@ -209,7 +211,7 @@ final class BatchingFileChangeWatcherTests: XCTestCase { // Test file modification publishedEvents = [] - watcher.onFileChanged(file: fileURL) + watcher.onFsEvent(url: fileURL, type: .changed, isDirectory: false) XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout") @@ -219,13 +221,225 @@ final class BatchingFileChangeWatcherTests: XCTestCase { // Test file deletion publishedEvents = [] - watcher.onFileDeleted(file: fileURL) + watcher.addEvent(file: fileURL, type: .deleted) XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout") guard !publishedEvents.isEmpty else { return } XCTAssertEqual(publishedEvents[0].count, 1) XCTAssertEqual(publishedEvents[0][0].type, .deleted) } + + // MARK: - Tests for Directory Change functionality + + func testDirectoryChangePublisherWithoutDirectoryPublisher() { + // Test that directory events are ignored when no directoryChangePublisher is provided + let watcher = createWatcher() + let directoryURL = URL(fileURLWithPath: "/test/project/directory") + + // Call onFsEvent with directory = true + watcher.onFsEvent(url: directoryURL, type: .created, isDirectory: true) + + // Wait a bit to ensure no events are published + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + XCTAssertTrue(self.publishedEvents.isEmpty, "No directory events should be published without directoryChangePublisher") + } + } + + func testDirectoryChangePublisherWithDirectoryPublisher() { + var publishedDirectoryEvents: [[FileEvent]] = [] + + let watcher = BatchingFileChangeWatcher( + watchedPaths: [URL(fileURLWithPath: "/test/project")], + changePublisher: { [weak self] events in + self?.publishedEvents.append(events) + }, + publishInterval: 0.1, + fsEventProvider: mockFSEventProvider, + directoryChangePublisher: { events in + publishedDirectoryEvents.append(events) + } + ) + + let directoryURL = URL(fileURLWithPath: "/test/project/directory") + + // Test directory creation + watcher.onFsEvent(url: directoryURL, type: .created, isDirectory: true) + + // Wait for directory events to be published + let start = Date() + while publishedDirectoryEvents.isEmpty && Date().timeIntervalSince(start) < 1.0 { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) + } + + XCTAssertFalse(publishedDirectoryEvents.isEmpty, "Directory events should be published") + XCTAssertEqual(publishedDirectoryEvents[0].count, 1) + XCTAssertEqual(publishedDirectoryEvents[0][0].uri, directoryURL.absoluteString) + XCTAssertEqual(publishedDirectoryEvents[0][0].type, .created) + + // Test directory modification + publishedDirectoryEvents = [] + watcher.onFsEvent(url: directoryURL, type: .changed, isDirectory: true) + + let start2 = Date() + while publishedDirectoryEvents.isEmpty && Date().timeIntervalSince(start2) < 1.0 { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) + } + + XCTAssertFalse(publishedDirectoryEvents.isEmpty, "Directory change events should be published") + XCTAssertEqual(publishedDirectoryEvents[0][0].type, .changed) + + // Test directory deletion + publishedDirectoryEvents = [] + watcher.onFsEvent(url: directoryURL, type: .deleted, isDirectory: true) + + let start3 = Date() + while publishedDirectoryEvents.isEmpty && Date().timeIntervalSince(start3) < 1.0 { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) + } + + XCTAssertFalse(publishedDirectoryEvents.isEmpty, "Directory deletion events should be published") + XCTAssertEqual(publishedDirectoryEvents[0][0].type, .deleted) + } + + // MARK: - Tests for onFsEvent method + + func testOnFsEventWithFileOperations() { + let watcher = createWatcher() + let fileURL = URL(fileURLWithPath: "/test/project/file.swift") + + // Test file creation via onFsEvent + watcher.onFsEvent(url: fileURL, type: .created, isDirectory: false) + XCTAssertTrue(waitForPublishedEvents(), "File creation event should be published") + + guard !publishedEvents.isEmpty else { return } + XCTAssertEqual(publishedEvents[0][0].type, .created) + + // Test file modification via onFsEvent + publishedEvents = [] + watcher.onFsEvent(url: fileURL, type: .changed, isDirectory: false) + XCTAssertTrue(waitForPublishedEvents(), "File change event should be published") + + guard !publishedEvents.isEmpty else { return } + XCTAssertEqual(publishedEvents[0][0].type, .changed) + + // Test file deletion via onFsEvent + publishedEvents = [] + watcher.onFsEvent(url: fileURL, type: .deleted, isDirectory: false) + XCTAssertTrue(waitForPublishedEvents(), "File deletion event should be published") + + guard !publishedEvents.isEmpty else { return } + XCTAssertEqual(publishedEvents[0][0].type, .deleted) + } + + func testOnFsEventWithNilIsDirectory() { + var publishedDirectoryEvents: [[FileEvent]] = [] + + let watcher = BatchingFileChangeWatcher( + watchedPaths: [URL(fileURLWithPath: "/test/project")], + changePublisher: { [weak self] events in + self?.publishedEvents.append(events) + }, + publishInterval: 0.1, + fsEventProvider: mockFSEventProvider, + directoryChangePublisher: { events in + publishedDirectoryEvents.append(events) + } + ) + + let fileURL = URL(fileURLWithPath: "/test/project/file.swift") + + // Test deletion with nil isDirectory (should trigger both file and directory deletion) + watcher.onFsEvent(url: fileURL, type: .deleted, isDirectory: nil) + + // Wait for both file and directory events + let start = Date() + while (publishedEvents.isEmpty || publishedDirectoryEvents.isEmpty) && Date().timeIntervalSince(start) < 1.0 { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) + } + + XCTAssertFalse(publishedEvents.isEmpty, "File deletion event should be published") + XCTAssertFalse(publishedDirectoryEvents.isEmpty, "Directory deletion event should be published") + XCTAssertEqual(publishedEvents[0][0].type, .deleted) + XCTAssertEqual(publishedDirectoryEvents[0][0].type, .deleted) + } + + // MARK: - Tests for Event Compression + + func testEventCompression() { + let watcher = createWatcher() + let fileURL = URL(fileURLWithPath: "/test/project/file.swift") + + // Add multiple events for the same file + watcher.addEvent(file: fileURL, type: .created) + watcher.addEvent(file: fileURL, type: .changed) + watcher.addEvent(file: fileURL, type: .deleted) + + XCTAssertTrue(waitForPublishedEvents(), "Events should be published") + + guard !publishedEvents.isEmpty else { return } + + // Should be compressed to only deletion event (deletion covers creation and change) + XCTAssertEqual(publishedEvents[0].count, 1) + XCTAssertEqual(publishedEvents[0][0].type, .deleted) + } + + func testEventCompressionCreatedOverridesDeleted() { + let watcher = createWatcher() + let fileURL = URL(fileURLWithPath: "/test/project/file.swift") + + // Add deletion then creation + watcher.addEvent(file: fileURL, type: .deleted) + watcher.addEvent(file: fileURL, type: .created) + + XCTAssertTrue(waitForPublishedEvents(), "Events should be published") + + guard !publishedEvents.isEmpty else { return } + + // Should be compressed to only creation event (creation overrides deletion) + XCTAssertEqual(publishedEvents[0].count, 1) + XCTAssertEqual(publishedEvents[0][0].type, .created) + } + + func testEventCompressionChangeDoesNotOverrideCreated() { + let watcher = createWatcher() + let fileURL = URL(fileURLWithPath: "/test/project/file.swift") + + // Add creation then change + watcher.addEvent(file: fileURL, type: .created) + watcher.addEvent(file: fileURL, type: .changed) + + XCTAssertTrue(waitForPublishedEvents(), "Events should be published") + + guard !publishedEvents.isEmpty else { return } + + // Should keep creation event (change doesn't override creation) + XCTAssertEqual(publishedEvents[0].count, 1) + XCTAssertEqual(publishedEvents[0][0].type, .created) + } + + func testEventCompressionMultipleFiles() { + let watcher = createWatcher() + let file1URL = URL(fileURLWithPath: "/test/project/file1.swift") + let file2URL = URL(fileURLWithPath: "/test/project/file2.swift") + + // Add events for multiple files + watcher.addEvent(file: file1URL, type: .created) + watcher.addEvent(file: file2URL, type: .created) + watcher.addEvent(file: file1URL, type: .changed) + + XCTAssertTrue(waitForPublishedEvents(), "Events should be published") + + guard !publishedEvents.isEmpty else { return } + + // Should have 2 events, one for each file + XCTAssertEqual(publishedEvents[0].count, 2) + + // file1 should be created (changed doesn't override created) + // file2 should be created + let eventTypes = publishedEvents[0].map { $0.type } + XCTAssertTrue(eventTypes.contains(.created)) + XCTAssertEqual(eventTypes.filter { $0 == .created }.count, 2) + } } extension BatchingFileChangeWatcherTests { @@ -258,7 +472,8 @@ final class FileChangeWatcherServiceTests: XCTestCase { }, publishInterval: 0.1, workspaceFileProvider: mockWorkspaceFileProvider, - watcherFactory: MockFileWatcherFactory() + watcherFactory: MockFileWatcherFactory(), + directoryChangePublisher: nil ) } @@ -299,13 +514,13 @@ final class FileChangeWatcherServiceTests: XCTestCase { // Set up mock files for the added project let file1URL = URL(fileURLWithPath: "/test/workspace/project2/file1.swift") - let file1 = FileReference( + let file1 = ConversationFileReference( url: file1URL, relativePath: file1URL.relativePath, fileName: file1URL.lastPathComponent ) let file2URL = URL(fileURLWithPath: "/test/workspace/project2/file2.swift") - let file2 = FileReference( + let file2 = ConversationFileReference( url: file2URL, relativePath: file2URL.relativePath, fileName: file2URL.lastPathComponent @@ -343,13 +558,13 @@ final class FileChangeWatcherServiceTests: XCTestCase { // Set up mock files for the removed project let file1URL = URL(fileURLWithPath: "/test/workspace/project2/file1.swift") - let file1 = FileReference( + let file1 = ConversationFileReference( url: file1URL, relativePath: file1URL.relativePath, fileName: file1URL.lastPathComponent ) let file2URL = URL(fileURLWithPath: "/test/workspace/project2/file2.swift") - let file2 = FileReference( + let file2 = ConversationFileReference( url: file2URL, relativePath: file2URL.relativePath, fileName: file2URL.lastPathComponent diff --git a/Tool/Tests/WorkspaceTests/WorkspaceDirectoryTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceDirectoryTests.swift new file mode 100644 index 00000000..c43916a8 --- /dev/null +++ b/Tool/Tests/WorkspaceTests/WorkspaceDirectoryTests.swift @@ -0,0 +1,241 @@ +import XCTest +import Foundation +@testable import Workspace + +class WorkspaceDirectoryTests: XCTestCase { + + // MARK: - Directory Skip Pattern Tests + + func testShouldSkipDirectory() throws { + // Test skip patterns at different positions in path + XCTAssertTrue(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/some/path/.git")), "Should skip .git at end") + XCTAssertTrue(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/some/.git/path")), "Should skip .git in middle") + XCTAssertTrue(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/.git")), "Should skip .git at root") + XCTAssertTrue(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/some/node_modules/package")), "Should skip node_modules in middle") + XCTAssertTrue(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/project/Preview Content")), "Should skip Preview Content") + XCTAssertTrue(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/project/.swiftpm")), "Should skip .swiftpm") + + XCTAssertFalse(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/some/valid/path")), "Should not skip valid paths") + XCTAssertFalse(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/some/gitfile.txt")), "Should not skip files containing skip pattern in name") + } + + // MARK: - Directory Validation Tests + + func testIsValidDirectory() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + do { + // Create valid directory + let validDirURL = try createSubdirectory(in: tmpDir, withName: "ValidDirectory") + XCTAssertTrue(WorkspaceDirectory.isValidDirectory(validDirURL), "Valid directory should return true") + + // Create directory with skip pattern name + let gitDirURL = try createSubdirectory(in: tmpDir, withName: ".git") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(gitDirURL), ".git directory should return false") + + let nodeModulesDirURL = try createSubdirectory(in: tmpDir, withName: "node_modules") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(nodeModulesDirURL), "node_modules directory should return false") + + let previewContentDirURL = try createSubdirectory(in: tmpDir, withName: "Preview Content") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(previewContentDirURL), "Preview Content directory should return false") + + let swiftpmDirURL = try createSubdirectory(in: tmpDir, withName: ".swiftpm") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(swiftpmDirURL), ".swiftpm directory should return false") + + // Test file (should return false) + let fileURL = try createFile(in: tmpDir, withName: "file.swift", contents: "// Swift") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(fileURL), "File should return false for isValidDirectory") + + // Test Xcode workspace directory (should return false due to shouldSkipURL) + let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "test.xcworkspace") + _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(xcworkspaceURL), "Xcode workspace should return false") + + // Test Xcode project directory (should return false due to shouldSkipURL) + let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "test.xcodeproj") + _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(xcprojectURL), "Xcode project should return false") + + } catch { + throw error + } + } + + // MARK: - Directory Enumeration Tests + + func testGetDirectoriesInActiveWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + do { + let myWorkspaceRoot = try createSubdirectory(in: tmpDir, withName: "myWorkspace") + let xcWorkspaceURL = try createXCWorkspaceFolder(in: myWorkspaceRoot, withName: "myWorkspace.xcworkspace", fileRefs: [ + "container:myProject.xcodeproj", + "group:../myDependency",]) + let _ = try createXCProjectFolder(in: myWorkspaceRoot, withName: "myProject.xcodeproj") + let myDependencyURL = try createSubdirectory(in: tmpDir, withName: "myDependency") + + // Create valid directories + let _ = try createSubdirectory(in: myWorkspaceRoot, withName: "Sources") + let _ = try createSubdirectory(in: myWorkspaceRoot, withName: "Tests") + let _ = try createSubdirectory(in: myDependencyURL, withName: "Library") + + // Create directories that should be skipped + _ = try createSubdirectory(in: myWorkspaceRoot, withName: ".git") + _ = try createSubdirectory(in: myWorkspaceRoot, withName: "node_modules") + _ = try createSubdirectory(in: myWorkspaceRoot, withName: "Preview Content") + _ = try createSubdirectory(in: myDependencyURL, withName: ".swiftpm") + + // Create some files (should be ignored) + _ = try createFile(in: myWorkspaceRoot, withName: "file.swift", contents: "") + _ = try createFile(in: myDependencyURL, withName: "file.swift", contents: "") + + let directories = WorkspaceDirectory.getDirectoriesInActiveWorkspace( + workspaceURL: xcWorkspaceURL, + workspaceRootURL: myWorkspaceRoot + ) + let directoryNames = directories.map { $0.url.lastPathComponent } + + // Should include valid directories but not skipped ones + XCTAssertTrue(directoryNames.contains("Sources"), "Should include Sources directory") + XCTAssertTrue(directoryNames.contains("Tests"), "Should include Tests directory") + XCTAssertTrue(directoryNames.contains("Library"), "Should include Library directory from dependency") + + // Should not include skipped directories + XCTAssertFalse(directoryNames.contains(".git"), "Should not include .git directory") + XCTAssertFalse(directoryNames.contains("node_modules"), "Should not include node_modules directory") + XCTAssertFalse(directoryNames.contains("Preview Content"), "Should not include Preview Content directory") + XCTAssertFalse(directoryNames.contains(".swiftpm"), "Should not include .swiftpm directory") + + // Should not include project metadata directories + XCTAssertFalse(directoryNames.contains("myProject.xcodeproj"), "Should not include Xcode project directory") + + } catch { + throw error + } + } + + func testGetDirectoriesInActiveWorkspaceWithSingleProject() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + do { + let xcprojectURL = try createXCProjectFolder(in: tmpDir, withName: "myProject.xcodeproj") + + // Create valid directories + let sourcesDir = try createSubdirectory(in: tmpDir, withName: "Sources") + let _ = try createSubdirectory(in: tmpDir, withName: "Tests") + + // Create nested directory structure + let _ = try createSubdirectory(in: sourcesDir, withName: "MyModule") + + // Create directories that should be skipped + _ = try createSubdirectory(in: tmpDir, withName: ".git") + _ = try createSubdirectory(in: tmpDir, withName: "Preview Content") + + let directories = WorkspaceDirectory.getDirectoriesInActiveWorkspace( + workspaceURL: xcprojectURL, + workspaceRootURL: tmpDir + ) + let directoryNames = directories.map { $0.url.lastPathComponent } + + // Should include valid directories + XCTAssertTrue(directoryNames.contains("Sources"), "Should include Sources directory") + XCTAssertTrue(directoryNames.contains("Tests"), "Should include Tests directory") + XCTAssertTrue(directoryNames.contains("MyModule"), "Should include nested MyModule directory") + + // Should not include skipped directories + XCTAssertFalse(directoryNames.contains(".git"), "Should not include .git directory") + XCTAssertFalse(directoryNames.contains("Preview Content"), "Should not include Preview Content directory") + + // Should not include project metadata + XCTAssertFalse(directoryNames.contains("myProject.xcodeproj"), "Should not include Xcode project directory") + + } catch { + throw error + } + } + + // MARK: - Test Helper Methods + // Following the DRY principle and Test Utility Pattern + // https://martinfowler.com/bliki/ObjectMother.html + + func deleteDirectoryIfExists(at url: URL) { + if FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.removeItem(at: url) + } catch { + print("Failed to delete directory at \(url.path)") + } + } + } + + func createTemporaryDirectory() throws -> URL { + let temporaryDirectoryURL = FileManager.default.temporaryDirectory + let directoryName = UUID().uuidString + let directoryURL = temporaryDirectoryURL.appendingPathComponent(directoryName) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) +#if DEBUG + print("Create temp directory \(directoryURL.path)") +#endif + return directoryURL + } + + func createSubdirectory(in directory: URL, withName name: String) throws -> URL { + let subdirectoryURL = directory.appendingPathComponent(name) + try FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil) + return subdirectoryURL + } + + func createFile(in directory: URL, withName name: String, contents: String) throws -> URL { + let fileURL = directory.appendingPathComponent(name) + let data = contents.data(using: .utf8) + FileManager.default.createFile(atPath: fileURL.path, contents: data, attributes: nil) + return fileURL + } + + func createXCProjectFolder(in baseDirectory: URL, withName projectName: String) throws -> URL { + let projectURL = try createSubdirectory(in: baseDirectory, withName: projectName) + if projectName.hasSuffix(".xcodeproj") { + _ = try createFile(in: projectURL, withName: "project.pbxproj", contents: "// Project file contents") + } + return projectURL + } + + func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, fileRefs: [String]?) throws -> URL { + let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName) + if let fileRefs { + _ = try createXCworkspacedataFile(directory: xcworkspaceURL, fileRefs: fileRefs) + } + return xcworkspaceURL + } + + func createXCworkspacedataFile(directory: URL, fileRefs: [String]) throws -> URL { + let contents = generateXCWorkspacedataContents(fileRefs: fileRefs) + return try createFile(in: directory, withName: "contents.xcworkspacedata", contents: contents) + } + + func generateXCWorkspacedataContents(fileRefs: [String]) -> String { + var contents = """ + + + """ + for fileRef in fileRefs { + contents += """ + + + """ + } + contents += "" + return contents + } +} From 65dc13428e97b395d7aa00ae384c8ff5cd23caf7 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 2 Sep 2025 10:19:35 +0000 Subject: [PATCH 20/26] Pre-release 0.41.136 --- Core/Sources/ChatService/ChatService.swift | 13 +- .../Skills/CurrentEditorSkill.swift | 22 +- Core/Sources/ConversationTab/Chat.swift | 267 ++++++++++++++++-- .../ConversationTab/ChatDropdownView.swift | 2 +- Core/Sources/ConversationTab/ChatPanel.swift | 70 ++++- .../ConversationTab/ConversationTab.swift | 5 + Core/Sources/ConversationTab/FilePicker.swift | 4 +- .../AdvancedSettings/ChatSection.swift | 138 ++++++--- .../CreateCustomCopilotFileView.swift | 192 +++++++++++++ .../CustomCopilotHelper.swift | 140 +++++++++ .../GlobalInstructionsView.swift | 1 + .../HostApp/BYOKSettings/ApiKeySheet.swift | 12 +- .../HostApp/BYOKSettings/BYOKObservable.swift | 9 +- .../HostApp/BYOKSettings/ModelSheet.swift | 12 +- .../SharedComponents/AdaptiveHelpLink.swift | 29 ++ .../GraphicalUserInterfaceController.swift | 1 + .../ChatWindow/ChatHistoryView.swift | 6 +- .../SuggestionWidget/ChatWindowView.swift | 32 +-- .../SuggestionWidget/Extensions/Helper.swift | 36 +++ ...idgetWindowsController+FixErrorPanel.swift | 91 ++++++ .../FeatureReducers/ChatPanelFeature.swift | 16 ++ .../FixErrorPanelFeature.swift | 239 ++++++++++++++++ .../FeatureReducers/WidgetFeature.swift | 12 + .../SuggestionWidget/FixErrorPanelView.swift | 89 ++++++ Core/Sources/SuggestionWidget/Styles.swift | 1 + .../WidgetPositionStrategy.swift | 43 +-- .../WidgetWindowsController.swift | 47 +++ BYOK.md => Docs/BYOK.md | 0 Docs/CustomInstructions.md | 183 ++++++++++++ Docs/{ => Images}/AppIcon.png | Bin .../accessibility-permission-request.png | Bin .../{ => Images}/accessibility-permission.png | Bin Docs/{ => Images}/background-item.png | Bin .../background-permission-required.png | Bin Docs/{ => Images}/chat_dark.gif | Bin .../connect-comm-bridge-failed.png | Bin Docs/{ => Images}/copilot-menu_dark.png | Bin Docs/{ => Images}/demo.gif | Bin Docs/{ => Images}/device-code.png | Bin Docs/{ => Images}/dmg-open.png | Bin Docs/{ => Images}/extension-permission.png | Bin .../macos-download-open-confirm.png | Bin Docs/{ => Images}/signin-button.png | Bin Docs/{ => Images}/update-message.png | Bin Docs/{ => Images}/xcode-menu.png | Bin Docs/{ => Images}/xcode-menu_dark.png | Bin Docs/PromptFiles.md | 78 +++++ .../FixError.imageset/Contents.json | 26 ++ .../FixError.imageset/FixError.svg | 3 + .../FixError.imageset/FixErrorLight.svg | 3 + README.md | 24 +- TROUBLESHOOTING.md | 6 +- Tool/Package.swift | 1 + Tool/Sources/ChatAPIService/Models.swift | 2 +- .../GitHubCopilotRequest+Conversation.swift | 30 ++ .../LanguageServer/GitHubCopilotRequest.swift | 6 +- .../LanguageServer/GitHubCopilotService.swift | 7 +- .../GitHubCopilotConversationService.swift | 2 +- .../SharedUIComponents/CustomTextEditor.swift | 78 ++++- .../SuggestionBasic/EditorInformation.swift | 25 +- .../Apps/XcodeAppInstanceInspector.swift | 6 +- Tool/Sources/XcodeInspector/Helpers.swift | 8 + .../Sources/XcodeInspector/SourceEditor.swift | 10 +- 63 files changed, 1816 insertions(+), 211 deletions(-) create mode 100644 Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift create mode 100644 Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift create mode 100644 Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift create mode 100644 Core/Sources/SuggestionWidget/Extensions/Helper.swift create mode 100644 Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FixErrorPanel.swift create mode 100644 Core/Sources/SuggestionWidget/FeatureReducers/FixErrorPanelFeature.swift create mode 100644 Core/Sources/SuggestionWidget/FixErrorPanelView.swift rename BYOK.md => Docs/BYOK.md (100%) create mode 100644 Docs/CustomInstructions.md rename Docs/{ => Images}/AppIcon.png (100%) rename Docs/{ => Images}/accessibility-permission-request.png (100%) rename Docs/{ => Images}/accessibility-permission.png (100%) rename Docs/{ => Images}/background-item.png (100%) rename Docs/{ => Images}/background-permission-required.png (100%) rename Docs/{ => Images}/chat_dark.gif (100%) rename Docs/{ => Images}/connect-comm-bridge-failed.png (100%) rename Docs/{ => Images}/copilot-menu_dark.png (100%) rename Docs/{ => Images}/demo.gif (100%) rename Docs/{ => Images}/device-code.png (100%) rename Docs/{ => Images}/dmg-open.png (100%) rename Docs/{ => Images}/extension-permission.png (100%) rename Docs/{ => Images}/macos-download-open-confirm.png (100%) rename Docs/{ => Images}/signin-button.png (100%) rename Docs/{ => Images}/update-message.png (100%) rename Docs/{ => Images}/xcode-menu.png (100%) rename Docs/{ => Images}/xcode-menu_dark.png (100%) create mode 100644 Docs/PromptFiles.md create mode 100644 ExtensionService/Assets.xcassets/FixError.imageset/Contents.json create mode 100644 ExtensionService/Assets.xcassets/FixError.imageset/FixError.svg create mode 100644 ExtensionService/Assets.xcassets/FixError.imageset/FixErrorLight.svg diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index f112563f..ccb50619 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -90,7 +90,7 @@ public final class ChatService: ChatServiceType, ObservableObject { @Published public internal(set) var isReceivingMessage = false @Published public internal(set) var fileEditMap: OrderedDictionary = [:] public internal(set) var requestType: RequestType? = nil - public let chatTabInfo: ChatTabInfo + public private(set) var chatTabInfo: ChatTabInfo private let conversationProvider: ConversationServiceProvider? private let conversationProgressHandler: ConversationProgressHandler private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared @@ -131,6 +131,11 @@ public final class ChatService: ChatServiceType, ObservableObject { // Memory will be deallocated automatically } + public func updateChatTabInfo(_ tabInfo: ChatTabInfo) { + // Only isSelected need to be updated + chatTabInfo.isSelected = tabInfo.isSelected + } + private func subscribeToNotifications() { memory.observeHistoryChange { [weak self] in Task { [weak self] in @@ -643,7 +648,7 @@ public final class ChatService: ChatServiceType, ObservableObject { return URL(fileURLWithPath: chatTabInfo.workspacePath) } - private func getProjectRootURL() async throws -> URL? { + public func getProjectRootURL() -> URL? { guard let workspaceURL = getWorkspaceURL() else { return nil } return WorkspaceXcodeWindowInspector.extractProjectURL( workspaceURL: workspaceURL, @@ -989,8 +994,6 @@ public final class SharedChatService { } public func loadChatTemplates() async -> [ChatTemplate]? { - guard self.chatTemplates == nil else { return self.chatTemplates } - do { if let templates = (try await conversationProvider?.templates()) { self.chatTemplates = templates @@ -1163,7 +1166,7 @@ extension ChatService { ) await memory.appendMessage(initialBotMessage) - guard let projectRootURL = try await getProjectRootURL() + guard let projectRootURL = getProjectRootURL() else { let round = CodeReviewRound.fromError(turnId: turnId, error: "Invalid git repository.") await appendCodeReviewRound(round) diff --git a/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift index 774e4077..19f4aa8d 100644 --- a/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift +++ b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift @@ -36,27 +36,27 @@ public class CurrentEditorSkill: ConversationSkill { public func resolveSkill(request: ConversationContextRequest, completion: JSONRPCResponseHandler){ let uri: String? = self.currentFile.url.absoluteString - var selection: JSONValue? + let response: JSONValue if let fileSelection = currentFile.selection { let start = fileSelection.start let end = fileSelection.end - selection = .hash([ - "start": .hash(["line": .number(Double(start.line)), "character": .number(Double(start.character))]), - "end": .hash(["line": .number(Double(end.line)), "character": .number(Double(end.character))]) + response = .hash([ + "uri": .string(uri ?? ""), + "selection": .hash([ + "start": .hash(["line": .number(Double(start.line)), "character": .number(Double(start.character))]), + "end": .hash(["line": .number(Double(end.line)), "character": .number(Double(end.character))]) + ]) ]) + } else { + // No text selection - only include file URI without selection metadata + response = .hash(["uri": .string(uri ?? "")]) } completion( AnyJSONRPCResponse( id: request.id, - result: JSONValue.array([ - JSONValue.hash([ - "uri" : .string(uri ?? ""), - "selection": selection ?? .null - ]), - JSONValue.null - ])) + result: JSONValue.array([response, JSONValue.null])) ) } } diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index afce9479..547ad026 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -11,6 +11,7 @@ import Logger import OrderedCollections import SwiftUI import GitHelper +import SuggestionBasic public struct DisplayedChatMessage: Equatable { public enum Role: Equatable { @@ -65,6 +66,84 @@ private var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } +struct ChatContext: Equatable { + var typedMessage: String + var attachedReferences: [ConversationAttachedReference] + var attachedImages: [ImageReference] + + init(typedMessage: String, attachedReferences: [ConversationAttachedReference] = [], attachedImages: [ImageReference] = []) { + self.typedMessage = typedMessage + self.attachedReferences = attachedReferences + self.attachedImages = attachedImages + } + + static func from(_ message: DisplayedChatMessage, projectURL: URL) -> ChatContext { + .init( + typedMessage: message.text, + attachedReferences: message.references.compactMap { + guard let url = $0.url else { return nil } + if $0.isDirectory { + return .directory(.init(url: url)) + } else { + let relativePath = url.path.replacingOccurrences(of: projectURL.path, with: "") + let fileName = url.lastPathComponent + return .file(.init(url: url, relativePath: relativePath, fileName: fileName)) + } + }, + attachedImages: message.imageReferences) + } +} + +struct ChatContextProvider: Equatable { + var contextStack: [ChatContext] + + init(contextStack: [ChatContext] = []) { + self.contextStack = contextStack + } + + mutating func reset() { + contextStack = [] + } + + mutating func getNextContext() -> ChatContext? { + guard !contextStack.isEmpty else { + return nil + } + + return contextStack.removeLast() + } + + func getPreviousContext(from history: [DisplayedChatMessage], projectURL: URL) -> ChatContext? { + let previousUserMessage: DisplayedChatMessage? = { + let userMessages = history.filter { $0.role == .user } + guard !userMessages.isEmpty else { + return nil + } + + let stackCount = contextStack.count + guard userMessages.count > stackCount else { + return nil + } + + let index = userMessages.count - stackCount - 1 + guard index >= 0 else { return nil } + + return userMessages[index] + }() + + var context: ChatContext? + if let previousUserMessage { + context = .from(previousUserMessage, projectURL: projectURL) + } + + return context + } + + mutating func pushContext(_ context: ChatContext) { + contextStack.append(context) + } +} + @Reducer struct Chat { public typealias MessageID = String @@ -74,15 +153,28 @@ struct Chat { // Not use anymore. the title of history tab will get from chat tab info // Keep this var as `ChatTabItemView` reference this var title: String = "New Chat" - var typedMessage = "" + var chatContext: ChatContext = .init(typedMessage: "", attachedReferences: [], attachedImages: []) + var contextProvider: ChatContextProvider = .init() var history: [DisplayedChatMessage] = [] var isReceivingMessage = false var requestType: ChatService.RequestType? = nil var chatMenu = ChatMenu.State() var focusedField: Field? var currentEditor: ConversationFileReference? = nil - var conversationAttachedReferences: [ConversationAttachedReference] = [] - var attachedImages: [ImageReference] = [] + var attachedReferences: [ConversationAttachedReference] { + chatContext.attachedReferences + } + var attachedImages: [ImageReference] { + chatContext.attachedImages + } + var typedMessage: String { + get { chatContext.typedMessage } + set { + chatContext.typedMessage = newValue + // User typed in. Need to reset contextProvider + contextProvider.reset() + } + } /// Cache the original content var fileEditMap: OrderedDictionary = [:] var diffViewerController: DiffViewWindowController? = nil @@ -153,6 +245,15 @@ struct Chat { // Code Review case codeReview(ConversationCodeReviewFeature.Action) + + // Chat Context + case reloadNextContext + case reloadPreviousContext + case resetContextProvider + + // External Action + case observeFixErrorNotification + case fixEditorErrorIssue(EditorErrorIssue) } let service: ChatService @@ -163,6 +264,7 @@ struct Chat { case observeIsReceivingMessageChange(UUID) case sendMessage(UUID) case observeFileEditChange(UUID) + case observeFixErrorNotification(UUID) } @Dependency(\.openURL) var openURL @@ -190,7 +292,8 @@ struct Chat { await send(.isReceivingMessageChanged) await send(.focusOnTextField) await send(.refresh) - + await send(.observeFixErrorNotification) + let publisher = NotificationCenter.default.publisher(for: .gitHubCopilotChatModeDidChange) for await _ in publisher.values { let isAgentMode = AppState.shared.isAgentModeEnabled() @@ -220,10 +323,13 @@ struct Chat { scope: AppState.shared.modelScope() )?.supportVision ?? false let attachedImages: [ImageReference] = shouldAttachImages ? state.attachedImages : [] - state.attachedImages = [] + + let references = state.attachedReferences + state.chatContext.attachedImages = [] - let references = state.conversationAttachedReferences - return .run { _ in + return .run { send in + await send(.resetContextProvider) + try await service .send( id, @@ -264,10 +370,12 @@ struct Chat { let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel( scope: AppState.shared.modelScope() )?.modelFamily - let references = state.conversationAttachedReferences + let references = state.attachedReferences let agentMode = AppState.shared.isAgentModeEnabled() - return .run { _ in + return .run { send in + await send(.resetContextProvider) + try await service .send( id, @@ -512,28 +620,28 @@ struct Chat { state.currentEditor = fileReference return .none case let .addReference(ref): - guard !state.conversationAttachedReferences.contains(ref) else { + guard !state.chatContext.attachedReferences.contains(ref) else { return .none } - state.conversationAttachedReferences.append(ref) + state.chatContext.attachedReferences.append(ref) return .none case let .removeReference(ref): - guard let index = state.conversationAttachedReferences.firstIndex(of: ref) else { + guard let index = state.chatContext.attachedReferences.firstIndex(of: ref) else { return .none } - state.conversationAttachedReferences.remove(at: index) + state.chatContext.attachedReferences.remove(at: index) return .none // MARK: - Image Context case let .addSelectedImage(imageReference): guard !state.attachedImages.contains(imageReference) else { return .none } - state.attachedImages.append(imageReference) - return .none + state.chatContext.attachedImages.append(imageReference) + return .run { send in await send(.resetContextProvider) } case let .removeSelectedImage(imageReference): guard let index = state.attachedImages.firstIndex(of: imageReference) else { return .none } - state.attachedImages.remove(at: index) - return .none + state.chatContext.attachedImages.remove(at: index) + return .run { send in await send(.resetContextProvider) } // MARK: - Agent Edits @@ -585,6 +693,103 @@ struct Chat { case .codeReview: return .none + + // MARK: Chat Context + case .reloadNextContext: + guard let context = state.contextProvider.getNextContext() else { + return .none + } + + state.chatContext = context + + return .run { send in + await send(.focusOnTextField) + } + + case .reloadPreviousContext: + guard let projectURL = service.getProjectRootURL(), + let context = state.contextProvider.getPreviousContext( + from: state.history, + projectURL: projectURL) + else { + return .none + } + + let currentContext = state.chatContext + state.chatContext = context + state.contextProvider.pushContext(currentContext) + + return .run { send in + await send(.focusOnTextField) + } + + case .resetContextProvider: + state.contextProvider.reset() + return .none + + // MARK: - External action + + case .observeFixErrorNotification: + return .run { send in + let publisher = NotificationCenter.default.publisher(for: .fixEditorErrorIssue) + + for await notification in publisher.values { + guard service.chatTabInfo.isSelected, + let issue = notification.userInfo?["editorErrorIssue"] as? EditorErrorIssue + else { + continue + } + + await send(.fixEditorErrorIssue(issue)) + } + }.cancellable( + id: CancelID.observeFixErrorNotification(id), + cancelInFlight: true) + + case .fixEditorErrorIssue(let issue): + guard issue.workspaceURL == service.getWorkspaceURL(), + !issue.lineAnnotations.isEmpty + else { + return .none + } + + guard !state.isReceivingMessage else { + return .run { _ in + await MainActor.run { + NotificationCenter.default.post( + name: .fixEditorErrorIssueError, + object: nil, + userInfo: ["error": FixEditorErrorIssueFailure.isReceivingMessage(id: issue.id)] + ) + } + } + } + + let errorAnnotationMessage: String = issue.lineAnnotations + .map { "❗\($0.originalAnnotation)" } + .joined(separator: "\n\n") + let message = "Analyze and fix the following error(s): \n\n\(errorAnnotationMessage)" + + let skillSet = state.buildSkillSet(isCurrentEditorContextEnabled: enableCurrentEditorContext) + let references: [ConversationAttachedReference] = [.file(.init(url: issue.fileURL))] + let selectedModel = AppState.shared.getSelectedModel() + let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel( + scope: AppState.shared.modelScope() + )?.modelFamily + let agentMode = AppState.shared.isAgentModeEnabled() + + return .run { _ in + try await service.send( + UUID().uuidString, + content: message, + skillSet: skillSet, + references: references, + model: selectedModelFamily, + modelProviderName: selectedModel?.providerName, + agentMode: agentMode, + userLanguage: chatResponseLocale + ) + }.cancellable(id: CancelID.sendMessage(self.id)) } } } @@ -658,3 +863,31 @@ private actor TimedDebounceFunction { await block() } } + +public struct EditorErrorIssue: Equatable { + public let lineAnnotations: [EditorInformation.LineAnnotation] + public let fileURL: URL + public let workspaceURL: URL + public let id: String + + public init( + lineAnnotations: [EditorInformation.LineAnnotation], + fileURL: URL, + workspaceURL: URL, + id: String + ) { + self.lineAnnotations = lineAnnotations + self.fileURL = fileURL + self.workspaceURL = workspaceURL + self.id = id + } +} + +public enum FixEditorErrorIssueFailure: Equatable { + case isReceivingMessage(id: String) +} + +public extension Notification.Name { + static let fixEditorErrorIssue = Notification.Name("com.github.CopilotForXcode.fixEditorErrorIssue") + static let fixEditorErrorIssueError = Notification.Name("com.github.CopilotForXcode.fixEditorErrorIssueError") +} diff --git a/Core/Sources/ConversationTab/ChatDropdownView.swift b/Core/Sources/ConversationTab/ChatDropdownView.swift index 0e109584..bdd12f50 100644 --- a/Core/Sources/ConversationTab/ChatDropdownView.swift +++ b/Core/Sources/ConversationTab/ChatDropdownView.swift @@ -11,7 +11,7 @@ protocol DropDownItem: Equatable { extension ChatTemplate: DropDownItem { var displayName: String { id } - var displayDescription: String { shortDescription } + var displayDescription: String { description } } extension ChatAgent: DropDownItem { diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index 9f03cb4d..f4a9edd8 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -517,7 +517,8 @@ struct ChatPanelInputArea: View { @State private var filteredAgent: [ChatAgent] = [] @State private var showingTemplates = false @State private var dropDownShowingType: ShowingType? = nil - + @State private var textEditorState: TextEditorState? = nil + @AppStorage(\.enableCurrentEditorContext) var enableCurrentEditorContext: Bool @State private var isCurrentEditorContextEnabled: Bool = UserDefaults.shared.value( for: \.enableCurrentEditorContext @@ -604,6 +605,11 @@ struct ChatPanelInputArea: View { submitChatMessage() } dropDownShowingType = nil + }, + onTextEditorStateChanged: { (state: TextEditorState?) in + DispatchQueue.main.async { + textEditorState = state + } } ) .focused(focusedField, equals: .textField) @@ -688,11 +694,57 @@ struct ChatPanelInputArea: View { } .keyboardShortcut("l", modifiers: [.command]) .accessibilityHidden(true) + + buildReloadContextButtons() } } } + private var reloadNextContextButton: some View { + Button(action: { + chat.send(.reloadNextContext) + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.downArrow, modifiers: []) + .accessibilityHidden(true) + } + + private var reloadPreviousContextButton: some View { + Button(action: { + chat.send(.reloadPreviousContext) + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.upArrow, modifiers: []) + .accessibilityHidden(true) + } + + @ViewBuilder + private func buildReloadContextButtons() -> some View { + if let textEditorState = textEditorState { + switch textEditorState { + case .empty, .singleLine: + ZStack { + reloadPreviousContextButton + reloadNextContextButton + } + case .multipleLines(let cursorAt): + switch cursorAt { + case .first: + reloadPreviousContextButton + case .last: + reloadNextContextButton + case .middle: + EmptyView() + } + } + } else { + EmptyView() + } + } + private var sendButton: some View { Button(action: { submitChatMessage() @@ -813,7 +865,7 @@ struct ChatPanelInputArea: View { let currentEditorItem: [ConversationFileReference] = [chat.state.currentEditor].compactMap { $0 } - let references = chat.state.conversationAttachedReferences + let references = chat.state.attachedReferences let chatContextItems: [Any] = buttonItems.map { $0 as ChatContextButtonType } + currentEditorItem + references @@ -932,25 +984,21 @@ struct ChatPanelInputArea: View { func chatTemplateCompletion(text: String) async -> [ChatTemplate] { guard text.count >= 1 && text.first == "/" else { return [] } - let prefix = text.dropFirst() - var promptTemplates: [ChatTemplate] = [] + let prefix = String(text.dropFirst()).lowercased() + let promptTemplates: [ChatTemplate] = await SharedChatService.shared.loadChatTemplates() ?? [] let releaseNotesTemplate: ChatTemplate = .init( id: "releaseNotes", description: "What's New", shortDescription: "What's New", scopes: [.chatPanel, .agentPanel] ) - - if !chat.isAgentMode { - promptTemplates = await SharedChatService.shared.loadChatTemplates() ?? [] - } let templates = promptTemplates + [releaseNotesTemplate] let skippedTemplates = [ "feedback", "help" ] return templates.filter { $0.scopes.contains(chat.isAgentMode ? .agentPanel : .chatPanel) && - $0.id.hasPrefix(prefix) && + $0.id.lowercased().hasPrefix(prefix) && !skippedTemplates.contains($0.id) } } @@ -1168,8 +1216,8 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider { ChatPanel( chat: .init( initialState: .init( - typedMessage: "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.", - + chatContext: .init( + typedMessage: "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."), history: ChatPanel_Preview.history, isReceivingMessage: false ), diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift index 36f62a23..2884f332 100644 --- a/Core/Sources/ConversationTab/ConversationTab.swift +++ b/Core/Sources/ConversationTab/ConversationTab.swift @@ -278,5 +278,10 @@ public class ConversationTab: ChatTab { return true } + + public func updateChatTabInfo(_ tabInfo: ChatTabInfo) { + // Sync tabInfo for service + service.updateChatTabInfo(tabInfo) + } } diff --git a/Core/Sources/ConversationTab/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift index eb0b0cc9..2b3fba9d 100644 --- a/Core/Sources/ConversationTab/FilePicker.swift +++ b/Core/Sources/ConversationTab/FilePicker.swift @@ -206,7 +206,7 @@ struct FileRowView: View { .resizable() .scaledToFit() .frame(width: 16, height: 16) - .foregroundColor(.secondary) + .hoverSecondaryForeground(isHovered: selectedId == id) .padding(.leading, 4) HStack(spacing: 4) { @@ -219,7 +219,7 @@ struct FileRowView: View { Text(ref.relativePath) .font(.caption) - .foregroundColor(.secondary) + .hoverSecondaryForeground(isHovered: selectedId == id) .lineLimit(1) .truncationMode(.middle) // Ensure relative path remains visible even when display name is very long diff --git a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift index a71e2aa3..cd441b84 100644 --- a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift @@ -6,25 +6,37 @@ import XcodeInspector struct ChatSection: View { @AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode - + var body: some View { SettingsSection(title: "Chat Settings") { // Auto Attach toggle SettingsToggle( - title: "Auto-attach Chat Window to Xcode", + title: "Auto-attach Chat Window to Xcode", isOn: $autoAttachChatToXcode ) - + Divider() - + // Response language picker ResponseLanguageSetting() .padding(SettingsToggle.defaultPadding) - + + Divider() + + // Copilot instructions - .github/copilot-instructions.md + CopilotInstructionSetting() + .padding(SettingsToggle.defaultPadding) + + Divider() + + // Custom Instructions - .github/instructions/*.instructions.md + PromptFileSetting(promptType: .instructions) + .padding(SettingsToggle.defaultPadding) + Divider() - - // Custom instructions - CustomInstructionSetting() + + // Custom Prompts - .github/prompts/*.prompt.md + PromptFileSetting(promptType: .prompt) .padding(SettingsToggle.defaultPadding) } } @@ -50,14 +62,14 @@ struct ResponseLanguageSetting: View { "tr": "Turkish", "pl": "Polish", "cs": "Czech", - "hu": "Hungarian" + "hu": "Hungarian", ] - + var selectedLanguage: String { if chatResponseLocale == "" { return "English" } - + return localeLanguageMap[chatResponseLocale] ?? "English" } @@ -73,7 +85,7 @@ struct ResponseLanguageSetting: View { VStack(alignment: .leading) { Text("Response Language") .font(.body) - Text("This change applies only to new chat sessions. Existing ones won’t be impacted.") + Text("This change applies only to new chat sessions. Existing ones won't be impacted.") .font(.footnote) } @@ -90,7 +102,7 @@ struct ResponseLanguageSetting: View { } } -struct CustomInstructionSetting: View { +struct CopilotInstructionSetting: View { @State var isGlobalInstructionsViewOpen = false @Environment(\.toast) var toast @@ -98,9 +110,9 @@ struct CustomInstructionSetting: View { WithPerceptionTracking { HStack { VStack(alignment: .leading) { - Text("Custom Instructions") + Text("Copilot Instructions") .font(.body) - Text("Configure custom instructions for GitHub Copilot to follow during chat sessions.") + Text("Configure `.github/copilot-instructions.md` to apply to all chat requests.") .font(.footnote) } @@ -122,39 +134,87 @@ struct CustomInstructionSetting: View { func openCustomInstructions() { Task { - let service = try? getService() - let inspectorData = try? await service?.getXcodeInspectorData() - var currentWorkspace: URL? = nil - if let url = inspectorData?.realtimeActiveWorkspaceURL, let workspaceURL = URL(string: url), workspaceURL.path != "/" { - currentWorkspace = workspaceURL - } else if let url = inspectorData?.latestNonRootWorkspaceURL { - currentWorkspace = URL(string: url) + guard let projectURL = await getCurrentProjectURL() else { + toast("No active workspace found", .error) + return } - // Open custom instructions for the current workspace - if let workspaceURL = currentWorkspace, let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(workspaceURL: workspaceURL, documentURL: nil) { + let configFile = projectURL.appendingPathComponent(".github/copilot-instructions.md") + + // If the file doesn't exist, create one with a proper structure + if !FileManager.default.fileExists(atPath: configFile.path) { + do { + // Create directory if it doesn't exist using reusable helper + let gitHubDir = projectURL.appendingPathComponent(".github") + try ensureDirectoryExists(at: gitHubDir) + + // Create empty file + try "".write(to: configFile, atomically: true, encoding: .utf8) + } catch { + toast("Failed to create config file .github/copilot-instructions.md: \(error)", .error) + } + } + + if FileManager.default.fileExists(atPath: configFile.path) { + NSWorkspace.shared.open(configFile) + } + } + } +} - let configFile = projectURL.appendingPathComponent(".github/copilot-instructions.md") +struct PromptFileSetting: View { + let promptType: PromptType + @State private var isCreateSheetPresented = false + @Environment(\.toast) var toast - // If the file doesn't exist, create one with a proper structure - if !FileManager.default.fileExists(atPath: configFile.path) { - do { - // Create directory if it doesn't exist - try FileManager.default.createDirectory( - at: projectURL.appendingPathComponent(".github"), - withIntermediateDirectories: true + var body: some View { + WithPerceptionTracking { + HStack { + VStack(alignment: .leading) { + Text(promptType.settingTitle) + .font(.body) + Text( + (try? AttributedString(markdown: promptType.description)) ?? AttributedString( + promptType.description ) - // Create empty file - try "".write(to: configFile, atomically: true, encoding: .utf8) - } catch { - toast("Failed to create config file .github/copilot-instructions.md: \(error)", .error) - } + ) + .font(.footnote) + } + + Spacer() + + Button("Create") { + isCreateSheetPresented = true } - if FileManager.default.fileExists(atPath: configFile.path) { - NSWorkspace.shared.open(configFile) + Button("Open \(promptType.directoryName.capitalized) Folder") { + openDirectory() } } + .sheet(isPresented: $isCreateSheetPresented) { + CreateCustomCopilotFileView( + isOpen: $isCreateSheetPresented, + promptType: promptType + ) + } + } + } + + private func openDirectory() { + Task { + guard let projectURL = await getCurrentProjectURL() else { + toast("No active workspace found", .error) + return + } + + let directory = promptType.getDirectoryPath(projectURL: projectURL) + + do { + try ensureDirectoryExists(at: directory) + NSWorkspace.shared.open(directory) + } catch { + toast("Failed to create \(promptType.directoryName) directory: \(error)", .error) + } } } } diff --git a/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift b/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift new file mode 100644 index 00000000..843f83b1 --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift @@ -0,0 +1,192 @@ +import Client +import SwiftUI +import XcodeInspector + +struct CreateCustomCopilotFileView: View { + var isOpen: Binding + let promptType: PromptType + + @State private var fileName = "" + @State private var projectURL: URL? + @State private var fileAlreadyExists = false + + @Environment(\.toast) var toast + + init(isOpen: Binding, promptType: PromptType) { + self.isOpen = isOpen + self.promptType = promptType + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center) { + Button(action: { self.isOpen.wrappedValue = false }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + + Text("Create \(promptType.displayName)") + .font(.system(size: 13, weight: .bold)) + Spacer() + + AdaptiveHelpLink(action: openHelpLink) + .padding() + } + .frame(height: 28) + .background(Color(nsColor: .separatorColor)) + + // Content + VStack(alignment: .leading, spacing: 8) { + Text("Enter the name of \(promptType.rawValue) file:") + .font(.body) + + TextField("File name", text: $fileName) + .textFieldStyle(.roundedBorder) + .onSubmit { + Task { await createPromptFile() } + } + .onChange(of: fileName) { _ in + updateFileExistence() + } + + validationMessageView + + Spacer() + + HStack(spacing: 12) { + Spacer() + + Button("Cancel") { + self.isOpen.wrappedValue = false + } + .buttonStyle(.bordered) + + Button("Create") { + Task { await createPromptFile() } + } + .buttonStyle(.borderedProminent) + .disabled(disableCreateButton) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 20) + } + .frame(width: 350, height: 160) + .onAppear { + fileName = "" + Task { await resolveProjectURL() } + } + } + + // MARK: - Derived values + + private var trimmedFileName: String { + fileName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var disableCreateButton: Bool { + trimmedFileName.isEmpty || fileAlreadyExists + } + + @ViewBuilder + private var validationMessageView: some View { + HStack(alignment: .center, spacing: 6) { + if fileAlreadyExists && !trimmedFileName.isEmpty { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + Text("'.github/\(promptType.directoryName)/\(trimmedFileName).\(promptType.fileExtension)' already exists") + .font(.caption) + .foregroundColor(.red) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + } else if trimmedFileName.isEmpty { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + Text("Enter a file name") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text(".github/\(promptType.directoryName)/\(trimmedFileName).\(promptType.fileExtension)") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + } + } + .transition(.opacity) + } + + // MARK: - Actions / Helpers + + private func openHelpLink() { + if let url = URL(string: promptType.helpLink) { + NSWorkspace.shared.open(url) + } + } + + /// Resolves the active project URL (if any) and updates state. + private func resolveProjectURL() async { + let projectURL = await getCurrentProjectURL() + await MainActor.run { + self.projectURL = projectURL + updateFileExistence() + } + } + + private func updateFileExistence() { + let name = trimmedFileName + guard !name.isEmpty, let projectURL else { + fileAlreadyExists = false + return + } + let filePath = promptType.getFilePath(fileName: name, projectURL: projectURL) + fileAlreadyExists = FileManager.default.fileExists(atPath: filePath.path) + } + + /// Creates the prompt file if it doesn't already exist. + private func createPromptFile() async { + guard let projectURL else { + await MainActor.run { + toast("No active workspace found", .error) + } + return + } + + let directoryPath = promptType.getDirectoryPath(projectURL: projectURL) + let filePath = promptType.getFilePath(fileName: trimmedFileName, projectURL: projectURL) + + // Re-check existence to avoid race with external creation. + if FileManager.default.fileExists(atPath: filePath.path) { + await MainActor.run { + self.fileAlreadyExists = true + toast("\(promptType.displayName) '\(trimmedFileName).\(promptType.fileExtension)' already exists", .warning) + } + return + } + + do { + try FileManager.default.createDirectory( + at: directoryPath, + withIntermediateDirectories: true + ) + + try promptType.defaultTemplate.write(to: filePath, atomically: true, encoding: .utf8) + + await MainActor.run { + toast("Created \(promptType.rawValue) file '\(trimmedFileName).\(promptType.fileExtension)'", .info) + NSWorkspace.shared.open(filePath) + self.isOpen.wrappedValue = false + } + } catch { + await MainActor.run { + toast("Failed to create \(promptType.rawValue) file: \(error)", .error) + } + } + } +} diff --git a/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift new file mode 100644 index 00000000..10eed005 --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift @@ -0,0 +1,140 @@ +import AppKit +import Client +import Foundation +import SwiftUI +import Toast +import XcodeInspector +import SystemUtils + +public enum PromptType: String, CaseIterable, Equatable { + case instructions = "instructions" + case prompt = "prompt" + + /// The directory name under .github where files of this type are stored + var directoryName: String { + switch self { + case .instructions: + return "instructions" + case .prompt: + return "prompts" + } + } + + /// The file extension for this prompt type + var fileExtension: String { + switch self { + case .instructions: + return ".instructions.md" + case .prompt: + return ".prompt.md" + } + } + + /// Human-readable name for display purposes + var displayName: String { + switch self { + case .instructions: + return "Instruction File" + case .prompt: + return "Prompt File" + } + } + + /// Human-readable name for settings + var settingTitle: String { + switch self { + case .instructions: + return "Custom Instructions" + case .prompt: + return "Prompt Files" + } + } + + /// Description for the prompt type + var description: String { + switch self { + case .instructions: + return "Configure `.github/instructions/*.instructions.md` files scoped to specific file patterns or tasks." + case .prompt: + return "Configure `.github/prompts/*.prompt.md` files for reusable prompt templates." + } + } + + /// Default template content for new files + var defaultTemplate: String { + switch self { + case .instructions: + return """ + --- + applyTo: '**' + --- + Provide project context and coding guidelines that AI should follow when generating code, or answering questions. + + """ + case .prompt: + return """ + --- + description: Tool Description + --- + Define the task to achieve, including specific requirements, constraints, and success criteria. + + """ + } + } + + var helpLink: String { + var editorPluginVersion = SystemUtils.editorPluginVersionString + if editorPluginVersion == "0.0.0" { + editorPluginVersion = "main" + } + + switch self { + case .instructions: + return "https://github.com/github/CopilotForXcode/blob/\(editorPluginVersion)/Docs/CustomInstructions.md" + case .prompt: + return "https://github.com/github/CopilotForXcode/blob/\(editorPluginVersion)/Docs/PromptFiles.md" + } + } + + /// Get the full file path for a given name and project URL + func getFilePath(fileName: String, projectURL: URL) -> URL { + let directory = getDirectoryPath(projectURL: projectURL) + return directory.appendingPathComponent("\(fileName)\(fileExtension)") + } + + /// Get the directory path for this prompt type + func getDirectoryPath(projectURL: URL) -> URL { + return projectURL.appendingPathComponent(".github/\(directoryName)") + } +} + +func getCurrentProjectURL() async -> URL? { + let service = try? getService() + let inspectorData = try? await service?.getXcodeInspectorData() + var currentWorkspace: URL? + + if let url = inspectorData?.realtimeActiveWorkspaceURL, + let workspaceURL = URL(string: url), + workspaceURL.path != "/" { + currentWorkspace = workspaceURL + } else if let url = inspectorData?.latestNonRootWorkspaceURL { + currentWorkspace = URL(string: url) + } + + guard let workspaceURL = currentWorkspace, + let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: workspaceURL, + documentURL: nil + ) else { + return nil + } + + return projectURL +} + +func ensureDirectoryExists(at url: URL) throws { + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: url.path) { + try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + } +} diff --git a/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift b/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift index b429f581..264002a2 100644 --- a/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift +++ b/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift @@ -43,6 +43,7 @@ struct GlobalInstructionsView: View { .foregroundColor(Color(nsColor: .placeholderTextColor)) .font(.body) .allowsHitTesting(false) + .padding(.horizontal, 6) } } .padding(8) diff --git a/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift index d957e2ac..e3ce7ba9 100644 --- a/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift +++ b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift @@ -26,17 +26,7 @@ struct ApiKeySheet: View { Spacer() Text("\(provider.title)").font(.headline) Spacer() - if #available(macOS 14.0, *) { - HelpLink(action: openHelpLink).controlSize(.small) - } else { - Button(action: openHelpLink) { - Image(systemName: "questionmark") - } - .controlSize(.small) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0) - .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5) - } + AdaptiveHelpLink(action: openHelpLink) } VStack(alignment: .leading, spacing: 4) { diff --git a/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift b/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift index 838ff4b7..fa0bff5f 100644 --- a/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift +++ b/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift @@ -3,6 +3,7 @@ import GitHubCopilotService import Logger import SwiftUI import XPCShared +import SystemUtils actor BYOKServiceActor { private let service: XPCExtensionService @@ -176,7 +177,13 @@ extension BYOKModelManagerObservable { } } -public let BYOKHelpLink = "https://github.com/github/CopilotForXcode/blob/main/BYOK.md" +public var BYOKHelpLink: String { + var editorPluginVersion = SystemUtils.editorPluginVersionString + if editorPluginVersion == "0.0.0" { + editorPluginVersion = "main" + } + return "https://github.com/github/CopilotForXcode/blob/\(editorPluginVersion)/Docs/BYOK.md" +} enum BYOKSheetType: Identifiable { case apiKey(BYOKProviderName) diff --git a/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift index e67e4f3b..095e436a 100644 --- a/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift +++ b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift @@ -32,17 +32,7 @@ struct ModelSheet: View { Spacer() Text("\(provider.title)").font(.headline) Spacer() - if #available(macOS 14.0, *) { - HelpLink(action: openHelpLink).controlSize(.small) - } else { - Button(action: openHelpLink) { - Image(systemName: "questionmark") - } - .controlSize(.small) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0) - .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5) - } + AdaptiveHelpLink(action: openHelpLink) } VStack(alignment: .leading, spacing: 8) { diff --git a/Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift b/Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift new file mode 100644 index 00000000..54ee7e9c --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// A small adaptive help link button that uses the native `HelpLink` on macOS 14+ +/// and falls back to a styled question-mark button on earlier versions. +struct AdaptiveHelpLink: View { + let action: () -> Void + var controlSize: ControlSize = .small + + init(controlSize: ControlSize = .small, action: @escaping () -> Void) { + self.controlSize = controlSize + self.action = action + } + + var body: some View { + Group { + if #available(macOS 14.0, *) { + HelpLink(action: action) + } else { + Button(action: action) { + Image(systemName: "questionmark") + } + .clipShape(Circle()) + .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0) + .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5) + } + } + .controlSize(controlSize) + } +} diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 117977b9..5489bf3c 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -281,6 +281,7 @@ struct GUI { await send(.openChatPanel(forceDetach: false)) await stopAndHandleCommand(chatTab) await send(.suggestionWidget(.chatPanel(.saveChatTabInfo([originalTab, currentTab], chatWorkspace)))) + await send(.suggestionWidget(.chatPanel(.syncChatTabInfo([originalTab, currentTab])))) } } diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift index 3817c812..a766e949 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift @@ -145,6 +145,7 @@ struct ChatHistorySearchBarView: View { struct ChatHistoryItemView: View { let store: StoreOf let previewInfo: ChatTabPreviewInfo + @Environment(\.colorScheme) var colorScheme @Binding var isChatHistoryVisible: Bool @State private var isHovered = false @@ -219,7 +220,10 @@ struct ChatHistoryItemView: View { }) .hoverRadiusBackground( isHovered: isHovered, - hoverColor: Color(nsColor: .textBackgroundColor.withAlphaComponent(0.55)), + hoverColor: Color( + nsColor: .controlColor + .withAlphaComponent(colorScheme == .dark ? 0.1 : 0.55) + ), cornerRadius: 4, showBorder: isHovered, borderColor: Color(nsColor: .separatorColor) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index cc4a82a8..c0004ec7 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -454,28 +454,16 @@ struct ChatTabContainer: View { selectedTabId: String ) -> some View { GeometryReader { geometry in - ZStack { - ForEach(tabInfoArray) { tabInfo in - if let tab = chatTabPool.getTab(of: tabInfo.id) { - let isActive = tab.id == selectedTabId - - if isActive { - // Only render the active tab with full layout - tab.body - .frame( - width: geometry.size.width, - height: geometry.size.height - ) - } else { - // Render inactive tabs with minimal footprint to avoid layout conflicts - tab.body - .frame(width: 1, height: 1) - .opacity(0) - .allowsHitTesting(false) - .clipped() - } - } - } + if tabInfoArray[id: selectedTabId] != nil, + let tab = chatTabPool.getTab(of: selectedTabId) { + tab.body + .frame( + width: geometry.size.width, + height: geometry.size.height + ) + } else { + // Fallback if selected tab is not found + EmptyView() } } } diff --git a/Core/Sources/SuggestionWidget/Extensions/Helper.swift b/Core/Sources/SuggestionWidget/Extensions/Helper.swift new file mode 100644 index 00000000..06b55832 --- /dev/null +++ b/Core/Sources/SuggestionWidget/Extensions/Helper.swift @@ -0,0 +1,36 @@ +import AppKit + +struct LocationStrategyHelper { + + /// `lineNumber` is 0-based + static func getLineFrame(_ lineNumber: Int, in editor: AXUIElement, with lines: [String]) -> CGRect? { + guard editor.isSourceEditor, + lineNumber < lines.count && lineNumber >= 0 + else { + return nil + } + + var characterPosition = 0 + for i in 0.. { + Reduce { state, action in + switch action { + case .appear: + return .run { send in + let stream = AsyncStream { continuation in + let observer = NotificationCenter.default.addObserver( + forName: .fixEditorErrorIssueError, + object: nil, + queue: .main + ) { notification in + guard let error = notification.userInfo?["error"] as? FixEditorErrorIssueFailure + else { + return + } + + Task { + await send(.onFailure(error)) + } + } + + continuation.onTermination = { _ in + NotificationCenter.default.removeObserver(observer) + } + } + + for await _ in stream { + // Stream continues until cancelled + } + }.cancellable( + id: CancelID.observeErrorNotification(id), + cancelInFlight: true + ) + + case .onFocusedEditorChanged(let editor): + state.focusedEditor = editor + return .merge( + .send(.startAnnotationCheck), + .send(.resetFixFailure) + ) + + case .onEditorContentChanged: + return .merge( + .send(.startAnnotationCheck), + .send(.resetFixFailure) + ) + + case .onScrollPositionChanged: + return .merge( + .send(.resetFixFailure), + // Force checking the annotation + .send(.onAnnotationCheckTimerFired), + .send(.checkDisplay) + ) + + case .onCursorPositionChanged: + return .merge( + .send(.resetFixFailure), + // Force checking the annotation + .send(.onAnnotationCheckTimerFired), + .send(.checkDisplay) + ) + + case .fixErrorIssue(let annotations): + guard let fileURL = state.focusedEditor?.realtimeDocumentURL ?? nil, + let workspaceURL = state.focusedEditor?.realtimeWorkspaceURL ?? nil + else { + return .none + } + + let fixId = UUID().uuidString + state.fixId = fixId + state.fixFailure = nil + + let editorErrorIssue: EditorErrorIssue = .init( + lineAnnotations: annotations, + fileURL: fileURL, + workspaceURL: workspaceURL, + id: fixId + ) + + let userInfo = [ + "editorErrorIssue": editorErrorIssue + ] + + return .run { _ in + await MainActor.run { + suggestionWidgetControllerDependency.onOpenChatClicked() + + NotificationCenter.default.post( + name: .fixEditorErrorIssue, + object: nil, + userInfo: userInfo + ) + } + } + + case .scheduleFixFailureReset: + return .run { send in + try await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds + await send(.resetFixFailure) + } + .cancellable(id: CancelID.scheduleFixFailureReset(id), cancelInFlight: true) + + case .resetFixFailure: + state.resetFailure() + return .cancel(id: CancelID.scheduleFixFailureReset(id)) + + case .onFailure(let failure): + guard case let .isReceivingMessage(fixId) = failure, + fixId == state.fixId + else { + return .none + } + + state.fixFailure = failure + + return .run { send in await send(.scheduleFixFailureReset)} + + case .checkDisplay: + state.isPanelDisplayed = !state.editorContentLines.isEmpty + && !state.errorAnnotationsAtCursorPosition.isEmpty + return .none + + // MARK: - Annotation Check + + case .startAnnotationCheck: + return .run { send in + let startTime = Date() + let maxDuration: TimeInterval = 60 * 5 + let interval: TimeInterval = 1 + + while Date().timeIntervalSince(startTime) < maxDuration { + try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) + + await send(.onAnnotationCheckTimerFired) + } + }.cancellable(id: CancelID.annotationCheck(id), cancelInFlight: true) + + case .onAnnotationCheckTimerFired: + guard let editor = state.focusedEditor else { + return .cancel(id: CancelID.annotationCheck(id)) + } + + let newEditorContent = editor.getContent() + let newLineAnnotations = newEditorContent.lineAnnotations + let newErrorLineAnnotations = newLineAnnotations.filter { $0.isError } + let errorAnnotations = state.errorAnnotations + + if state.editorContent != newEditorContent { + state.editorContent = newEditorContent + } + + if errorAnnotations != newErrorLineAnnotations { + return .merge( + .send(.checkDisplay), + .cancel(id: CancelID.annotationCheck(id)) + ) + } else { + return .none + } + } + } + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 83b516d5..16d0041d 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -40,6 +40,10 @@ public struct WidgetFeature { // MARK: CodeReview public var codeReviewPanelState = CodeReviewPanelFeature.State() + + // MARK: FixError + + public var fixErrorPanelState = FixErrorPanelFeature.State() // MARK: CircularWidget @@ -116,6 +120,7 @@ public struct WidgetFeature { case chatPanel(ChatPanelFeature.Action) case circularWidget(CircularWidgetFeature.Action) case codeReviewPanel(CodeReviewPanelFeature.Action) + case fixErrorPanel(FixErrorPanelFeature.Action) } var windowsController: WidgetWindowsController? { @@ -147,6 +152,10 @@ public struct WidgetFeature { Scope(state: \.codeReviewPanelState, action: \.codeReviewPanel) { CodeReviewPanelFeature() } + + Scope(state: \.fixErrorPanelState, action: \.fixErrorPanel) { + FixErrorPanelFeature() + } Reduce { state, action in switch action { @@ -411,6 +420,9 @@ public struct WidgetFeature { case .codeReviewPanel: return .none + + case .fixErrorPanel: + return .none } } } diff --git a/Core/Sources/SuggestionWidget/FixErrorPanelView.swift b/Core/Sources/SuggestionWidget/FixErrorPanelView.swift new file mode 100644 index 00000000..9381e2b6 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FixErrorPanelView.swift @@ -0,0 +1,89 @@ +import SwiftUI +import ComposableArchitecture +import SuggestionBasic +import ConversationTab + +private typealias FixErrorViewStore = ViewStore + +private struct ViewState: Equatable { + let errorAnnotationsAtCursorPosition: [EditorInformation.LineAnnotation] + let fixFailure: FixEditorErrorIssueFailure? + let isPanelDisplayed: Bool + + init(state: FixErrorPanelFeature.State) { + self.errorAnnotationsAtCursorPosition = state.errorAnnotationsAtCursorPosition + self.fixFailure = state.fixFailure + self.isPanelDisplayed = state.isPanelDisplayed + } +} + +struct FixErrorPanelView: View { + let store: StoreOf + + @State private var showFailurePopover = false + + var body: some View { + WithViewStore(self.store, observe: ViewState.init) { viewStore in + WithPerceptionTracking { + + VStack { + buildFixErrorButton(viewStore: viewStore) + .popover(isPresented: $showFailurePopover) { + if let fixFailure = viewStore.fixFailure { + buildFailureView(failure: fixFailure) + .padding(.horizontal, 4) + } + } + } + .onAppear { viewStore.send(.appear) } + .onChange(of: viewStore.fixFailure) { + showFailurePopover = $0 != nil + } + .animation(.easeInOut(duration: 0.2), value: viewStore.isPanelDisplayed) + } + } + } + + @ViewBuilder + private func buildFixErrorButton(viewStore: FixErrorViewStore) -> some View { + let annotations = viewStore.errorAnnotationsAtCursorPosition + let rect = annotations.first(where: { $0.rect != nil })?.rect ?? nil + let annotationHeight = rect?.height ?? 16 + let iconSize = annotationHeight * 0.8 + + Group { + if !annotations.isEmpty { + ZStack { + Button(action: { + store.send(.fixErrorIssue(annotations)) + }) { + Image("FixError") + .resizable() + .scaledToFit() + .frame(width: iconSize, height: iconSize) + } + .buttonStyle(.plain) + } + } else { + Color.clear + .frame(width: 0, height: 0) + } + } + } + + @ViewBuilder + private func buildFailureView(failure: FixEditorErrorIssueFailure) -> some View { + let message: String = { + switch failure { + case .isReceivingMessage: "Copilot is still processing the last message. Please wait…" + } + }() + + Text(message) + .font(.system(size: 14)) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .cornerRadius(4) + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } +} diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index 6a7ea438..2c3dc199 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -16,6 +16,7 @@ enum Style { static let trafficLightButtonSize: Double = 12 static let codeReviewPanelWidth: Double = 550 static let codeReviewPanelHeight: Double = 450 + static let fixPanelToAnnotationSpacing: Double = 8 } extension Color { diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 6ad035fb..45ef1aeb 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -401,43 +401,12 @@ public struct CodeReviewLocationStrategy { for: originalLineNumber, originalLines: originalLines, currentLines: currentLines - ) // 1-based - // Calculate the character position for the start of the target line - var characterPosition = 0 - for i in 0 ..< currentLineNumber { - characterPosition += currentLines[i].count + 1 // +1 for newline character - } - - var range = CFRange(location: characterPosition, length: currentLines[currentLineNumber].count) - let rangeValue = AXValueCreate(AXValueType.cfRange, &range) - - var boundsValue: CFTypeRef? - let result = AXUIElementCopyParameterizedAttributeValue( - editor, - kAXBoundsForRangeParameterizedAttribute as CFString, - rangeValue!, - &boundsValue - ) - - if result == .success, - let bounds = boundsValue - { - var rect = CGRect.zero - let success = AXValueGetValue(bounds as! AXValue, AXValueType.cgRect, &rect) - - if success == true { - return ( - currentLineNumber, - CGRect( - x: rect.minX, - y: rect.minY, - width: rect.width, - height: rect.height - ) - ) - } + ) // 0-based + + guard let rect = LocationStrategyHelper.getLineFrame(currentLineNumber, in: editor, with: currentLines) else { + return (nil, nil) } - - return (nil, nil) + + return (currentLineNumber, rect) } } diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index ca2e52f4..8f464efe 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -61,6 +61,10 @@ actor WidgetWindowsController: NSObject { }.store(in: &cancellable) xcodeInspector.$focusedEditor.sink { [weak self] editor in + Task { @MainActor [weak self] in + self?.store.send(.fixErrorPanel(.onFocusedEditorChanged(editor))) + } + guard let editor else { return } Task { [weak self] in await self?.observe(toEditor: editor) } }.store(in: &cancellable) @@ -89,6 +93,9 @@ actor WidgetWindowsController: NSObject { // Observe state change of code review setupCodeReviewPanelObservers() + + // Observe state change of fix error + setupFixErrorPanelObservers() } private func setupCodeReviewPanelObservers() { @@ -132,6 +139,8 @@ private extension WidgetWindowsController { await hideSuggestionPanelWindow() } await adjustChatPanelWindowLevel() + + await updateFixErrorPanelWindowLocation() } guard currentApplicationProcessIdentifier != app.processIdentifier else { return } currentApplicationProcessIdentifier = app.processIdentifier @@ -231,6 +240,8 @@ private extension WidgetWindowsController { updateWindowLocation(animated: false, immediately: false) updateWindowOpacity(immediately: false) await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) + + await handleFixErrorEditorNotification(notification: notification) } } else { for await notification in merge(selectionRangeChange, scroll, valueChange) { @@ -245,6 +256,8 @@ private extension WidgetWindowsController { updateWindowLocation(animated: false, immediately: false) updateWindowOpacity(immediately: false) await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) + + await handleFixErrorEditorNotification(notification: notification) } } } @@ -1014,6 +1027,39 @@ public final class WidgetWindows { return it }() + @MainActor + lazy var fixErrorPanelWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init( + x: 0, + y: 0, + width: Style.panelWidth, + height: Style.panelHeight + ), + styleMask: .borderless, + backing: .buffered, + defer: true + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.hasShadow = true + it.level = widgetLevel(2) + it.contentView = NSHostingView( + rootView: FixErrorPanelView( + store: store.scope( + state: \.fixErrorPanelState, + action: \.fixErrorPanel + ) + ).environment(cursorPositionTracker) + ) + it.canBecomeKeyChecker = { true } + it.alphaValue = 0 + it.setIsVisible(false) + return it + }() + @MainActor lazy var chatPanelWindow = { let it = ChatPanelWindow( @@ -1072,6 +1118,7 @@ public final class WidgetWindows { toastWindow.orderFrontRegardless() sharedPanelWindow.orderFrontRegardless() suggestionPanelWindow.orderFrontRegardless() + fixErrorPanelWindow.orderFrontRegardless() if chatPanelWindow.level.rawValue > NSWindow.Level.normal.rawValue { chatPanelWindow.orderFrontRegardless() } diff --git a/BYOK.md b/Docs/BYOK.md similarity index 100% rename from BYOK.md rename to Docs/BYOK.md diff --git a/Docs/CustomInstructions.md b/Docs/CustomInstructions.md new file mode 100644 index 00000000..4f6fc24d --- /dev/null +++ b/Docs/CustomInstructions.md @@ -0,0 +1,183 @@ +# Use custom instructions in GitHub Copilot for Xcode + +Custom instructions enable you to define common guidelines and rules that automatically influence how AI generates code and handles other development tasks. Instead of manually including context in every chat prompt, specify custom instructions in a Markdown file to ensure consistent AI responses that align with your coding practices and project requirements. + +You can configure custom instructions to apply automatically to all chat requests or to specific files only. Alternatively, you can manually attach custom instructions to a specific chat prompt. + +> [!NOTE] +> Custom instructions are not taken into account for code completions as you type in the editor. + +## Type of instructions files + +GitHub Copilot for Xcode supports two types of Markdown-based instructions files: + +* A single [`.github/copilot-instructions.md`](#use-a-githubcopilotinstructionsmd-file) file + * Automatically applies to all chat requests in the workspace + * Stored within the workspace or global + +* One or more [`.instructions.md`](#use-instructionsmd-files) files + * Created for specific tasks or files + * Use `applyTo` frontmatter to define what files the instructions should be applied to + * Stored in the workspace + +Whitespace between instructions is ignored, so the instructions can be written as a single paragraph, each on a new line, or separated by blank lines for legibility. + +Reference specific context, such as files or URLs, in your instructions by using Markdown links. + +## Custom instructions examples + +The following examples demonstrate how to use custom instructions. For more community-contributed examples, see the [Awesome Copilot repository](https://github.com/github/awesome-copilot/tree/main). + +
+Example: General coding guidelines + +```markdown +--- +applyTo: "**" +--- +# Project general coding standards + +## Naming Conventions +- Use PascalCase for component names, interfaces, and type aliases +- Use camelCase for variables, functions, and methods +- Use ALL_CAPS for constants + +## Error Handling +- Use try/catch blocks for async operations +- Always log errors with contextual information +``` + +
+ +
+Example: Language-specific coding guidelines + +Notice how these instructions reference the general coding guidelines file. You can separate the instructions into multiple files to keep them organized and focused on specific topics. + +```markdown +--- +applyTo: "**/*.swift" +--- +# Project coding standards for Swift + +Apply the [general coding guidelines](./general-coding.instructions.md) to all code. + +## Swift Guidelines +- Use Swift for all new code +- Follow functional programming principles where possible +- Use interfaces for data structures and type definitions +- Use optional chaining (?.) and nullish coalescing (??) operators +``` + +
+ +
+Example: Documentation writing guidelines + +You can create instructions files for different types of tasks, including non-development activities like writing documentation. + +```markdown +--- +applyTo: "docs/**/*.md" +--- +# Project documentation writing guidelines + +## General Guidelines +- Write clear and concise documentation. +- Use consistent terminology and style. +- Include code examples where applicable. + +## Grammar +* Use present tense verbs (is, open) instead of past tense (was, opened). +* Write factual statements and direct commands. Avoid hypotheticals like "could" or "would". +* Use active voice where the subject performs the action. +* Write in second person (you) to speak directly to readers. + +## Markdown Guidelines +- Use headings to organize content. +- Use bullet points for lists. +- Include links to related resources. +- Use code blocks for code snippets. +``` + +
+ +## Use a `.github/copilot-instructions.md` file + +Define your custom instructions in a single `.github/copilot-instructions.md` Markdown file in the root of your workspace or globally. Copilot applies the instructions in this file automatically to all chat requests within this workspace. + +To create a `.github/copilot-instructions.md` file: + +1. **Open Settings > Advanced > Chat Settings** +1. To the right of "Copilot Instructions", click **Current Workspace** or **Global** to choose whether the custom instructions apply to the current workspace or all workspaces. +1. Describe your instructions by using natural language and in Markdown format. + +> [!NOTE] +> GitHub Copilot provides cross-platform support for the `.github/copilot-instructions.md` configuration file. This file is automatically detected and applied in VSCode, Visual Studio, 3rd-party IDEs, and GitHub.com. + +* **Workspace instructions files**: are only available within the workspace. +* **Global**: is available across multiple workspaces and is stored in the preferences. + +For more information, you can read the [How-to docs](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions?tool=xcode). + +## Use `.instructions.md` files + +Instead of using a single instructions file that applies to all chat requests, you can create multiple `.instructions.md` files that apply to specific file types or tasks. For example, you can create instructions files for different programming languages, frameworks, or project types. + +By using the `applyTo` frontmatter property in the instructions file header, you can specify a glob pattern to define which files the instructions should be applied to automatically. Instructions files are used when creating or modifying files and are typically not applied for read operations. + +Alternatively, you can manually attach an instructions file to a specific chat prompt by using the file picker. + +### Instructions file format + +Instructions files use the `.instructions.md` extension and have this structure: + +* **Header** (optional): YAML frontmatter + * `description`: Description shown on hover in Chat view + * `applyTo`: Glob pattern for automatic application (use `**` for all files) + +* **Body**: Instructions in Markdown format + +Example: + +```markdown +--- +applyTo: "**/*.swift" +--- +# Project coding standards for Swift +- Follow the Swift official guide for Swift. +- Always prioritize readability and clarity. +- Write clear and concise comments for each function. +- Ensure functions have descriptive names and include type hints. +- Maintain proper indentation (use 4 spaces for each level of indentation). +``` + +### Create an instructions file + +1. **Open Settings > Advanced > Chat Settings** + +1. To the right of "Custom Instructions", click **Create** to create a new `*.instructions.md` file. + +1. Enter a name for your instructions file. + +1. Author the custom instructions by using Markdown formatting. + + Specify the `applyTo` metadata property in the header to configure when the instructions should be applied automatically. For example, you can specify `applyTo: "**/*.swift"` to apply the instructions only to Swift files. + + To reference additional workspace files, use Markdown links (`[App](../App.swift)`). + +To modify or view an existing instructions file, click **Open Instructions Folder** to open the instructions file directory. + +## Tips for defining custom instructions + +* Keep your instructions short and self-contained. Each instruction should be a single, simple statement. If you need to provide multiple pieces of information, use multiple instructions. + +* For task or language-specific instructions, use multiple `*.instructions.md` files per topic and apply them selectively by using the `applyTo` property. + +* Store project-specific instructions in your workspace to share them with other team members and include them in your version control. + +* Reuse and reference instructions files in your [prompt files](PromptFiles.md) to keep them clean and focused, and to avoid duplicating instructions. + +## Related content + +* [Community contributed instructions, prompts, and chat modes](https://github.com/github/awesome-copilot) \ No newline at end of file diff --git a/Docs/AppIcon.png b/Docs/Images/AppIcon.png similarity index 100% rename from Docs/AppIcon.png rename to Docs/Images/AppIcon.png diff --git a/Docs/accessibility-permission-request.png b/Docs/Images/accessibility-permission-request.png similarity index 100% rename from Docs/accessibility-permission-request.png rename to Docs/Images/accessibility-permission-request.png diff --git a/Docs/accessibility-permission.png b/Docs/Images/accessibility-permission.png similarity index 100% rename from Docs/accessibility-permission.png rename to Docs/Images/accessibility-permission.png diff --git a/Docs/background-item.png b/Docs/Images/background-item.png similarity index 100% rename from Docs/background-item.png rename to Docs/Images/background-item.png diff --git a/Docs/background-permission-required.png b/Docs/Images/background-permission-required.png similarity index 100% rename from Docs/background-permission-required.png rename to Docs/Images/background-permission-required.png diff --git a/Docs/chat_dark.gif b/Docs/Images/chat_dark.gif similarity index 100% rename from Docs/chat_dark.gif rename to Docs/Images/chat_dark.gif diff --git a/Docs/connect-comm-bridge-failed.png b/Docs/Images/connect-comm-bridge-failed.png similarity index 100% rename from Docs/connect-comm-bridge-failed.png rename to Docs/Images/connect-comm-bridge-failed.png diff --git a/Docs/copilot-menu_dark.png b/Docs/Images/copilot-menu_dark.png similarity index 100% rename from Docs/copilot-menu_dark.png rename to Docs/Images/copilot-menu_dark.png diff --git a/Docs/demo.gif b/Docs/Images/demo.gif similarity index 100% rename from Docs/demo.gif rename to Docs/Images/demo.gif diff --git a/Docs/device-code.png b/Docs/Images/device-code.png similarity index 100% rename from Docs/device-code.png rename to Docs/Images/device-code.png diff --git a/Docs/dmg-open.png b/Docs/Images/dmg-open.png similarity index 100% rename from Docs/dmg-open.png rename to Docs/Images/dmg-open.png diff --git a/Docs/extension-permission.png b/Docs/Images/extension-permission.png similarity index 100% rename from Docs/extension-permission.png rename to Docs/Images/extension-permission.png diff --git a/Docs/macos-download-open-confirm.png b/Docs/Images/macos-download-open-confirm.png similarity index 100% rename from Docs/macos-download-open-confirm.png rename to Docs/Images/macos-download-open-confirm.png diff --git a/Docs/signin-button.png b/Docs/Images/signin-button.png similarity index 100% rename from Docs/signin-button.png rename to Docs/Images/signin-button.png diff --git a/Docs/update-message.png b/Docs/Images/update-message.png similarity index 100% rename from Docs/update-message.png rename to Docs/Images/update-message.png diff --git a/Docs/xcode-menu.png b/Docs/Images/xcode-menu.png similarity index 100% rename from Docs/xcode-menu.png rename to Docs/Images/xcode-menu.png diff --git a/Docs/xcode-menu_dark.png b/Docs/Images/xcode-menu_dark.png similarity index 100% rename from Docs/xcode-menu_dark.png rename to Docs/Images/xcode-menu_dark.png diff --git a/Docs/PromptFiles.md b/Docs/PromptFiles.md new file mode 100644 index 00000000..79f34caf --- /dev/null +++ b/Docs/PromptFiles.md @@ -0,0 +1,78 @@ +# Use prompt files in GitHub Copilot for Xcode + +Prompt files are Markdown files that define reusable prompts for common development tasks like generating code, performing code reviews, or scaffolding project components. They are standalone prompts that you can run directly in chat, enabling the creation of a library of standardized development workflows. + +They can include task-specific guidelines or reference custom instructions to ensure consistent execution. Unlike custom instructions that apply to all requests, prompt files are triggered on-demand for specific tasks. + +> [!NOTE] +> Prompt files are currently experimental and may change in future releases. + +GitHub Copilot for Xcode currently supports workspace prompt files, which are only available within the workspace and are stored in the `.github/prompts` folder of the workspace. + +## Prompt file examples + +The following examples demonstrate how to use prompt files. For more community-contributed examples, see the [Awesome Copilot repository](https://github.com/github/awesome-copilot/tree/main). + +
+Example: generate a Swift form component + + +```markdown +--- +description: 'Generate a new Swift sheet component' +--- +Your goal is to generate a new Swift sheet component. + +Ask for the sheet name and fields if not provided. + +Requirements for the form: +* Use sheet design system components: [design-system/Sheet.md](../docs/design-system/Sheet.md) +* Always define Swift types for your sheet data +* Create previews for the component +``` + +
+ +## Prompt file format + +Prompt files are Markdown files and use the `.prompt.md` extension and have this structure: + +* **Header** (optional): YAML frontmatter + * `description`: Short description of the prompt + +* **Body**: Prompt instructions in Markdown format + + Reference other workspace files, prompt files, or instruction files by using Markdown links. Use relative paths to reference these files, and ensure that the paths are correct based on the location of the prompt file. + + +## Create a prompt file + +1. **Open Settings > Advanced > Chat Settings** + +1. To the right of "Prompt Files", click **Create** to create a new `*.prompt.md` file. + +1. Enter a name for your prompt file. + +1. Author the chat prompt by using Markdown formatting. + + Within a prompt file, reference additional workspace files as Markdown links (`[App](../App.swift)`). + + You can also reference other `.prompt.md` files to create a hierarchy of prompts. You can also reference [instructions files](CustomInstructions.md) in the same way. + +To modify or view an existing prompt file, click **Open Prompts Folder** to open the prompts file directory. + +## Use a prompt file in chat + +In the Chat view, type `/` followed by the prompt file name in the chat input field. + +This option enables you to pass additional information in the chat input field. For example, `/create-swift-sheet`. + +## Tips for defining prompt files + +* Clearly describe what the prompt should accomplish and what output format is expected. +* Provide examples of the expected input and output to guide the AI's responses. +* Use Markdown links to reference custom instructions rather than duplicating guidelines in each prompt. + +## Related resources + +* [Community contributed instructions, prompts, and chat modes](https://github.com/github/awesome-copilot) \ No newline at end of file diff --git a/ExtensionService/Assets.xcassets/FixError.imageset/Contents.json b/ExtensionService/Assets.xcassets/FixError.imageset/Contents.json new file mode 100644 index 00000000..e88a9474 --- /dev/null +++ b/ExtensionService/Assets.xcassets/FixError.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "FixErrorLight.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "FixError.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/FixError.imageset/FixError.svg b/ExtensionService/Assets.xcassets/FixError.imageset/FixError.svg new file mode 100644 index 00000000..222ea9a8 --- /dev/null +++ b/ExtensionService/Assets.xcassets/FixError.imageset/FixError.svg @@ -0,0 +1,3 @@ + + + diff --git a/ExtensionService/Assets.xcassets/FixError.imageset/FixErrorLight.svg b/ExtensionService/Assets.xcassets/FixError.imageset/FixErrorLight.svg new file mode 100644 index 00000000..6932b7fb --- /dev/null +++ b/ExtensionService/Assets.xcassets/FixError.imageset/FixErrorLight.svg @@ -0,0 +1,3 @@ + + + diff --git a/README.md b/README.md index d9c550d1..c6b9553a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GitHub Copilot for Xcode +# 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 and a chat assistant to answer your coding questions. @@ -6,7 +6,7 @@ tool that helps you write code faster and smarter. Copilot for Xcode is an Xcode ## Chat GitHub Copilot Chat provides suggestions to your specific coding tasks via chat. -Chat of GitHub Copilot for Xcode +Chat of GitHub Copilot for Xcode ## Agent Mode @@ -23,7 +23,7 @@ Agent Mode integrates with Xcode's environment, creating a seamless development ## 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 +Code Completion of GitHub Copilot for Xcode ## Requirements @@ -44,20 +44,20 @@ You can receive auto-complete type suggestions from GitHub Copilot either by sta Drag `GitHub Copilot for Xcode` into the `Applications` folder:

- Screenshot of opened dmg + Screenshot of opened dmg

Updates can be downloaded and installed by the app. 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

1. A background item will be added to enable the GitHub Copilot for Xcode extension app to connect to the host app. This permission is usually automatically added when first launching the app.

- Screenshot of background item + Screenshot of background item

1. Three permissions are required for GitHub Copilot for Xcode to function properly: `Background`, `Accessibility`, and `Xcode Source Editor Extension`. For more details on why these permissions are required see [TROUBLESHOOTING.md](./TROUBLESHOOTING.md). @@ -65,7 +65,7 @@ You can receive auto-complete type suggestions from GitHub Copilot either by sta The first time the application is run the `Accessibility` permission should be requested:

- Screenshot of accessibility permission request + Screenshot of accessibility permission request

The `Xcode Source Editor Extension` permission needs to be enabled manually. Click @@ -74,7 +74,7 @@ You can receive auto-complete type suggestions from GitHub Copilot either by sta and enable `GitHub Copilot`:

- Screenshot of extension permission + Screenshot of extension permission

1. After granting the extension permission, open Xcode. Verify that the @@ -82,7 +82,7 @@ You can receive auto-complete type suggestions from GitHub Copilot either by sta menu.

- Screenshot of Xcode Editor GitHub Copilot menu item + Screenshot of Xcode Editor GitHub Copilot menu item

Keyboard shortcuts can be set for all menu items in the `Key Bindings` @@ -90,7 +90,7 @@ You can receive auto-complete type suggestions from GitHub Copilot either by sta 1. To sign into GitHub Copilot, click the `Sign in` button in the settings application. This will open a browser window and copy a code to the clipboard. Paste the code into the GitHub login page and authorize the application.

- Screenshot of sign-in popup + Screenshot of sign-in popup

1. To install updates, click `Check for Updates` from the menu item or in the @@ -115,13 +115,13 @@ You can receive auto-complete type suggestions from GitHub Copilot either by sta 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 + Screenshot of Xcode Editor GitHub Copilot menu item

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

- Screenshot of GitHub Copilot menu item + Screenshot of GitHub Copilot menu item

## How to use Code Completion diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 4c179941..68be3768 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -60,14 +60,14 @@ GitHub Copilot for Xcode requires background permission to connect with the host

- Background Permission + Background Permission

This permission is typically granted automatically when you first launch GitHub Copilot for Xcode. However, if you encounter connection issues, alerts, or errors as follows:

- Alert of Background Permission Required - Error connecting to the communication bridge + Alert of Background Permission Required + Error connecting to the communication bridge

Please ensure that this permission is enabled. You can manually navigate to the background permission setting based on your macOS version: diff --git a/Tool/Package.swift b/Tool/Package.swift index c0a2785f..7ebbf859 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -140,6 +140,7 @@ let package = Package( name: "SuggestionBasic", dependencies: [ "LanguageClient", + "AXExtension", .product(name: "Parsing", package: "swift-parsing"), .product(name: "CodableWrappers", package: "CodableWrappers"), ] diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index e5896c04..0da9335c 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -61,7 +61,7 @@ public struct ConversationReference: Codable, Equatable, Hashable { uri: String, status: Status?, kind: Kind, - referenceType: ReferenceType + referenceType: ReferenceType = .file ) { self.uri = uri self.status = status diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift index e2401935..355e04ee 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift @@ -38,6 +38,31 @@ public enum Reference: Codable, Equatable, Hashable { } } + private enum CodingKeys: String, CodingKey { + case type + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "file": + let fileRef = try FileReference(from: decoder) + self = .file(fileRef) + case "directory": + let directoryRef = try DirectoryReference(from: decoder) + self = .directory(directoryRef) + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown reference type: \(type)" + ) + ) + } + } + public static func from(_ ref: ConversationAttachedReference) -> Reference { switch ref { case .file(let fileRef): @@ -159,6 +184,11 @@ struct ConversationRatingParams: Codable { var source: ConversationSource? } +// MARK: Conversation templates +struct ConversationTemplatesParams: Codable { + var workspaceFolders: [WorkspaceFolder]? +} + // MARK: Conversation turn struct TurnCreateParams: Codable { var workDoneToken: String diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 33ab0701..e82c13ac 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -371,9 +371,13 @@ enum GitHubCopilotRequest { struct GetTemplates: GitHubCopilotRequestType { typealias Response = Array + + var params: ConversationTemplatesParams var request: ClientRequest { - .custom("conversation/templates", .hash([:]), ClientRequest.NullHandler) + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/templates", dict, ClientRequest.NullHandler) } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 8e3eaa0d..fae337cd 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -81,7 +81,7 @@ public protocol GitHubCopilotConversationServiceType { func rateConversation(turnId: String, rating: ConversationRating) async throws func copyCode(turnId: String, codeBlockIndex: Int, copyType: CopyKind, copiedCharacters: Int, totalCharacters: Int, copiedText: String) async throws func cancelProgress(token: String) async - func templates() async throws -> [ChatTemplate] + func templates(workspaceFolders: [WorkspaceFolder]?) async throws -> [ChatTemplate] func models() async throws -> [CopilotModel] func registerTools(tools: [LanguageModelToolInformation]) async throws } @@ -662,10 +662,11 @@ public final class GitHubCopilotService: } @GitHubCopilotSuggestionActor - public func templates() async throws -> [ChatTemplate] { + public func templates(workspaceFolders: [WorkspaceFolder]? = nil) async throws -> [ChatTemplate] { do { + let params = ConversationTemplatesParams(workspaceFolders: workspaceFolders) let response = try await sendRequest( - GitHubCopilotRequest.GetTemplates() + GitHubCopilotRequest.GetTemplates(params: params) ) return response } catch { diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift index 2acd130c..c795d5f2 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -97,7 +97,7 @@ public final class GitHubCopilotConversationService: ConversationServiceType { public func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]? { guard let service = await serviceLocator.getService(from: workspace) else { return nil } - return try await service.templates() + return try await service.templates(workspaceFolders: getWorkspaceFolders(workspace: workspace)) } public func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]? { diff --git a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift index e1ba7578..dce712c4 100644 --- a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift +++ b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift @@ -1,5 +1,15 @@ import SwiftUI +public enum TextEditorState { + case empty + case singleLine + case multipleLines(cursorAt: TextEditorLinePosition) +} + +public enum TextEditorLinePosition { + case first, last, middle +} + public struct AutoresizingCustomTextEditor: View { @Binding public var text: String public let font: NSFont @@ -7,6 +17,7 @@ public struct AutoresizingCustomTextEditor: View { public let maxHeight: Double public let minHeight: Double public let onSubmit: () -> Void + public let onTextEditorStateChanged: ((TextEditorState?) -> Void)? @State private var textEditorHeight: CGFloat @@ -15,7 +26,8 @@ public struct AutoresizingCustomTextEditor: View { font: NSFont, isEditable: Bool, maxHeight: Double, - onSubmit: @escaping () -> Void + onSubmit: @escaping () -> Void, + onTextEditorStateChanged: ((TextEditorState?) -> Void)? = nil ) { _text = text self.font = font @@ -23,6 +35,7 @@ public struct AutoresizingCustomTextEditor: View { self.maxHeight = maxHeight self.minHeight = Double(font.ascender + abs(font.descender) + font.leading) // Following the original padding: .top(1), .bottom(2) self.onSubmit = onSubmit + self.onTextEditorStateChanged = onTextEditorStateChanged // Initialize with font height + 3 as in the original logic _textEditorHeight = State(initialValue: self.minHeight) @@ -38,7 +51,8 @@ public struct AutoresizingCustomTextEditor: View { onSubmit: onSubmit, heightDidChange: { height in self.textEditorHeight = min(height, maxHeight) - } + }, + onTextEditorStateChanged: onTextEditorStateChanged ) .frame(height: textEditorHeight) .padding(.top, 1) @@ -58,6 +72,7 @@ public struct CustomTextEditor: NSViewRepresentable { public let isEditable: Bool public let onSubmit: () -> Void public let heightDidChange: (CGFloat) -> Void + public let onTextEditorStateChanged: ((TextEditorState?) -> Void)? public init( text: Binding, @@ -66,7 +81,8 @@ public struct CustomTextEditor: NSViewRepresentable { maxHeight: Double, minHeight: Double, onSubmit: @escaping () -> Void, - heightDidChange: @escaping (CGFloat) -> Void + heightDidChange: @escaping (CGFloat) -> Void, + onTextEditorStateChanged: ((TextEditorState?) -> Void)? = nil ) { _text = text self.font = font @@ -75,6 +91,7 @@ public struct CustomTextEditor: NSViewRepresentable { self.minHeight = minHeight self.onSubmit = onSubmit self.heightDidChange = heightDidChange + self.onTextEditorStateChanged = onTextEditorStateChanged } public func makeNSView(context: Context) -> NSScrollView { @@ -129,12 +146,56 @@ public extension CustomTextEditor { self.view = view } + private func getEditorState(textView: NSTextView) -> TextEditorState? { + let selectedRange = textView.selectedRange() + let text = textView.string + + guard !text.isEmpty else { return .empty } + + // Get actual visual lines + guard let layoutManager = textView.layoutManager, + let _ = textView.textContainer else { + return nil + } + let textRange = NSRange(location: 0, length: text.count) + var lineCount = 0 + var cursorLineIndex: Int? + + // Ensure including wrapped line + layoutManager + .enumerateLineFragments( + forGlyphRange: layoutManager.glyphRange(forCharacterRange: textRange, actualCharacterRange: nil) + ) { (_, _, _, glyphRange, _) in + let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + + if selectedRange.location >= charRange.location && selectedRange.location <= NSMaxRange(charRange) { + cursorLineIndex = lineCount + } + + lineCount += 1 + } + + guard let cursorLineIndex else { return nil } + + guard lineCount > 1 else { return .singleLine } + + if cursorLineIndex == 0 { + return .multipleLines(cursorAt: .first) + } else if cursorLineIndex == lineCount - 1 { + return .multipleLines(cursorAt: .last) + } else { + return .multipleLines(cursorAt: .middle) + } + } + func calculateAndUpdateHeight(textView: NSTextView) { guard let layoutManager = textView.layoutManager, let textContainer = textView.textContainer else { return } + layoutManager.ensureLayout(for: textContainer) + let usedRect = layoutManager.usedRect(for: textContainer) // Add padding for text insets if needed @@ -166,7 +227,15 @@ public extension CustomTextEditor { // Update height after text changes calculateAndUpdateHeight(textView: textView) } - + + // Add selection change detection + public func textViewDidChangeSelection(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + + let editorState = getEditorState(textView: textView) + view.onTextEditorStateChanged?(editorState) + } + public func textView( _ textView: NSTextView, doCommandBy commandSelector: Selector @@ -193,4 +262,3 @@ public extension CustomTextEditor { } } } - diff --git a/Tool/Sources/SuggestionBasic/EditorInformation.swift b/Tool/Sources/SuggestionBasic/EditorInformation.swift index 8518b8b0..1897e413 100644 --- a/Tool/Sources/SuggestionBasic/EditorInformation.swift +++ b/Tool/Sources/SuggestionBasic/EditorInformation.swift @@ -1,14 +1,20 @@ import Foundation import Parsing +import AppKit +import AXExtension public struct EditorInformation { - public struct LineAnnotation { + public struct LineAnnotation: Equatable { public var type: String - public var line: Int + public var line: Int // 1-Based public var message: String + public var originalAnnotation: String + public var rect: CGRect? = nil + + public var isError: Bool { type == "Error" } } - public struct SourceEditorContent { + public struct SourceEditorContent: Equatable { /// The content of the source editor. public var content: String /// The content of the source editor in lines. Every line should ends with `\n`. @@ -44,14 +50,18 @@ public struct EditorInformation { selections: [CursorRange], cursorPosition: CursorPosition, cursorOffset: Int, - lineAnnotations: [String] + lineAnnotationElements: [AXUIElement] ) { self.content = content self.lines = lines self.selections = selections self.cursorPosition = cursorPosition self.cursorOffset = cursorOffset - self.lineAnnotations = lineAnnotations.map(EditorInformation.parseLineAnnotation) + self.lineAnnotations = lineAnnotationElements.map { + var parsedLineAnnotation = EditorInformation.parseLineAnnotation($0.description) + parsedLineAnnotation.rect = $0.rect + return parsedLineAnnotation + } } } @@ -153,14 +163,15 @@ public struct EditorInformation { return LineAnnotation( type: type.trimmingCharacters(in: .whitespacesAndNewlines), line: line, - message: message.trimmingCharacters(in: .whitespacesAndNewlines) + message: message.trimmingCharacters(in: .whitespacesAndNewlines), + originalAnnotation: annotation ) } do { return try lineAnnotationParser.parse(annotation[...]) } catch { - return .init(type: "", line: 0, message: annotation) + return .init(type: "", line: 0, message: annotation, originalAnnotation: annotation) } } } diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 4b64d287..c1d1b415 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -81,11 +81,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { public var realtimeDocumentURL: URL? { appElement.realtimeDocumentURL } public var realtimeWorkspaceURL: URL? { - guard let window = appElement.focusedWindow, - window.identifier == "Xcode.WorkspaceWindow" - else { return nil } - - return WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) + appElement.realtimeWorkspaceURL } public var realtimeProjectURL: URL? { diff --git a/Tool/Sources/XcodeInspector/Helpers.swift b/Tool/Sources/XcodeInspector/Helpers.swift index 87b2313f..3899412b 100644 --- a/Tool/Sources/XcodeInspector/Helpers.swift +++ b/Tool/Sources/XcodeInspector/Helpers.swift @@ -25,6 +25,14 @@ extension AXUIElement { return WorkspaceXcodeWindowInspector.extractDocumentURL(windowElement: window) } + var realtimeWorkspaceURL: URL? { + guard let window = self.focusedWindow, + window.identifier == "Xcode.WorkspaceWindow" + else { return nil } + + return WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) + } + static func fromRunningApplication(_ runningApplication: NSRunningApplication) -> AXUIElement { let app = AXUIElementCreateApplication(runningApplication.processIdentifier) app.setMessagingTimeout(2) diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 8caf9813..601e095d 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -40,11 +40,14 @@ public class SourceEditor { appElement.realtimeDocumentURL } + public var realtimeWorkspaceURL: URL? { + appElement.realtimeWorkspaceURL + } + public func getLatestEvaluatedContent() -> Content { let selectionRange = element.selectedTextRange let (content, lines, selections) = cache.latest() let lineAnnotationElements = element.children.filter { $0.identifier == "Line Annotation" } - let lineAnnotations = lineAnnotationElements.map(\.description) return .init( content: content, @@ -52,7 +55,7 @@ public class SourceEditor { selections: selections, cursorPosition: selections.first?.start ?? .outOfScope, cursorOffset: selectionRange?.lowerBound ?? 0, - lineAnnotations: lineAnnotations + lineAnnotationElements: lineAnnotationElements ) } @@ -66,7 +69,6 @@ public class SourceEditor { let (lines, selections) = cache.get(content: content, selectedTextRange: selectionRange) let lineAnnotationElements = element.children.filter { $0.identifier == "Line Annotation" } - let lineAnnotations = lineAnnotationElements.map(\.description) axNotifications.send(.init(kind: .evaluatedContentChanged, element: element)) @@ -76,7 +78,7 @@ public class SourceEditor { selections: selections, cursorPosition: selections.first?.start ?? .outOfScope, cursorOffset: selectionRange?.lowerBound ?? 0, - lineAnnotations: lineAnnotations + lineAnnotationElements: lineAnnotationElements ) } From be64a90e99a35b0da96c97c4bcdaf717cf709533 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 3 Sep 2025 09:45:56 +0000 Subject: [PATCH 21/26] Release 0.42.0 --- CHANGELOG.md | 16 ++++++++++++++ .../Model.imageset/Contents.json | 16 ++++++++++++++ .../Model.imageset/ai-model-16.svg | 1 + Core/Sources/ChatService/ChatService.swift | 8 +++++-- .../CreateCustomCopilotFileView.swift | 8 +++---- .../CustomCopilotHelper.swift | 4 ++-- .../HostApp/BYOKSettings/ModelSheet.swift | 5 +++++ Core/Sources/HostApp/HostApp.swift | 4 ++-- ...idgetWindowsController+FixErrorPanel.swift | 2 +- ReleaseNotes.md | 21 ++++++++++--------- .../XcodeInspector/AppInstanceInspector.swift | 5 +++++ 11 files changed, 69 insertions(+), 21 deletions(-) create mode 100644 Copilot for Xcode/Assets.xcassets/Model.imageset/Contents.json create mode 100644 Copilot for Xcode/Assets.xcassets/Model.imageset/ai-model-16.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c099b50..1d3ee782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.42.0 - September 3, 2025 +### Added +- Support for Bring Your Own Keys (BYOK) with model providers including Azure, OpenAI, Anthropic, Gemini, Groq, and OpenRouter. See [BYOK.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/BYOK.md). +- Use the current selection as chat context. +- Add folders as chat context. +- Shortcut to quickly fix errors in Xcode. +- Support for custom instruction files at `.github/instructions/*.instructions.md`. See [CustomInstructions.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/CustomInstructions.md). +- Support for prompt files at `.github/prompts/*.prompt.md`. See [PromptFiles.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/PromptFiles.md). +- Use ↑/↓ keys to reuse previous chat context in the chat view. + +### Changed +- Default chat mode is now set to “Agent”. + +### Fixed +- Cannot copy url from Safari browser to chat view. + ## 0.41.0 - August 14, 2025 ### Added - Code review feature. diff --git a/Copilot for Xcode/Assets.xcassets/Model.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/Model.imageset/Contents.json new file mode 100644 index 00000000..0923b9bd --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/Model.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ai-model-16.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Copilot for Xcode/Assets.xcassets/Model.imageset/ai-model-16.svg b/Copilot for Xcode/Assets.xcassets/Model.imageset/ai-model-16.svg new file mode 100644 index 00000000..8b7c28e2 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/Model.imageset/ai-model-16.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index ccb50619..cfbc068c 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -561,7 +561,7 @@ public final class ChatService: ChatServiceType, ObservableObject { skillSet: skillSet, references: lastUserRequest.references ?? [], model: model != nil ? model : lastUserRequest.model, - modelProviderName: modelProviderName != nil ? modelProviderName : lastUserRequest.modelProviderName, + modelProviderName: modelProviderName, agentMode: lastUserRequest.agentMode, userLanguage: lastUserRequest.userLanguage, turnId: id @@ -793,7 +793,11 @@ public final class ChatService: ChatServiceType, ObservableObject { } do { CopilotModelManager.switchToFallbackModel() - try await resendMessage(id: progress.turnId, model: fallbackModel.id) + try await resendMessage( + id: progress.turnId, + model: fallbackModel.id, + modelProviderName: nil + ) } catch { Logger.gitHubCopilot.error(error) resetOngoingRequest() diff --git a/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift b/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift index 843f83b1..d3b45f07 100644 --- a/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift +++ b/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift @@ -96,7 +96,7 @@ struct CreateCustomCopilotFileView: View { if fileAlreadyExists && !trimmedFileName.isEmpty { Image(systemName: "xmark.circle.fill") .foregroundColor(.red) - Text("'.github/\(promptType.directoryName)/\(trimmedFileName).\(promptType.fileExtension)' already exists") + Text("'.github/\(promptType.directoryName)/\(trimmedFileName)\(promptType.fileExtension)' already exists") .font(.caption) .foregroundColor(.red) .lineLimit(2) @@ -110,7 +110,7 @@ struct CreateCustomCopilotFileView: View { .font(.caption) .foregroundColor(.secondary) } else { - Text(".github/\(promptType.directoryName)/\(trimmedFileName).\(promptType.fileExtension)") + Text(".github/\(promptType.directoryName)/\(trimmedFileName)\(promptType.fileExtension)") .font(.caption) .foregroundColor(.secondary) .lineLimit(2) @@ -165,7 +165,7 @@ struct CreateCustomCopilotFileView: View { if FileManager.default.fileExists(atPath: filePath.path) { await MainActor.run { self.fileAlreadyExists = true - toast("\(promptType.displayName) '\(trimmedFileName).\(promptType.fileExtension)' already exists", .warning) + toast("\(promptType.displayName) '\(trimmedFileName)\(promptType.fileExtension)' already exists", .warning) } return } @@ -179,7 +179,7 @@ struct CreateCustomCopilotFileView: View { try promptType.defaultTemplate.write(to: filePath, atomically: true, encoding: .utf8) await MainActor.run { - toast("Created \(promptType.rawValue) file '\(trimmedFileName).\(promptType.fileExtension)'", .info) + toast("Created \(promptType.rawValue) file '\(trimmedFileName)\(promptType.fileExtension)'", .info) NSWorkspace.shared.open(filePath) self.isOpen.wrappedValue = false } diff --git a/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift index 10eed005..072dd21f 100644 --- a/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift +++ b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift @@ -56,7 +56,7 @@ public enum PromptType: String, CaseIterable, Equatable { case .instructions: return "Configure `.github/instructions/*.instructions.md` files scoped to specific file patterns or tasks." case .prompt: - return "Configure `.github/prompts/*.prompt.md` files for reusable prompt templates." + return "Configure `.github/prompts/*.prompt.md` files for reusable prompts. Trigger with '/' commands in the Chat view." } } @@ -74,7 +74,7 @@ public enum PromptType: String, CaseIterable, Equatable { case .prompt: return """ --- - description: Tool Description + description: Prompt Description --- Define the task to achieve, including specific requirements, constraints, and success criteria. diff --git a/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift index 095e436a..4474dff4 100644 --- a/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift +++ b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift @@ -150,6 +150,11 @@ struct ModelSheet: View { vision: supportVision ) ) + + if let originalModel = existingModel, trimmedModelId != originalModel.modelId { + // Delete existing model if the model ID has changed + try await dataManager.deleteModel(originalModel) + } try await dataManager.saveModel(modelParams) dismiss() diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index b9f39791..4b22edbb 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -27,13 +27,13 @@ public enum TabIndex: Int, CaseIterable { case .general: return "CopilotLogo" case .advanced: return "gearshape.2.fill" case .mcp: return "wrench.and.screwdriver.fill" - case .byok: return "cube" + case .byok: return "Model" } } var isSystemImage: Bool { switch self { - case .general: return false + case .general, .byok: return false default: return true } } diff --git a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FixErrorPanel.swift b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FixErrorPanel.swift index 5329ae1b..51f9b374 100644 --- a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FixErrorPanel.swift +++ b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FixErrorPanel.swift @@ -38,7 +38,7 @@ extension WidgetWindowsController { @MainActor func updateFixErrorPanelWindowLocation() async { guard let activeApp = await XcodeInspector.shared.safe.activeApplication, - activeApp.isXcode + (activeApp.isXcode || activeApp.isCopilotForXcodeExtensionService) else { hideFixErrorWindow() return diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 00538da1..3a34e1c1 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,19 +1,20 @@ -### GitHub Copilot for Xcode 0.41.0 +### GitHub Copilot for Xcode 0.42.0 **🚀 Highlights** -* Code review feature. -* Chat: Support for new model `GPT-5`. -* Agent mode: Added support for new tool to read web URL contents. -* Support disabling MCP when it's disabled by policy. -* Support for opening MCP logs directly from the MCP settings page. -* OAuth support for remote GitHub MCP server. +* Support for Bring Your Own Keys (BYOK) with model providers including Azure, OpenAI, Anthropic, Gemini, Groq, and OpenRouter. See [BYOK.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/BYOK.md). +* Support for custom instruction files at `.github/instructions/*.instructions.md`. See [CustomInstructions.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/CustomInstructions.md). +* Support for prompt files at `.github/prompts/*.prompt.md`. See [PromptFiles.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/PromptFiles.md). +* Default chat mode is now set to “Agent”. + **💪 Improvements** -* Performance: Improved instant-apply speed for edit_file tool. +* Use the current selection as chat context. +* Add folders as chat context. +* Shortcut to quickly fix errors in Xcode. +* Use ↑/↓ keys to reuse previous chat context in the chat view. **🛠️ Bug Fixes** -* Chat Agent repeatedly reverts its own changes when editing the same file. -* Performance: Avoid chat panel being stuck when sending a large text for chat. +* Cannot copy url from Safari browser to chat view. diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift index b1caae3e..3bb2f76e 100644 --- a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -22,6 +22,11 @@ public class AppInstanceInspector: ObservableObject { guard !runningApplication.isTerminated else { return false } return runningApplication.isXcode } + + public var isCopilotForXcodeExtensionService: Bool { + guard !runningApplication.isTerminated else { return false } + return runningApplication.isCopilotForXcodeExtensionService + } public var isExtensionService: Bool { guard !runningApplication.isTerminated else { return false } From b3fe4dd969dfd61d29f558b5c24d72ac64200683 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 4 Sep 2025 07:06:56 +0000 Subject: [PATCH 22/26] Release 0.43.0 --- CHANGELOG.md | 4 ++++ Tool/Sources/SharedUIComponents/CustomTextEditor.swift | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d3ee782..9554f9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.43.0 - September 4, 2025 +### Fixed +- Cannot type non-Latin characters in the chat input field. + ## 0.42.0 - September 3, 2025 ### Added - Support for Bring Your Own Keys (BYOK) with model providers including Azure, OpenAI, Anthropic, Gemini, Groq, and OpenRouter. See [BYOK.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/BYOK.md). diff --git a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift index dce712c4..d7cd5ec4 100644 --- a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift +++ b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift @@ -232,6 +232,13 @@ public extension CustomTextEditor { public func textViewDidChangeSelection(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } + // Prevent layout interference during input method composition (Chinese, Japanese, Korean) + // when text view is empty, layout calculations on marked text can trigger NSSecureCoding warnings + // which can disrupt composition + if textView.hasMarkedText() { + return + } + let editorState = getEditorState(textView: textView) view.onTextEditorStateChanged?(editorState) } From 43810343be2cf90ee4a3d571cc3371fb6582c7aa Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 15 Sep 2025 09:07:09 +0000 Subject: [PATCH 23/26] Pre-release 0.43.139 --- Copilot for Xcode.xcodeproj/project.pbxproj | 6 +- Copilot for Xcode/App.swift | 18 +- .../ConversationTab/ChatContextMenu.swift | 1 + Core/Sources/ConversationTab/ChatPanel.swift | 36 +- Core/Sources/ConversationTab/FilePicker.swift | 9 +- .../ModelPicker/ChatModePicker.swift | 5 +- .../ModelPicker/ModeButton.swift | 6 +- .../ModelPicker/ModelPicker.swift | 20 +- Core/Sources/ConversationTab/Styles.swift | 4 - .../TerminalViews/RunInTerminalToolView.swift | 13 +- .../ConversationTab/Views/BotMessage.swift | 14 +- .../CodeReviewRound/CodeReviewMainView.swift | 12 +- .../FileSelectionSection.swift | 22 +- .../ReviewResultsSection.swift | 12 +- .../ReviewSummarySection.swift | 5 +- .../Views/ConversationAgentProgressView.swift | 16 +- .../Views/ConversationProgressStepView.swift | 11 +- .../Views/FunctionMessage.swift | 1 + .../Views/ImageReferenceItemView.swift | 3 +- .../Views/ThemedMarkdownText.swift | 24 +- .../ConversationTab/Views/UserMessage.swift | 7 +- .../Views/WorkingSetView.swift | 13 +- .../VisionViews/HoverableImageView.swift | 3 +- .../VisionViews/VisionMenuView.swift | 13 +- .../AdvancedSettings/ChatSection.swift | 160 +++++- Core/Sources/HostApp/HostApp.swift | 6 +- Core/Sources/HostApp/MCPConfigView.swift | 71 ++- Core/Sources/HostApp/TabContainer.swift | 5 +- Core/Sources/Service/XPCService.swift | 48 ++ .../SuggestionWidget/ChatPanelWindow.swift | 19 + .../ChatWindow/ChatHistoryView.swift | 10 +- .../ChatWindow/ChatLoginView.swift | 15 +- .../ChatWindow/ChatNoAXPermissionView.swift | 5 +- .../ChatWindow/ChatNoSubscriptionView.swift | 8 +- .../ChatWindow/ChatNoWorkspaceView.swift | 6 +- .../ChatWindow/CopilotIntroView.swift | 4 +- .../SuggestionWidget/ChatWindowView.swift | 16 +- ...idgetWindowsController+FixErrorPanel.swift | 55 ++- .../FixErrorPanelFeature.swift | 107 ++-- .../SuggestionWidget/FixErrorPanelView.swift | 9 +- Core/Sources/SuggestionWidget/Styles.swift | 2 +- .../SuggestionPanelContent/ErrorPanel.swift | 2 + .../SuggestionPanelContent/WarningPanel.swift | 2 +- .../WidgetWindowsController.swift | 50 +- .../Contents.json | 38 ++ .../Sparkle.imageset/Contents.json | 3 + Server/package-lock.json | 80 ++- Server/package.json | 4 +- .../LanguageServer/GitHubCopilotRequest.swift | 26 + .../BYOK.swift} | 0 .../Conversation.swift} | 0 .../MCP.swift} | 0 .../MCPRegistry.swift | 459 ++++++++++++++++++ .../Message.swift} | 0 .../Telemetry.swift} | 0 .../LanguageServer/GitHubCopilotService.swift | 23 + .../HostAppActivator/HostAppActivator.swift | 10 +- Tool/Sources/Preferences/Keys.swift | 8 + Tool/Sources/Preferences/UserDefaults.swift | 5 + .../CopilotIntroSheet.swift | 2 +- .../CopilotMessageHeader.swift | 7 +- .../SharedUIComponents/CopyButton.swift | 2 +- .../SharedUIComponents/CustomTextEditor.swift | 18 +- .../SharedUIComponents/DownvoteButton.swift | 7 +- .../SharedUIComponents/InsertButton.swift | 7 +- .../SharedUIComponents/InstructionView.swift | 16 +- .../MixedStateCheckbox.swift | 12 +- .../ScaledComponent/FontScaleManager.swift | 104 ++++ .../ScaledComponent/ScaledFont.swift | 82 ++++ .../ScaledComponent/ScaledFrame.swift | 46 ++ .../ScaledComponent/ScaledModifier.swift | 66 +++ .../SharedUIComponents/UpvoteButton.swift | 7 +- .../SuggestionBasic/EditorInformation.swift | 2 +- .../XPCShared/XPCExtensionService.swift | 64 +++ .../XPCShared/XPCServiceProtocol.swift | 3 + 75 files changed, 1688 insertions(+), 287 deletions(-) create mode 100644 ExtensionService/Assets.xcassets/FixErrorBackgroundColor.colorset/Contents.json rename Tool/Sources/GitHubCopilotService/LanguageServer/{GitHubCopilotRequest+BYOK.swift => GitHubCopilotRequestTypes/BYOK.swift} (100%) rename Tool/Sources/GitHubCopilotService/LanguageServer/{GitHubCopilotRequest+Conversation.swift => GitHubCopilotRequestTypes/Conversation.swift} (100%) rename Tool/Sources/GitHubCopilotService/LanguageServer/{GitHubCopilotRequest+MCP.swift => GitHubCopilotRequestTypes/MCP.swift} (100%) create mode 100644 Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift rename Tool/Sources/GitHubCopilotService/LanguageServer/{GithubCopilotRequest+Message.swift => GitHubCopilotRequestTypes/Message.swift} (100%) rename Tool/Sources/GitHubCopilotService/LanguageServer/{GitHubCopilotRequest+Telemetry.swift => GitHubCopilotRequestTypes/Telemetry.swift} (100%) create mode 100644 Tool/Sources/SharedUIComponents/ScaledComponent/FontScaleManager.swift create mode 100644 Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFont.swift create mode 100644 Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift create mode 100644 Tool/Sources/SharedUIComponents/ScaledComponent/ScaledModifier.swift diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 844f7d7c..2cd753c1 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -188,8 +188,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server"; path = "Server/node_modules/@github/copilot-language-server/native/darwin-x64/copilot-language-server"; sourceTree = SOURCE_ROOT; }; - 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server-arm64"; path = "Server/node_modules/@github/copilot-language-server/native/darwin-arm64/copilot-language-server-arm64"; sourceTree = SOURCE_ROOT; }; + 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server"; path = "Server/node_modules/@github/copilot-language-server-darwin-x64/copilot-language-server"; sourceTree = SOURCE_ROOT; }; + 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server-arm64"; path = "Server/node_modules/@github/copilot-language-server-darwin-arm64/copilot-language-server-arm64"; sourceTree = SOURCE_ROOT; }; 3E5DB74F2D6B88EE00418952 /* ReleaseNotes.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ReleaseNotes.md; sourceTree = ""; }; 424ACA202CA4697200FA20F2 /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = ""; }; 427C63272C6E868B000E557C /* OpenSettingsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSettingsCommand.swift; sourceTree = ""; }; @@ -719,7 +719,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=/usr/local/bin:/opt/homebrew/bin:$PATH\n\nnpm -C Server install\ncp Server/node_modules/@github/copilot-language-server/native/darwin-arm64/copilot-language-server Server/node_modules/@github/copilot-language-server/native/darwin-arm64/copilot-language-server-arm64\n\necho \"Build and copy webview js/html files as the bundle resources\"\nnpm -C Server run build\nmkdir -p \"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/webViewDist\"\ncp -R Server/dist/* \"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/webViewDist/\"\n"; + shellScript = "export PATH=/usr/local/bin:/opt/homebrew/bin:$PATH\n\nnpm -C Server install --force\ncp Server/node_modules/@github/copilot-language-server-darwin-arm64/copilot-language-server Server/node_modules/@github/copilot-language-server-darwin-arm64/copilot-language-server-arm64\n\necho \"Build and copy webview js/html files as the bundle resources\"\nnpm -C Server run build\nmkdir -p \"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/webViewDist\"\ncp -R Server/dist/* \"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/webViewDist/\"\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift index 70cb846e..c2efb015 100644 --- a/Copilot for Xcode/App.swift +++ b/Copilot for Xcode/App.swift @@ -20,7 +20,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { enum LaunchMode { case chat case settings - case mcp + case tools case byok } @@ -49,8 +49,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { let launchArgs = CommandLine.arguments if launchArgs.contains("--settings") { return .settings - } else if launchArgs.contains("--mcp") { - return .mcp + } else if launchArgs.contains("--tools") { + return .tools } else if launchArgs.contains("--byok") { return .byok } else { @@ -62,8 +62,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { switch mode { case .settings: openSettings() - case .mcp: - openMCPSettings() + case .tools: + openToolsSettings() case .byok: openBYOKSettings() case .chat: @@ -86,10 +86,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - private func openMCPSettings() { + private func openToolsSettings() { DispatchQueue.main.async { activateAndOpenSettings() - hostAppStore.send(.setActiveTab(.mcp)) + hostAppStore.send(.setActiveTab(.tools)) } } @@ -193,13 +193,13 @@ struct CopilotForXcodeApp: App { } DistributedNotificationCenter.default().addObserver( - forName: .openMCPSettingsWindowRequest, + forName: .openToolsSettingsWindowRequest, object: nil, queue: .main ) { _ in DispatchQueue.main.async { activateAndOpenSettings() - hostAppStore.send(.setActiveTab(.mcp)) + hostAppStore.send(.setActiveTab(.tools)) } } diff --git a/Core/Sources/ConversationTab/ChatContextMenu.swift b/Core/Sources/ConversationTab/ChatContextMenu.swift index 3e1ac095..cf1e5f76 100644 --- a/Core/Sources/ConversationTab/ChatContextMenu.swift +++ b/Core/Sources/ConversationTab/ChatContextMenu.swift @@ -79,6 +79,7 @@ struct ChatContextMenu: View { store.send(.customCommandButtonTapped(command)) }) { Text(command.name) + .scaledFont(.body) } } } diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index f4a9edd8..a84be0c6 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -271,6 +271,7 @@ struct ChatPanelMessages: View { } }) { Image(systemName: "chevron.down") + .scaledFrame(width: 14, height: 14) .padding(8) .background { Circle() @@ -420,10 +421,11 @@ struct ChatFollowUp: View { }) { HStack(spacing: 4) { Image(systemName: "sparkles") + .scaledFont(.body) .foregroundColor(.blue) Text(followUp.message) - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) .foregroundColor(.blue) } } @@ -490,8 +492,10 @@ struct ChatPanelInputArea: View { Group { if #available(macOS 13.0, *) { Image(systemName: "eraser.line.dashed.fill") + .scaledFont(.body) } else { Image(systemName: "trash.fill") + .scaledFont(.body) } } .padding(6) @@ -527,6 +531,12 @@ struct ChatPanelInputArea: View { @State private var isCCRFFEnabled: Bool @State private var cancellables = Set() + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + init( chat: StoreOf, focusedField: FocusState.Binding @@ -588,7 +598,7 @@ struct ChatPanelInputArea: View { Text("Edit files in your workspace in agent mode") : Text("Ask Copilot or type / for commands") } - .font(.system(size: 14)) + .scaledFont(size: 14) .foregroundColor(Color(nsColor: .placeholderTextColor)) .padding(8) .padding(.horizontal, 4) @@ -597,7 +607,7 @@ struct ChatPanelInputArea: View { HStack(spacing: 0) { AutoresizingCustomTextEditor( text: $chat.typedMessage, - font: .systemFont(ofSize: 14), + font: .systemFont(ofSize: 14 * fontScale), isEditable: true, maxHeight: 400, onSubmit: { @@ -750,6 +760,7 @@ struct ChatPanelInputArea: View { submitChatMessage() }) { Image(systemName: "paperplane.fill") + .scaledFont(.body) .padding(4) } .keyboardShortcut(KeyEquivalent.return, modifiers: []) @@ -761,6 +772,7 @@ struct ChatPanelInputArea: View { chat.send(.stopRespondingButtonTapped) }) { Image(systemName: "stop.circle") + .scaledFont(.body) .padding(4) } } @@ -781,6 +793,9 @@ struct ChatPanelInputArea: View { var codeReviewIcon: some View { Image("codeReview") + .resizable() + .scaledToFit() + .scaledFrame(width: 14, height: 14) .padding(6) } @@ -809,6 +824,7 @@ struct ChatPanelInputArea: View { } label: { codeReviewIcon } + .scaledFont(.body) .opacity(isRequestingCodeReview ? 0 : 1) .help("Code Review") } @@ -882,14 +898,14 @@ struct ChatPanelInputArea: View { focusedField.wrappedValue = .textField } } - }) { + }) { Image(systemName: "paperclip") .resizable() .aspectRatio(contentMode: .fill) - .frame(width: 16, height: 16) - .padding(4) + .scaledFrame(width: 16, height: 16) + .scaledPadding(4) .foregroundColor(.primary.opacity(0.85)) - .font(Font.system(size: 11, weight: .semibold)) + .scaledFont(size: 11, weight: .semibold) } .buttonStyle(HoverButtonStyle(padding: 0)) .help("Add Context") @@ -929,7 +945,7 @@ struct ChatPanelInputArea: View { Button(action: { chat.send(.removeReference(ref)) }) { Image(systemName: "xmark") .resizable() - .frame(width: 8, height: 8) + .scaledFrame(width: 8, height: 8) .foregroundColor(.primary.opacity(0.85)) .padding(4) } @@ -948,7 +964,7 @@ struct ChatPanelInputArea: View { drawFileIcon(url, isDirectory: isDirectory) .resizable() .scaledToFit() - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) .foregroundColor(.primary.opacity(0.85)) .padding(4) .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) @@ -976,7 +992,7 @@ struct ChatPanelInputArea: View { ? .secondary : .primary.opacity(0.85) ) - .font(.body) + .scaledFont(.body) .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) .help(url.getPathRelativeToHome()) } diff --git a/Core/Sources/ConversationTab/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift index 2b3fba9d..e6eebc2b 100644 --- a/Core/Sources/ConversationTab/FilePicker.swift +++ b/Core/Sources/ConversationTab/FilePicker.swift @@ -13,6 +13,7 @@ public struct FilePicker: View { @State private var searchText = "" @State private var selectedId: Int = 0 @State private var localMonitor: Any? = nil + @AppStorage(\.chatFontSize) var chatFontSize // Only showup direct sub directories private var defaultReferencesForDisplay: [ConversationAttachedReference]? { @@ -76,6 +77,7 @@ public struct FilePicker: View { .foregroundColor(.secondary) TextField("Search files...", text: $searchText) + .scaledFont(.body) .textFieldStyle(PlainTextFieldStyle()) .foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor)) .focused($isSearchBarFocused) @@ -92,6 +94,7 @@ public struct FilePicker: View { } }) { Image(systemName: "xmark.circle.fill") + .scaledFont(.body) .foregroundColor(.secondary) } .buttonStyle(HoverButtonStyle()) @@ -205,20 +208,20 @@ struct FileRowView: View { drawFileIcon(ref.url, isDirectory: ref.isDirectory) .resizable() .scaledToFit() - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) .hoverSecondaryForeground(isHovered: selectedId == id) .padding(.leading, 4) HStack(spacing: 4) { Text(ref.displayName) - .font(.body) + .scaledFont(.body) .hoverPrimaryForeground(isHovered: selectedId == id) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) Text(ref.relativePath) - .font(.caption) + .scaledFont(.caption) .hoverSecondaryForeground(isHovered: selectedId == id) .lineLimit(1) .truncationMode(.middle) diff --git a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift b/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift index 94cd8051..44d8c8cc 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift @@ -3,6 +3,7 @@ import Persist import ConversationServiceProvider import GitHubCopilotService import Combine +import SharedUIComponents public extension Notification.Name { static let gitHubCopilotChatModeDidChange = Notification @@ -70,8 +71,8 @@ public struct ChatModePicker: View { } ) } - .padding(1) - .frame(height: 20, alignment: .topLeading) + .scaledPadding(1) + .scaledFrame(height: 20, alignment: .topLeading) .background(.primary.opacity(0.1)) .cornerRadius(5) .padding(4) diff --git a/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift b/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift index b204e04c..53106ba2 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedUIComponents public struct ModeButton: View { let title: String @@ -11,8 +12,9 @@ public struct ModeButton: View { public var body: some View { Button(action: action) { Text(title) - .padding(.horizontal, 6) - .padding(.vertical, 0) + .scaledFont(.body) + .scaledPadding(.horizontal, 6) + .scaledPadding(.vertical, 0) .frame(maxHeight: .infinity, alignment: .center) .background(isSelected ? activeBackground : Color.clear) .foregroundColor(isSelected ? activeTextColor : inactiveTextColor) diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift index 396f9a26..c5a77eb4 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift @@ -24,6 +24,12 @@ struct ModelPicker: View { @State var isMCPFFEnabled: Bool @State private var cancellables = Set() + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } let minimumPadding: Int = 48 let attributes: [NSAttributedString.Key: NSFont] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] @@ -186,7 +192,7 @@ struct ModelPicker: View { // Model picker menu component private var modelPickerMenu: some View { - Menu(selectedModel?.displayName ?? selectedModel?.modelName ?? "") { + Menu { // Group models by premium status let premiumModels = copilotModels.filter { $0.billing?.isPremium == true @@ -211,10 +217,14 @@ struct ModelPicker: View { if standardModels.isEmpty { Link("Add Premium Models", destination: URL(string: "https://aka.ms/github-copilot-upgrade-plan")!) } + } label: { + Text(selectedModel?.displayName ?? selectedModel?.modelName ?? "") + // scaledFont not work here. workaround by direclty use the fontScale + .font(.system(size: 13 * fontScale)) } .menuStyle(BorderlessButtonMenuStyle()) .frame(maxWidth: labelWidth()) - .padding(4) + .scaledPadding(4) .background( RoundedRectangle(cornerRadius: 5) .fill(isHovered ? Color.gray.opacity(0.1) : Color.clear) @@ -253,7 +263,7 @@ struct ModelPicker: View { Group { if isMCPFFEnabled { Button(action: { - try? launchHostAppMCPSettings() + try? launchHostAppToolsSettings() }) { mcpIcon.foregroundColor(.primary.opacity(0.85)) } @@ -273,7 +283,7 @@ struct ModelPicker: View { Image(systemName: "wrench.and.screwdriver") .resizable() .scaledToFit() - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) .padding(4) .font(Font.system(size: 11, weight: .semibold)) } @@ -347,7 +357,7 @@ struct ModelPicker: View { let width = displayName.size( withAttributes: attributes ).width - return CGFloat(width + 20) + return CGFloat(width * fontScale + 20) } @MainActor diff --git a/Core/Sources/ConversationTab/Styles.swift b/Core/Sources/ConversationTab/Styles.swift index a0366ccd..f2309324 100644 --- a/Core/Sources/ConversationTab/Styles.swift +++ b/Core/Sources/ConversationTab/Styles.swift @@ -183,10 +183,6 @@ struct RoundedCorners: Shape { // Chat Message Styles extension View { - func chatMessageHeaderTextStyle() -> some View { - // semibold -> 600 - font(.system(size: 13, weight: .semibold)) - } func chatContextReferenceStyle(isCurrentEditor: Bool, r: Double) -> some View { background( diff --git a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift index c75e864e..9c827ac4 100644 --- a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift +++ b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift @@ -3,6 +3,7 @@ import XcodeInspector import ConversationServiceProvider import ComposableArchitecture import Terminal +import SharedUIComponents struct RunInTerminalToolView: View { let tool: AgentToolCall @@ -17,7 +18,6 @@ struct RunInTerminalToolView: View { @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark @AppStorage(\.chatFontSize) var chatFontSize - @AppStorage(\.chatCodeFont) var chatCodeFont @Environment(\.colorScheme) var colorScheme init(tool: AgentToolCall, chat: StoreOf) { @@ -71,11 +71,10 @@ struct RunInTerminalToolView: View { Image("Terminal") .resizable() .scaledToFit() - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) Text(self.title) - .font(.system(size: chatFontSize)) - .fontWeight(.semibold) + .scaledFont(.system(size: chatFontSize, weight: .semibold)) .foregroundStyle(.primary) .background(Color.clear) .frame(maxWidth: .infinity, alignment: .leading) @@ -119,11 +118,11 @@ struct RunInTerminalToolView: View { if command != nil { HStack(spacing: 4) { statusIcon - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) Text(command!) .textSelection(.enabled) - .font(.system(size: chatFontSize, design: .monospaced)) + .scaledFont(.system(size: chatFontSize, design: .monospaced)) .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(codeForegroundColor) @@ -151,10 +150,12 @@ struct RunInTerminalToolView: View { Button("Cancel") { chat.send(.toolCallCancelled(tool.id)) } + .scaledFont(.body) Button("Continue") { chat.send(.toolCallAccepted(tool.id)) } + .scaledFont(.body) .buttonStyle(BorderedProminentButtonStyle()) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index 70dcd3f2..98a6173a 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -81,9 +81,12 @@ struct BotMessage: View { }, label: { HStack(spacing: 4) { Image(systemName: isReferencesPresented ? "chevron.down" : "chevron.right") + .resizable() + .scaledToFit() + .scaledFrame(width: 14, height: 14) Text(MakeReferenceTitle(references: references)) - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) } .background { RoundedRectangle(cornerRadius: r - 4) @@ -113,7 +116,7 @@ struct BotMessage: View { .scaleEffect(0.7) Text("Working...") - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) .foregroundColor(.secondary) } } @@ -186,16 +189,19 @@ struct BotMessage: View { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) } + .scaledFont(.body) Button("Set as Extra System Prompt") { chat.send(.setAsExtraPromptButtonTapped(id)) } + .scaledFont(.body) Divider() Button("Delete") { chat.send(.deleteMessageButtonTapped(id)) } + .scaledFont(.body) } } } @@ -256,12 +262,12 @@ struct ReferenceList: View { drawFileIcon(reference.url, isDirectory: reference.isDirectory) .resizable() .scaledToFit() - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) Text(reference.fileName) .truncationMode(.middle) .lineLimit(1) .layoutPriority(1) - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift index a7f1b68b..8c0132f9 100644 --- a/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import ConversationServiceProvider import LanguageServerProtocol import SwiftUI +import SharedUIComponents // MARK: - Main View @@ -34,7 +35,7 @@ struct CodeReviewMainView: View { var helloMessageView: some View { Text(Self.HelloMessage) - .font(.system(size: chatFontSize)) + .scaledFont(.system(size: chatFontSize)) } var statusIcon: some View { @@ -44,16 +45,19 @@ struct CodeReviewMainView: View { ProgressView() .controlSize(.small) .frame(width: 16, height: 16) - .scaleEffect(0.7) + .scaledScaleEffect(0.7) case .completed: Image(systemName: "checkmark") .foregroundColor(.green) + .scaledFont(.body) case .error: Image(systemName: "xmark.circle") .foregroundColor(.red) + .scaledFont(.body) case .cancelled: Image(systemName: "slash.circle") .foregroundColor(.gray) + .scaledFont(.body) case .waitForConfirmation: EmptyView() case .accepted: @@ -70,10 +74,10 @@ struct CodeReviewMainView: View { default: HStack(spacing: 4) { statusIcon - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) Text("Running Code Review...") - .font(.system(size: chatFontSize)) + .scaledFont(.system(size: chatFontSize)) .foregroundColor(.secondary) Spacer() diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift index abfb3478..16921189 100644 --- a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift @@ -48,10 +48,11 @@ private struct FileSelectionHeader: View { HStack(alignment: .top, spacing: 6) { Image("Sparkle") .resizable() - .frame(width: 16, height: 16) + .scaledToFit() + .scaledFrame(width: 16, height: 16) Text("You’ve selected following \(fileCount) file(s) with code changes. Review them or unselect any files you don't need, then click Continue.") - .font(.system(size: chatFontSize)) + .scaledFont(.system(size: chatFontSize)) .multilineTextAlignment(.leading) } } @@ -69,12 +70,14 @@ private struct FileSelectionActions: View { } .buttonStyle(.bordered) .controlSize(.large) + .scaledFont(.body) Button("Continue") { store.send(.codeReview(.accept(id: roundId, selectedFiles: selectedFileUris))) } .buttonStyle(.borderedProminent) .controlSize(.large) + .scaledFont(.body) } } } @@ -89,6 +92,11 @@ private struct FileSelectionList: View { @State private var checkboxMixedState: CheckboxMixedState = .off @Binding var selectedFileUris: [DocumentUri] @AppStorage(\.chatFontSize) private var chatFontSize + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } private static let defaultVisibleFileCount = 5 @@ -141,9 +149,11 @@ private struct FileSelectionList: View { let selectedCount = selectedFileUris.count let totalCount = fileUris.count let title = "All (\(selectedCount)/\(totalCount))" + let font: NSFont = .systemFont(ofSize: chatFontSize * fontScale) return MixedStateCheckbox( title: title, + font: font, state: $checkboxMixedState ) { switch checkboxMixedState { @@ -181,12 +191,13 @@ private struct ExpandFilesButton: View { HStack(spacing: 2) { Image("chevron.down") .resizable() - .frame(width: 16, height: 16) + .scaledToFit() + .scaledFrame(width: 16, height: 16) Button(action: { isExpanded = true }) { Text("Show more") - .font(.system(size: chatFontSize)) .underline() + .scaledFont(.system(size: chatFontSize)) .lineSpacing(20) } .buttonStyle(PlainButtonStyle()) @@ -249,9 +260,10 @@ private struct FileSelectionRow: View { drawFileIcon(fileURL) .resizable() .scaledToFit() - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) Text(fileURL?.lastPathComponent ?? fileUri) + .scaledFont(.body) .lineLimit(1) .truncationMode(.middle) } diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift index d2e74d9d..67dcf282 100644 --- a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift @@ -60,7 +60,7 @@ private struct ReviewResultsHeader: View { var body: some View { HStack(spacing: 4) { Text("Reviewed Changes") - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) Spacer() } @@ -80,7 +80,8 @@ private struct ExpandReviewsButton: View { } label: { Image("chevron.down") .resizable() - .frame(width: 16, height: 16) + .scaledToFit() + .scaledFrame(width: 16, height: 16) } .buttonStyle(PlainButtonStyle()) @@ -149,7 +150,8 @@ private struct ReviewResultRowContent: View { HStack(spacing: 4) { drawFileIcon(fileURL) .resizable() - .frame(width: 16, height: 16) + .scaledToFit() + .scaledFrame(width: 16, height: 16) Button(action: { if hasComments { @@ -157,7 +159,7 @@ private struct ReviewResultRowContent: View { } }) { Text(fileURL.lastPathComponent) - .font(.system(size: chatFontSize)) + .scaledFont(.system(size: chatFontSize)) .foregroundColor(isHovered ? Color("ItemSelectedColor") : .primary) } .buttonStyle(PlainButtonStyle()) @@ -172,7 +174,7 @@ private struct ReviewResultRowContent: View { } Text(commentCountText) - .font(.system(size: chatFontSize - 1)) + .scaledFont(size: chatFontSize - 1) .lineSpacing(20) .foregroundColor(.secondary) diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift index e924f1bb..cb6548a8 100644 --- a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift @@ -1,5 +1,6 @@ import SwiftUI import ConversationServiceProvider +import SharedUIComponents struct ReviewSummarySection: View { var round: CodeReviewRound @@ -8,7 +9,7 @@ struct ReviewSummarySection: View { var body: some View { if round.status == .error, let errorMessage = round.error { Text(errorMessage) - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) } else if round.status == .completed, let request = round.request, let response = round.response { CompletedSummary(request: request, response: response) } else { @@ -39,6 +40,6 @@ struct CompletedSummary: View { } } - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) } } diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift index cf4b8a61..7114a5ee 100644 --- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Combine import ChatTab import ChatService +import SharedUIComponents struct ProgressAgentRound: View { let rounds: [AgentRound] @@ -66,11 +67,13 @@ struct ToolConfirmationView: View { Button("Cancel") { chat.send(.toolCallCancelled(tool.id)) } + .scaledFont(.body) Button("Continue") { chat.send(.toolCallAccepted(tool.id)) } .buttonStyle(BorderedProminentButtonStyle()) + .scaledFont(.body) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 4) @@ -96,12 +99,12 @@ struct GenericToolTitleView: View { HStack(spacing: 4) { Text(toolStatus) .textSelection(.enabled) - .font(.system(size: chatFontSize, weight: fontWeight)) + .scaledFont(size: chatFontSize, weight: fontWeight) .foregroundStyle(.primary) .background(Color.clear) Text(toolName) .textSelection(.enabled) - .font(.system(size: chatFontSize, weight: fontWeight)) + .scaledFont(size: chatFontSize, weight: fontWeight) .foregroundStyle(.primary) .padding(.vertical, 2) .padding(.horizontal, 4) @@ -129,16 +132,19 @@ struct ToolStatusItemView: View { case .running: ProgressView() .controlSize(.small) - .scaleEffect(0.7) + .scaledScaleEffect(0.7) case .completed: Image(systemName: "checkmark") .foregroundColor(.green.opacity(0.5)) + .scaledFont(.body) case .error: Image(systemName: "xmark.circle") .foregroundColor(.red.opacity(0.5)) + .scaledFont(.body) case .cancelled: Image(systemName: "slash.circle") .foregroundColor(.gray.opacity(0.5)) + .scaledFont(.body) case .waitForConfirmation: EmptyView() case .accepted: @@ -184,10 +190,10 @@ struct ToolStatusItemView: View { WithPerceptionTracking { HStack(spacing: 4) { statusIcon - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) progressTitleText - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) .lineLimit(1) Spacer() diff --git a/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift index 7b6c845e..b6c0f524 100644 --- a/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift @@ -3,6 +3,7 @@ import ConversationServiceProvider import ComposableArchitecture import Combine import ChatService +import SharedUIComponents struct ProgressStep: View { let steps: [ConversationProgressStep] @@ -30,17 +31,19 @@ struct StatusItemView: View { case .running: ProgressView() .controlSize(.small) - .frame(width: 16, height: 16) - .scaleEffect(0.7) + .scaledScaleEffect(0.7) case .completed: Image(systemName: "checkmark") .foregroundColor(.green) + .scaledFont(.body) case .failed: Image(systemName: "xmark.circle") .foregroundColor(.red) + .scaledFont(.body) case .cancelled: Image(systemName: "slash.circle") .foregroundColor(.gray) + .scaledFont(.body) } } } @@ -57,10 +60,10 @@ struct StatusItemView: View { WithPerceptionTracking { HStack(spacing: 4) { statusIcon - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) statusTitle - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) .lineLimit(1) Spacer() diff --git a/Core/Sources/ConversationTab/Views/FunctionMessage.swift b/Core/Sources/ConversationTab/Views/FunctionMessage.swift index 8fbd6ac9..4a883f97 100644 --- a/Core/Sources/ConversationTab/Views/FunctionMessage.swift +++ b/Core/Sources/ConversationTab/Views/FunctionMessage.swift @@ -73,6 +73,7 @@ struct FunctionMessage: View { } .buttonStyle(.borderedProminent) .controlSize(.regular) + .scaledFont(.body) .onHover { isHovering in if isHovering { NSCursor.pointingHand.push() diff --git a/Core/Sources/ConversationTab/Views/ImageReferenceItemView.swift b/Core/Sources/ConversationTab/Views/ImageReferenceItemView.swift index ef2ac6c7..bc57dc18 100644 --- a/Core/Sources/ConversationTab/Views/ImageReferenceItemView.swift +++ b/Core/Sources/ConversationTab/Views/ImageReferenceItemView.swift @@ -1,6 +1,7 @@ import ConversationServiceProvider import SwiftUI import Foundation +import SharedUIComponents struct ImageReferenceItemView: View { let item: ImageReference @@ -43,7 +44,7 @@ struct ImageReferenceItemView: View { Text(text) .lineLimit(1) - .font(.system(size: 12)) + .scaledFont(size: 12) .foregroundColor(.primary.opacity(0.85)) .truncationMode(.middle) .frame(width: textWidth, alignment: .leading) diff --git a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift index 3af730eb..b3b73599 100644 --- a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift +++ b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift @@ -5,6 +5,7 @@ import ChatService import ComposableArchitecture import SuggestionBasic import ChatTab +import SharedUIComponents public struct MarkdownActionProvider { let supportInsert: Bool @@ -23,8 +24,21 @@ public struct ThemedMarkdownText: View { @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark @AppStorage(\.chatFontSize) var chatFontSize - @AppStorage(\.chatCodeFont) var chatCodeFont @Environment(\.colorScheme) var colorScheme + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + var scaledChatCodeFont: NSFont { + .monospacedSystemFont(ofSize: 12 * fontScale, weight: .regular) + } + + var scaledChatFontSize: CGFloat { + chatFontSize * fontScale + } let text: String let context: MarkdownActionProvider @@ -46,8 +60,8 @@ public struct ThemedMarkdownText: View { Markdown(text) .textSelection(.enabled) .markdownTheme(.custom( - fontSize: chatFontSize, - codeFont: chatCodeFont.value.nsFont, + fontSize: scaledChatFontSize, + codeFont: scaledChatCodeFont, codeBlockBackgroundColor: { if syncCodeHighlightTheme { if colorScheme == .light, let color = codeBackgroundColorLight.value { @@ -126,6 +140,8 @@ struct MarkdownCodeBlockView: View { labelColor: codeBlockLabelColor, context: context ) + // Force recreation when font size changes + .id("code-block-\(codeFont.pointSize)") } else { ScrollView(.horizontal) { AsyncCodeBlockView( @@ -142,6 +158,8 @@ struct MarkdownCodeBlockView: View { labelColor: codeBlockLabelColor, context: context ) + // Force recreation when font size changes + .id("code-block-\(codeFont.pointSize)") } } } diff --git a/Core/Sources/ConversationTab/Views/UserMessage.swift b/Core/Sources/ConversationTab/Views/UserMessage.swift index e7dc2d32..858c802b 100644 --- a/Core/Sources/ConversationTab/Views/UserMessage.swift +++ b/Core/Sources/ConversationTab/Views/UserMessage.swift @@ -29,12 +29,13 @@ struct UserMessage: View { avatarImage .resizable() .aspectRatio(contentMode: .fill) - .frame(width: 24, height: 24) + .scaledFrame(width: 24, height: 24) .clipShape(Circle()) } else { Image(systemName: "person.circle") .resizable() - .frame(width: 24, height: 24) + .scaledToFit() + .scaledFrame(width: 24, height: 24) } } } @@ -54,7 +55,7 @@ struct UserMessage: View { AvatarView() Text(statusObserver.authStatus.username ?? "") - .chatMessageHeaderTextStyle() + .scaledFont(size: 13, weight: .semibold) .padding(2) Spacer() diff --git a/Core/Sources/ConversationTab/Views/WorkingSetView.swift b/Core/Sources/ConversationTab/Views/WorkingSetView.swift index 677c44dc..afec6179 100644 --- a/Core/Sources/ConversationTab/Views/WorkingSetView.swift +++ b/Core/Sources/ConversationTab/Views/WorkingSetView.swift @@ -62,6 +62,7 @@ struct WorkingSetHeader: View { ) -> some View { Button(action: action) { Text(text) + .scaledFont(.body) .foregroundColor(textForegroundColor) .padding(.horizontal, 6) .padding(.vertical, 2) @@ -81,7 +82,7 @@ struct WorkingSetHeader: View { HStack(spacing: 0) { Text(getTitle()) .foregroundColor(.secondary) - .font(.system(size: 13)) + .scaledFont(size: 13) Spacer() @@ -138,17 +139,17 @@ struct FileEditView: View { switch imageType { case .system(let name): Image(systemName: name) - .font(.system(size: 16, weight: .regular)) + .scaledFont(.system(size: 15, weight: .regular)) case .asset(let name): Image(name) .renderingMode(.template) .resizable() .aspectRatio(contentMode: .fit) - .frame(height: 16) + .scaledFrame(height: 16) } } .foregroundColor(.white) - .frame(width: 22) + .scaledFrame(width: 22) .frame(maxHeight: .infinity) } .buttonStyle(HoverButtonStyle(padding: 0, hoverColor: .white.opacity(0.2))) @@ -192,11 +193,11 @@ struct FileEditView: View { drawFileIcon(fileEdit.fileURL) .resizable() .scaledToFit() - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) .foregroundColor(.secondary) Text(fileEdit.fileURL.lastPathComponent) - .font(.system(size: 13)) + .scaledFont(size: 13) .foregroundColor(isHovering ? .white : Color("WorkingSetItemColor")) } diff --git a/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift b/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift index 56383d34..42ec5eb5 100644 --- a/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift +++ b/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift @@ -3,6 +3,7 @@ import ComposableArchitecture import Persist import ConversationServiceProvider import GitHubCopilotService +import SharedUIComponents public struct HoverableImageView: View { @Environment(\.colorScheme) var colorScheme @@ -53,7 +54,7 @@ public struct HoverableImageView: View { }) { Image(systemName: "xmark") .foregroundColor(.primary) - .font(.system(size: 13)) + .scaledFont(.system(size: 13)) .frame(width: 24, height: 24) .background( RoundedRectangle(cornerRadius: hoverableImageCornerRadius) diff --git a/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift b/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift index 8e18d40d..ca12c71a 100644 --- a/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift +++ b/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift @@ -91,24 +91,27 @@ public struct VisionMenuView: View { Image(systemName: "macwindow") Text("Capture Window") } + .scaledFont(.body) Button(action: { runScreenCapture(args: ["-s", "-c"]) }) { Image(systemName: "macwindow.and.cursorarrow") Text("Capture Selection") } + .scaledFont(.body) Button(action: { showImagePicker() }) { Image(systemName: "photo") Text("Attach File") } + .scaledFont(.body) } label: { Image(systemName: "photo.badge.plus") .resizable() .aspectRatio(contentMode: .fill) - .frame(width: 16, height: 16) - .padding(4) + .scaledFrame(width: 16, height: 16) + .scaledPadding(4) .foregroundColor(.primary.opacity(0.85)) - .font(Font.system(size: 11, weight: .semibold)) + .scaledFont(size: 11, weight: .semibold) } .buttonStyle(HoverButtonStyle(padding: 0)) .help("Attach images") @@ -122,9 +125,13 @@ public struct VisionMenuView: View { action: { NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture")!) }).keyboardShortcut(.defaultAction) + .scaledFont(.body) + Button("Deny", role: .cancel, action: {}) + .scaledFont(.body) } message: { Text("Grant access to this application in Privacy & Security settings, located in System Settings") + .scaledFont(.body) } } } diff --git a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift index cd441b84..036f98a2 100644 --- a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift @@ -3,26 +3,14 @@ import ComposableArchitecture import SwiftUI import Toast import XcodeInspector +import SharedUIComponents struct ChatSection: View { @AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode + @AppStorage(\.enableFixError) var enableFixError var body: some View { SettingsSection(title: "Chat Settings") { - // Auto Attach toggle - SettingsToggle( - title: "Auto-attach Chat Window to Xcode", - isOn: $autoAttachChatToXcode - ) - - Divider() - - // Response language picker - ResponseLanguageSetting() - .padding(SettingsToggle.defaultPadding) - - Divider() - // Copilot instructions - .github/copilot-instructions.md CopilotInstructionSetting() .padding(SettingsToggle.defaultPadding) @@ -38,6 +26,34 @@ struct ChatSection: View { // Custom Prompts - .github/prompts/*.prompt.md PromptFileSetting(promptType: .prompt) .padding(SettingsToggle.defaultPadding) + + Divider() + + // Auto Attach toggle + SettingsToggle( + title: "Auto-attach Chat Window to Xcode", + isOn: $autoAttachChatToXcode + ) + + Divider() + + // Fix error toggle + SettingsToggle( + title: "Quick fix for error", + isOn: $enableFixError + ) + + Divider() + + // Response language picker + ResponseLanguageSetting() + .padding(SettingsToggle.defaultPadding) + + Divider() + + // Font Size + FontSizeSetting() + .padding(SettingsToggle.defaultPadding) } } } @@ -102,6 +118,122 @@ struct ResponseLanguageSetting: View { } } +struct FontSizeSetting: View { + static let defaultSliderThumbRadius: CGFloat = Font.body.builtinSize + + @AppStorage(\.chatFontSize) var chatFontSize + @ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 100 + + @State private var sliderValue: Double = 0 + @State private var textWidth: CGFloat = 0 + @State private var sliderWidth: CGFloat = 0 + + @StateObject private var fontScaleManager: FontScaleManager = .shared + + var maxSliderValue: Double { + FontScaleManager.maxScale * 100 + } + + var minSliderValue: Double { + FontScaleManager.minScale * 100 + } + + var defaultSliderValue: Double { + FontScaleManager.defaultScale * 100 + } + + var sliderFontSize: Double { + chatFontSize * sliderValue / 100 + } + + var maxScaleFontSize: Double { + FontScaleManager.maxScale * chatFontSize + } + + var body: some View { + WithPerceptionTracking { + HStack { + VStack(alignment: .leading) { + Text("Font Size") + .font(.body) + Text("Use the slider to set the preferred size.") + .font(.footnote) + } + + Spacer() + + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 8) { + Text("A") + .font(.system(size: sliderFontSize)) + .frame(width: maxScaleFontSize) + + Slider(value: $sliderValue, in: minSliderValue...maxSliderValue, step: 10) { _ in + fontScaleManager.setFontScale(sliderValue / 100) + } + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + sliderWidth = geometry.size.width + } + } + ) + + Text("\(Int(sliderValue))%") + .font(.body) + .foregroundColor(.primary) + .frame(width: 40, alignment: .center) + } + .frame(height: maxScaleFontSize) + + Text("Default") + .font(.caption) + .foregroundColor(.primary) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + textWidth = geometry.size.width + } + } + ) + .padding(.leading, calculateDefaultMarkerXPosition() + 2) + .onHover { + if $0 { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + .onTapGesture { + fontScaleManager.resetFontScale() + } + } + .frame(width: 350, height: 35) + } + .onAppear { + sliderValue = fontScaleManager.currentScale * 100 + } + .onChange(of: fontScaleManager.currentScale) { + // Use rounded value for floating-point precision issue + sliderValue = round($0 * 10) / 10 * 100 + } + } + } + + private func calculateDefaultMarkerXPosition() -> CGFloat { + let sliderRange = maxSliderValue - minSliderValue + let normalizedPosition = (defaultSliderValue - minSliderValue) / sliderRange + + let usableWidth = sliderWidth - (Self.defaultSliderThumbRadius * 2) + + let markerPosition = Self.defaultSliderThumbRadius + (CGFloat(normalizedPosition) * usableWidth) + + return markerPosition - textWidth / 2 + maxScaleFontSize + } +} + struct CopilotInstructionSetting: View { @State var isGlobalInstructionsViewOpen = false @Environment(\.toast) var toast diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index 4b22edbb..e9d2253e 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -10,14 +10,14 @@ extension KeyboardShortcuts.Name { public enum TabIndex: Int, CaseIterable { case general = 0 case advanced = 1 - case mcp = 2 + case tools = 2 case byok = 3 var title: String { switch self { case .general: return "General" case .advanced: return "Advanced" - case .mcp: return "MCP" + case .tools: return "Tools" case .byok: return "Models" } } @@ -26,7 +26,7 @@ public enum TabIndex: Int, CaseIterable { switch self { case .general: return "CopilotLogo" case .advanced: return "gearshape.2.fill" - case .mcp: return "wrench.and.screwdriver.fill" + case .tools: return "wrench.and.screwdriver.fill" case .byok: return "Model" } } diff --git a/Core/Sources/HostApp/MCPConfigView.swift b/Core/Sources/HostApp/MCPConfigView.swift index df80423a..e6b1e88b 100644 --- a/Core/Sources/HostApp/MCPConfigView.swift +++ b/Core/Sources/HostApp/MCPConfigView.swift @@ -16,43 +16,62 @@ struct MCPConfigView: View { @State private var lastModificationDate: Date? = nil @State private var fileMonitorTask: Task? = nil @State private var isMCPFFEnabled = false + @State private var selectedOption = ToolType.MCP @Environment(\.colorScheme) var colorScheme private static var lastSyncTimestamp: Date? = nil + + enum ToolType: String, CaseIterable, Identifiable { + case MCP + var id: Self { self } + } var body: some View { WithPerceptionTracking { ScrollView { - VStack(alignment: .leading, spacing: 8) { - MCPIntroView(isMCPFFEnabled: $isMCPFFEnabled) - if isMCPFFEnabled { - MCPToolsListView() - } - } - .padding(20) - .onAppear { - setupConfigFilePath() - Task { - await updateMCPFeatureFlag() - } - } - .onDisappear { - stopMonitoringConfigFile() + Picker("", selection: $selectedOption) { + Text("MCP").tag(ToolType.MCP) } - .onChange(of: isMCPFFEnabled) { newMCPFFEnabled in - if newMCPFFEnabled { - startMonitoringConfigFile() - refreshConfiguration(()) + .pickerStyle(.segmented) + .frame(width: 400) + + Group { + if selectedOption == .MCP { + VStack(alignment: .leading, spacing: 8) { + MCPIntroView(isMCPFFEnabled: $isMCPFFEnabled) + if isMCPFFEnabled { + MCPToolsListView() + } + } + .onAppear { + setupConfigFilePath() + Task { + await updateMCPFeatureFlag() + } + } + .onDisappear { + stopMonitoringConfigFile() + } + .onChange(of: isMCPFFEnabled) { newMCPFFEnabled in + if newMCPFFEnabled { + startMonitoringConfigFile() + refreshConfiguration(()) + } else { + stopMonitoringConfigFile() + } + } + .onReceive(DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in + Task { + await updateMCPFeatureFlag() + } + } } else { - stopMonitoringConfigFile() - } - } - .onReceive(DistributedNotificationCenter.default() - .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in - Task { - await updateMCPFeatureFlag() + // Built-In Tools View + EmptyView() } } + .padding(20) } } } diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index c9a4c55c..e037f595 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -42,7 +42,7 @@ public struct TabContainer: View { let service = try getService() let featureFlags = try await service.getCopilotFeatureFlags() isAgentModeFFEnabled = featureFlags?.agentMode ?? true - if hostAppStore.state.activeTabIndex == .mcp && !isAgentModeFFEnabled { + if hostAppStore.state.activeTabIndex == .tools && !isAgentModeFFEnabled { hostAppStore.send(.setActiveTab(.general)) } } catch { @@ -59,7 +59,7 @@ public struct TabContainer: View { GeneralView(store: store.scope(state: \.general, action: \.general)).tabBarItem(for: .general) AdvancedSettings().tabBarItem(for: .advanced) if isAgentModeFFEnabled { - MCPConfigView().tabBarItem(for: .mcp) + MCPConfigView().tabBarItem(for: .tools) } BYOKConfigView().tabBarItem(for: .byok) } @@ -68,7 +68,6 @@ public struct TabContainer: View { } .focusable(false) .padding(.top, 8) - .background(.ultraThinMaterial.opacity(0.01)) .background(Color(nsColor: .controlBackgroundColor)) .handleToast() .onPreferenceChange(TabBarItemPreferenceKey.self) { items in diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index ad7849fa..4e006184 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -308,6 +308,54 @@ public class XPCService: NSObject, XPCServiceProtocol { } } + // MARK: - MCP Registry + + public func listMCPRegistryServers(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) { + let decoder = JSONDecoder() + var listMCPRegistryServersParams: MCPRegistryListServersParams? + do { + listMCPRegistryServersParams = try decoder.decode(MCPRegistryListServersParams.self, from: params) + } catch { + Logger.service.error("Failed to decode MCP Registry list servers parameters: \(error)") + return + } + + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.listMCPRegistryServers(listMCPRegistryServersParams!) + let data = try? JSONEncoder().encode(response) + reply(data, nil) + } catch { + Logger.service.error("Failed to list MCP Registry servers: \(error)") + reply(nil, error) + } + } + } + + public func getMCPRegistryServer(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) { + let decoder = JSONDecoder() + var getMCPRegistryServerParams: MCPRegistryGetServerParams? + do { + getMCPRegistryServerParams = try decoder.decode(MCPRegistryGetServerParams.self, from: params) + } catch { + Logger.service.error("Failed to decode MCP Registry get server parameters: \(error)") + return + } + + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.getMCPRegistryServer(getMCPRegistryServerParams!) + let data = try? JSONEncoder().encode(response) + reply(data, nil) + } catch { + Logger.service.error("Failed to get MCP Registry servers: \(error)") + reply(nil, error) + } + } + } + // MARK: - FeatureFlags public func getCopilotFeatureFlags( withReply reply: @escaping (Data?) -> Void diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index d6cf456d..543afb3e 100644 --- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -4,12 +4,14 @@ import ComposableArchitecture import Foundation import SwiftUI import ConversationTab +import SharedUIComponents final class ChatPanelWindow: NSWindow { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } private let storeObserver = NSObject() + private let fontScaleManager: FontScaleManager = .shared var minimizeWindow: () -> Void = {} @@ -121,4 +123,21 @@ final class ChatPanelWindow: NSWindow { override func close() { minimizeWindow() } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if event.modifierFlags.contains(.command) { + switch event.charactersIgnoringModifiers { + case "-": + fontScaleManager.decreaseFontScale() + return true + case "=": + fontScaleManager.increaseFontScale() + return true + default: + break + } + } + + return super.performKeyEquivalent(with: event) + } } diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift index a766e949..5896af93 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift @@ -42,7 +42,7 @@ struct ChatHistoryView: View { var body: some View { HStack { Text("Chat History") - .font(.system(size: 13, weight: .bold)) + .scaledFont(.system(size: 13, weight: .bold)) .lineLimit(nil) Spacer() @@ -51,6 +51,7 @@ struct ChatHistoryView: View { isChatHistoryVisible = false }) { Image(systemName: "xmark") + .scaledFont(.body) } .buttonStyle(HoverButtonStyle()) .help("Close") @@ -123,8 +124,10 @@ struct ChatHistorySearchBarView: View { HStack(spacing: 5) { Image(systemName: "magnifyingglass") .foregroundColor(.secondary) + .scaledFont(.body) TextField("Search", text: $searchText) + .scaledFont(.body) .textFieldStyle(PlainTextFieldStyle()) .focused($isSearchBarFocused) .foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor)) @@ -171,7 +174,7 @@ struct ChatHistoryItemView: View { // directly get title from chat tab info Text(previewInfo.title ?? "New Chat") .frame(alignment: .leading) - .font(.system(size: 14, weight: .semibold)) + .scaledFont(.system(size: 14, weight: .semibold)) .foregroundColor(.primary) .lineLimit(1) @@ -186,7 +189,7 @@ struct ChatHistoryItemView: View { HStack(spacing: 0) { Text(formatDate(previewInfo.updatedAt)) .frame(alignment: .leading) - .font(.system(size: 13, weight: .regular)) + .scaledFont(.system(size: 13, weight: .regular)) .foregroundColor(.secondary) .lineLimit(1) @@ -205,6 +208,7 @@ struct ChatHistoryItemView: View { }) { Image(systemName: "trash") .foregroundColor(.primary) + .scaledFont(.body) .opacity(isHovered ? 1 : 0) } .buttonStyle(HoverButtonStyle()) diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift index 871dd24e..5ca14718 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift @@ -17,15 +17,15 @@ struct ChatLoginView: View { .resizable() .renderingMode(.template) .scaledToFill() - .frame(width: 60.0, height: 60.0) + .scaledFrame(width: 60.0, height: 60.0) .foregroundColor(.secondary) Text("Welcome to Copilot") - .font(.largeTitle) + .scaledFont(.largeTitle) .multilineTextAlignment(.center) Text("Your AI-powered coding assistant") - .font(.body) + .scaledFont(.body) .multilineTextAlignment(.center) } @@ -37,11 +37,15 @@ struct ChatLoginView: View { openURL(url) } } + .scaledFont(.body) .buttonStyle(.borderedProminent) HStack{ Text("Already have an account?") + .scaledFont(.body) + Button("Sign In") { viewModel.signIn() } + .scaledFont(.body) .buttonStyle(.borderless) .foregroundColor(Color("TextLinkForegroundColor")) @@ -55,7 +59,7 @@ struct ChatLoginView: View { 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)) + .scaledFont(.system(size: 12)) } .padding() .frame( @@ -71,7 +75,10 @@ struct ChatLoginView: View { presenting: viewModel.signInResponse ) { _ in Button("Cancel", role: .cancel, action: {}) + .scaledFont(.body) + Button("Copy Code and Open", action: viewModel.copyAndOpen) + .scaledFont(.body) } message: { response in Text(""" Please enter the above code in the GitHub website \ diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift index 299c46cc..4a82fb61 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift @@ -18,11 +18,11 @@ struct ChatNoAXPermissionView: View { .foregroundColor(.primary) Text("Accessibility Permission Required") - .font(.largeTitle) + .scaledFont(.largeTitle) .multilineTextAlignment(.center) Text("Please grant accessibility permission for Github Copilot to work with Xcode.") - .font(.body) + .scaledFont(.body) .multilineTextAlignment(.center) HStack{ @@ -31,6 +31,7 @@ struct ChatNoAXPermissionView: View { openURL(url) } } + .scaledFont(.body) .buttonStyle(.borderedProminent) } diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift index 5c052411..a453d633 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift @@ -20,11 +20,11 @@ struct ChatNoSubscriptionView: View { .foregroundColor(.primary) Text("No Copilot Subscription Found") - .font(.system(size: 24)) + .scaledFont(.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)) + .scaledFont(.system(size: 12)) .multilineTextAlignment(.center) HStack{ @@ -33,9 +33,11 @@ struct ChatNoSubscriptionView: View { openURL(url) } } + .scaledFont(.body) .buttonStyle(.borderedProminent) Button("Retry") { viewModel.checkStatus() } + .scaledFont(.body) .buttonStyle(.bordered) if viewModel.isRunningAction || viewModel.waitingForSignIn { @@ -47,7 +49,7 @@ struct ChatNoSubscriptionView: View { 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)) + .scaledFont(.system(size: 12)) } .padding() .frame( diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift index 8d7cbf60..172a477c 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift @@ -13,15 +13,15 @@ struct ChatNoWorkspaceView: View { .resizable() .renderingMode(.template) .scaledToFill() - .frame(width: 64.0, height: 64.0) + .scaledFrame(width: 64.0, height: 64.0) .foregroundColor(.secondary) Text("No Active Xcode Workspace") - .font(.largeTitle) + .scaledFont(.largeTitle) .multilineTextAlignment(.center) Text("To use Copilot, open Xcode with an active workspace in focus") - .font(.body) + .scaledFont(.body) .multilineTextAlignment(.center) } diff --git a/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift b/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift index b3d5eb5b..a7fdcec7 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift @@ -76,15 +76,15 @@ struct CopilotIntroItemView: View { .padding(.leading, 8) Text(title) - .font(.body) .kerning(0.096) + .scaledFont(.body) .multilineTextAlignment(.center) .foregroundColor(.primary) } .frame(maxWidth: .infinity, alignment: .leading) Text(description) - .font(.body) + .scaledFont(.body) .foregroundColor(.secondary) .padding(.leading, 28) .padding(.top, 4) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index c0004ec7..59d71231 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -163,7 +163,7 @@ struct ChatTitleBar: View { ) { Image(systemName: "minus") .foregroundStyle(.black.opacity(0.5)) - .font(Font.system(size: 8).weight(.heavy)) + .scaledFont(Font.system(size: 8).weight(.heavy)) } .opacity(0) .keyboardShortcut("m", modifiers: [.command]) @@ -181,7 +181,7 @@ struct ChatTitleBar: View { ) { Image(systemName: "pin.fill") .foregroundStyle(.black.opacity(0.5)) - .font(Font.system(size: 6).weight(.black)) + .scaledFont(Font.system(size: 6).weight(.black)) .transformEffect(.init(translationX: 0, y: 0.5)) } } @@ -213,7 +213,7 @@ struct ChatTitleBar: View { ? color : Color(nsColor: .separatorColor) ) - .frame( + .scaledFrame( width: Style.trafficLightButtonSize, height: Style.trafficLightButtonSize ) @@ -322,10 +322,10 @@ struct ChatBar: View { .resizable() .renderingMode(.original) .scaledToFit() - .frame(width: 24, height: 24) + .scaledFrame(width: 24, height: 24) Text(store.chatHistory.selectedWorkspaceName!) - .font(.system(size: 13, weight: .bold)) + .scaledFont(.system(size: 13, weight: .bold)) .padding(.leading, 4) .truncationMode(.tail) .frame(maxWidth: 192, alignment: .leading) @@ -344,6 +344,7 @@ struct ChatBar: View { store.send(.createNewTapButtonClicked(kind: nil)) }) { Image(systemName: "plus.bubble") + .scaledFont(.body) } .buttonStyle(HoverButtonStyle()) .padding(.horizontal, 4) @@ -364,8 +365,10 @@ struct ChatBar: View { }) { if #available(macOS 15.0, *) { Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .scaledFont(.body) } else { Image(systemName: "clock.arrow.circlepath") + .scaledFont(.body) } } .buttonStyle(HoverButtonStyle()) @@ -385,6 +388,7 @@ struct ChatBar: View { store.send(.openSettings) }) { Image(systemName: "gearshape") + .scaledFont(.body) } .buttonStyle(HoverButtonStyle()) .padding(.horizontal, 4) @@ -506,7 +510,7 @@ struct CreateOtherChatTabMenuStyle: MenuStyle { func makeBody(configuration: Configuration) -> some View { Image(systemName: "chevron.down") .resizable() - .frame(width: 7, height: 4) + .scaledFrame(width: 7, height: 4) .frame(maxHeight: .infinity) .padding(.leading, 4) .padding(.trailing, 8) diff --git a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FixErrorPanel.swift b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FixErrorPanel.swift index 51f9b374..1dbf2aab 100644 --- a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FixErrorPanel.swift +++ b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FixErrorPanel.swift @@ -1,7 +1,14 @@ import AppKit import XcodeInspector +import Preferences extension WidgetWindowsController { + + @MainActor + var isFixErrorEnabled: Bool { + UserDefaults.shared.value(for: \.enableFixError) + } + @MainActor func hideFixErrorWindow() { windows.fixErrorPanelWindow.alphaValue = 0 @@ -16,27 +23,36 @@ extension WidgetWindowsController { } func setupFixErrorPanelObservers() { - store.publisher - .map(\.fixErrorPanelState.errorAnnotations) - .removeDuplicates() - .sink { [weak self] _ in - Task { @MainActor [weak self] in - await self?.updateFixErrorPanelWindowLocation() + Task { @MainActor in + let errorAnnotationsPublisher = store.publisher + .map(\.fixErrorPanelState.errorAnnotationsAtCursorPosition) + .removeDuplicates() + .sink { [weak self] _ in + Task { [weak self] in + await self?.updateFixErrorPanelWindowLocation() + } } - }.store(in: &cancellable) - - store.publisher - .map(\.fixErrorPanelState.isPanelDisplayed) - .removeDuplicates() - .sink { [weak self ] _ in - Task { @MainActor [weak self] in - await self?.updateFixErrorPanelWindowLocation() + + let isPanelDisplayedPublisher = store.publisher + .map(\.fixErrorPanelState.isPanelDisplayed) + .removeDuplicates() + .sink { [weak self ] _ in + Task { [weak self] in + await self?.updateFixErrorPanelWindowLocation() + } } - }.store(in: &cancellable) + + await self.storeCancellables([errorAnnotationsPublisher, isPanelDisplayedPublisher]) + } } @MainActor func updateFixErrorPanelWindowLocation() async { + guard isFixErrorEnabled else { + hideFixErrorWindow() + return + } + guard let activeApp = await XcodeInspector.shared.safe.activeApplication, (activeApp.isXcode || activeApp.isCopilotForXcodeExtensionService) else { @@ -69,12 +85,17 @@ extension WidgetWindowsController { // Locate the window to the middle in Y fixErrorPanelWindowFrame.origin.y = screen.frame.maxY - annotationRect.minY - annotationRect.height / 2 - fixErrorPanelWindowFrame.height / 2 + screen.frame.minY - windows.fixErrorPanelWindow.setFrame(fixErrorPanelWindowFrame, display: true, animate: true) + windows.fixErrorPanelWindow.setFrame(fixErrorPanelWindowFrame, display: true, animate: false) displayFixErrorWindow() } @MainActor func handleFixErrorEditorNotification(notification: SourceEditor.AXNotification) async { + guard isFixErrorEnabled else { + hideFixErrorWindow() + return + } + switch notification.kind { case .scrollPositionChanged: store.send(.fixErrorPanel(.onScrollPositionChanged)) @@ -85,7 +106,5 @@ extension WidgetWindowsController { default: break } - - await updateFixErrorPanelWindowLocation() } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/FixErrorPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/FixErrorPanelFeature.swift index bd94029a..71850fa5 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/FixErrorPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/FixErrorPanelFeature.swift @@ -9,7 +9,7 @@ import ConversationTab public struct FixErrorPanelFeature { @ObservableState public struct State: Equatable { - public var focusedEditor: SourceEditor? = nil + public var focusedEditor: SourceEditor? = nil public var editorContent: EditorInformation.SourceEditorContent? = nil public var fixId: String? = nil public var fixFailure: FixEditorErrorIssueFailure? = nil @@ -17,22 +17,32 @@ public struct FixErrorPanelFeature { editorContent?.cursorPosition } public var isPanelDisplayed: Bool = false - - public var errorAnnotations: [EditorInformation.LineAnnotation] { - editorContent?.lineAnnotations.filter { $0.isError } ?? [] + public var shouldCheckingAnnotations: Bool = false { + didSet { + if shouldCheckingAnnotations { + annotationCheckStartTime = Date() + } + } } + public var maxCheckDuration: TimeInterval = 30.0 + public var annotationCheckStartTime: Date? = nil public var editorContentLines: [String] { editorContent?.lines ?? [] } public var errorAnnotationsAtCursorPosition: [EditorInformation.LineAnnotation] { - let errorAnnotations = errorAnnotations - guard !errorAnnotations.isEmpty, let cursorPosition = cursorPosition else { + guard let editorContent = editorContent else { return [] } - return errorAnnotations.filter { $0.line == cursorPosition.line + 1 } + return getErrorAnnotationsAtCursor(from: editorContent) + } + + public func getErrorAnnotationsAtCursor(from editorContent: EditorInformation.SourceEditorContent) -> [EditorInformation.LineAnnotation] { + return editorContent.lineAnnotations + .filter { $0.isError } + .filter { $0.line == editorContent.cursorPosition.line + 1 } } public mutating func resetFailure() { @@ -49,6 +59,7 @@ public struct FixErrorPanelFeature { case fixErrorIssue([EditorInformation.LineAnnotation]) case scheduleFixFailureReset + case observeErrorNotification case appear case onFailure(FixEditorErrorIssueFailure) @@ -58,6 +69,7 @@ public struct FixErrorPanelFeature { // Annotation checking case startAnnotationCheck case onAnnotationCheckTimerFired + case stopCheckingAnnotation } let id = UUID() @@ -76,6 +88,12 @@ public struct FixErrorPanelFeature { Reduce { state, action in switch action { case .appear: + return .run { send in + await send(.observeErrorNotification) + await send(.startAnnotationCheck) + } + + case .observeErrorNotification: return .run { send in let stream = AsyncStream { continuation in let observer = NotificationCenter.default.addObserver( @@ -105,35 +123,28 @@ public struct FixErrorPanelFeature { id: CancelID.observeErrorNotification(id), cancelInFlight: true ) - case .onFocusedEditorChanged(let editor): state.focusedEditor = editor - return .merge( - .send(.startAnnotationCheck), - .send(.resetFixFailure) - ) + state.editorContent = nil + state.shouldCheckingAnnotations = true + return .none case .onEditorContentChanged: - return .merge( - .send(.startAnnotationCheck), - .send(.resetFixFailure) - ) + state.shouldCheckingAnnotations = true + return .none case .onScrollPositionChanged: - return .merge( - .send(.resetFixFailure), - // Force checking the annotation - .send(.onAnnotationCheckTimerFired), - .send(.checkDisplay) - ) + if state.shouldCheckingAnnotations { + state.shouldCheckingAnnotations = false + } + if state.editorContent != nil { + state.editorContent = nil + } + return .none case .onCursorPositionChanged: - return .merge( - .send(.resetFixFailure), - // Force checking the annotation - .send(.onAnnotationCheckTimerFired), - .send(.checkDisplay) - ) + state.shouldCheckingAnnotations = true + return .none case .fixErrorIssue(let annotations): guard let fileURL = state.focusedEditor?.realtimeDocumentURL ?? nil, @@ -200,11 +211,9 @@ public struct FixErrorPanelFeature { case .startAnnotationCheck: return .run { send in - let startTime = Date() - let maxDuration: TimeInterval = 60 * 5 - let interval: TimeInterval = 1 + let interval: TimeInterval = 2 - while Date().timeIntervalSince(startTime) < maxDuration { + while !Task.isCancelled { try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) await send(.onAnnotationCheckTimerFired) @@ -212,27 +221,45 @@ public struct FixErrorPanelFeature { }.cancellable(id: CancelID.annotationCheck(id), cancelInFlight: true) case .onAnnotationCheckTimerFired: - guard let editor = state.focusedEditor else { - return .cancel(id: CancelID.annotationCheck(id)) + // Check if max duration exceeded + if let startTime = state.annotationCheckStartTime, + Date().timeIntervalSince(startTime) > state.maxCheckDuration { + return .run { send in + await send(.stopCheckingAnnotation) + await send(.checkDisplay) + } + } + + guard state.shouldCheckingAnnotations, + let editor = state.focusedEditor + else { + return .run { send in + await send(.checkDisplay) + } } let newEditorContent = editor.getContent() - let newLineAnnotations = newEditorContent.lineAnnotations - let newErrorLineAnnotations = newLineAnnotations.filter { $0.isError } - let errorAnnotations = state.errorAnnotations + let newErrorAnnotationsAtCursorPosition = state.getErrorAnnotationsAtCursor(from: newEditorContent) + let errorAnnotationsAtCursorPosition = state.errorAnnotationsAtCursorPosition if state.editorContent != newEditorContent { state.editorContent = newEditorContent } - if errorAnnotations != newErrorLineAnnotations { + if Set(errorAnnotationsAtCursorPosition) != Set(newErrorAnnotationsAtCursorPosition) { + // Keep checking annotations as Xcode may update them asynchronously after content changes return .merge( - .send(.checkDisplay), - .cancel(id: CancelID.annotationCheck(id)) + .run { send in + await send(.checkDisplay) + } ) } else { return .none } + + case .stopCheckingAnnotation: + state.shouldCheckingAnnotations = false + return .none } } } diff --git a/Core/Sources/SuggestionWidget/FixErrorPanelView.swift b/Core/Sources/SuggestionWidget/FixErrorPanelView.swift index 9381e2b6..0799b7a0 100644 --- a/Core/Sources/SuggestionWidget/FixErrorPanelView.swift +++ b/Core/Sources/SuggestionWidget/FixErrorPanelView.swift @@ -21,6 +21,7 @@ struct FixErrorPanelView: View { let store: StoreOf @State private var showFailurePopover = false + @Environment(\.colorScheme) var colorScheme var body: some View { WithViewStore(self.store, observe: ViewState.init) { viewStore in @@ -49,7 +50,7 @@ struct FixErrorPanelView: View { let annotations = viewStore.errorAnnotationsAtCursorPosition let rect = annotations.first(where: { $0.rect != nil })?.rect ?? nil let annotationHeight = rect?.height ?? 16 - let iconSize = annotationHeight * 0.8 + let iconSize = annotationHeight * 0.7 Group { if !annotations.isEmpty { @@ -61,8 +62,14 @@ struct FixErrorPanelView: View { .resizable() .scaledToFit() .frame(width: iconSize, height: iconSize) + .padding((annotationHeight - iconSize) / 2) + .foregroundColor(.white) } .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color("FixErrorBackgroundColor").opacity(0.8)) + ) } } else { Color.clear diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index 2c3dc199..ee6d05ca 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -16,7 +16,7 @@ enum Style { static let trafficLightButtonSize: Double = 12 static let codeReviewPanelWidth: Double = 550 static let codeReviewPanelHeight: Double = 450 - static let fixPanelToAnnotationSpacing: Double = 8 + static let fixPanelToAnnotationSpacing: Double = 1 } extension Color { diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift index b5791d17..2ef813dc 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedUIComponents struct ErrorPanel: View { var description: String @@ -16,6 +17,7 @@ struct ErrorPanel: View { // close button Button(action: onCloseButtonTap) { Image(systemName: "xmark") + .scaledFont(.body) .padding([.leading, .bottom], 16) .padding([.top, .trailing], 8) .foregroundColor(.white) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift index c06a915a..d5a1719e 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift @@ -27,7 +27,7 @@ struct WarningPanel: View { .renderingMode(.template) .scaledToFit() .foregroundColor(.primary) - .frame(width: 14, height: 14) + .scaledFrame(width: 14, height: 14) Text("Monthly completion limit reached.") .font(.system(size: 12)) diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 8f464efe..cb75004c 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -99,23 +99,33 @@ actor WidgetWindowsController: NSObject { } private func setupCodeReviewPanelObservers() { - store.publisher - .map(\.codeReviewPanelState.currentIndex) - .removeDuplicates() - .sink { [weak self] _ in - Task { [weak self] in - await self?.updateCodeReviewWindowLocation(.onCurrentReviewIndexChanged) + Task { @MainActor in + let currentIndexPublisher = store.publisher + .map(\.codeReviewPanelState.currentIndex) + .removeDuplicates() + .sink { [weak self] _ in + Task { [weak self] in + await self?.updateCodeReviewWindowLocation(.onCurrentReviewIndexChanged) + } } - }.store(in: &cancellable) - - store.publisher - .map(\.codeReviewPanelState.isPanelDisplayed) - .removeDuplicates() - .sink { [weak self] isPanelDisplayed in - Task { [weak self] in - await self?.updateCodeReviewWindowLocation(.onIsPanelDisplayedChanged(isPanelDisplayed)) + + let isPanelDisplayedPublisher = store.publisher + .map(\.codeReviewPanelState.isPanelDisplayed) + .removeDuplicates() + .sink { [weak self] isPanelDisplayed in + Task { [weak self] in + await self?.updateCodeReviewWindowLocation(.onIsPanelDisplayedChanged(isPanelDisplayed)) + } } - }.store(in: &cancellable) + + await self.storeCancellables([currentIndexPublisher, isPanelDisplayedPublisher]) + } + } + + func storeCancellables(_ newCancellables: [AnyCancellable]) { + for cancellable in newCancellables { + self.cancellable.insert(cancellable) + } } } @@ -528,7 +538,11 @@ extension WidgetWindowsController { let currentXcodeRect = currentFocusedWindow.rect, let notif = notif else { return } - + + guard let sourceEditor = await xcodeInspector.safe.focusedEditor, + sourceEditor.realtimeWorkspaceURL != nil + else { return } + if let previousXcodeApp = (await previousXcodeApp), currentXcodeApp.processIdentifier == previousXcodeApp.processIdentifier { if currentFocusedWindow.isFullScreen == true { @@ -622,6 +636,8 @@ extension WidgetWindowsController { } await adjustChatPanelWindowLevel() + + await updateFixErrorPanelWindowLocation() } let now = Date() @@ -1044,7 +1060,7 @@ public final class WidgetWindows { it.isOpaque = false it.backgroundColor = .clear it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] - it.hasShadow = true + it.hasShadow = false it.level = widgetLevel(2) it.contentView = NSHostingView( rootView: FixErrorPanelView( diff --git a/ExtensionService/Assets.xcassets/FixErrorBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/FixErrorBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..57288f5d --- /dev/null +++ b/ExtensionService/Assets.xcassets/FixErrorBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "48", + "green" : "59", + "red" : "255" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "58", + "green" : "69", + "red" : "255" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json b/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json index db53bbf8..2f1e961f 100644 --- a/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json @@ -18,5 +18,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true } } diff --git a/Server/package-lock.json b/Server/package-lock.json index a823aae8..4ff4ca32 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,9 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.362.0", + "@github/copilot-language-server": "^1.371.0", + "@github/copilot-language-server-darwin-arm64": "^1.371.0", + "@github/copilot-language-server-darwin-x64": "^1.371.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,17 +38,87 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.362.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.362.0.tgz", - "integrity": "sha512-4lBt/nVRGCiOZx1Btuvp043ZhGqb3/o8aZzG564nOTOgy3MtQfsNCwzbci+7hZXEuRIPM4bZjBqIzTZ2S2nu9Q==", + "version": "1.371.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.371.0.tgz", + "integrity": "sha512-49CT02ElprSuG9zxM4y6TRQri0/a5doazxj3Qfz/whMtOTxiHhfHD/lmPUZXcEgOVVEovTUN7Znzx2ZLPtx3Fw==", "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" }, "bin": { "copilot-language-server": "dist/language-server.js" + }, + "optionalDependencies": { + "@github/copilot-language-server-darwin-arm64": "1.371.0", + "@github/copilot-language-server-darwin-x64": "1.371.0", + "@github/copilot-language-server-linux-arm64": "1.371.0", + "@github/copilot-language-server-linux-x64": "1.371.0", + "@github/copilot-language-server-win32-x64": "1.371.0" } }, + "node_modules/@github/copilot-language-server-darwin-arm64": { + "version": "1.371.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.371.0.tgz", + "integrity": "sha512-1uWyuseYXFUIZhHFljP1O1ivTfnPxLRaDxIjqDxlU6+ugkuqp5s5LiHRED+4s4cIx4H9QMzkCVnE9Bms1nKN2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "os": [ + "darwin" + ] + }, + "node_modules/@github/copilot-language-server-darwin-x64": { + "version": "1.371.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.371.0.tgz", + "integrity": "sha512-nokRUPq4qPvJZ0QEZEEQPb+t2i/BrrjDDuNtBQtIIeBvJFW0YtwhbhEAtFpJtYZ5G+/nkbKMKYufFiLCvUnJ4A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "os": [ + "darwin" + ] + }, + "node_modules/@github/copilot-language-server-linux-arm64": { + "version": "1.371.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.371.0.tgz", + "integrity": "sha512-1cCJCs5j3+wl6NcNs1AUXpqFFogHdQLRiBeMBPEUaFSI95H5WwPphwe0OlmrVfRJQ19rqDfeT58B1jHMX6fM/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@github/copilot-language-server-linux-x64": { + "version": "1.371.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.371.0.tgz", + "integrity": "sha512-mMK5iGpaUQuM3x0H5I9iDRQQ3NZLzatXJtQkCPT30fXb2yZFNED+yU7nKAxwGX91MMeTyOzotNvh2ZzITmajDA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@github/copilot-language-server-win32-x64": { + "version": "1.371.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.371.0.tgz", + "integrity": "sha512-j7W1c6zTRUou4/l2M2HNfjfT8O588pRAf6zgllwnMrc2EYD8O4kzNiK1c5pHrlP1p5bnGqsUIrOuozu9EUkCLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", diff --git a/Server/package.json b/Server/package.json index b434a35f..02fc7d58 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,9 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.362.0", + "@github/copilot-language-server": "^1.371.0", + "@github/copilot-language-server-darwin-arm64": "^1.371.0", + "@github/copilot-language-server-darwin-x64": "^1.371.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index e82c13ac..1bf64d6d 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -403,6 +403,32 @@ enum GitHubCopilotRequest { } } + // MARK: MCP Registry + + struct MCPRegistryListServers: GitHubCopilotRequestType { + typealias Response = MCPRegistryServerList + + var params: MCPRegistryListServersParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("mcp/registry/listServers", dict, ClientRequest.NullHandler) + } + } + + struct MCPRegistryGetServer: GitHubCopilotRequestType { + typealias Response = MCPRegistryServerDetail + + var params: MCPRegistryGetServerParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("mcp/registry/getServer", dict, ClientRequest.NullHandler) + } + } + // MARK: - Conversation Agents struct GetAgents: GitHubCopilotRequestType { diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+BYOK.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/BYOK.swift similarity index 100% rename from Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+BYOK.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/BYOK.swift diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift similarity index 100% rename from Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift similarity index 100% rename from Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift new file mode 100644 index 00000000..dc891647 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift @@ -0,0 +1,459 @@ +import Foundation +import JSONRPC +import ConversationServiceProvider + +/// Schema definitions for MCP Registry API based on the OpenAPI spec: +/// https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/openapi.yaml + +// MARK: - Repository + +public struct Repository: Codable { + public let url: String + public let source: String + public let id: String? + public let subfolder: String? + + enum CodingKeys: String, CodingKey { + case url, source, id, subfolder + } +} + +// MARK: - Server Status + +public enum ServerStatus: String, Codable { + case active + case deprecated +} + +// MARK: - Base Input Protocol + +public protocol InputProtocol: Codable { + var description: String? { get } + var isRequired: Bool? { get } + var format: ArgumentFormat? { get } + var value: String? { get } + var isSecret: Bool? { get } + var defaultValue: String? { get } + var choices: [String]? { get } +} + +// MARK: - Input (base type) + +public struct Input: InputProtocol { + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let choices: [String]? + + enum CodingKeys: String, CodingKey { + case description + case isRequired = "is_required" + case format + case value + case isSecret = "is_secret" + case defaultValue = "default" + case choices + } +} + +// MARK: - Input with Variables + +public struct InputWithVariables: InputProtocol { + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let choices: [String]? + public let variables: [String: Input]? + + enum CodingKeys: String, CodingKey { + case description + case isRequired = "is_required" + case format + case value + case isSecret = "is_secret" + case defaultValue = "default" + case choices + case variables + } +} + +// MARK: - Argument Format + +public enum ArgumentFormat: String, Codable { + case string + case number + case boolean + case filepath +} + +// MARK: - Argument Type + +public enum ArgumentType: String, Codable { + case positional + case named +} + +// MARK: - Base Argument Protocol + +public protocol ArgumentProtocol: InputProtocol { + var type: ArgumentType { get } + var variables: [String: Input]? { get } +} + +// MARK: - Positional Argument + +public struct PositionalArgument: ArgumentProtocol { + public let type: ArgumentType = .positional + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let choices: [String]? + public let variables: [String: Input]? + public let valueHint: String? + public let isRepeated: Bool? + + enum CodingKeys: String, CodingKey { + case type, description, format, value, choices, variables + case isRequired = "is_required" + case isSecret = "is_secret" + case defaultValue = "default" + case valueHint = "value_hint" + case isRepeated = "is_repeated" + } +} + +// MARK: - Named Argument + +public struct NamedArgument: ArgumentProtocol { + public let type: ArgumentType = .named + public let name: String + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let choices: [String]? + public let variables: [String: Input]? + public let isRepeated: Bool? + + enum CodingKeys: String, CodingKey { + case type, name, description, format, value, choices, variables + case isRequired = "is_required" + case isSecret = "is_secret" + case defaultValue = "default" + case isRepeated = "is_repeated" + } +} + +// MARK: - Argument Enum + +public enum Argument: Codable { + case positional(PositionalArgument) + case named(NamedArgument) + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Discriminator.self) + let type = try container.decode(ArgumentType.self, forKey: .type) + switch type { + case .positional: + self = .positional(try PositionalArgument(from: decoder)) + case .named: + self = .named(try NamedArgument(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .positional(let arg): + try arg.encode(to: encoder) + case .named(let arg): + try arg.encode(to: encoder) + } + } + + private enum Discriminator: String, CodingKey { + case type + } +} + +// MARK: - KeyValueInput + +public struct KeyValueInput: InputProtocol { + public let name: String + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let choices: [String]? + public let variables: [String: Input]? + + enum CodingKeys: String, CodingKey { + case name, description, format, value, choices, variables + case isRequired = "is_required" + case isSecret = "is_secret" + case defaultValue = "default" + } +} + +// MARK: - Package + +public struct Package: Codable { + public let registryType: String? + public let registryBaseURL: String? + public let identifier: String? + public let version: String? + public let fileSHA256: String? + public let runtimeHint: String? + public let runtimeArguments: [Argument]? + public let packageArguments: [Argument]? + public let environmentVariables: [KeyValueInput]? + + enum CodingKeys: String, CodingKey { + case version, identifier + case registryType = "registry_type" + case registryBaseURL = "registry_base_url" + case fileSHA256 = "file_sha256" + case runtimeHint = "runtime_hint" + case runtimeArguments = "runtime_arguments" + case packageArguments = "package_arguments" + case environmentVariables = "environment_variables" + } +} + +// MARK: - Transport Type + +public enum TransportType: String, Codable { + case streamable = "streamable" + case streamableHttp = "streamable-http" + case sse = "sse" +} + +// MARK: - Remote + +public struct Remote: Codable { + public let transportType: TransportType + public let url: String + public let headers: [KeyValueInput]? + + enum CodingKeys: String, CodingKey { + case url, headers + case transportType = "type" + } +} + +// MARK: - Publisher Provided Meta + +public struct PublisherProvidedMeta: Codable { + public let tool: String? + public let version: String? + public let buildInfo: BuildInfo? + private let additionalProperties: [String: AnyCodable]? + + public struct BuildInfo: Codable { + public let commit: String? + public let timestamp: String? + public let pipelineID: String? + + enum CodingKeys: String, CodingKey { + case commit, timestamp + case pipelineID = "pipeline_id" + } + } + + enum CodingKeys: String, CodingKey { + case tool, version + case buildInfo = "build_info" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + tool = try container.decodeIfPresent(String.self, forKey: .tool) + version = try container.decodeIfPresent(String.self, forKey: .version) + buildInfo = try container.decodeIfPresent(BuildInfo.self, forKey: .buildInfo) + + // Capture additional properties + let allKeys = try decoder.container(keyedBy: AnyCodingKey.self) + var extras: [String: AnyCodable] = [:] + + for key in allKeys.allKeys { + if !["tool", "version", "build_info"].contains(key.stringValue) { + extras[key.stringValue] = try allKeys.decode(AnyCodable.self, forKey: key) + } + } + additionalProperties = extras.isEmpty ? nil : extras + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(tool, forKey: .tool) + try container.encodeIfPresent(version, forKey: .version) + try container.encodeIfPresent(buildInfo, forKey: .buildInfo) + + if let additionalProperties = additionalProperties { + var dynamicContainer = encoder.container(keyedBy: AnyCodingKey.self) + for (key, value) in additionalProperties { + try dynamicContainer.encode(value, forKey: AnyCodingKey(stringValue: key)!) + } + } + } +} + +// MARK: - Official Meta + +public struct OfficialMeta: Codable { + public let id: String + public let publishedAt: String + public let updatedAt: String + public let isLatest: Bool + + enum CodingKeys: String, CodingKey { + case id + case publishedAt = "published_at" + case updatedAt = "updated_at" + case isLatest = "is_latest" + } +} + +// MARK: - Server Meta + +public struct ServerMeta: Codable { + public let publisherProvided: PublisherProvidedMeta? + public let official: OfficialMeta? + private let additionalProperties: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case publisherProvided = "io.modelcontextprotocol.registry/publisher-provided" + case official = "io.modelcontextprotocol.registry/official" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + publisherProvided = try container.decodeIfPresent(PublisherProvidedMeta.self, forKey: .publisherProvided) + official = try container.decodeIfPresent(OfficialMeta.self, forKey: .official) + + // Capture additional properties + let allKeys = try decoder.container(keyedBy: AnyCodingKey.self) + var extras: [String: AnyCodable] = [:] + + let knownKeys = ["io.modelcontextprotocol.registry/publisher-provided", "io.modelcontextprotocol.registry/official"] + for key in allKeys.allKeys { + if !knownKeys.contains(key.stringValue) { + extras[key.stringValue] = try allKeys.decode(AnyCodable.self, forKey: key) + } + } + additionalProperties = extras.isEmpty ? nil : extras + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(publisherProvided, forKey: .publisherProvided) + try container.encodeIfPresent(official, forKey: .official) + + if let additionalProperties = additionalProperties { + var dynamicContainer = encoder.container(keyedBy: AnyCodingKey.self) + for (key, value) in additionalProperties { + try dynamicContainer.encode(value, forKey: AnyCodingKey(stringValue: key)!) + } + } + } +} + +// MARK: - Dynamic Coding Key Helper + +private struct AnyCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } +} + +// MARK: - Server Detail + +public struct MCPRegistryServerDetail: Codable { + public let name: String + public let description: String + public let status: ServerStatus? + public let repository: Repository? + public let version: String + public let websiteURL: String? + public let createdAt: String? + public let updatedAt: String? + public let schemaURL: String? + public let packages: [Package]? + public let remotes: [Remote]? + public let meta: ServerMeta? + + enum CodingKeys: String, CodingKey { + case name, description, status, repository, version, packages, remotes + case websiteURL = "website_url" + case createdAt = "created_at" + case updatedAt = "updated_at" + case schemaURL = "$schema" + case meta = "_meta" + } +} + +// MARK: - Server List Metadata + +public struct MCPRegistryServerListMetadata: Codable { + public let nextCursor: String? + public let count: Int? + + enum CodingKeys: String, CodingKey { + case nextCursor = "next_cursor" + case count + } +} + +// MARK: - Server List + +public struct MCPRegistryServerList: Codable { + public let servers: [MCPRegistryServerDetail] + public let metadata: MCPRegistryServerListMetadata? +} + +// MARK: - Request Parameters + +public struct MCPRegistryListServersParams: Codable { + public let baseUrl: String + public let cursor: String? + public let limit: Int? + + public init(baseUrl: String, cursor: String? = nil, limit: Int? = nil) { + self.baseUrl = baseUrl + self.cursor = cursor + self.limit = limit + } +} + +public struct MCPRegistryGetServerParams: Codable { + public let baseUrl: String + public let id: String + public let version: String? + + public init(baseUrl: String, id: String, version: String?) { + self.baseUrl = baseUrl + self.id = id + self.version = version + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GithubCopilotRequest+Message.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Message.swift similarity index 100% rename from Tool/Sources/GitHubCopilotService/LanguageServer/GithubCopilotRequest+Message.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Message.swift diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Telemetry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Telemetry.swift similarity index 100% rename from Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Telemetry.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Telemetry.swift diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index fae337cd..6b5f90c5 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -733,6 +733,29 @@ public final class GitHubCopilotService: } } + @GitHubCopilotSuggestionActor + public func listMCPRegistryServers(_ params: MCPRegistryListServersParams) async throws -> MCPRegistryServerList { + do { + let response = try await sendRequest( + GitHubCopilotRequest.MCPRegistryListServers(params: params) + ) + return response + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func getMCPRegistryServer(_ params: MCPRegistryGetServerParams) async throws -> MCPRegistryServerDetail { + do { + let response = try await sendRequest( + GitHubCopilotRequest.MCPRegistryGetServer(params: params) + ) + return response + } catch { + throw error + } + } @GitHubCopilotSuggestionActor public func rateConversation(turnId: String, rating: ConversationRating) async throws { diff --git a/Tool/Sources/HostAppActivator/HostAppActivator.swift b/Tool/Sources/HostAppActivator/HostAppActivator.swift index ca7f7016..28172e69 100644 --- a/Tool/Sources/HostAppActivator/HostAppActivator.swift +++ b/Tool/Sources/HostAppActivator/HostAppActivator.swift @@ -7,8 +7,8 @@ public let HostAppURL = locateHostBundleURL(url: Bundle.main.bundleURL) public extension Notification.Name { static let openSettingsWindowRequest = Notification .Name("com.github.CopilotForXcode.OpenSettingsWindowRequest") - static let openMCPSettingsWindowRequest = Notification - .Name("com.github.CopilotForXcode.OpenMCPSettingsWindowRequest") + static let openToolsSettingsWindowRequest = Notification + .Name("com.github.CopilotForXcode.OpenToolsSettingsWindowRequest") static let openBYOKSettingsWindowRequest = Notification .Name("com.github.CopilotForXcode.OpenBYOKSettingsWindowRequest") } @@ -56,7 +56,7 @@ public func launchHostAppSettings() throws { } } -public func launchHostAppMCPSettings() throws { +public func launchHostAppToolsSettings() throws { // Try the AppleScript approach first, but only if app is already running if let hostApp = getRunningHostApp() { let activated = hostApp.activate(options: [.activateIgnoringOtherApps]) @@ -65,14 +65,14 @@ public func launchHostAppMCPSettings() throws { _ = tryLaunchWithAppleScript() DistributedNotificationCenter.default().postNotificationName( - .openMCPSettingsWindowRequest, + .openToolsSettingsWindowRequest, object: nil ) Logger.ui.info("\(hostAppName()) MCP settings notification sent after activation") return } else { // If app is not running, launch it with the settings flag - try launchHostAppWithArgs(args: ["--mcp"]) + try launchHostAppWithArgs(args: ["--tools"]) } } diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index c4296fc7..6be5999f 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -243,6 +243,10 @@ public extension UserDefaultPreferenceKeys { // MARK: - Chat public extension UserDefaultPreferenceKeys { + + var fontScale: PreferenceKey { + .init(defaultValue: 1.0, key: "FontScale") + } var chatFontSize: PreferenceKey { .init(defaultValue: 13, key: "ChatFontSize") @@ -312,6 +316,10 @@ public extension UserDefaultPreferenceKeys { var autoAttachChatToXcode: PreferenceKey { .init(defaultValue: true, key: "AutoAttachChatToXcode") } + + var enableFixError: PreferenceKey { + .init(defaultValue: true, key: "EnableFixError") + } } // MARK: - Theme diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift index dfaa5b67..56c19b47 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -16,6 +16,7 @@ public extension UserDefaults { shared.setupDefaultValue(for: \.realtimeSuggestionDebounce) shared.setupDefaultValue(for: \.suggestionPresentationMode) shared.setupDefaultValue(for: \.autoAttachChatToXcode) + shared.setupDefaultValue(for: \.enableFixError) shared.setupDefaultValue(for: \.widgetColorScheme) shared.setupDefaultValue(for: \.customCommands) shared.setupDefaultValue( @@ -61,6 +62,10 @@ public extension UserDefaults { weight: .regular ))) ) + shared.setupDefaultValue( + for: \.fontScale, + defaultValue: shared.value(for: \.fontScale) + ) } } diff --git a/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift b/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift index a077a320..ed39cd4f 100644 --- a/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift +++ b/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift @@ -27,7 +27,7 @@ struct CopilotIntroItem: View { .renderingMode(.template) .foregroundColor(.blue) .scaledToFit() - .frame(width: 28, height: 28) + .scaledFrame(width: 28, height: 28) VStack(alignment: .leading, spacing: 5) { Text(heading) .font(.system(size: 11, weight: .bold)) diff --git a/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift b/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift index 922ed55f..2a09dbf3 100644 --- a/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift +++ b/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift @@ -12,18 +12,17 @@ public struct CopilotMessageHeader: View { ZStack { Circle() .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - .frame(width: 24, height: 24) + .scaledFrame(width: 24, height: 24) Image("CopilotLogo") .resizable() .renderingMode(.template) .scaledToFit() - .frame(width: 12, height: 12) + .scaledFrame(width: 14, height: 14) } Text("GitHub Copilot") - .font(.system(size: 13)) - .fontWeight(.semibold) + .scaledFont(.system(size: 13, weight: .semibold)) .padding(.leading, 4) Spacer() diff --git a/Tool/Sources/SharedUIComponents/CopyButton.swift b/Tool/Sources/SharedUIComponents/CopyButton.swift index 0e79a0b4..d9673c17 100644 --- a/Tool/Sources/SharedUIComponents/CopyButton.swift +++ b/Tool/Sources/SharedUIComponents/CopyButton.swift @@ -29,7 +29,7 @@ public struct CopyButton: View { Image(systemName: isCopied ? "checkmark.circle" : "doc.on.doc") .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) + .scaledFrame(width: 14, height: 14) .foregroundColor(foregroundColor ?? .secondary) .conditionalFontWeight(fontWeight) .padding(4) diff --git a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift index d7cd5ec4..5b0e967a 100644 --- a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift +++ b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift @@ -127,12 +127,20 @@ public struct CustomTextEditor: NSViewRepresentable { public func updateNSView(_ nsView: NSScrollView, context: Context) { let textView = (context.coordinator.theTextView.documentView as! NSTextView) textView.isEditable = isEditable - guard textView.string != text else { return } - textView.string = text - textView.undoManager?.removeAllActions() - // Update height calculation when text changes - context.coordinator.calculateAndUpdateHeight(textView: textView) + if textView.font != font { + textView.font = font + // Update height calculation when text changes + context.coordinator.calculateAndUpdateHeight(textView: textView) + } + + if textView.string != text { + textView.string = text + textView.undoManager?.removeAllActions() + // Update height calculation when text changes + context.coordinator.calculateAndUpdateHeight(textView: textView) + } + } } diff --git a/Tool/Sources/SharedUIComponents/DownvoteButton.swift b/Tool/Sources/SharedUIComponents/DownvoteButton.swift index 952aadbc..703a2f24 100644 --- a/Tool/Sources/SharedUIComponents/DownvoteButton.swift +++ b/Tool/Sources/SharedUIComponents/DownvoteButton.swift @@ -18,13 +18,8 @@ public struct DownvoteButton: View { Image(systemName: isSelected ? "hand.thumbsdown.fill" : "hand.thumbsdown") .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) -// .frame(width: 20, height: 20, alignment: .center) + .scaledFrame(width: 14, height: 14) .foregroundColor(.secondary) -// .background( -// .regularMaterial, -// in: RoundedRectangle(cornerRadius: 4, style: .circular) -// ) .padding(4) .help("Unhelpful") } diff --git a/Tool/Sources/SharedUIComponents/InsertButton.swift b/Tool/Sources/SharedUIComponents/InsertButton.swift index 355d8982..dc465210 100644 --- a/Tool/Sources/SharedUIComponents/InsertButton.swift +++ b/Tool/Sources/SharedUIComponents/InsertButton.swift @@ -20,13 +20,8 @@ public struct InsertButton: View { self.icon .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) -// .frame(width: 20, height: 20, alignment: .center) + .scaledFrame(width: 14, height: 14) .foregroundColor(.secondary) -// .background( -// .regularMaterial, -// in: RoundedRectangle(cornerRadius: 4, style: .circular) -// ) .padding(4) } .buttonStyle(HoverButtonStyle(padding: 0)) diff --git a/Tool/Sources/SharedUIComponents/InstructionView.swift b/Tool/Sources/SharedUIComponents/InstructionView.swift index 774ea7c8..42ef9049 100644 --- a/Tool/Sources/SharedUIComponents/InstructionView.swift +++ b/Tool/Sources/SharedUIComponents/InstructionView.swift @@ -18,22 +18,22 @@ public struct Instruction: View { .resizable() .renderingMode(.template) .scaledToFill() - .frame(width: 60.0, height: 60.0) + .scaledFrame(width: 60.0, height: 60.0) .foregroundColor(.secondary) if isAgentMode { Text("Copilot Agent Mode") - .font(.title) + .scaledFont(.title) .foregroundColor(.primary) Text("Ask Copilot to edit your files in agent mode.\nIt will automatically use multiple requests to \nedit files, run terminal commands, and fix errors.") - .font(.system(size: 14, weight: .light)) + .scaledFont(.system(size: 14, weight: .light)) .multilineTextAlignment(.center) .lineSpacing(4) } Text("Copilot is powered by AI, so mistakes are possible. Review output carefully before use.") - .font(.system(size: 14, weight: .light)) + .scaledFont(.system(size: 14, weight: .light)) .multilineTextAlignment(.center) .lineSpacing(4) } @@ -42,18 +42,18 @@ public struct Instruction: View { if isAgentMode { Label("to configure MCP server", systemImage: "wrench.and.screwdriver") .foregroundColor(Color("DescriptionForegroundColor")) - .font(.system(size: 14)) + .scaledFont(.system(size: 14)) } Label("to reference context", systemImage: "paperclip") .foregroundColor(Color("DescriptionForegroundColor")) - .font(.system(size: 14)) + .scaledFont(.system(size: 14)) if !isAgentMode { Text("@ to chat with extensions") .foregroundColor(Color("DescriptionForegroundColor")) - .font(.system(size: 14)) + .scaledFont(.system(size: 14)) Text("Type / to use commands") .foregroundColor(Color("DescriptionForegroundColor")) - .font(.system(size: 14)) + .scaledFont(.system(size: 14)) } } } diff --git a/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift b/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift index c5c1e12d..bee224dd 100644 --- a/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift +++ b/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift @@ -7,23 +7,26 @@ public enum CheckboxMixedState { public struct MixedStateCheckbox: View { let title: String + let font: NSFont let action: () -> Void @Binding var state: CheckboxMixedState - public init(title: String, state: Binding, action: @escaping () -> Void) { + public init(title: String, font: NSFont, state: Binding, action: @escaping () -> Void) { self.title = title + self.font = font self.action = action self._state = state } public var body: some View { - MixedStateCheckboxView(title: title, state: state, action: action) + MixedStateCheckboxView(title: title, font: font, state: state, action: action) } } private struct MixedStateCheckboxView: NSViewRepresentable { let title: String + let font: NSFont let state: CheckboxMixedState let action: () -> Void @@ -32,6 +35,7 @@ private struct MixedStateCheckboxView: NSViewRepresentable { button.setButtonType(.switch) button.allowsMixedState = true button.title = title + button.font = font button.target = context.coordinator button.action = #selector(Coordinator.onButtonClicked) button.setContentHuggingPriority(.required, for: .horizontal) @@ -56,6 +60,10 @@ private struct MixedStateCheckboxView: NSViewRepresentable { } func updateNSView(_ nsView: NSButton, context: Context) { + if nsView.font != font { + nsView.font = font + } + nsView.title = title switch state { diff --git a/Tool/Sources/SharedUIComponents/ScaledComponent/FontScaleManager.swift b/Tool/Sources/SharedUIComponents/ScaledComponent/FontScaleManager.swift new file mode 100644 index 00000000..f7b25d55 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/FontScaleManager.swift @@ -0,0 +1,104 @@ +import SwiftUI +import Combine + +extension Notification.Name { + static let fontScaleDidChange = Notification + .Name("com.github.CopilotForXcode.FontScaleDidChange") +} + +@MainActor +public class FontScaleManager: ObservableObject { + @AppStorage(\.fontScale) private var fontScale { + didSet { + // Only post notification if this change originated locally + postNotificationIfNeeded() + } + } + + public static let shared: FontScaleManager = .init() + + public static let maxScale: Double = 2.0 + public static let minScale: Double = 0.8 + public static let scaleStep: Double = 0.1 + public static let defaultScale: Double = 1.0 + + private let processIdentifier = UUID().uuidString + private var lastReceivedNotificationId: String? + + private init() { + // Listen for font scale changes from other processes + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleFontScaleChanged(_:)), + name: .fontScaleDidChange, + object: nil + ) + } + + deinit { + DistributedNotificationCenter.default().removeObserver(self) + } + + private func postNotificationIfNeeded() { + // Don't post notification if we're processing an external notification + guard lastReceivedNotificationId == nil else { return } + + let notificationId = UUID().uuidString + DistributedNotificationCenter.default().postNotificationName( + .fontScaleDidChange, + object: nil, + userInfo: [ + "fontScale": fontScale, + "sourceProcess": processIdentifier, + "notificationId": notificationId + ], + deliverImmediately: true + ) + } + + @objc private func handleFontScaleChanged(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let scale = userInfo["fontScale"] as? Double, + let sourceProcess = userInfo["sourceProcess"] as? String, + let notificationId = userInfo["notificationId"] as? String else { + return + } + + // Ignore notifications from this process + guard sourceProcess != processIdentifier else { return } + + // Ignore duplicate notifications + guard notificationId != lastReceivedNotificationId else { return } + + // Only update if the value actually changed (with epsilon for floating-point) + guard abs(fontScale - scale) > 0.001 else { return } + + lastReceivedNotificationId = notificationId + fontScale = scale + lastReceivedNotificationId = nil + } + + public func increaseFontScale() { + fontScale = min(fontScale + Self.scaleStep, Self.maxScale) + } + + public func decreaseFontScale() { + fontScale = max(fontScale - Self.scaleStep, Self.minScale) + } + + public func setFontScale(_ scale: Double) { + guard scale <= Self.maxScale && scale >= Self.minScale else { + return + } + + fontScale = scale + } + + public func resetFontScale() { + fontScale = Self.defaultScale + } + + public var currentScale: Double { + fontScale + } +} diff --git a/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFont.swift b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFont.swift new file mode 100644 index 00000000..2d01ebcc --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFont.swift @@ -0,0 +1,82 @@ +import SwiftUI +import AppKit + +// MARK: built-in fonts +// Refer to https://developer.apple.com/design/human-interface-guidelines/typography#macOS-built-in-text-styles +extension Font { + + public var builtinSize: CGFloat { + let textStyle = nsTextStyle ?? .body + + return NSFont.preferredFont(forTextStyle: textStyle).pointSize + } + + // Map SwiftUI Font to NSFont.TextStyle + private var nsTextStyle: NSFont.TextStyle? { + switch self { + case .largeTitle: .largeTitle + case .title: .title1 + case .title2: .title2 + case .title3: .title3 + case .headline: .headline + case .subheadline: .subheadline + case .body: .body + case .callout: .callout + case .footnote: .footnote + case .caption: .caption1 + case .caption2: .caption2 + default: nil + } + } + + var builtinWeight: Font.Weight { + switch self { + case .headline: .bold + case .caption2: .medium + default: .regular + } + } +} + +public extension View { + func scaledFont(_ font: Font) -> some View { + ScaledFontView(self, font: font) + } + + func scaledFont(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> some View { + ScaledFontView(self, size: size, weight: weight, design: design) + } +} + + +public struct ScaledFontView: View { + let fontSize: CGFloat + let fontWeight: Font.Weight + var fontDesign: Font.Design + let content: Content + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + init(_ content: Content, font: Font) { + self.fontSize = font.builtinSize + self.fontWeight = font.builtinWeight + self.fontDesign = .default + self.content = content + } + + public init(_ content: Content, size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) { + self.fontSize = size + self.fontWeight = weight + self.fontDesign = design + self.content = content + } + + public var body: some View { + content + .font(.system(size: fontSize * fontScale, weight: fontWeight, design: fontDesign)) + } +} diff --git a/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift new file mode 100644 index 00000000..9693ac8b --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift @@ -0,0 +1,46 @@ +import SwiftUI + +extension View { + public func scaledFrame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View { + ScaledFrameView(self, width: width, height: height, alignment: alignment) + } +} + +struct ScaledFrameView: View { + let content: Content + let width: CGFloat? + let height: CGFloat? + let alignment: Alignment + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + var scaledWidth: CGFloat? { + guard let width else { + return nil + } + return width * fontScale + } + + var scaledHeight: CGFloat? { + guard let height else { + return nil + } + return height * fontScale + } + + init(_ content: Content, width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) { + self.content = content + self.width = width + self.height = height + self.alignment = alignment + } + + var body: some View { + content + .frame(width: scaledWidth, height: scaledHeight, alignment: alignment) + } +} diff --git a/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledModifier.swift b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledModifier.swift new file mode 100644 index 00000000..a5f51378 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledModifier.swift @@ -0,0 +1,66 @@ +import SwiftUI + +// MARK: - padding +public extension View { + func scaledPadding(_ length: CGFloat?) -> some View { + scaledPadding(.all, length) + } + + func scaledPadding(_ edges: Edge.Set = .all, _ length: CGFloat? = nil) -> some View { + ScaledPaddingView(self, edges: edges, length: length) + } +} + +struct ScaledPaddingView: View { + let content: Content + let edges: Edge.Set + let length: CGFloat? + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + init(_ content: Content, edges: Edge.Set, length: CGFloat? = nil) { + self.content = content + self.edges = edges + self.length = length + } + + var body: some View { + content + .padding(edges, length.map { $0 * fontScale }) + } +} + + +// MARK: - scaleEffect +public extension View { + func scaledScaleEffect(_ s: CGFloat, anchor: UnitPoint = .center) -> some View { + ScaledScaleEffectView(self, s, anchor: anchor) + } +} + +struct ScaledScaleEffectView: View { + let content: Content + let s: CGFloat + let anchor: UnitPoint + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + init(_ content: Content, _ s: CGFloat, anchor: UnitPoint = .center) { + self.content = content + self.s = s + self.anchor = anchor + } + + var body: some View { + content + .scaleEffect(s * fontScale, anchor: anchor) + } +} diff --git a/Tool/Sources/SharedUIComponents/UpvoteButton.swift b/Tool/Sources/SharedUIComponents/UpvoteButton.swift index b4e13e2a..7a0b88b6 100644 --- a/Tool/Sources/SharedUIComponents/UpvoteButton.swift +++ b/Tool/Sources/SharedUIComponents/UpvoteButton.swift @@ -18,13 +18,8 @@ public struct UpvoteButton: View { Image(systemName: isSelected ? "hand.thumbsup.fill" : "hand.thumbsup") .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) -// .frame(width: 20, height: 20, alignment: .center) + .scaledFrame(width: 14, height: 14) .foregroundColor(.secondary) -// .background( -// .regularMaterial, -// in: RoundedRectangle(cornerRadius: 4, style: .circular) -// ) .padding(4) .help("Helpful") } diff --git a/Tool/Sources/SuggestionBasic/EditorInformation.swift b/Tool/Sources/SuggestionBasic/EditorInformation.swift index 1897e413..5c717f5a 100644 --- a/Tool/Sources/SuggestionBasic/EditorInformation.swift +++ b/Tool/Sources/SuggestionBasic/EditorInformation.swift @@ -4,7 +4,7 @@ import AppKit import AXExtension public struct EditorInformation { - public struct LineAnnotation: Equatable { + public struct LineAnnotation: Equatable, Hashable { public var type: String public var line: Int // 1-Based public var message: String diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index 69b5b0f2..f7ac3501 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -357,6 +357,8 @@ extension XPCExtensionService { } } } + + // MARK: MCP Server Tools @XPCServiceActor public func getAvailableMCPServerToolsCollections() async throws -> [MCPServerToolsCollection]? { @@ -392,6 +394,68 @@ extension XPCExtensionService { } } + // MARK: MCP Registry + + @XPCServiceActor + public func listMCPRegistryServers(_ params: MCPRegistryListServersParams) async throws -> MCPRegistryServerList? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.listMCPRegistryServers(params) { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(MCPRegistryServerList.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func getMCPRegistryServer(_ params: MCPRegistryGetServerParams) async throws -> MCPRegistryServerDetail? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.getMCPRegistryServer(params) { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(MCPRegistryServerDetail.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + @XPCServiceActor public func getCopilotFeatureFlags() async throws -> FeatureFlags? { return try await withXPCServiceConnected { diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift index 7670d590..72ca0e88 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -22,8 +22,11 @@ public protocol XPCServiceProtocol { func getXPCServiceAccessibilityPermission(withReply reply: @escaping (ObservedAXStatus) -> Void) func getXPCServiceExtensionPermission(withReply reply: @escaping (ExtensionPermissionStatus) -> Void) func getXcodeInspectorData(withReply reply: @escaping (Data?, Error?) -> Void) + func getAvailableMCPServerToolsCollections(withReply reply: @escaping (Data?) -> Void) func updateMCPServerToolsStatus(tools: Data) + func listMCPRegistryServers(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func getMCPRegistryServer(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) func getCopilotFeatureFlags(withReply reply: @escaping (Data?) -> Void) From 079132f43710f94edb400190123e72d135909d5c Mon Sep 17 00:00:00 2001 From: smoku8282 Date: Fri, 19 Sep 2025 05:46:00 +0200 Subject: [PATCH 24/26] Create launch.json --- .vscode/launch.json | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..d2895982 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,43 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Edge", + "request": "launch", + "type": "msedge", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + }, + { + "name": "Launch Chrome", + "request": "launch", + "type": "chrome", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + }, + { + "name": "Launch Edge", + "request": "launch", + "type": "msedge", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + }, + { + "name": "Launch Chrome", + "request": "launch", + "type": "chrome", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + }, + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + } + ] +} \ No newline at end of file From dee1fd1d2b14665fdc0d9769de98cafcac4e9df2 Mon Sep 17 00:00:00 2001 From: smoku8282 Date: Fri, 19 Sep 2025 06:34:50 +0200 Subject: [PATCH 25/26] Create copilot-instructions.md --- .github/copilot-instructions.md | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..b79131c4 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,46 @@ +# Copilot Agent Instructions for GitHub Copilot for Xcode + +## Project Overview +- **Purpose:** This project is an Xcode extension and companion app that brings GitHub Copilot's AI code suggestions and chat to Xcode, with deep integration for inline completions, chat, and agent-driven codebase modifications. +- **Architecture:** + - **Core/**: Swift Package containing main business logic, services, and UI components. Organized by feature (e.g., `ChatService`, `SuggestionService`, `PromptToCodeService`). + - **EditorExtension/**: Implements Xcode Source Editor Extension commands (e.g., Accept/Reject Suggestion, Open Chat, etc.). + - **CommunicationBridge/**: Handles XPC communication between the main app and extension services. + - **ExtensionService/**: Manages the lifecycle and UI of the extension's background service. + - **Server/**: Node.js backend for advanced features (optional, rarely modified). + - **Docs/**: Images and documentation assets. + +## Key Workflows +- **Build & Run:** + - Open `Copilot for Xcode.xcworkspace` in Xcode. + - Build the `GitHub Copilot for Xcode` app target for macOS 12+. + - The extension is enabled via System Preferences > Extensions > Xcode Source Editor. +- **Testing:** + - Run Swift Package tests from the `Core/` directory using Xcode or `swift test`. +- **Debugging:** + - Use the `CommunicationBridge` and `ExtensionService` logs for troubleshooting XPC and extension issues. + - See `TROUBLESHOOTING.md` for permission and integration issues. + +## Project Conventions +- **Feature Folders:** Each major feature in `Core/Sources/` is a separate folder with its own logic and tests. +- **Dependency Injection:** Uses [swift-dependencies](https://github.com/pointfreeco/swift-dependencies) and [Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) for state and effect management. +- **XPC Communication:** All cross-process calls use protocols in `Tool/` and are implemented in `CommunicationBridge/` and `ExtensionService/`. +- **Permissions:** Requires `Accessibility`, `Background`, and `Xcode Source Editor Extension` permissions. See `TROUBLESHOOTING.md` for details. +- **External Packages:** Managed in `Core/Package.swift`. Do not add dependencies directly to Xcode project files. + +## Integration Points +- **Xcode Extension:** Commands in `EditorExtension/` are registered in `Info.plist` and invoked via the Xcode Editor menu. +- **App ↔ Extension:** Communication via XPC, with protocols defined in `Tool/` and implemented in `CommunicationBridge/ServiceDelegate.swift`. +- **Updates:** Uses [Sparkle](https://sparkle-project.org/) for in-app updates. + +## Examples +- To add a new chat feature: create a folder in `Core/Sources/`, add logic, register in `Package.swift`, and connect via the appropriate service. +- To add a new editor command: implement in `EditorExtension/`, update `Info.plist`, and test in Xcode. + +## References +- See `README.md` for user setup and onboarding. +- See `TROUBLESHOOTING.md` for common integration and permission issues. +- See `Core/Package.swift` for dependency and target structure. + +--- +For questions about unclear patterns or missing documentation, ask for clarification or check the latest onboarding docs in `Docs/`. From 75aa71ae784388a557bfb796528802294130f4a5 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 19 Sep 2025 06:12:56 +0000 Subject: [PATCH 26/26] Pre-release 0.43.140 --- .github/actions/set-xcode-version/action.yml | 2 +- Copilot-for-Xcode-Info.plist | 2 + Core/Sources/ConversationTab/ChatPanel.swift | 4 +- .../ModelPicker/ModelPicker.swift | 23 ++- .../TerminalViews/RunInTerminalToolView.swift | 4 +- .../Views/WorkingSetView.swift | 2 +- .../AdvancedSettings/ChatSection.swift | 40 +++- Core/Sources/HostApp/BYOKConfigView.swift | 2 +- .../BYOKSettings/ProviderConfigView.swift | 101 ++++------ .../HostApp/MCPSettings/MCPIntroView.swift | 184 ------------------ .../SharedComponents/CardGroupBoxStyle.swift | 13 +- .../DisclosureSettingsRow.swift | 77 ++++++++ .../SharedComponents/SettingsToggle.swift | 2 + Core/Sources/HostApp/TabContainer.swift | 15 +- ...ConfigView.swift => ToolsConfigView.swift} | 7 +- .../AppState+LanguageModelTools.swift | 24 +++ .../ToolsSettings/BuiltInToolsListView.swift | 136 +++++++++++++ .../CopilotBuiltInToolManagerObservable.swift | 51 +++++ .../CopilotMCPToolManagerObservable.swift | 0 .../MCPAppState.swift | 0 .../MCPConfigConstants.swift | 0 .../HostApp/ToolsSettings/MCPIntroView.swift | 45 +++++ .../ToolsSettings/MCPManualInstallView.swift | 160 +++++++++++++++ .../MCPServerToolsSection.swift | 7 +- .../MCPToolsListContainerView.swift | 0 .../MCPToolsListView.swift | 2 - .../ToolRowView.swift} | 15 +- Core/Sources/Service/XPCService.swift | 44 +++++ .../ChatWindow/ChatHistoryView.swift | 8 +- .../SuggestionWidget/ChatWindowView.swift | 12 +- EditorExtension/Info.plist | 2 + ExtensionService/Info.plist | 2 + Server/package-lock.json | 52 ++--- Server/package.json | 6 +- Tool/Package.swift | 4 +- .../LSPTypes.swift | 85 ++++++++ .../LanguageServer/ClientToolRegistry.swift | 7 +- .../CopilotLanguageModelToolManager.swift | 106 ++++++++++ .../LanguageServer/GitHubCopilotRequest.swift | 14 +- .../GitHubCopilotRequestTypes/MCP.swift | 14 +- .../LanguageServer/GitHubCopilotService.swift | 110 ++++++++++- .../Services/FeatureFlagNotifier.swift | 16 +- .../GitHubCopilotConversationService.swift | 4 +- Tool/Sources/Persist/AppState.swift | 11 +- Tool/Sources/Preferences/Keys.swift | 4 + .../CopilotMessageHeader.swift | 2 +- .../SharedUIComponents/InstructionView.swift | 4 +- Tool/Sources/Status/Status.swift | 2 + .../XPCShared/XPCExtensionService.swift | 46 +++++ .../XPCShared/XPCServiceProtocol.swift | 4 +- 50 files changed, 1122 insertions(+), 355 deletions(-) delete mode 100644 Core/Sources/HostApp/MCPSettings/MCPIntroView.swift create mode 100644 Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift rename Core/Sources/HostApp/{MCPConfigView.swift => ToolsConfigView.swift} (97%) create mode 100644 Core/Sources/HostApp/ToolsSettings/AppState+LanguageModelTools.swift create mode 100644 Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift create mode 100644 Core/Sources/HostApp/ToolsSettings/CopilotBuiltInToolManagerObservable.swift rename Core/Sources/HostApp/{MCPSettings => ToolsSettings}/CopilotMCPToolManagerObservable.swift (100%) rename Core/Sources/HostApp/{MCPSettings => ToolsSettings}/MCPAppState.swift (100%) rename Core/Sources/HostApp/{MCPSettings => ToolsSettings}/MCPConfigConstants.swift (100%) create mode 100644 Core/Sources/HostApp/ToolsSettings/MCPIntroView.swift create mode 100644 Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift rename Core/Sources/HostApp/{MCPSettings => ToolsSettings}/MCPServerToolsSection.swift (97%) rename Core/Sources/HostApp/{MCPSettings => ToolsSettings}/MCPToolsListContainerView.swift (100%) rename Core/Sources/HostApp/{MCPSettings => ToolsSettings}/MCPToolsListView.swift (97%) rename Core/Sources/HostApp/{MCPSettings/MCPToolRowView.swift => ToolsSettings/ToolRowView.swift} (75%) create mode 100644 Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift diff --git a/.github/actions/set-xcode-version/action.yml b/.github/actions/set-xcode-version/action.yml index 4d8dc817..b8831351 100644 --- a/.github/actions/set-xcode-version/action.yml +++ b/.github/actions/set-xcode-version/action.yml @@ -6,7 +6,7 @@ inputs: Xcode version to use, in semver(ish)-style matching the format on the Actions runner image. See available versions at https://github.com/actions/runner-images/blame/main/images/macos/macos-14-Readme.md#xcode required: false - default: '16.2' + default: '26.0' outputs: xcode-path: description: "Path to current Xcode version" diff --git a/Copilot-for-Xcode-Info.plist b/Copilot-for-Xcode-Info.plist index 12d852d9..62815b26 100644 --- a/Copilot-for-Xcode-Info.plist +++ b/Copilot-for-Xcode-Info.plist @@ -30,5 +30,7 @@ $(TeamIdentifierPrefix) STANDARD_TELEMETRY_CHANNEL_KEY $(STANDARD_TELEMETRY_CHANNEL_KEY) + GITHUB_APP_ID + $(GITHUB_APP_ID) \ No newline at end of file diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index a84be0c6..c5608cf8 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -58,7 +58,7 @@ public struct ChatPanel: View { } .padding(.leading, 16) .padding(.bottom, 16) - .background(Color(nsColor: .windowBackgroundColor)) + .background(.ultraThinMaterial) .onAppear { chat.send(.appear) } @@ -686,7 +686,7 @@ struct ChatPanelInputArea: View { } .overlay { RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .controlColor), lineWidth: 1) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) } .background { Button(action: { diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift index c5a77eb4..228ad965 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift @@ -23,6 +23,7 @@ struct ModelPicker: View { @State private var agentScopeCache: ScopeCache = ScopeCache() @State var isMCPFFEnabled: Bool + @State var isBYOKFFEnabled: Bool @State private var cancellables = Set() @StateObject private var fontScaleManager = FontScaleManager.shared @@ -47,12 +48,14 @@ struct ModelPicker: View { CopilotModelManager.getDefaultChatModel() self._selectedModel = State(initialValue: initialModel) self.isMCPFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.mcp + self.isBYOKFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.byok updateAgentPicker() } private func subscribeToFeatureFlagsDidChangeEvent() { FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in isMCPFFEnabled = featureFlags.mcp + isBYOKFFEnabled = featureFlags.byok }) .store(in: &cancellables) } @@ -142,7 +145,10 @@ struct ModelPicker: View { func updateCurrentModel() { let currentModel = AppState.shared.getSelectedModel() - let allAvailableModels = copilotModels + byokModels + var allAvailableModels = copilotModels + if isBYOKFFEnabled { + allAvailableModels += byokModels + } // Check if current model exists in available models for current scope using model comparison let modelExists = allAvailableModels.contains { model in @@ -207,11 +213,13 @@ struct ModelPicker: View { // Display premium models section if available modelSection(title: "Premium Models", models: premiumModels) - // Display byok models section if available - modelSection(title: "Other Models", models: byokModels) - - Button("Manage Models...") { - try? launchHostAppBYOKSettings() + if isBYOKFFEnabled { + // Display byok models section if available + modelSection(title: "Other Models", models: byokModels) + + Button("Manage Models...") { + try? launchHostAppBYOKSettings() + } } if standardModels.isEmpty { @@ -342,6 +350,9 @@ struct ModelPicker: View { .onChange(of: chatMode) { _ in updateCurrentModel() } + .onChange(of: isBYOKFFEnabled) { _ in + updateCurrentModel() + } .onReceive(NotificationCenter.default.publisher(for: .gitHubCopilotSelectedModelDidChange)) { _ in updateCurrentModel() } diff --git a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift index 9c827ac4..5d57b03d 100644 --- a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift +++ b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift @@ -74,7 +74,7 @@ struct RunInTerminalToolView: View { .scaledFrame(width: 16, height: 16) Text(self.title) - .scaledFont(.system(size: chatFontSize, weight: .semibold)) + .scaledFont(size: chatFontSize, weight: .semibold) .foregroundStyle(.primary) .background(Color.clear) .frame(maxWidth: .infinity, alignment: .leading) @@ -122,7 +122,7 @@ struct RunInTerminalToolView: View { Text(command!) .textSelection(.enabled) - .scaledFont(.system(size: chatFontSize, design: .monospaced)) + .scaledFont(size: chatFontSize, design: .monospaced) .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(codeForegroundColor) diff --git a/Core/Sources/ConversationTab/Views/WorkingSetView.swift b/Core/Sources/ConversationTab/Views/WorkingSetView.swift index afec6179..8709a4c8 100644 --- a/Core/Sources/ConversationTab/Views/WorkingSetView.swift +++ b/Core/Sources/ConversationTab/Views/WorkingSetView.swift @@ -139,7 +139,7 @@ struct FileEditView: View { switch imageType { case .system(let name): Image(systemName: name) - .scaledFont(.system(size: 15, weight: .regular)) + .scaledFont(size: 15, weight: .regular) case .asset(let name): Image(name) .renderingMode(.template) diff --git a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift index 036f98a2..569b0a56 100644 --- a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift @@ -4,10 +4,12 @@ import SwiftUI import Toast import XcodeInspector import SharedUIComponents +import Logger struct ChatSection: View { @AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode @AppStorage(\.enableFixError) var enableFixError + @State private var isEditorPreviewEnabled: Bool = false var body: some View { SettingsSection(title: "Chat Settings") { @@ -23,11 +25,13 @@ struct ChatSection: View { Divider() - // Custom Prompts - .github/prompts/*.prompt.md - PromptFileSetting(promptType: .prompt) - .padding(SettingsToggle.defaultPadding) - - Divider() + if isEditorPreviewEnabled { + // Custom Prompts - .github/prompts/*.prompt.md + PromptFileSetting(promptType: .prompt) + .padding(SettingsToggle.defaultPadding) + + Divider() + } // Auto Attach toggle SettingsToggle( @@ -55,6 +59,28 @@ struct ChatSection: View { FontSizeSetting() .padding(SettingsToggle.defaultPadding) } + .onAppear { + Task { + await updateEditorPreviewFeatureFlag() + } + } + .onReceive(DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in + Task { + await updateEditorPreviewFeatureFlag() + } + } + } + + private func updateEditorPreviewFeatureFlag() async { + do { + let service = try getService() + if let featureFlags = try await service.getCopilotFeatureFlags() { + isEditorPreviewEnabled = featureFlags.editorPreviewFeatures + } + } catch { + Logger.client.error("Failed to get copilot feature flags: \(error)") + } } } @@ -112,7 +138,7 @@ struct ResponseLanguageSetting: View { Text(option.displayName).tag(option.localeCode) } } - .frame(maxWidth: 200, alignment: .leading) + .frame(maxWidth: 200, alignment: .trailing) } } } @@ -198,7 +224,7 @@ struct FontSizeSetting: View { } } ) - .padding(.leading, calculateDefaultMarkerXPosition() + 2) + .padding(.leading, calculateDefaultMarkerXPosition() + 6) .onHover { if $0 { NSCursor.pointingHand.push() diff --git a/Core/Sources/HostApp/BYOKConfigView.swift b/Core/Sources/HostApp/BYOKConfigView.swift index a55b995a..50d569da 100644 --- a/Core/Sources/HostApp/BYOKConfigView.swift +++ b/Core/Sources/HostApp/BYOKConfigView.swift @@ -22,7 +22,7 @@ public struct BYOKConfigView: View { private func expansionBinding(for provider: BYOKProvider) -> Binding { Binding( - get: { expansionStates[provider] ?? true }, + get: { expansionStates[provider] ?? false }, set: { expansionStates[provider] = $0 } ) } diff --git a/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift index 5ea63b54..b29ed3cc 100644 --- a/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift +++ b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift @@ -97,72 +97,53 @@ struct BYOKProviderConfigView: View { // MARK: - UI Components private var ProviderLabelView: some View { - HStack(spacing: 8) { - Image(systemName: "chevron.right").font(.footnote.bold()) - .foregroundColor(.secondary) - .rotationEffect(.degrees(isExpanded ? 90 : 0)) - .animation(.easeInOut(duration: 0.3), value: isExpanded) - .buttonStyle(.borderless) - .opacity(hasApiKey ? 1 : 0) - .allowsHitTesting(hasApiKey) - - HStack(spacing: 8) { - Text(provider.title) - .foregroundColor( - hasApiKey ? .primary : Color( - nsColor: colorScheme == .light ? .tertiaryLabelColor : .secondaryLabelColor - ) - ) - .bold() + - Text(hasModels ? " (\(allModels.filter { $0.isRegistered }.count) of \(allModels.count) Enabled)" : "") - .foregroundColor(.primary) - } - .padding(.vertical, 4) - } + Text(provider.title) + .foregroundColor( + hasApiKey ? .primary : Color( + nsColor: colorScheme == .light ? .tertiaryLabelColor : .secondaryLabelColor + ) + ) + .bold() + + Text(hasModels ? " (\(allModels.filter { $0.isRegistered }.count) of \(allModels.count) Enabled)" : "") + .foregroundColor(.primary) } private var ProviderHeaderRowView: some View { - HStack(alignment: .center, spacing: 16) { - ProviderLabelView - - Spacer() - - if let errorMessage = errorMessage { - Badge(text: "Can't connect. Check your API key or network.", level: .danger, icon: "xmark.circle.fill") - .help("Unable to connect to \(provider.title). \(errorMessage) Refresh or recheck your key setup.") - } - - if hasApiKey { - if dataManager.isLoadingProvider(provider) { - ProgressView().controlSize(.small) - } else { - ConfiguredProviderActions + DisclosureSettingsRow( + isExpanded: $isExpanded, + isEnabled: hasApiKey, + accessibilityLabel: { expanded in "\(provider.title) \(expanded ? "collapse" : "expand")" }, + onToggle: { wasExpanded, nowExpanded in + if wasExpanded && !nowExpanded && isSearchBarVisible { + searchText = "" + withAnimation(.easeInOut) { isSearchBarVisible = false } } - } else { - UnconfiguredProviderAction - } - } - .padding(.leading, 20) - .padding(.trailing, 24) - .padding(.vertical, 8) - .background(QuaternarySystemFillColor.opacity(0.75)) - .contentShape(Rectangle()) - .onTapGesture { - guard hasApiKey else { return } - let wasExpanded = isExpanded - withAnimation(.easeInOut) { - isExpanded.toggle() - } - // If we just collapsed, and the search bar was open, reset it. - if wasExpanded && !isExpanded && isSearchBarVisible { - searchText = "" - withAnimation(.easeInOut) { - isSearchBarVisible = false + }, + title: { ProviderLabelView }, + actions: { + Group { + if let errorMessage = errorMessage { + Badge( + text: "Can't connect. Check your API key or network.", + level: .danger, + icon: "xmark.circle.fill" + ) + .help("Unable to connect to \(provider.title). \(errorMessage) Refresh or recheck your key setup.") + } + if hasApiKey { + if dataManager.isLoadingProvider(provider) { + ProgressView().controlSize(.small) + } else { + ConfiguredProviderActions + } + } else { + UnconfiguredProviderAction + } } + .padding(.trailing, 4) + .frame(height: 30) } - } - .accessibilityAddTraits(.isButton) - .accessibilityLabel("\(provider.title) \(isExpanded ? "collapse" : "expand")") + ) } @ViewBuilder diff --git a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift b/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift deleted file mode 100644 index 3785dfee..00000000 --- a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift +++ /dev/null @@ -1,184 +0,0 @@ -import Client -import Foundation -import Logger -import SharedUIComponents -import SwiftUI - -struct MCPIntroView: View { - var exampleConfig: String { - """ - { - "servers": { - "my-mcp-server": { - "type": "stdio", - "command": "my-command", - "args": [], - "env": { - "TOKEN": "my_token" - } - } - } - } - """ - } - - @State private var isExpanded = true - @Binding private var isMCPFFEnabled: Bool - - public init(isExpanded: Bool = true, isMCPFFEnabled: Binding) { - self.isExpanded = isExpanded - self._isMCPFFEnabled = isMCPFFEnabled - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - if !isMCPFFEnabled { - GroupBox { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "info.circle.fill") - .font(.body) - .foregroundColor(.gray) - Text( - "MCP servers are disabled by your organization’s policy. To enable them, please contact your administrator. [Get More Info about Copilot policies](https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-organization/manage-policies)" - ) - } - } - .groupBoxStyle( - CardGroupBoxStyle( - backgroundColor: Color(nsColor: .textBackgroundColor) - ) - ) - } - - GroupBox( - label: Text("Model Context Protocol (MCP) Configuration") - .fontWeight(.bold) - ) { - Text( - "MCP is an open standard that connects AI models to external tools. In Xcode, it enhances GitHub Copilot's agent mode by connecting to any MCP server and integrating its tools into your workflow. [Learn More](https://modelcontextprotocol.io/introduction)" - ) - }.groupBoxStyle(CardGroupBoxStyle()) - - if isMCPFFEnabled { - DisclosureGroup(isExpanded: $isExpanded) { - exampleConfigView() - } label: { - sectionHeader() - } - .padding(.horizontal, 0) - .padding(.vertical, 10) - - HStack(spacing: 8) { - Button { - openConfigFile() - } label: { - HStack(spacing: 0) { - Image(systemName: "square.and.pencil") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12, height: 12, alignment: .center) - .padding(4) - Text("Edit Config") - } - .conditionalFontWeight(.semibold) - } - .buttonStyle(.borderedProminent) - .help("Configure your MCP server") - - Button { - openMCPRunTimeLogFolder() - } label: { - HStack(spacing: 0) { - Image(systemName: "folder") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12, height: 12, alignment: .center) - .padding(4) - Text("Open MCP Log Folder") - } - .conditionalFontWeight(.semibold) - } - .buttonStyle(.bordered) - .help("Open MCP Runtime Log Folder") - } - } - } - - } - - @ViewBuilder - private func exampleConfigView() -> some View { - Text(exampleConfig) - .font(.system(.body, design: .monospaced)) - .padding(.horizontal, 16) - .padding(.top, 8) - .padding(.bottom, 6) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - Color(nsColor: .textBackgroundColor).opacity(0.5) - ) - .textSelection(.enabled) - .cornerRadius(4) - .overlay( - RoundedRectangle(cornerRadius: 4) - .inset(by: 0.5) - .stroke(Color("GroupBoxStrokeColor"), lineWidth: 1) - ) - } - - @ViewBuilder - private func sectionHeader() -> some View { - HStack(spacing: 8) { - Text("Example Configuration").foregroundColor(.primary.opacity(0.85)) - - CopyButton( - copy: { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(exampleConfig, forType: .string) - }, - foregroundColor: .primary.opacity(0.85), - fontWeight: .semibold - ) - .frame(width: 10, height: 10) - } - .padding(.leading, 4) - } - - private func openConfigFile() { - let url = URL(fileURLWithPath: mcpConfigFilePath) - NSWorkspace.shared.open(url) - } - - private func openMCPRunTimeLogFolder() { - let url = URL( - fileURLWithPath: FileLoggingLocation.mcpRuntimeLogsPath.description, - isDirectory: true - ) - - // Create directory if it doesn't exist - if !FileManager.default.fileExists(atPath: url.path) { - do { - try FileManager.default.createDirectory( - atPath: url.path, - withIntermediateDirectories: true, - attributes: nil - ) - } catch { - Logger.client.error("Failed to create MCP runtime log folder: \(error)") - return - } - } - - NSWorkspace.shared.open(url) - } -} - -#Preview { - MCPIntroView(isExpanded: true, isMCPFFEnabled: .constant(true)) - .frame(width: 800) -} - -#Preview { - MCPIntroView(isExpanded: true, isMCPFFEnabled: .constant(false)) - .frame(width: 800) -} diff --git a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift index 85205d04..1cc98444 100644 --- a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift +++ b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift @@ -2,8 +2,13 @@ import SwiftUI public struct CardGroupBoxStyle: GroupBoxStyle { public var backgroundColor: Color - public init(backgroundColor: Color = Color("GroupBoxBackgroundColor")) { + public var borderColor: Color + public init( + backgroundColor: Color = QuaternarySystemFillColor.opacity(0.75), + borderColor: Color = SecondarySystemFillColor + ) { self.backgroundColor = backgroundColor + self.borderColor = borderColor } public func makeBody(configuration: Configuration) -> some View { VStack(alignment: .leading, spacing: 11) { @@ -13,11 +18,11 @@ public struct CardGroupBoxStyle: GroupBoxStyle { .padding(8) .frame(maxWidth: .infinity, alignment: .topLeading) .background(backgroundColor) - .cornerRadius(4) + .cornerRadius(12) .overlay( - RoundedRectangle(cornerRadius: 4) + RoundedRectangle(cornerRadius: 12) .inset(by: 0.5) - .stroke(Color("GroupBoxStrokeColor"), lineWidth: 1) + .stroke(borderColor, lineWidth: 1) ) } } diff --git a/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift b/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift new file mode 100644 index 00000000..38559dcc --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift @@ -0,0 +1,77 @@ +import SwiftUI + +public struct DisclosureSettingsRow: View { + @Binding private var isExpanded: Bool + private let isEnabled: Bool + private let background: Color + private let padding: EdgeInsets + private let spacing: CGFloat + private let accessibilityLabel: (Bool) -> String + private let onToggle: ((Bool, Bool) -> Void)? + @ViewBuilder private let title: () -> Title + @ViewBuilder private let subtitle: () -> Subtitle + @ViewBuilder private let actions: () -> Actions + + public init( + isExpanded: Binding, + isEnabled: Bool = true, + background: Color = QuaternarySystemFillColor.opacity(0.75), + padding: EdgeInsets = EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20), + spacing: CGFloat = 16, + accessibilityLabel: @escaping (Bool) -> String = { expanded in expanded ? "collapse" : "expand" }, + onToggle: ((Bool, Bool) -> Void)? = nil, + @ViewBuilder title: @escaping () -> Title, + @ViewBuilder subtitle: @escaping (() -> Subtitle) = { EmptyView() }, + @ViewBuilder actions: @escaping () -> Actions + ) { + _isExpanded = isExpanded + self.isEnabled = isEnabled + self.background = background + self.padding = padding + self.spacing = spacing + self.accessibilityLabel = accessibilityLabel + self.onToggle = onToggle + self.title = title + self.subtitle = subtitle + self.actions = actions + } + + public var body: some View { + HStack(alignment: .center, spacing: spacing) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "chevron.right") + .font(.footnote.bold()) + .foregroundColor(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .animation(.easeInOut(duration: 0.3), value: isExpanded) + .opacity(isEnabled ? 1 : 0) + .allowsHitTesting(isEnabled) + title() + } + .padding(.vertical, 4) + + subtitle() + .padding(.leading, 16) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + actions() + } + .padding(padding) + .background(background) + .contentShape(Rectangle()) + .onTapGesture { + guard isEnabled else { return } + let previous = isExpanded + withAnimation(.easeInOut) { + isExpanded.toggle() + } + onToggle?(previous, isExpanded) + } + .accessibilityAddTraits(.isButton) + .accessibilityLabel(accessibilityLabel(isExpanded)) + } +} diff --git a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift index 5c51d21f..2576dc1c 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift @@ -11,7 +11,9 @@ struct SettingsToggle: View { Text(title) Spacer() Toggle(isOn: isOn) {} + .controlSize(.mini) .toggleStyle(.switch) + .padding(.vertical, 4) } .padding(SettingsToggle.defaultPadding) } diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index e037f595..3b81636a 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -17,6 +17,7 @@ public struct TabContainer: View { @ObservedObject var toastController: ToastController @State private var tabBarItems = [TabBarItem]() @State private var isAgentModeFFEnabled = true + @State private var isBYOKFFEnabled = true @Binding var tag: TabIndex public init() { @@ -37,14 +38,18 @@ public struct TabContainer: View { ) } - private func updateAgentModeFeatureFlag() async { + private func updateHostAppFeatureFlags() async { do { let service = try getService() let featureFlags = try await service.getCopilotFeatureFlags() isAgentModeFFEnabled = featureFlags?.agentMode ?? true + isBYOKFFEnabled = featureFlags?.byok ?? true if hostAppStore.state.activeTabIndex == .tools && !isAgentModeFFEnabled { hostAppStore.send(.setActiveTab(.general)) } + if hostAppStore.state.activeTabIndex == .byok && !isBYOKFFEnabled { + hostAppStore.send(.setActiveTab(.general)) + } } catch { Logger.client.error("Failed to get copilot feature flags: \(error)") } @@ -61,7 +66,9 @@ public struct TabContainer: View { if isAgentModeFFEnabled { MCPConfigView().tabBarItem(for: .tools) } - BYOKConfigView().tabBarItem(for: .byok) + if isBYOKFFEnabled { + BYOKConfigView().tabBarItem(for: .byok) + } } .environment(\.tabBarTabTag, tag) .frame(minHeight: 400) @@ -76,13 +83,13 @@ public struct TabContainer: View { .onAppear { store.send(.appear) Task { - await updateAgentModeFeatureFlag() + await updateHostAppFeatureFlags() } } .onReceive(DistributedNotificationCenter.default() .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in Task { - await updateAgentModeFeatureFlag() + await updateHostAppFeatureFlags() } } } diff --git a/Core/Sources/HostApp/MCPConfigView.swift b/Core/Sources/HostApp/ToolsConfigView.swift similarity index 97% rename from Core/Sources/HostApp/MCPConfigView.swift rename to Core/Sources/HostApp/ToolsConfigView.swift index e6b1e88b..16b1bb8e 100644 --- a/Core/Sources/HostApp/MCPConfigView.swift +++ b/Core/Sources/HostApp/ToolsConfigView.swift @@ -22,7 +22,7 @@ struct MCPConfigView: View { private static var lastSyncTimestamp: Date? = nil enum ToolType: String, CaseIterable, Identifiable { - case MCP + case MCP, BuiltIn var id: Self { self } } @@ -31,6 +31,7 @@ struct MCPConfigView: View { ScrollView { Picker("", selection: $selectedOption) { Text("MCP").tag(ToolType.MCP) + Text("Built-In").tag(ToolType.BuiltIn) } .pickerStyle(.segmented) .frame(width: 400) @@ -40,6 +41,7 @@ struct MCPConfigView: View { VStack(alignment: .leading, spacing: 8) { MCPIntroView(isMCPFFEnabled: $isMCPFFEnabled) if isMCPFFEnabled { + MCPManualInstallView() MCPToolsListView() } } @@ -67,8 +69,7 @@ struct MCPConfigView: View { } } } else { - // Built-In Tools View - EmptyView() + BuiltInToolsListView() } } .padding(20) diff --git a/Core/Sources/HostApp/ToolsSettings/AppState+LanguageModelTools.swift b/Core/Sources/HostApp/ToolsSettings/AppState+LanguageModelTools.swift new file mode 100644 index 00000000..867a0df1 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AppState+LanguageModelTools.swift @@ -0,0 +1,24 @@ +import ConversationServiceProvider +import Foundation +import Persist + +public let LANGUAGE_MODEL_TOOLS_STATUS = "languageModelToolsStatus" + +extension AppState { + public func getLanguageModelToolsStatus() -> [ToolStatusUpdate]? { + guard let savedJSON = get(key: LANGUAGE_MODEL_TOOLS_STATUS), + let data = try? JSONEncoder().encode(savedJSON), + let savedStatus = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data) else { + return nil + } + return savedStatus + } + + public func updateLanguageModelToolsStatus(_ updates: [ToolStatusUpdate]) { + update(key: LANGUAGE_MODEL_TOOLS_STATUS, value: updates) + } + + public func clearLanguageModelToolsStatus() { + update(key: LANGUAGE_MODEL_TOOLS_STATUS, value: "") + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift b/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift new file mode 100644 index 00000000..021ea437 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift @@ -0,0 +1,136 @@ +import Client +import Combine +import ConversationServiceProvider +import GitHubCopilotService +import Logger +import Persist +import SwiftUI + +struct BuiltInToolsListView: View { + @ObservedObject private var builtInToolManager = CopilotBuiltInToolManagerObservable.shared + @State private var isSearchBarVisible: Bool = false + @State private var searchText: String = "" + @State private var toolEnabledStates: [String: Bool] = [:] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + GroupBox(label: headerView) { + contentView + } + .groupBoxStyle(CardGroupBoxStyle()) + } + .onAppear { + initializeToolStates() + } + .onChange(of: builtInToolManager.availableLanguageModelTools) { _ in + initializeToolStates() + } + } + + // MARK: - Header View + + private var headerView: some View { + HStack(alignment: .center) { + Text("Built-In Tools").fontWeight(.bold) + Spacer() + SearchBar(isVisible: $isSearchBarVisible, text: $searchText) + } + .clipped() + } + + // MARK: - Content View + + private var contentView: some View { + let filteredTools = filteredLanguageModelTools() + + if filteredTools.isEmpty { + return AnyView(EmptyStateView()) + } else { + return AnyView(toolsListView(tools: filteredTools)) + } + } + + // MARK: - Tools List View + + private func toolsListView(tools: [LanguageModelTool]) -> some View { + VStack(spacing: 0) { + ForEach(tools, id: \.name) { tool in + ToolRow( + toolName: tool.displayName ?? tool.name, + toolDescription: tool.displayDescription, + toolStatus: tool.status, + isServerEnabled: true, + isToolEnabled: toolBindingFor(tool), + onToolToggleChanged: { isEnabled in + handleToolToggleChange(tool: tool, isEnabled: isEnabled) + } + ) + } + } + } + + // MARK: - Helper Methods + + private func initializeToolStates() { + var map: [String: Bool] = [:] + for tool in builtInToolManager.availableLanguageModelTools { + // Preserve existing state if already toggled locally + if let existing = toolEnabledStates[tool.name] { + map[tool.name] = existing + } else { + map[tool.name] = (tool.status == .enabled) + } + } + toolEnabledStates = map + } + + private func toolBindingFor(_ tool: LanguageModelTool) -> Binding { + Binding( + get: { toolEnabledStates[tool.name] ?? (tool.status == .enabled) }, + set: { newValue in + toolEnabledStates[tool.name] = newValue + } + ) + } + + private func filteredLanguageModelTools() -> [LanguageModelTool] { + let key = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !key.isEmpty else { return builtInToolManager.availableLanguageModelTools } + + return builtInToolManager.availableLanguageModelTools.filter { tool in + tool.name.lowercased().contains(key) || + (tool.description?.lowercased().contains(key) ?? false) || + (tool.displayName?.lowercased().contains(key) ?? false) + } + } + + private func handleToolToggleChange(tool: LanguageModelTool, isEnabled: Bool) { + // Optimistically update local state already done in binding. + let toolUpdate = ToolStatusUpdate(name: tool.name, status: isEnabled ? .enabled : .disabled) + updateToolStatus([toolUpdate]) + } + + private func updateToolStatus(_ toolUpdates: [ToolStatusUpdate]) { + Task { + do { + let service = try getService() + let updatedTools = try await service.updateToolsStatus(toolUpdates) + if updatedTools == nil { + Logger.client.error("Failed to update built-in tool status: No updated tools returned") + } + // CopilotLanguageModelToolManager will broadcast changes; our local + // toolEnabledStates keep rows visible even if disabled. + } catch { + Logger.client.error("Failed to update built-in tool status: \(error.localizedDescription)") + } + } + } +} + +/// Empty state view when no tools are available +private struct EmptyStateView: View { + var body: some View { + Text("No built-in tools available. Make sure background permissions are granted.") + .foregroundColor(.secondary) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/CopilotBuiltInToolManagerObservable.swift b/Core/Sources/HostApp/ToolsSettings/CopilotBuiltInToolManagerObservable.swift new file mode 100644 index 00000000..ae36f221 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/CopilotBuiltInToolManagerObservable.swift @@ -0,0 +1,51 @@ +import Client +import Combine +import ConversationServiceProvider +import Logger +import Persist +import SwiftUI + +class CopilotBuiltInToolManagerObservable: ObservableObject { + static let shared = CopilotBuiltInToolManagerObservable() + + @Published var availableLanguageModelTools: [LanguageModelTool] = [] + private var cancellables = Set() + + private init() { + DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotToolsDidChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + Task { + await self.refreshLanguageModelTools() + } + } + .store(in: &cancellables) + + Task { + await refreshLanguageModelTools() + } + } + + @MainActor + public func refreshLanguageModelTools() async { + do { + let service = try getService() + let languageModelTools = try await service.getAvailableLanguageModelTools() + + guard let tools = languageModelTools else { return } + + // Update the published list with all tools (both enabled and disabled) + availableLanguageModelTools = tools + + // Update AppState for persistence + let statusUpdates = tools.map { + ToolStatusUpdate(name: $0.name, status: $0.status) + } + AppState.shared.updateLanguageModelToolsStatus(statusUpdates) + } catch { + Logger.client.error("Failed to fetch language model tools: \(error)") + } + } +} diff --git a/Core/Sources/HostApp/MCPSettings/CopilotMCPToolManagerObservable.swift b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift similarity index 100% rename from Core/Sources/HostApp/MCPSettings/CopilotMCPToolManagerObservable.swift rename to Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift diff --git a/Core/Sources/HostApp/MCPSettings/MCPAppState.swift b/Core/Sources/HostApp/ToolsSettings/MCPAppState.swift similarity index 100% rename from Core/Sources/HostApp/MCPSettings/MCPAppState.swift rename to Core/Sources/HostApp/ToolsSettings/MCPAppState.swift diff --git a/Core/Sources/HostApp/MCPSettings/MCPConfigConstants.swift b/Core/Sources/HostApp/ToolsSettings/MCPConfigConstants.swift similarity index 100% rename from Core/Sources/HostApp/MCPSettings/MCPConfigConstants.swift rename to Core/Sources/HostApp/ToolsSettings/MCPConfigConstants.swift diff --git a/Core/Sources/HostApp/ToolsSettings/MCPIntroView.swift b/Core/Sources/HostApp/ToolsSettings/MCPIntroView.swift new file mode 100644 index 00000000..84b754a5 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPIntroView.swift @@ -0,0 +1,45 @@ +import Client +import Foundation +import Logger +import SharedUIComponents +import SwiftUI + +struct MCPIntroView: View { + @Binding private var isMCPFFEnabled: Bool + + public init(isMCPFFEnabled: Binding) { + _isMCPFFEnabled = isMCPFFEnabled + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if !isMCPFFEnabled { + GroupBox { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "info.circle.fill") + .font(.body) + .foregroundColor(.gray) + Text( + "MCP servers are disabled by your organization’s policy. To enable them, please contact your administrator. [Get More Info about Copilot policies](https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-organization/manage-policies)" + ) + } + } + .groupBoxStyle( + CardGroupBoxStyle( + backgroundColor: Color(nsColor: .textBackgroundColor) + ) + ) + } + } + } +} + +#Preview { + MCPIntroView(isMCPFFEnabled: .constant(true)) + .frame(width: 800) +} + +#Preview { + MCPIntroView(isMCPFFEnabled: .constant(false)) + .frame(width: 800) +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift new file mode 100644 index 00000000..36334622 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift @@ -0,0 +1,160 @@ +import AppKit +import Logger +import SharedUIComponents +import SwiftUI + +struct MCPManualInstallView: View { + @State private var isExpanded: Bool = false + + var body: some View { + VStack(spacing: 0) { + DisclosureSettingsRow( + isExpanded: $isExpanded, + accessibilityLabel: { $0 ? "Collapse manual install section" : "Expand manual install section" }, + title: { Text("Manual Install").font(.headline) }, + subtitle: { Text("Add MCP Servers to power AI with tools for files, databases, and external APIs.") }, + actions: { + HStack(spacing: 8) { + Button { + openMCPRunTimeLogFolder() + } label: { + HStack(spacing: 0) { + Image(systemName: "folder") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Open MCP Log Folder") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .help("Open MCP Runtime Log Folder") + + Button { + openConfigFile() + } label: { + HStack(spacing: 0) { + Image(systemName: "square.and.pencil") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Edit Config") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .help("Configure your MCP server") + } + .padding(.vertical, 12) + } + ) + + if isExpanded { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("Example Configuration").foregroundColor(.primary.opacity(0.85)) + + CopyButton( + copy: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(exampleConfig, forType: .string) + }, + foregroundColor: .primary.opacity(0.85), + fontWeight: .semibold + ) + .frame(width: 10, height: 10) + } + .padding(.leading, 4) + + exampleConfigView() + } + .padding(.top, 8) + .padding([.leading, .trailing, .bottom], 20) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + } + .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .inset(by: 0.5) + .stroke(SecondarySystemFillColor, lineWidth: 1) + .animation(.easeInOut(duration: 0.3), value: isExpanded) + ) + .animation(.easeInOut(duration: 0.3), value: isExpanded) + } + + var exampleConfig: String { + """ + { + "servers": { + "my-mcp-server": { + "type": "stdio", + "command": "my-command", + "args": [], + "env": { + "TOKEN": "my_token" + } + } + } + } + """ + } + + @ViewBuilder + private func exampleConfigView() -> some View { + Text(exampleConfig) + .font(.system(.body, design: .monospaced)) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + Color(nsColor: .textBackgroundColor).opacity(0.5) + ) + .textSelection(.enabled) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .inset(by: 0.5) + .stroke(Color("GroupBoxStrokeColor"), lineWidth: 1) + ) + } + + private func openMCPRunTimeLogFolder() { + let url = URL( + fileURLWithPath: FileLoggingLocation.mcpRuntimeLogsPath.description, + isDirectory: true + ) + + // Create directory if it doesn't exist + if !FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.createDirectory( + atPath: url.path, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + Logger.client.error("Failed to create MCP runtime log folder: \(error)") + return + } + } + + NSWorkspace.shared.open(url) + } + + private func openConfigFile() { + let url = URL(fileURLWithPath: mcpConfigFilePath) + NSWorkspace.shared.open(url) + } +} + +#Preview { + MCPManualInstallView() + .padding() + .frame(width: 900) +} diff --git a/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift similarity index 97% rename from Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift rename to Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift index 9641a45a..f9c45687 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift @@ -47,12 +47,15 @@ struct MCPServerToolsSection: View { VStack(spacing: 0) { divider ForEach(serverTools.tools, id: \.name) { tool in - MCPToolRow( - tool: tool, + ToolRow( + toolName: tool.name, + toolDescription: tool.description, + toolStatus: tool._status, isServerEnabled: isServerEnabled, isToolEnabled: toolBindingFor(tool), onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) } ) + .padding(.leading, 36) } } .onChange(of: serverTools) { newValue in diff --git a/Core/Sources/HostApp/MCPSettings/MCPToolsListContainerView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift similarity index 100% rename from Core/Sources/HostApp/MCPSettings/MCPToolsListContainerView.swift rename to Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift diff --git a/Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift similarity index 97% rename from Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift rename to Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift index 3b0cbe42..2cb6f530 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift @@ -86,9 +86,7 @@ private struct EmptyStateView: View { // Private components now defined in separate files: // MCPToolsListContainerView - in MCPToolsListContainerView.swift // MCPServerToolsSection - in MCPServerToolsSection.swift -// MCPToolRow - in MCPToolRowView.swift /// Private alias for maintaining backward compatibility private typealias ToolsListView = MCPToolsListContainerView private typealias ServerToolsSection = MCPServerToolsSection -private typealias ToolRow = MCPToolRow diff --git a/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift similarity index 75% rename from Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift rename to Core/Sources/HostApp/ToolsSettings/ToolRowView.swift index f6a8e20f..66a0ab81 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift +++ b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift @@ -1,9 +1,11 @@ import SwiftUI -import GitHubCopilotService +import ConversationServiceProvider /// Individual tool row -struct MCPToolRow: View { - let tool: MCPTool +struct ToolRow: View { + let toolName: String + let toolDescription: String? + let toolStatus: ToolStatus let isServerEnabled: Bool @Binding var isToolEnabled: Bool let onToolToggleChanged: (Bool) -> Void @@ -16,9 +18,9 @@ struct MCPToolRow: View { )) { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .center, spacing: 8) { - Text(tool.name).fontWeight(.medium) + Text(toolName).fontWeight(.medium) - if let description = tool.description { + if let description = toolDescription { Text(description) .font(.system(size: 11)) .foregroundColor(.secondary) @@ -31,9 +33,8 @@ struct MCPToolRow: View { } } } - .padding(.leading, 36) .padding(.vertical, 0) - .onChange(of: tool._status) { isToolEnabled = $0 == .enabled } + .onChange(of: toolStatus) { isToolEnabled = $0 == .enabled } .onChange(of: isServerEnabled) { if !$0 { isToolEnabled = false } } } } diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 4e006184..72769162 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -9,6 +9,7 @@ import XPCShared import HostAppActivator import XcodeInspector import GitHubCopilotViewModel +import ConversationServiceProvider public class XPCService: NSObject, XPCServiceProtocol { // MARK: - Service @@ -355,6 +356,49 @@ public class XPCService: NSObject, XPCServiceProtocol { } } } + + // MARK: - Language Model Tools + public func getAvailableLanguageModelTools(withReply reply: @escaping (Data?) -> Void) { + let availableLanguageModelTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() + if let availableLanguageModelTools = availableLanguageModelTools { + // Encode and send the data + let data = try? JSONEncoder().encode(availableLanguageModelTools) + reply(data) + } else { + reply(nil) + } + } + + public func updateToolsStatus(tools: Data, withReply reply: @escaping (Data?) -> Void) { + // Decode the data + let decoder = JSONDecoder() + var toolStatusUpdates: [ToolStatusUpdate] = [] + do { + toolStatusUpdates = try decoder.decode([ToolStatusUpdate].self, from: tools) + if toolStatusUpdates.isEmpty { + let emptyData = try JSONEncoder().encode([LanguageModelTool]()) + reply(emptyData) + return + } + } catch { + Logger.service.error("Failed to decode built-in tools: \(error)") + reply(nil) + return + } + + Task { @MainActor in + let updatedTools = await GitHubCopilotService.updateAllCLSTools(tools: toolStatusUpdates) + + // Encode and return the updated tools + do { + let data = try JSONEncoder().encode(updatedTools) + reply(data) + } catch { + Logger.service.error("Failed to encode updated tools: \(error)") + reply(nil) + } + } + } // MARK: - FeatureFlags public func getCopilotFeatureFlags( diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift index 5896af93..1c956781 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift @@ -42,7 +42,7 @@ struct ChatHistoryView: View { var body: some View { HStack { Text("Chat History") - .scaledFont(.system(size: 13, weight: .bold)) + .scaledFont(size: 13, weight: .bold) .lineLimit(nil) Spacer() @@ -174,7 +174,7 @@ struct ChatHistoryItemView: View { // directly get title from chat tab info Text(previewInfo.title ?? "New Chat") .frame(alignment: .leading) - .scaledFont(.system(size: 14, weight: .semibold)) + .scaledFont(size: 14, weight: .semibold) .foregroundColor(.primary) .lineLimit(1) @@ -189,7 +189,7 @@ struct ChatHistoryItemView: View { HStack(spacing: 0) { Text(formatDate(previewInfo.updatedAt)) .frame(alignment: .leading) - .scaledFont(.system(size: 13, weight: .regular)) + .scaledFont(size: 13, weight: .regular) .foregroundColor(.secondary) .lineLimit(1) @@ -228,7 +228,7 @@ struct ChatHistoryItemView: View { nsColor: .controlColor .withAlphaComponent(colorScheme == .dark ? 0.1 : 0.55) ), - cornerRadius: 4, + cornerRadius: 8, showBorder: isHovered, borderColor: Color(nsColor: .separatorColor) ) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 59d71231..a7e05e88 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -59,7 +59,7 @@ struct ChatView: View { var body: some View { VStack(spacing: 0) { - Rectangle().fill(.regularMaterial).frame(height: 28) + Rectangle().fill(Material.bar).frame(height: 28) Divider() @@ -67,7 +67,7 @@ struct ChatView: View { VStack(spacing: 0) { ChatBar(store: store, isChatHistoryVisible: $isChatHistoryVisible) .frame(height: 32) - .background(Color(nsColor: .windowBackgroundColor)) + .background(.ultraThinMaterial) Divider() @@ -89,7 +89,7 @@ struct ChatHistoryViewWrapper: View { var body: some View { WithPerceptionTracking { VStack(spacing: 0) { - Rectangle().fill(.regularMaterial).frame(height: 28) + Rectangle().fill(Material.bar).frame(height: 28) Divider() @@ -97,7 +97,7 @@ struct ChatHistoryViewWrapper: View { store: store, isChatHistoryVisible: $isChatHistoryVisible ) - .background(Color(nsColor: .windowBackgroundColor)) + .background(.ultraThinMaterial) .frame( maxWidth: .infinity, maxHeight: .infinity @@ -136,7 +136,7 @@ struct ChatLoadingView: View { .xcodeStyleFrame(cornerRadius: 10) .ignoresSafeArea(edges: .top) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .windowBackgroundColor)) + .background(.ultraThinMaterial) } } @@ -325,7 +325,7 @@ struct ChatBar: View { .scaledFrame(width: 24, height: 24) Text(store.chatHistory.selectedWorkspaceName!) - .scaledFont(.system(size: 13, weight: .bold)) + .scaledFont(size: 13, weight: .bold) .padding(.leading, 4) .truncationMode(.tail) .frame(maxWidth: 192, alignment: .leading) diff --git a/EditorExtension/Info.plist b/EditorExtension/Info.plist index 13a9bdb6..b6fa3b60 100644 --- a/EditorExtension/Info.plist +++ b/EditorExtension/Info.plist @@ -44,5 +44,7 @@ $(TeamIdentifierPrefix) STANDARD_TELEMETRY_CHANNEL_KEY $(STANDARD_TELEMETRY_CHANNEL_KEY) + GITHUB_APP_ID + $(GITHUB_APP_ID) diff --git a/ExtensionService/Info.plist b/ExtensionService/Info.plist index 19f114ff..f7f84340 100644 --- a/ExtensionService/Info.plist +++ b/ExtensionService/Info.plist @@ -29,5 +29,7 @@ $(COPILOT_FORUM_URL) STANDARD_TELEMETRY_CHANNEL_KEY $(STANDARD_TELEMETRY_CHANNEL_KEY) + GITHUB_APP_ID + $(GITHUB_APP_ID) diff --git a/Server/package-lock.json b/Server/package-lock.json index 4ff4ca32..691f9447 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,9 +8,9 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.371.0", - "@github/copilot-language-server-darwin-arm64": "^1.371.0", - "@github/copilot-language-server-darwin-x64": "^1.371.0", + "@github/copilot-language-server": "^1.373.0", + "@github/copilot-language-server-darwin-arm64": "^1.373.0", + "@github/copilot-language-server-darwin-x64": "^1.373.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -38,9 +38,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.371.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.371.0.tgz", - "integrity": "sha512-49CT02ElprSuG9zxM4y6TRQri0/a5doazxj3Qfz/whMtOTxiHhfHD/lmPUZXcEgOVVEovTUN7Znzx2ZLPtx3Fw==", + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.373.0.tgz", + "integrity": "sha512-tcRyxEvm36M30x5v3u/OuPnPENZJsmbMkcY+6A45Fsr0ZtUJF7BtAS/Si/2QTCVJndA2Oi7taicIuqSDucAR/Q==", "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" @@ -49,17 +49,17 @@ "copilot-language-server": "dist/language-server.js" }, "optionalDependencies": { - "@github/copilot-language-server-darwin-arm64": "1.371.0", - "@github/copilot-language-server-darwin-x64": "1.371.0", - "@github/copilot-language-server-linux-arm64": "1.371.0", - "@github/copilot-language-server-linux-x64": "1.371.0", - "@github/copilot-language-server-win32-x64": "1.371.0" + "@github/copilot-language-server-darwin-arm64": "1.373.0", + "@github/copilot-language-server-darwin-x64": "1.373.0", + "@github/copilot-language-server-linux-arm64": "1.373.0", + "@github/copilot-language-server-linux-x64": "1.373.0", + "@github/copilot-language-server-win32-x64": "1.373.0" } }, "node_modules/@github/copilot-language-server-darwin-arm64": { - "version": "1.371.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.371.0.tgz", - "integrity": "sha512-1uWyuseYXFUIZhHFljP1O1ivTfnPxLRaDxIjqDxlU6+ugkuqp5s5LiHRED+4s4cIx4H9QMzkCVnE9Bms1nKN2w==", + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.373.0.tgz", + "integrity": "sha512-pzZZnQX3jIYmQ0/LgcB54xfnbFTmCmymSL1v5OemH9qpG3Xi4ekTnRy/YRGStxHAbM5mvPX9QDJJ+/CFTvSBGg==", "cpu": [ "arm64" ], @@ -69,9 +69,9 @@ ] }, "node_modules/@github/copilot-language-server-darwin-x64": { - "version": "1.371.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.371.0.tgz", - "integrity": "sha512-nokRUPq4qPvJZ0QEZEEQPb+t2i/BrrjDDuNtBQtIIeBvJFW0YtwhbhEAtFpJtYZ5G+/nkbKMKYufFiLCvUnJ4A==", + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.373.0.tgz", + "integrity": "sha512-1yfXy5cum7it3jUJ43ruymtj9StERUPEEY2nM9lCGgtv+Wn7ip0k2IFQvzfp/ql0FCivH0O954pqkrHO7GUYZg==", "cpu": [ "x64" ], @@ -81,9 +81,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-arm64": { - "version": "1.371.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.371.0.tgz", - "integrity": "sha512-1cCJCs5j3+wl6NcNs1AUXpqFFogHdQLRiBeMBPEUaFSI95H5WwPphwe0OlmrVfRJQ19rqDfeT58B1jHMX6fM/Q==", + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.373.0.tgz", + "integrity": "sha512-dijhk5AlP3SQuECFXEHyNlzGxV0HClWM3yP54pod8Wu3Yb6Xo5Ek9ClEiNPc1f0FOiVT3DJ0ldmtm6Tb2/2xTA==", "cpu": [ "arm64" ], @@ -94,9 +94,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-x64": { - "version": "1.371.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.371.0.tgz", - "integrity": "sha512-mMK5iGpaUQuM3x0H5I9iDRQQ3NZLzatXJtQkCPT30fXb2yZFNED+yU7nKAxwGX91MMeTyOzotNvh2ZzITmajDA==", + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.373.0.tgz", + "integrity": "sha512-YCjhxglxPEneJUAycT90GWpNpswWsl1/RCYe7hG7lxKN6At0haE9XF/i/bisvwyqSBB9vUOFp2TB/XhwD9dQWg==", "cpu": [ "x64" ], @@ -107,9 +107,9 @@ ] }, "node_modules/@github/copilot-language-server-win32-x64": { - "version": "1.371.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.371.0.tgz", - "integrity": "sha512-j7W1c6zTRUou4/l2M2HNfjfT8O588pRAf6zgllwnMrc2EYD8O4kzNiK1c5pHrlP1p5bnGqsUIrOuozu9EUkCLQ==", + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.373.0.tgz", + "integrity": "sha512-lxMIjKwVbpg2JAgo11Ddwv7i0FSgCxjC+2XG6f/3ItG8M0dRkGzJzVNl9sQaTbZPria8T4vNB9nRM0Lpe92LUA==", "cpu": [ "x64" ], diff --git a/Server/package.json b/Server/package.json index 02fc7d58..a67ea506 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,9 +7,9 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.371.0", - "@github/copilot-language-server-darwin-arm64": "^1.371.0", - "@github/copilot-language-server-darwin-x64": "^1.371.0", + "@github/copilot-language-server": "^1.373.0", + "@github/copilot-language-server-darwin-arm64": "^1.373.0", + "@github/copilot-language-server-darwin-x64": "^1.373.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/Tool/Package.swift b/Tool/Package.swift index 7ebbf859..541990d8 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -256,7 +256,7 @@ let package = Package( .target( name: "Status", - dependencies: ["Cache"] + dependencies: ["Cache", "Preferences"] ), .target( @@ -363,7 +363,7 @@ let package = Package( // MARK: - AppKitExtension - .target(name: "AppKitExtension"), + .target(name: "AppKitExtension", dependencies: ["Logger"]), // MARK: - GitHelper .target( diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift index a0ea9920..125dfd3d 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift @@ -95,6 +95,29 @@ public struct RegisterToolsParams: Codable, Equatable { } } +public struct UpdateToolsStatusParams: Codable, Equatable { + public let tools: [ToolStatusUpdate] + + public init(tools: [ToolStatusUpdate]) { + self.tools = tools + } +} + +public struct ToolStatusUpdate: Codable, Equatable { + public let name: String + public let status: ToolStatus + + public init(name: String, status: ToolStatus) { + self.name = name + self.status = status + } +} + +public enum ToolStatus: String, Codable, Equatable, Hashable { + case enabled = "enabled" + case disabled = "disabled" +} + public struct LanguageModelToolInformation: Codable, Equatable { /// The name of the tool. public let name: String @@ -158,6 +181,68 @@ public struct LanguageModelToolConfirmationMessages: Codable, Equatable { } } +public struct LanguageModelTool: Codable, Equatable { + public let id: String + public let type: ToolType + public let toolProvider: ToolProvider + public let nameForModel: String + public let name: String + public let displayName: String? + public let description: String? + public let displayDescription: String + public let inputSchema: [String: AnyCodable]? + public let annotations: ToolAnnotations? + public let status: ToolStatus + + public init( + id: String, + type: ToolType, + toolProvider: ToolProvider, + nameForModel: String, + name: String, + displayName: String?, + description: String?, + displayDescription: String, + inputSchema: [String : AnyCodable]?, + annotations: ToolAnnotations?, + status: ToolStatus + ) { + self.id = id + self.type = type + self.toolProvider = toolProvider + self.nameForModel = nameForModel + self.name = name + self.displayName = displayName + self.description = description + self.displayDescription = displayDescription + self.inputSchema = inputSchema + self.annotations = annotations + self.status = status + } +} + +public enum ToolType: String, Codable, CaseIterable { + case shared = "shared" + case client = "client" + case mcp = "mcp" +} + +public struct ToolProvider: Codable, Equatable { + public let id: String + public let displayName: String + public let displayNamePrefix: String? + public let description: String + public let isFirstPartyTool: Bool +} + +public struct ToolAnnotations: Codable, Equatable { + public let title: String? + public let readOnlyHint: Bool? + public let destructiveHint: Bool? + public let idempotentHint: Bool? + public let openWorldHint: Bool? +} + public struct InvokeClientToolParams: Codable, Equatable { /// The name of the tool to be invoked. public let name: String diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift index e78c9cde..3d7d5cfd 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift @@ -1,7 +1,7 @@ import ConversationServiceProvider -func registerClientTools(server: GitHubCopilotConversationServiceType) async { +func registerClientTools(server: GitHubCopilotConversationServiceType) async -> [LanguageModelTool] { var tools: [LanguageModelToolInformation] = [] let runInTerminalTool = LanguageModelToolInformation( name: ToolName.runInTerminal.rawValue, @@ -122,6 +122,9 @@ func registerClientTools(server: GitHubCopilotConversationServiceType) async { tools.append(fetchWebPageTool) if !tools.isEmpty { - try? await server.registerTools(tools: tools) + let response = try? await server.registerTools(tools: tools) + return response ?? [] } + + return [] } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift new file mode 100644 index 00000000..554a4bfe --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift @@ -0,0 +1,106 @@ +import ConversationServiceProvider +import Foundation +import Logger + +public extension Notification.Name { + static let gitHubCopilotToolsDidChange = Notification + .Name("com.github.CopilotForXcode.CopilotToolsDidChange") +} + +public class CopilotLanguageModelToolManager { + private static var availableLanguageModelTools: [LanguageModelTool]? + + public static func updateToolsStatus(_ tools: [LanguageModelTool]) { + // If we have no previous snapshot, just adopt what we received. + guard let previous = availableLanguageModelTools, !previous.isEmpty else { + let sorted = sortTools(tools) + guard sorted != availableLanguageModelTools else { return } + availableLanguageModelTools = sorted + DispatchQueue.main.async { + Logger.client.info("Notify about language model tools change: \(getLanguageModelToolsSummary())") + DistributedNotificationCenter.default().post(name: .gitHubCopilotToolsDidChange, object: nil) + } + return + } + + // Map previous and new by name for merging. + let previousByName = Dictionary(uniqueKeysWithValues: previous.map { ($0.name, $0) }) + let incomingByName = Dictionary(uniqueKeysWithValues: tools.map { ($0.name, $0) }) + + var merged: [LanguageModelTool] = [] + + for (name, oldTool) in previousByName { + if let updated = incomingByName[name] { + merged.append(updated) + } else { + if oldTool.status == .disabled { + merged.append(oldTool) // already disabled, keep as-is + } else { + // Synthesize a disabled copy (all fields same except status). + let disabledCopy = LanguageModelTool( + id: oldTool.id, + type: oldTool.type, + toolProvider: oldTool.toolProvider, + nameForModel: oldTool.nameForModel, + name: oldTool.name, + displayName: oldTool.displayName, + description: oldTool.description, + displayDescription: oldTool.displayDescription, + inputSchema: oldTool.inputSchema, + annotations: oldTool.annotations, + status: .disabled + ) + merged.append(disabledCopy) + } + } + } + + for (name, newTool) in incomingByName { + if previousByName[name] == nil { + merged.append(newTool) + } + } + + let sorted = sortTools(merged) + guard sorted != availableLanguageModelTools else { return } + availableLanguageModelTools = sorted + + DispatchQueue.main.async { + Logger.client.info("Notify about language model tools change (merged): \(getLanguageModelToolsSummary())") + DistributedNotificationCenter.default().post(name: .gitHubCopilotToolsDidChange, object: nil) + } + } + + // Extracted sorting logic to keep behavior identical. + private static func sortTools(_ tools: [LanguageModelTool]) -> [LanguageModelTool] { + tools.sorted { lhs, rhs in + let lKey = lhs.displayName ?? lhs.name + let rKey = rhs.displayName ?? rhs.name + let primary = lKey.localizedCaseInsensitiveCompare(rKey) + if primary == .orderedSame { + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return primary == .orderedAscending + } + } + + private static func getLanguageModelToolsSummary() -> String { + guard let tools = availableLanguageModelTools else { return "" } + return "\(tools.filter { $0.status == .enabled }.count) enabled, \(tools.filter { $0.status == .disabled }.count) disabled." + } + + public static func getAvailableLanguageModelTools() -> [LanguageModelTool]? { + return availableLanguageModelTools + } + + public static func hasLanguageModelTools() -> Bool { + return availableLanguageModelTools != nil && !availableLanguageModelTools!.isEmpty + } + + public static func clearLanguageModelTools() { + availableLanguageModelTools = [] + DispatchQueue.main.async { + DistributedNotificationCenter.default().post(name: .gitHubCopilotToolsDidChange, object: nil) + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 1bf64d6d..0d89df9c 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -454,7 +454,7 @@ enum GitHubCopilotRequest { } struct RegisterTools: GitHubCopilotRequestType { - struct Response: Codable {} + typealias Response = Array var params: RegisterToolsParams @@ -464,6 +464,18 @@ enum GitHubCopilotRequest { return .custom("conversation/registerTools", dict, ClientRequest.NullHandler) } } + + struct UpdateToolsStatus: GitHubCopilotRequestType { + typealias Response = Array + + var params: UpdateToolsStatusParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/updateToolsStatus", dict, ClientRequest.NullHandler) + } + } // MARK: Copy code diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift index 1ad669a1..e7d37610 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift @@ -1,6 +1,7 @@ import Foundation import JSONRPC import LanguageServerProtocol +import ConversationServiceProvider public enum MCPServerStatus: String, Codable, Equatable, Hashable { case running = "running" @@ -8,11 +9,6 @@ public enum MCPServerStatus: String, Codable, Equatable, Hashable { case error = "error" } -public enum MCPToolStatus: String, Codable, Equatable, Hashable { - case enabled = "enabled" - case disabled = "disabled" -} - public struct InputSchema: Codable, Equatable, Hashable { public var type: String = "object" public var properties: [String: JSONValue]? @@ -81,14 +77,14 @@ public struct ToolAnnotations: Codable, Equatable, Hashable { public struct MCPTool: Codable, Equatable, Hashable { public let name: String public let description: String? - public let _status: MCPToolStatus + public let _status: ToolStatus public let inputSchema: InputSchema public var annotations: ToolAnnotations? public init( name: String, description: String? = nil, - _status: MCPToolStatus, + _status: ToolStatus, inputSchema: InputSchema, annotations: ToolAnnotations? = nil ) { @@ -132,9 +128,9 @@ public struct GetAllToolsParams: Codable, Hashable { public struct UpdatedMCPToolsStatus: Codable, Hashable { public var name: String - public var status: MCPToolStatus + public var status: ToolStatus - public init(name: String, status: MCPToolStatus) { + public init(name: String, status: ToolStatus) { self.name = name self.status = status } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 6b5f90c5..5a2acb3c 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -83,7 +83,8 @@ public protocol GitHubCopilotConversationServiceType { func cancelProgress(token: String) async func templates(workspaceFolders: [WorkspaceFolder]?) async throws -> [ChatTemplate] func models() async throws -> [CopilotModel] - func registerTools(tools: [LanguageModelToolInformation]) async throws + func registerTools(tools: [LanguageModelToolInformation]) async throws -> [LanguageModelTool] + func updateToolsStatus(params: UpdateToolsStatusParams) async throws -> [LanguageModelTool] } protocol GitHubCopilotLSP { @@ -252,6 +253,7 @@ public class GitHubCopilotBaseService { experimental: nil ) + let authAppId = Bundle.main.infoDictionary?["GITHUB_APP_ID"] as? String return InitializeParams( processId: Int(ProcessInfo.processInfo.processIdentifier), locale: nil, @@ -270,7 +272,8 @@ public class GitHubCopilotBaseService { /// The editor has support for watching files over LSP "watchedFiles": watchedFiles, "didChangeFeatureFlags": true - ] + ], + "githubAppId": authAppId.map(JSONValue.string) ?? .null, ], capabilities: capabilities, trace: .off, @@ -381,6 +384,30 @@ func getTerminalEnvironmentVariables(_ variableNames: [String]) -> [String: Stri public static let shared = TheActor() } +actor ToolInitializationActor { + private var isInitialized = false + private var unrestoredTools: [ToolStatusUpdate] = [] + + func loadUnrestoredToolsIfNeeded() -> [ToolStatusUpdate] { + guard !isInitialized else { return unrestoredTools } + isInitialized = true + + // Load tools only once + if let savedJSON = AppState.shared.get(key: "languageModelToolsStatus"), + let data = try? JSONEncoder().encode(savedJSON), + let savedTools = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data) { + let currentlyAvailableTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? [] + let availableToolNames = Set(currentlyAvailableTools.map { $0.name }) + + unrestoredTools = savedTools.filter { + availableToolNames.contains($0.name) && $0.status == .disabled + } + } + + return unrestoredTools + } +} + public final class GitHubCopilotService: GitHubCopilotBaseService, GitHubCopilotSuggestionServiceType, @@ -397,6 +424,7 @@ public final class GitHubCopilotService: private var isMCPInitialized = false private var unrestoredMcpServers: [String] = [] private var mcpRuntimeLogFileName: String = "" + private static let toolInitializationActor = ToolInitializationActor() private var lastSentConfiguration: JSONValue? override init(designatedServer: any GitHubCopilotLSP) { @@ -455,7 +483,9 @@ public final class GitHubCopilotService: GitHubCopilotService.services.append(self) Task { - await registerClientTools(server: self) + let tools = await registerClientTools(server: self) + CopilotLanguageModelToolManager.updateToolsStatus(tools) + await restoreRegisteredToolsStatus() } } catch { Logger.gitHubCopilot.error(error) @@ -711,11 +741,24 @@ public final class GitHubCopilotService: } @GitHubCopilotSuggestionActor - public func registerTools(tools: [LanguageModelToolInformation]) async throws { + public func registerTools(tools: [LanguageModelToolInformation]) async throws -> [LanguageModelTool] { do { - _ = try await sendRequest( + let response = try await sendRequest( GitHubCopilotRequest.RegisterTools(params: RegisterToolsParams(tools: tools)) ) + return response + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func updateToolsStatus(params: UpdateToolsStatusParams) async throws -> [LanguageModelTool] { + do { + let response = try await sendRequest( + GitHubCopilotRequest.UpdateToolsStatus(params: params) + ) + return response } catch { throw error } @@ -1159,6 +1202,63 @@ public final class GitHubCopilotService: Logger.gitHubCopilot.error("Failed to update MCP Tools status: \(updateError)") } } + + public static func updateAllCLSTools(tools: [ToolStatusUpdate]) async -> [LanguageModelTool] { + var updateError: Error? = nil + var updatedTools: [LanguageModelTool] = [] + + for service in services { + if service.projectRootURL.path == "/" { + continue // Skip services with root project URL + } + + do { + updatedTools = try await service.updateToolsStatus( + params: .init(tools: tools) + ) + } catch let error as ServerError { + updateError = GitHubCopilotError.languageServerError(error) + } catch { + updateError = error + } + } + + CopilotLanguageModelToolManager.updateToolsStatus(updatedTools) + Logger.gitHubCopilot.info("Updated All Built-In Tools: \(tools.count) tools") + + if let updateError { + Logger.gitHubCopilot.error("Failed to update Built-In Tools status: \(updateError)") + } + + return updatedTools + } + + private func loadUnrestoredLanguageModelTools() -> [ToolStatusUpdate] { + if let savedJSON = AppState.shared.get(key: "languageModelToolsStatus"), + let data = try? JSONEncoder().encode(savedJSON), + let savedTools = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data) { + return savedTools + } + return [] + } + + private func restoreRegisteredToolsStatus() async { + // Get unrestored tools from the shared coordinator + let toolsToRestore = await GitHubCopilotService.toolInitializationActor.loadUnrestoredToolsIfNeeded() + + guard !toolsToRestore.isEmpty else { + Logger.gitHubCopilot.info("No previously disabled tools need to be restored") + return + } + + do { + let updatedTools = try await updateToolsStatus(params: .init(tools: toolsToRestore)) + CopilotLanguageModelToolManager.updateToolsStatus(updatedTools) + Logger.gitHubCopilot.info("Restored \(toolsToRestore.count) disabled tools for service at \(projectRootURL.path)") + } catch { + Logger.gitHubCopilot.error("Failed to restore tools for service at \(projectRootURL.path): \(error)") + } + } private func loadUnrestoredMCPServers() -> [String] { if let savedJSON = AppState.shared.get(key: "mcpToolsStatus"), diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift index a4248e8f..8b343d61 100644 --- a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift +++ b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift @@ -20,6 +20,7 @@ public struct DidChangeFeatureFlagsParams: Hashable, Codable { let envelope: [String: JSONValue] let token: [String: String] let activeExps: ActiveExperimentForFeatureFlags + let byok: Bool? } public struct FeatureFlags: Hashable, Codable { @@ -31,6 +32,8 @@ public struct FeatureFlags: Hashable, Codable { public var agentMode: Bool public var mcp: Bool public var ccr: Bool // Copilot Code Review + public var byok: Bool + public var editorPreviewFeatures: Bool public var activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags public init( @@ -42,6 +45,8 @@ public struct FeatureFlags: Hashable, Codable { agentMode: Bool = true, mcp: Bool = true, ccr: Bool = true, + byok: Bool = true, + editorPreviewFeatures: Bool = true, activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags = [:] ) { self.restrictedTelemetry = restrictedTelemetry @@ -52,6 +57,8 @@ public struct FeatureFlags: Hashable, Codable { self.agentMode = agentMode self.mcp = mcp self.ccr = ccr + self.byok = byok + self.editorPreviewFeatures = editorPreviewFeatures self.activeExperimentForFeatureFlags = activeExperimentForFeatureFlags } } @@ -69,7 +76,12 @@ public class FeatureFlagNotifierImpl: FeatureFlagNotifier { public var featureFlagsDidChange: PassthroughSubject init( - didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams = .init(envelope: [:], token: [:], activeExps: [:]), + didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams = .init( + envelope: [:], + token: [:], + activeExps: [:], + byok: nil + ), featureFlags: FeatureFlags = FeatureFlags(), featureFlagsDidChange: PassthroughSubject = PassthroughSubject() ) { @@ -88,6 +100,8 @@ public class FeatureFlagNotifierImpl: FeatureFlagNotifier { self.featureFlags.agentMode = self.didChangeFeatureFlagsParams.token["agent_mode"] != "0" self.featureFlags.mcp = self.didChangeFeatureFlagsParams.token["mcp"] != "0" self.featureFlags.ccr = self.didChangeFeatureFlagsParams.token["ccr"] != "0" + self.featureFlags.byok = self.didChangeFeatureFlagsParams.byok != false + self.featureFlags.editorPreviewFeatures = self.didChangeFeatureFlagsParams.token["editor_preview_features"] != "0" self.featureFlags.activeExperimentForFeatureFlags = self.didChangeFeatureFlagsParams.activeExps } diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift index c795d5f2..2557d0ee 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -97,7 +97,9 @@ public final class GitHubCopilotConversationService: ConversationServiceType { public func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]? { guard let service = await serviceLocator.getService(from: workspace) else { return nil } - return try await service.templates(workspaceFolders: getWorkspaceFolders(workspace: workspace)) + let isPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures + let workspaceFolders = isPreviewEnabled ? getWorkspaceFolders(workspace: workspace) : nil + return try await service.templates(workspaceFolders: workspaceFolders) } public func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]? { diff --git a/Tool/Sources/Persist/AppState.swift b/Tool/Sources/Persist/AppState.swift index 3a2834fb..e9e8424a 100644 --- a/Tool/Sources/Persist/AppState.swift +++ b/Tool/Sources/Persist/AppState.swift @@ -68,7 +68,7 @@ public class AppState { public func update(key: String, value: T) { queue.async { - let userName = Status.currentUser() ?? "" + let userName = UserDefaults.shared.value(for: \.currentUserName) self.initCacheForUserIfNeeded(userName) self.cache[userName]![key] = JSONValue.convertToJSONValue(value) self.saveCacheForUser(userName) @@ -77,7 +77,7 @@ public class AppState { public func get(key: String) -> JSONValue? { return queue.sync { - let userName = Status.currentUser() ?? "" + let userName = UserDefaults.shared.value(for: \.currentUserName) initCacheForUserIfNeeded(userName) return (self.cache[userName] ?? [:])[key] } @@ -88,7 +88,8 @@ public class AppState { } private func saveCacheForUser(_ userName: String? = nil) { - if let user = userName ?? Status.currentUser(), !user.isEmpty { // save cache for non-empty user + let user = userName ?? UserDefaults.shared.value(for: \.currentUserName) + if !user.isEmpty { // save cache for non-empty user let cacheFilePath = configFilePath(userName: user) do { let data = try JSONEncoder().encode(self.cache[user] ?? [:]) @@ -100,8 +101,8 @@ public class AppState { } private func initCacheForUserIfNeeded(_ userName: String? = nil) { - if let user = userName ?? Status.currentUser(), !user.isEmpty, - loadStatus[user] != true { // load cache for non-empty user + let user = userName ?? UserDefaults.shared.value(for: \.currentUserName) + if !user.isEmpty, loadStatus[user] != true { // load cache for non-empty user self.loadStatus[user] = true self.cache[user] = [:] let cacheFilePath = configFilePath(userName: user) diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 6be5999f..1e063da7 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -595,4 +595,8 @@ public extension UserDefaultPreferenceKeys { var verboseLoggingEnabled: PreferenceKey { .init(defaultValue: false, key: "VerboseLoggingEnabled") } + + var currentUserName: PreferenceKey { + .init(defaultValue: "", key: "CurrentUserName") + } } diff --git a/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift b/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift index 2a09dbf3..34494137 100644 --- a/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift +++ b/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift @@ -22,7 +22,7 @@ public struct CopilotMessageHeader: View { } Text("GitHub Copilot") - .scaledFont(.system(size: 13, weight: .semibold)) + .scaledFont(size: 13, weight: .semibold) .padding(.leading, 4) Spacer() diff --git a/Tool/Sources/SharedUIComponents/InstructionView.swift b/Tool/Sources/SharedUIComponents/InstructionView.swift index 42ef9049..86a45280 100644 --- a/Tool/Sources/SharedUIComponents/InstructionView.swift +++ b/Tool/Sources/SharedUIComponents/InstructionView.swift @@ -27,13 +27,13 @@ public struct Instruction: View { .foregroundColor(.primary) Text("Ask Copilot to edit your files in agent mode.\nIt will automatically use multiple requests to \nedit files, run terminal commands, and fix errors.") - .scaledFont(.system(size: 14, weight: .light)) + .scaledFont(size: 14, weight: .light) .multilineTextAlignment(.center) .lineSpacing(4) } Text("Copilot is powered by AI, so mistakes are possible. Review output carefully before use.") - .scaledFont(.system(size: 14, weight: .light)) + .scaledFont(size: 14, weight: .light) .multilineTextAlignment(.center) .lineSpacing(4) } diff --git a/Tool/Sources/Status/Status.swift b/Tool/Sources/Status/Status.swift index 62176c94..8a5f2ff4 100644 --- a/Tool/Sources/Status/Status.swift +++ b/Tool/Sources/Status/Status.swift @@ -1,4 +1,5 @@ import AppKit +import Preferences import Foundation @objc public enum ExtensionPermissionStatus: Int { @@ -87,6 +88,7 @@ public final actor Status { public func updateAuthStatus(_ status: AuthStatus.Status, username: String? = nil, message: String? = nil) { currentUserName = username + UserDefaults.shared.set(username ?? "", for: \.currentUserName) let newStatus = AuthStatus(status: status, username: username, message: message) guard newStatus != authStatus else { return } authStatus = newStatus diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index f7ac3501..c48cb640 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -1,5 +1,6 @@ import Foundation import GitHubCopilotService +import ConversationServiceProvider import Logger import Status @@ -456,6 +457,51 @@ extension XPCExtensionService { } } + @XPCServiceActor + public func getAvailableLanguageModelTools() async throws -> [LanguageModelTool]? { + return try await withXPCServiceConnected { + service, continuation in + service.getAvailableLanguageModelTools { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let tools = try JSONDecoder().decode([LanguageModelTool].self, from: data) + continuation.resume(tools) + } catch { + continuation.reject(error) + } + } + } + } + + @XPCServiceActor + public func updateToolsStatus(_ update: [ToolStatusUpdate]) async throws -> [LanguageModelTool]? { + return try await withXPCServiceConnected { + service, continuation in + do { + let data = try JSONEncoder().encode(update) + service.updateToolsStatus(tools: data) { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode([LanguageModelTool].self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + @XPCServiceActor public func getCopilotFeatureFlags() async throws -> FeatureFlags? { return try await withXPCServiceConnected { diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift index 72ca0e88..1ffddc10 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -27,7 +27,9 @@ public protocol XPCServiceProtocol { func updateMCPServerToolsStatus(tools: Data) func listMCPRegistryServers(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) func getMCPRegistryServer(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) - + func getAvailableLanguageModelTools(withReply reply: @escaping (Data?) -> Void) + func updateToolsStatus(tools: Data, withReply reply: @escaping (Data?) -> Void) + func getCopilotFeatureFlags(withReply reply: @escaping (Data?) -> Void) func signOutAllGitHubCopilotService()