diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 56e21c3..8f25f9d 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 3ABBEA2B2C8BA00300C61D61 /* copilot-language-server-arm64 in Resources */ = {isa = PBXBuildFile; fileRef = 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */; }; 3ABBEA2C2C8BA00800C61D61 /* copilot-language-server-arm64 in Resources */ = {isa = PBXBuildFile; fileRef = 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */; }; 3ABBEA2D2C8BA00B00C61D61 /* copilot-language-server in Resources */ = {isa = PBXBuildFile; fileRef = 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */; }; + 3E5DB7502D6B8FA500418952 /* ReleaseNotes.md in Resources */ = {isa = PBXBuildFile; fileRef = 3E5DB74F2D6B88EE00418952 /* ReleaseNotes.md */; }; 424ACA212CA4697200FA20F2 /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 424ACA202CA4697200FA20F2 /* Credits.rtf */; }; 427C63282C6E868B000E557C /* OpenSettingsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C63272C6E868B000E557C /* OpenSettingsCommand.swift */; }; 5EC511E32C90CE7400632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B1D2938973000C9DCDA /* Assets.xcassets */; }; @@ -189,6 +190,7 @@ /* 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; }; + 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 = ""; }; C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRealtimeSuggestionsCommand.swift; sourceTree = ""; }; @@ -345,6 +347,7 @@ C8189B0D2938972F00C9DCDA = { isa = PBXGroup; children = ( + 3E5DB74F2D6B88EE00418952 /* ReleaseNotes.md */, C887BC832965D96000931567 /* DEVELOPMENT.md */, C8520308293D805800460097 /* README.md */, C8F103292A7A365000D28F4F /* launchAgent.plist */, @@ -678,6 +681,7 @@ C861E6152994F6080056CB02 /* Assets.xcassets in Resources */, 3ABBEA2D2C8BA00B00C61D61 /* copilot-language-server in Resources */, C81291D72994FE6900196E12 /* Main.storyboard in Resources */, + 3E5DB7502D6B8FA500418952 /* ReleaseNotes.md in Resources */, 5EC511E42C90CE9800632BAB /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Chat.svg b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Chat.svg new file mode 100644 index 0000000..4b00fb1 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Chat.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Contents.json new file mode 100644 index 0000000..0368a06 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Chat.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/Contents.json new file mode 100644 index 0000000..78e08e6 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "CopilotError.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/CopilotError.svg b/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/CopilotError.svg new file mode 100644 index 0000000..ad10745 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/CopilotError.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json index 8ad86a7..9a465b0 100644 --- a/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json +++ b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json @@ -8,5 +8,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true } } diff --git a/Copilot for Xcode/Assets.xcassets/DangerBackgroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/DangerBackgroundColor.colorset/Contents.json new file mode 100644 index 0000000..38242f1 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/DangerBackgroundColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xF4", + "green" : "0xF3", + "red" : "0xFD" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/DangerForegroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/DangerForegroundColor.colorset/Contents.json new file mode 100644 index 0000000..db248f8 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/DangerForegroundColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x1C", + "green" : "0x0E", + "red" : "0xB1" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/DangerStrokeColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/DangerStrokeColor.colorset/Contents.json new file mode 100644 index 0000000..5fbecf4 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/DangerStrokeColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xB2", + "green" : "0xAC", + "red" : "0xEE" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Package.swift b/Core/Package.swift index 6cf84fb..1661a76 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -194,6 +194,10 @@ let package = Package( .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout") ] ), + .testTarget( + name: "ConversationTabTests", + dependencies: ["ConversationTab"] + ), // MARK: - UI diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 99ea272..62e6383 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -88,6 +88,23 @@ public final class ChatService: ChatServiceType, ObservableObject { activeRequestId = workDoneToken await memory.appendMessage(ChatMessage(id: id, role: .user, content: content, references: [])) + + if content.hasPrefix("/releaseNotes") { + if let fileURL = Bundle.main.url(forResource: "ReleaseNotes", withExtension: "md"), + let whatsNewContent = try? String(contentsOf: fileURL) + { + let progressMessage = ChatMessage( + id: UUID().uuidString, + role: .assistant, + content: whatsNewContent, + references: [] + ) + await memory.appendMessage(progressMessage) + } + resetOngoingRequest() + return + } + let skillCapabilities: [String] = [ CurrentEditorSkill.ID, ProblemsInActiveDocumentSkill.ID ] let supportedSkills: [String] = skillSet.map { $0.id } let ignoredSkills: [String] = skillCapabilities.filter { diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index eaee3a5..819ce13 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -56,6 +56,7 @@ struct Chat { enum Field: String, Hashable { case textField + case fileSearchBar } } diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index ff231d6..61efdce 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -17,36 +17,40 @@ public struct ChatPanel: View { @Namespace var inputAreaNamespace public var body: some View { - VStack(spacing: 0) { - - if chat.history.isEmpty { - VStack { - Spacer() - Instruction() - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .padding(.leading, -16) - } else { - ChatPanelMessages(chat: chat) + WithPerceptionTracking { + VStack(spacing: 0) { - if chat.history.last?.role == .system { - ChatCLSError(chat: chat).padding(.trailing, 16) + if chat.history.isEmpty { + VStack { + Spacer() + Instruction() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(.trailing, 16) } else { - ChatFollowUp(chat: chat) - .padding(.trailing, 16) - .padding(.vertical, 8) + ChatPanelMessages(chat: chat) + .accessibilityElement(children: .combine) + .accessibilityLabel("Chat Mesessages Group") + + if chat.history.last?.role == .system { + ChatCLSError(chat: chat).padding(.trailing, 16) + } else { + ChatFollowUp(chat: chat) + .padding(.trailing, 16) + .padding(.vertical, 8) + } } + + ChatPanelInputArea(chat: chat) + .padding(.trailing, 16) } - - ChatPanelInputArea(chat: chat) - .padding(.trailing, 16) + .padding(.leading, 16) + .padding(.bottom, 16) + .background(Color(nsColor: .windowBackgroundColor)) + .onAppear { chat.send(.appear) } } - .padding(.leading, 16) - .padding(.bottom, 16) - .background(Color(nsColor: .windowBackgroundColor)) - .onAppear { chat.send(.appear) } } } @@ -120,8 +124,8 @@ struct ChatPanelMessages: View { view } } - .padding(.leading, -8) } + .padding(.leading, -8) .listStyle(.plain) .listRowBackground(EmptyView()) .modify { view in @@ -483,8 +487,6 @@ struct ChatPanelInputArea: View { @State var cancellable = Set() @State private var isFilePickerPresented = false @State private var allFiles: [FileReference] = [] - @State private var searchText = "" - @State private var selectedFiles: [FileReference] = [] @State private var filteredTemplates: [ChatTemplate] = [] @State private var showingTemplates = false @@ -532,17 +534,29 @@ struct ChatPanelInputArea: View { attachedFilesView if isFilePickerPresented { - filePickerView - .transition(.move(edge: .bottom)) - .onAppear() { - allFiles = ContextUtils.getFilesInActiveWorkspace() + FilePicker( + allFiles: $allFiles, + onSubmit: { file in + chat.send(.addSelectedFile(file)) + }, + onExit: { + isFilePickerPresented = false + focusedField.wrappedValue = .textField } + ) + .transition(.move(edge: .bottom)) + .onAppear() { + allFiles = ContextUtils.getFilesInActiveWorkspace() + } } HStack(spacing: 0) { Button(action: { withAnimation { isFilePickerPresented.toggle() + if !isFilePickerPresented { + focusedField.wrappedValue = .textField + } } }) { Image(systemName: "paperclip") @@ -571,6 +585,9 @@ struct ChatPanelInputArea: View { if showingTemplates { ChatTemplateDropdownView(templates: $filteredTemplates) { template in chat.typedMessage = "/" + template.id + " " + if template.id == "releaseNotes" { + submitChatMessage() + } } } } @@ -592,13 +609,15 @@ struct ChatPanelInputArea: View { EmptyView() } .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) - + .accessibilityHidden(true) + Button(action: { focusedField.wrappedValue = .textField }) { EmptyView() } .keyboardShortcut("l", modifiers: [.command]) + .accessibilityHidden(true) } } } @@ -648,92 +667,25 @@ struct ChatPanelInputArea: View { } .padding(.horizontal, 8) } - - private var filePickerView: some View { - VStack(spacing: 8) { - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - - TextField("Search files...", text: $searchText) - .textFieldStyle(PlainTextFieldStyle()) - .foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor)) - - Button(action: { - withAnimation { - isFilePickerPresented = false - } - }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.secondary) - } - .buttonStyle(HoverButtonStyle()) - .help("Close") - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color.gray.opacity(0.1)) - ) - .cornerRadius(6) - .padding(.horizontal, 4) - .padding(.top, 4) - - ScrollView { - LazyVStack(alignment: .leading, spacing: 4) { - ForEach(filteredFiles, id: \.self) { doc in - FileRowView(doc: doc) - .contentShape(Rectangle()) - .onTapGesture { - chat.send(.addSelectedFile(doc)) - } - } - - if filteredFiles.isEmpty { - Text("No results found") - .foregroundColor(.secondary) - .padding(.leading, 4) - .padding(.vertical, 4) - } - } - } - .frame(maxHeight: 200) - .padding(.horizontal, 4) - .padding(.bottom, 4) - } - .fixedSize(horizontal: false, vertical: true) - .cornerRadius(6) - .shadow(radius: 2) -// .background( -// RoundedRectangle(cornerRadius: r) -// .fill(.ultraThickMaterial) -// ) - .overlay( - RoundedRectangle(cornerRadius: r) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) - .padding(.horizontal, 12) - } - - private var filteredFiles: [FileReference] { - if searchText.isEmpty { - return allFiles - } - - return allFiles.filter { doc in - (doc.fileName ?? doc.url.lastPathComponent) .localizedCaseInsensitiveContains(searchText) - } - } func chatTemplateCompletion(text: String) async -> [ChatTemplate] { guard text.count >= 1 && text.first == "/" else { return [] } let prefix = text.dropFirst() - let templates = await ChatService.shared.loadChatTemplates() ?? [] - guard !templates.isEmpty else { - return [] + let promptTemplates = await ChatService.shared.loadChatTemplates() ?? [] + let releaseNotesTemplate: ChatTemplate = .init( + id: "releaseNotes", + description: "What's New", + shortDescription: "What's New", + scopes: [ChatPromptTemplateScope.chatPanel] + ) + + guard !promptTemplates.isEmpty else { + return [releaseNotesTemplate] } + let templates = promptTemplates + [releaseNotesTemplate] let skippedTemplates = [ "feedback", "help" ] + return templates.filter { $0.scopes.contains(.chatPanel) && $0.id.hasPrefix(prefix) && !skippedTemplates.contains($0.id)} } @@ -776,37 +728,6 @@ struct ChatPanelInputArea: View { chat.send(.sendButtonTapped(UUID().uuidString)) } } - - struct FileRowView: View { - @State private var isHovered = false - let doc: FileReference - - var body: some View { - HStack { - drawFileIcon(doc.url) - .resizable() - .frame(width: 16, height: 16) - .foregroundColor(.secondary) - .padding(.leading, 4) - - VStack(alignment: .leading) { - Text(doc.fileName ?? doc.url.lastPathComponent) - .font(.body) - .hoverPrimaryForeground(isHovered: isHovered) - Text(doc.relativePath ?? doc.url.path) - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - } - .padding(.vertical, 4) - .hoverRadiusBackground(isHovered: isHovered, cornerRadius: 6) - .onHover(perform: { hovering in - isHovered = hovering - }) - } - } } // MARK: - Previews diff --git a/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift b/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift index f99167a..a1c1c29 100644 --- a/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift +++ b/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift @@ -60,10 +60,13 @@ public struct ChatTemplateDropdownView: View { switch event.keyCode { case 126: // Up arrow moveSelection(up: true) + return nil case 125: // Down arrow moveSelection(up: false) + return nil case 36: // Return key handleEnter() + return nil case 48: // Tab key handleTab() return nil // not forwarding the Tab Event which will replace the typed message to "\t" diff --git a/Core/Sources/ConversationTab/ContextUtils.swift b/Core/Sources/ConversationTab/ContextUtils.swift index 71277a6..6db7e9a 100644 --- a/Core/Sources/ConversationTab/ContextUtils.swift +++ b/Core/Sources/ConversationTab/ContextUtils.swift @@ -28,50 +28,118 @@ public struct ContextUtils { public static func getFilesInActiveWorkspace() -> [FileReference] { guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL, - let projectURL = XcodeInspector.shared.realtimeActiveProjectURL else { + let workspaceRootURL = XcodeInspector.shared.realtimeActiveProjectURL else { return [] } + return getFilesInActiveWorkspace(workspaceURL: workspaceURL, workspaceRootURL: workspaceRootURL) + } + + static func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference] { + var files: [FileReference] = [] do { let fileManager = FileManager.default - let enumerator = fileManager.enumerator( - at: projectURL, - includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey], - options: [.skipsHiddenFiles] - ) - - var files: [FileReference] = [] - while let fileURL = enumerator?.nextObject() as? URL { - // Skip items matching the specified pattern - if matchesPatterns(fileURL, patterns: skipPatterns) { - enumerator?.skipDescendants() + var subprojects: [URL] = [] + if isXCWorkspace(workspaceURL) { + subprojects = getSubprojectURLs(in: workspaceURL) + } else { + subprojects.append(workspaceRootURL) + } + for subproject in subprojects { + guard FileManager.default.fileExists(atPath: subproject.path) else { continue } - let resourceValues = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) - // Handle directories if needed - if resourceValues.isDirectory == true { - continue - } + let enumerator = fileManager.enumerator( + at: subproject, + includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) - guard resourceValues.isRegularFile == true else { continue } - if supportedFileExtensions.contains(fileURL.pathExtension.lowercased()) == false { - continue - } + while let fileURL = enumerator?.nextObject() as? URL { + // Skip items matching the specified pattern + if matchesPatterns(fileURL, patterns: skipPatterns) + || isXCWorkspace(fileURL) || isXCProject(fileURL) { + enumerator?.skipDescendants() + continue + } - let relativePath = fileURL.path.replacingOccurrences(of: projectURL.path, with: "") - let fileName = fileURL.lastPathComponent + let resourceValues = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) + // Handle directories if needed + if resourceValues.isDirectory == true { + continue + } - let file = FileReference(url: fileURL, - relativePath: relativePath, - fileName: fileName) - files.append(file) - } + guard resourceValues.isRegularFile == true else { continue } + if supportedFileExtensions.contains(fileURL.pathExtension.lowercased()) == false { + continue + } - return files + let relativePath = fileURL.path.replacingOccurrences(of: workspaceRootURL.path, with: "") + let fileName = fileURL.lastPathComponent + + let file = FileReference(url: fileURL, + relativePath: relativePath, + fileName: fileName) + files.append(file) + } + } } catch { Logger.client.error("Failed to get files in workspace: \(error)") + } + + return files + } + + static func isXCWorkspace(_ url: URL) -> Bool { + return url.pathExtension == "xcworkspace" && FileManager.default.fileExists(atPath: url.appendingPathComponent("contents.xcworkspacedata").path) + } + + static func isXCProject(_ url: URL) -> Bool { + return url.pathExtension == "xcodeproj" && FileManager.default.fileExists(atPath: url.appendingPathComponent("project.pbxproj").path) + } + + static func getSubprojectURLs(in workspaceURL: URL) -> [URL] { + let workspaceFile = workspaceURL.appendingPathComponent("contents.xcworkspacedata") + guard let data = try? Data(contentsOf: workspaceFile) else { + Logger.client.error("Failed to read workspace file at \(workspaceFile.path)") return [] } + + return getSubprojectURLs(workspaceURL: workspaceURL, data: data) + } + + 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 { + // 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 } } diff --git a/Core/Sources/ConversationTab/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift new file mode 100644 index 0000000..8a2fd61 --- /dev/null +++ b/Core/Sources/ConversationTab/FilePicker.swift @@ -0,0 +1,184 @@ +import ComposableArchitecture +import ConversationServiceProvider +import SharedUIComponents +import SwiftUI + +public struct FilePicker: View { + @Binding var allFiles: [FileReference] + var onSubmit: (_ file: FileReference) -> 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] { + if searchText.isEmpty { + return allFiles + } + + return allFiles.filter { doc in + (doc.fileName ?? doc.url.lastPathComponent) .localizedCaseInsensitiveContains(searchText) + } + } + + public var body: some View { + WithPerceptionTracking { + VStack(spacing: 8) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField("Search files...", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor)) + .focused($isSearchBarFocused) + .onChange(of: searchText) { newValue in + selectedId = 0 + } + .onAppear() { + isSearchBarFocused = true + } + + Button(action: { + withAnimation { + onExit() + } + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(HoverButtonStyle()) + .help("Close") + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.1)) + ) + .cornerRadius(6) + .padding(.horizontal, 4) + .padding(.top, 4) + + 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 { + Text("No results found") + .foregroundColor(.secondary) + .padding(.leading, 4) + .padding(.vertical, 4) + } + } + } + .frame(maxHeight: 200) + .padding(.horizontal, 4) + .padding(.bottom, 4) + .onAppear { + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + if !isSearchBarFocused { // if file search bar is not focused, ignore the event + return event + } + + switch event.keyCode { + case 126: // Up arrow + moveSelection(up: true, proxy: proxy) + return nil + case 125: // Down arrow + moveSelection(up: false, proxy: proxy) + return nil + case 36: // Return key + handleEnter() + return nil + case 53: // Esc key + withAnimation { + onExit() + } + return nil + default: + break + } + return event + } + } + .onDisappear { + if let monitor = localMonitor { + NSEvent.removeMonitor(monitor) + localMonitor = nil + } + } + } + } + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(6) + .shadow(radius: 2) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .padding(.horizontal, 12) + } + } + + private func moveSelection(up: Bool, proxy: ScrollViewProxy) { + let files = filteredFiles + guard !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 } + onSubmit(files[selectedId]) + } +} + +struct FileRowView: View { + @State private var isHovered = false + let doc: FileReference + let id: Int + @Binding var selectedId: Int + + var body: some View { + WithPerceptionTracking { + HStack { + drawFileIcon(doc.url) + .resizable() + .frame(width: 16, height: 16) + .foregroundColor(.secondary) + .padding(.leading, 4) + + VStack(alignment: .leading) { + Text(doc.fileName ?? doc.url.lastPathComponent) + .font(.body) + .hoverPrimaryForeground(isHovered: selectedId == id) + Text(doc.relativePath ?? doc.url.path) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.vertical, 4) + .hoverRadiusBackground(isHovered: isHovered || selectedId == id, + hoverColor: (selectedId == id ? nil : Color.gray.opacity(0.1)), + cornerRadius: 6) + .onHover(perform: { hovering in + isHovered = hovering + }) + } + } +} diff --git a/Core/Sources/ConversationTab/ViewExtension.swift b/Core/Sources/ConversationTab/ViewExtension.swift index 6a4a0f8..08509c3 100644 --- a/Core/Sources/ConversationTab/ViewExtension.swift +++ b/Core/Sources/ConversationTab/ViewExtension.swift @@ -1,33 +1,30 @@ import SwiftUI -let BLUE_IN_LIGHT_THEME = Color(red: 98/255, green: 154/255, blue: 248/255) -let BLUE_IN_DARK_THEME = Color(red: 55/255, green: 108/255, blue: 194/255) +let ITEM_SELECTED_COLOR = Color("ItemSelectedColor") struct HoverBackgroundModifier: ViewModifier { - @Environment(\.colorScheme) var colorScheme var isHovered: Bool func body(content: Content) -> some View { content - .background(isHovered ? (colorScheme == .dark ? BLUE_IN_DARK_THEME : BLUE_IN_LIGHT_THEME) : Color.clear) + .background(isHovered ? ITEM_SELECTED_COLOR : Color.clear) } } struct HoverRadiusBackgroundModifier: ViewModifier { - @Environment(\.colorScheme) var colorScheme var isHovered: Bool + var hoverColor: Color? var cornerRadius: CGFloat = 0 func body(content: Content) -> some View { content.background( RoundedRectangle(cornerRadius: cornerRadius) - .fill(isHovered ? (colorScheme == .dark ? BLUE_IN_DARK_THEME : BLUE_IN_LIGHT_THEME) : Color.clear) + .fill(isHovered ? hoverColor ?? ITEM_SELECTED_COLOR : Color.clear) ) } } struct HoverForegroundModifier: ViewModifier { - @Environment(\.colorScheme) var colorScheme var isHovered: Bool var defaultColor: Color @@ -45,6 +42,10 @@ extension View { self.modifier(HoverRadiusBackgroundModifier(isHovered: isHovered, cornerRadius: cornerRadius)) } + public func hoverRadiusBackground(isHovered: Bool, hoverColor: Color?, cornerRadius: CGFloat) -> some View { + self.modifier(HoverRadiusBackgroundModifier(isHovered: isHovered, hoverColor: hoverColor, cornerRadius: cornerRadius)) + } + public func hoverForeground(isHovered: Bool, defaultColor: Color) -> some View { self.modifier(HoverForegroundModifier(isHovered: isHovered, defaultColor: defaultColor)) } diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index 511d603..15c2826 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -86,6 +86,7 @@ struct BotMessage: View { .foregroundStyle(.secondary) }) .buttonStyle(HoverButtonStyle()) + .accessibilityValue(isReferencesPresented ? "Collapse" : "Expand") if isReferencesPresented { ReferenceList(references: references, chat: chat) diff --git a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift index 2ce752a..4f51acc 100644 --- a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift +++ b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift @@ -29,7 +29,12 @@ struct GeneralSettingsView: View { SettingsLink( url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", title: "Accessibility Permission", - subtitle: accessibilityPermissionSubtitle + subtitle: accessibilityPermissionSubtitle, + badge: store.isAccessibilityPermissionGranted == .notGranted ? + .init( + text: "Not Granted", + level: .danger + ) : nil ) Divider() SettingsLink( diff --git a/Core/Sources/HostApp/SharedComponents/Badge.swift b/Core/Sources/HostApp/SharedComponents/Badge.swift new file mode 100644 index 0000000..c3ccc84 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/Badge.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct BadgeItem { + enum Level: String, Equatable { + case warning = "Warning" + case danger = "Danger" + } + let text: String + let level: Level + + init(text: String, level: Level) { + self.text = text + self.level = level + } +} + +struct Badge: View { + let text: String + let level: BadgeItem.Level + + init(badgeItem: BadgeItem) { + self.text = badgeItem.text + self.level = badgeItem.level + } + + init(text: String, level: BadgeItem.Level) { + self.text = text + self.level = level + } + + var body: some View { + Text(text).font(.callout) + .padding(.horizontal, 4) + .foregroundColor( + Color("\(level.rawValue)ForegroundColor") + ) + .background( + Color("\(level.rawValue)BackgroundColor"), + in: RoundedRectangle( + cornerRadius: 8, + style: .circular + ) + ) + .overlay( + RoundedRectangle( + cornerRadius: 8, + style: .circular + ) + .stroke(Color("\(level.rawValue)StrokeColor"), lineWidth: 1) + ) + } +} diff --git a/Core/Sources/HostApp/SharedComponents/SettingsLink.swift b/Core/Sources/HostApp/SharedComponents/SettingsLink.swift index b3c00cf..b2d358e 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsLink.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsLink.swift @@ -4,22 +4,43 @@ struct SettingsLink: View { let url: URL let title: String let subtitle: String? + let badge: BadgeItem? - init(_ url: URL, title: String, subtitle: String? = nil) { + init( + _ url: URL, + title: String, + subtitle: String? = nil, + badge: BadgeItem? = nil + ) { self.url = url self.title = title self.subtitle = subtitle + self.badge = badge } - init(url: String, title: String, subtitle: String? = nil) { - self.init(URL(string: url)!, title: title, subtitle: subtitle) + init( + url: String, + title: String, + subtitle: String? = nil, + badge: BadgeItem? = nil + ) { + self.init( + URL(string: url)!, + title: title, + subtitle: subtitle, + badge: badge + ) } var body: some View { Link(destination: url) { VStack(alignment: .leading) { - Text(title) - .font(.body) + HStack{ + Text(title).font(.body) + if let badge = self.badge { + Badge(badgeItem: badge) + } + } if let subtitle = subtitle { Text(subtitle) .font(.footnote) @@ -37,6 +58,7 @@ struct SettingsLink: View { SettingsLink( url: "https://example.com", title: "Example", - subtitle: "This is an example" + subtitle: "This is an example", + badge: .init(text: "Not Granted", level: .danger) ) } diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift new file mode 100644 index 0000000..b62d3b8 --- /dev/null +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift @@ -0,0 +1,56 @@ +import SwiftUI +import Perception +import GitHubCopilotViewModel +import SharedUIComponents + +struct ChatNoAXPermissionView: View { + @Environment(\.openURL) private var openURL + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + VStack(alignment: .center, spacing: 20) { + Spacer() + Image("CopilotError") + .resizable() + .renderingMode(.template) + .scaledToFill() + .frame(width: 64.0, height: 64.0) + .foregroundColor(.primary) + + Text("Accessibility Permission Required") + .font(.largeTitle) + .multilineTextAlignment(.center) + + Text("Please grant accessibility permission for Github Copilot to work with Xcode.") + .font(.body) + .multilineTextAlignment(.center) + + HStack{ + Button("Open Permission Settings") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + openURL(url) + } + } + .buttonStyle(.borderedProminent) + } + + Spacer() + } + .padding() + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + } + .xcodeStyleFrame(cornerRadius: 10) + .ignoresSafeArea(edges: .top) + } + } +} + +struct ChatNoAXPermission_Previews: PreviewProvider { + static var previews: some View { + ChatNoAXPermissionView() + } +} diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index d12e889..e220a9a 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -20,15 +20,23 @@ struct ChatWindowView: View { WithPerceptionTracking { let _ = store.currentChatWorkspace?.selectedTabId // force re-evaluation ZStack { - switch statusObserver.authStatus.status { - case .loggedIn: - ChatView(store: store, isChatHistoryVisible: $isChatHistoryVisible) - case .notLoggedIn: - ChatLoginView(viewModel: GitHubCopilotViewModel.shared) - case .notAuthorized: - ChatNoSubscriptionView(viewModel: GitHubCopilotViewModel.shared) - default: - ChatLoadingView() + if statusObserver.observedAXStatus == .notGranted { + ChatNoAXPermissionView() + } else { + switch statusObserver.authStatus.status { + case .loggedIn: + if isChatHistoryVisible { + ChatHistoryViewWrapper(store: store, isChatHistoryVisible: $isChatHistoryVisible) + } else { + ChatView(store: store, isChatHistoryVisible: $isChatHistoryVisible) + } + case .notLoggedIn: + ChatLoginView(viewModel: GitHubCopilotViewModel.shared) + case .notAuthorized: + ChatNoSubscriptionView(viewModel: GitHubCopilotViewModel.shared) + default: + ChatLoadingView() + } } } .onChange(of: store.isPanelDisplayed) { isDisplayed in @@ -64,8 +72,16 @@ struct ChatView: View { } .xcodeStyleFrame(cornerRadius: 10) .ignoresSafeArea(edges: .top) - - if isChatHistoryVisible { + } +} + +struct ChatHistoryViewWrapper: View { + let store: StoreOf + @Binding var isChatHistoryVisible: Bool + + + var body: some View { + WithPerceptionTracking { VStack(spacing: 0) { Rectangle().fill(.regularMaterial).frame(height: 28) @@ -334,10 +350,14 @@ struct ChatBar: View { Button(action: { isChatHistoryVisible = true }) { - Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") + Image("HistoryIcon") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) } - .buttonStyle(HoverButtonStyle()) + .buttonStyle(HoverButtonStyle(padding: -2)) .help("Show Chats...") + .accessibilityLabel("Show Chats...") } } } diff --git a/Core/Tests/ConversationTabTests/ContextUtilsTests.swift b/Core/Tests/ConversationTabTests/ContextUtilsTests.swift new file mode 100644 index 0000000..e6048a9 --- /dev/null +++ b/Core/Tests/ConversationTabTests/ContextUtilsTests.swift @@ -0,0 +1,214 @@ +import XCTest +import Foundation +@testable import ConversationTab + +class ContextUtilsTests: XCTestCase { + func testMatchesPatterns() { + let url1 = URL(fileURLWithPath: "/path/to/file.swift") + let url2 = URL(fileURLWithPath: "/path/to/.git") + let patterns = [".git", ".svn"] + + XCTAssertTrue(ContextUtils.matchesPatterns(url2, patterns: patterns)) + XCTAssertFalse(ContextUtils.matchesPatterns(url1, patterns: patterns)) + } + + func testIsXCWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + do { + let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "myWorkspace.xcworkspace") + XCTAssertFalse(ContextUtils.isXCWorkspace(xcworkspaceURL)) + let xcworkspaceDataURL = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "") + XCTAssertTrue(ContextUtils.isXCWorkspace(xcworkspaceURL)) + } catch { + deleteDirectoryIfExists(at: tmpDir) + throw error + } + deleteDirectoryIfExists(at: tmpDir) + } + + func testIsXCProject() throws { + let tmpDir = try createTemporaryDirectory() + do { + let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "myProject.xcodeproj") + XCTAssertFalse(ContextUtils.isXCProject(xcprojectURL)) + let xcprojectDataURL = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + XCTAssertTrue(ContextUtils.isXCProject(xcprojectURL)) + } catch { + deleteDirectoryIfExists(at: tmpDir) + throw error + } + deleteDirectoryIfExists(at: tmpDir) + } + + 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: "") + _ = try createFile(in: tmpDir, withName: "file1.swift", contents: "") + _ = try createFile(in: tmpDir, withName: "file2.swift", contents: "") + _ = try createSubdirectory(in: tmpDir, withName: ".git") + let files = ContextUtils.getFilesInActiveWorkspace(workspaceURL: xcprojectURL, workspaceRootURL: tmpDir) + let fileNames = files.map { $0.url.lastPathComponent } + XCTAssertEqual(files.count, 2) + XCTAssertTrue(fileNames.contains("file1.swift")) + XCTAssertTrue(fileNames.contains("file2.swift")) + } catch { + deleteDirectoryIfExists(at: tmpDir) + throw error + } + deleteDirectoryIfExists(at: tmpDir) + } + + func testGetFilesInActiveWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + 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: [ + "container:myProject.xcodeproj", + "group:../notExistedDir/notExistedProject.xcodeproj", + "group:../myDependency",]) + _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + + // Files under workspace should be included + _ = try createFile(in: myWorkspaceRoot, withName: "file1.swift", contents: "") + // unsupported patterns and file extension should be excluded + _ = try createFile(in: myWorkspaceRoot, withName: "unsupportedFileExtension.xyz", contents: "") + _ = try createSubdirectory(in: myWorkspaceRoot, withName: ".git") + + // Files under project metadata folder should be excluded + _ = try createFile(in: xcprojectURL, withName: "fileUnderProjectMetadata.swift", contents: "") + + // Files under dependency should be included + _ = 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: "") + + let files = ContextUtils.getFilesInActiveWorkspace(workspaceURL: xcWorkspaceURL, workspaceRootURL: myWorkspaceRoot) + let fileNames = files.map { $0.url.lastPathComponent } + XCTAssertEqual(files.count, 2) + 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() + 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 = ContextUtils.getSubprojectURLs(in: xcworkspaceURL) + XCTAssertEqual(subprojectURLs.count, 2) + 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() { + let workspaceURL = URL(fileURLWithPath: "/path/to/workspace.xcworkspace") + let xcworkspaceData = """ + + + + + + + + + + + + + + + + """.data(using: .utf8)! + + let subprojectURLs = ContextUtils.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") + } + + 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 createFileFor_contents_dot_xcworkspacedata(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 + } +} diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 959f914..aa1e9b0 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -27,7 +27,6 @@ extension AppDelegate { withLength: NSStatusItem.squareLength ) statusBarItem.button?.image = NSImage(named: "MenuBarIcon") - statusBarItem.button?.image?.isTemplate = false let statusBarMenu = NSMenu(title: "Status Bar Menu") statusBarMenu.identifier = statusBarMenuIdentifier @@ -49,7 +48,7 @@ extension AppDelegate { keyEquivalent: "" ) - let openCopilotForXcodeItem = NSMenuItem( + openCopilotForXcodeItem = NSMenuItem( title: "Settings", action: #selector(openCopilotForXcode), keyEquivalent: "" @@ -66,12 +65,12 @@ extension AppDelegate { xcodeInspectorDebug.submenu = xcodeInspectorDebugMenu xcodeInspectorDebug.isHidden = false - extensionStatusItem = NSMenuItem( + axStatusItem = NSMenuItem( title: "", action: #selector(openExtensionStatusLink), keyEquivalent: "" ) - extensionStatusItem.isHidden = true + axStatusItem.isHidden = true let quitItem = NSMenuItem( title: "Quit", @@ -104,14 +103,14 @@ extension AppDelegate { action: nil, keyEquivalent: "" ) - extensionStatusItem.isHidden = true + axStatusItem.isHidden = true upSellItem = NSMenuItem( title: "", action: #selector(openUpSellLink), keyEquivalent: "" ) - extensionStatusItem.isHidden = true + axStatusItem.isHidden = true let openDocs = NSMenuItem( title: "View Documentation", @@ -142,7 +141,7 @@ extension AppDelegate { statusBarMenu.addItem(authStatusItem) statusBarMenu.addItem(upSellItem) statusBarMenu.addItem(.separator()) - statusBarMenu.addItem(extensionStatusItem) + statusBarMenu.addItem(axStatusItem) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(openCopilotForXcodeItem) statusBarMenu.addItem(.separator()) diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index f10a358..97d4434 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -33,7 +33,8 @@ class ExtensionUpdateCheckerDelegate: UpdateCheckerDelegate { class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let service = Service.shared var statusBarItem: NSStatusItem! - var extensionStatusItem: NSMenuItem! + var axStatusItem: NSMenuItem! + var openCopilotForXcodeItem: NSMenuItem! var accountItem: NSMenuItem! var authStatusItem: NSMenuItem! var upSellItem: NSMenuItem! @@ -359,19 +360,46 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func updateStatusBarItem() { Task { @MainActor in let status = await Status.shared.getStatus() + /// Update status bar icon self.statusBarItem.button?.image = status.icon.nsImage + + /// Update auth status related status bar items switch status.authStatus { case .notLoggedIn: configureNotLoggedIn() case .loggedIn: configureLoggedIn(status: status) case .notAuthorized: configureNotAuthorized(status: status) case .unknown: configureUnknown() } + + /// Update accessibility permission status bar item if let message = status.message { - self.extensionStatusItem.title = message - self.extensionStatusItem.isHidden = false - self.extensionStatusItem.isEnabled = status.url != nil + self.axStatusItem.title = message + if let image = NSImage( + systemSymbolName: "exclamationmark.circle.fill", + accessibilityDescription: "Accessibility permission not granted" + ) { + image.isTemplate = false + image.withSymbolConfiguration(.init(paletteColors: [.red])) + self.axStatusItem.image = image + } + self.axStatusItem.isHidden = false + self.axStatusItem.isEnabled = status.url != nil + } else { + self.axStatusItem.isHidden = true + } + + /// Update settings status bar item + if status.extensionStatus == .failed { + if let image = NSImage( + systemSymbolName: "exclamationmark.circle.fill", + accessibilityDescription: "Extension permission not granted" + ) { + image.isTemplate = false + image.withSymbolConfiguration(.init(paletteColors: [.red])) + self.openCopilotForXcodeItem.image = image + } } else { - self.extensionStatusItem.isHidden = true + self.openCopilotForXcodeItem.image = nil } self.markAsProcessing(status.inProgress) } diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/Contents.json new file mode 100644 index 0000000..c48d288 --- /dev/null +++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "light1x.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "dark1x.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 1.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/dark1x.svg similarity index 100% rename from ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 1.svg rename to ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/dark1x.svg diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 1.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/light1x.svg similarity index 100% rename from ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 1.svg rename to ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/light1x.svg diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/Contents.json b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/Contents.json deleted file mode 100644 index 57a72d4..0000000 --- a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "insertButton.svg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "insertButton 1.svg", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "insertButton 2.svg", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 2.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 2.svg deleted file mode 100644 index b0e60fb..0000000 --- a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 2.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton.svg deleted file mode 100644 index b0e60fb..0000000 --- a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/Contents.json b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/Contents.json deleted file mode 100644 index 7f79e25..0000000 --- a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "insert1.svg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "insert1 1.svg", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "insert1 2.svg", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 2.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 2.svg deleted file mode 100644 index 1f52da3..0000000 --- a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 2.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1.svg deleted file mode 100644 index 1f52da3..0000000 --- a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/ExtensionService/Assets.xcassets/HistoryIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/HistoryIcon.imageset/Contents.json new file mode 100644 index 0000000..83896fa --- /dev/null +++ b/ExtensionService/Assets.xcassets/HistoryIcon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "history.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/HistoryIcon.imageset/history.svg b/ExtensionService/Assets.xcassets/HistoryIcon.imageset/history.svg new file mode 100644 index 0000000..3746249 --- /dev/null +++ b/ExtensionService/Assets.xcassets/HistoryIcon.imageset/history.svg @@ -0,0 +1,3 @@ + + + diff --git a/ExtensionService/Assets.xcassets/ItemSelectedColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ItemSelectedColor.colorset/Contents.json new file mode 100644 index 0000000..955c473 --- /dev/null +++ b/ExtensionService/Assets.xcassets/ItemSelectedColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "248", + "green" : "154", + "red" : "98" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "194", + "green" : "108", + "red" : "55" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json index fe26a6c..4ab2fab 100644 --- a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json @@ -1,19 +1,18 @@ { "images" : [ { - "filename" : "active-16.png", - "idiom" : "universal", - "scale" : "1x" + "filename" : "Status=active, Mode=dark.svg", + "idiom" : "universal" }, { - "filename" : "active-32.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "active-48.png", - "idiom" : "universal", - "scale" : "3x" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Status=active, Mode=white.svg", + "idiom" : "universal" } ], "info" : { @@ -21,6 +20,6 @@ "version" : 1 }, "properties" : { - "template-rendering-intent" : "template" + "preserves-vector-representation" : true } } diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Status=active, Mode=dark.svg b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Status=active, Mode=dark.svg new file mode 100644 index 0000000..7e472bd --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Status=active, Mode=dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Status=active, Mode=white.svg b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Status=active, Mode=white.svg new file mode 100644 index 0000000..22dd8c1 --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Status=active, Mode=white.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-16.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-16.png deleted file mode 100644 index e53ee85..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-16.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-32.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-32.png deleted file mode 100644 index dfab434..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-32.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-48.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-48.png deleted file mode 100644 index 43dafb5..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-48.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json index 5528911..4829284 100644 --- a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json @@ -1,19 +1,8 @@ { "images" : [ { - "filename" : "inactive-16.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "inactive-32.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "inactive-48.png", - "idiom" : "universal", - "scale" : "3x" + "filename" : "Status=inactive, Mode=dark.svg", + "idiom" : "universal" } ], "info" : { @@ -21,6 +10,7 @@ "version" : 1 }, "properties" : { + "preserves-vector-representation" : true, "template-rendering-intent" : "template" } } diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Status=inactive, Mode=dark.svg b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Status=inactive, Mode=dark.svg new file mode 100644 index 0000000..58b44f0 --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Status=inactive, Mode=dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-16.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-16.png deleted file mode 100644 index e737a2b..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-16.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-32.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-32.png deleted file mode 100644 index 57798c9..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-32.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-48.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-48.png deleted file mode 100644 index c4d086e..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-48.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json index 7e671d9..4ebbfc1 100644 --- a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json @@ -1,19 +1,8 @@ { "images" : [ { - "filename" : "error-16.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "error-32.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "error-48.png", - "idiom" : "universal", - "scale" : "3x" + "filename" : "Status=error, Mode=dark.svg", + "idiom" : "universal" } ], "info" : { @@ -21,6 +10,7 @@ "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/MenuBarWarningIcon.imageset/Status=error, Mode=dark.svg new file mode 100644 index 0000000..d3263f5 --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Status=error, Mode=dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-16.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-16.png deleted file mode 100644 index 7166cc2..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-16.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-32.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-32.png deleted file mode 100644 index 2f6ae68..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-32.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-48.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-48.png deleted file mode 100644 index 08ed245..0000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-48.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Contents.json index 4e408c3..c4b93a1 100644 --- a/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Contents.json @@ -2,19 +2,14 @@ "images" : [ { "filename" : "Xcode_16x16.svg", - "idiom" : "universal", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "Xcode_32x32.svg", - "idiom" : "universal", - "scale" : "2x", - "size" : "32x32" + "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true } } diff --git a/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_32x32.svg b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_32x32.svg deleted file mode 100644 index af5de9b..0000000 --- a/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_32x32.svg +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ReleaseNotes.md b/ReleaseNotes.md new file mode 100644 index 0000000..0c1917d --- /dev/null +++ b/ReleaseNotes.md @@ -0,0 +1,14 @@ +### GitHub Copilot for Xcode 0.31.0 +**Highlights:** + +* **Chat view**: Ask Copilot for help with coding tasks directly in the chat view. +* **Slash commands**: Use quick commands, like `/explain` for code explanations. +* **Reference code**: Scope chats to specific files for more relevant assistance. +* **Multiple conversations**: Maintain different threads, each with their own context. +* **Chat history management**: Keep track of past conversations for future reference. +* **Free access**: Get [2,000 code completions and 50 chat messages](https://github.com/copilot) per month for free, simply by signing in with your GitHub account or by creating a new one. + +**Fixes and improvements:** + +* Fix acception does not work under certain circumstaances. +* Support switching focus between chat textfield and file search bar. diff --git a/Server/package-lock.json b/Server/package-lock.json index 6602031..149cd7e 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,13 +8,14 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.265.0" + "@github/copilot-language-server": "^1.273.0" } }, "node_modules/@github/copilot-language-server": { - "version": "1.265.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.265.0.tgz", - "integrity": "sha512-p74KG3jphQ8CPfzd+AvpNGrOV4EAvv/U1AXxI1iODjSp1m4kJRiDjI5nQAZVi6FWgoHb5wtNedCf3ZxKHwal5Q==", + "version": "1.273.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.273.0.tgz", + "integrity": "sha512-aVpGOprSPYYgRHzjnim5ug1L0SSlTEwLlYNITFTCIaVJmNr5T8gdq09ZiDlZXKQHlo7kQty+WhHZCAZd9bor+w==", + "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 60ed22f..c868fd5 100644 --- a/Server/package.json +++ b/Server/package.json @@ -4,6 +4,6 @@ "description": "Package for downloading @github/copilot-language-server", "private": true, "dependencies": { - "@github/copilot-language-server": "^1.265.0" + "@github/copilot-language-server": "^1.273.0" } } diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index cd25c46..32348cc 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -91,6 +91,13 @@ "identifier" : "WorkspaceSuggestionServiceTests", "name" : "WorkspaceSuggestionServiceTests" } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "ConversationTabTests", + "name" : "ConversationTabTests" + } } ], "version" : 1 diff --git a/Tool/Package.swift b/Tool/Package.swift index b66e7b0..698e609 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -264,6 +264,8 @@ let package = Package( "GitHubCopilotService", "BuiltinExtension", "SystemUtils", + "UserDefaultsObserver", + "Preferences" ]), diff --git a/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift b/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift index 9192e83..a077a32 100644 --- a/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift +++ b/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift @@ -59,23 +59,29 @@ struct CopilotIntroContent: View { .font(.title.bold()) .padding(.bottom, 38) - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 20) { CopilotIntroItem( imageName: "CopilotLogo", heading: "In-line Code Suggestions", - text: "Copilot's code suggestions and text completion now available in Xcode. Press Tab ⇥ to accept a suggestion." + text: "Receive context-aware code suggestions and text completion in your Xcode editor. Just press Tab ⇥ to accept a suggestion." ) CopilotIntroItem( systemImage: "option", - heading: "Full Suggestion", - text: "Press Option ⌥ key to display the full suggestion. Only the first line of suggestions are shown inline." + heading: "Full Suggestions", + text: "Press Option ⌥ for full multi-line suggestions. Only the first line is shown inline. Use Copilot Chat to refine, explain, or improve them." + ) + + CopilotIntroItem( + imageName: "ChatIcon", + heading: "Chat", + text: "Get real-time coding assistance, debug issues, and generate code snippets directly within Xcode." ) CopilotIntroItem( imageName: "GitHubMark", heading: "GitHub Context", - text: "Copilot utilizes project context to deliver smarter code suggestions relevant to your unique codebase." + text: "Copilot gives smarter code suggestions using your GitHub and project context. Use chat to discuss your code, debug issues, or get explanations." ) } .padding(.bottom, 64) diff --git a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift index e1b0044..9023b4e 100644 --- a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift +++ b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift @@ -88,6 +88,7 @@ public struct CustomTextEditor: NSViewRepresentable { textView.isAutomaticQuoteSubstitutionEnabled = false 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.") // Configure scroll view let scrollView = context.coordinator.theTextView diff --git a/Tool/Sources/SharedUIComponents/InsertButton.swift b/Tool/Sources/SharedUIComponents/InsertButton.swift index 7f10cd9..355d898 100644 --- a/Tool/Sources/SharedUIComponents/InsertButton.swift +++ b/Tool/Sources/SharedUIComponents/InsertButton.swift @@ -6,7 +6,7 @@ public struct InsertButton: View { @Environment(\.colorScheme) var colorScheme private var icon: Image { - return colorScheme == .dark ? Image("CodeBlockInsertIconDark") : Image("CodeBlockInsertIconLight") + return Image("CodeBlockInsertIcon") } public init(insert: @escaping () -> Void) { diff --git a/Tool/Sources/SharedUIComponents/InstructionView.swift b/Tool/Sources/SharedUIComponents/InstructionView.swift index f3da854..73de164 100644 --- a/Tool/Sources/SharedUIComponents/InstructionView.swift +++ b/Tool/Sources/SharedUIComponents/InstructionView.swift @@ -32,7 +32,7 @@ public struct Instruction: View { .font(.system(size: 14)) } } - } + }.frame(maxWidth: 350) } } } diff --git a/Tool/Sources/Status/Status.swift b/Tool/Sources/Status/Status.swift index f91fe59..7e6f516 100644 --- a/Tool/Sources/Status/Status.swift +++ b/Tool/Sources/Status/Status.swift @@ -1,40 +1,23 @@ import AppKit import Foundation -public enum ExtensionPermissionStatus { - case unknown - case succeeded - case failed -} +public enum ExtensionPermissionStatus { case unknown, succeeded, failed } @objc public enum ObservedAXStatus: Int { - case unknown = -1 - case granted = 1 - case notGranted = 0 + case unknown = -1, granted = 1, notGranted = 0 } public struct CLSStatus: Equatable { - public enum Status { - case unknown, normal, inProgress, error, warning, inactive - } - + public enum Status { case unknown, normal, inProgress, error, warning, inactive } public let status: Status public let message: String - - public var isInactiveStatus: Bool { - status == .inactive && !message.isEmpty - } - - public var isErrorStatus: Bool { - (status == .warning || status == .error) && !message.isEmpty - } + + 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 enum Status { case unknown, loggedIn, notLoggedIn, notAuthorized } public let status: Status public let username: String? public let message: String? @@ -51,7 +34,7 @@ private struct CLSStatusInfo { let message: String } -private struct ExtensionStatusInfo { +private struct AccessibilityStatusInfo { let icon: StatusResponse.Icon? let message: String? let url: String? @@ -64,28 +47,33 @@ public extension Notification.Name { public struct StatusResponse { public struct Icon { + /// Name of the icon resource public let name: String - // isTemplate = true, monochrome icon; isTemplate = false, colored icon - public let isTemplate: Bool - public init(name: String, isTemplate: Bool = true) { + public init(name: String) { self.name = name - self.isTemplate = isTemplate } public var nsImage: NSImage? { - let image = NSImage(named: name) - image?.isTemplate = isTemplate - return image + 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? } @@ -97,7 +85,7 @@ public final actor Status { private var clsStatus = CLSStatus(status: .unknown, message: "") private var authStatus = AuthStatus(status: .unknown, username: nil, message: nil) - private let okIcon = StatusResponse.Icon(name: "MenuBarIcon", isTemplate: false) + private let okIcon = StatusResponse.Icon(name: "MenuBarIcon") private let errorIcon = StatusResponse.Icon(name: "MenuBarWarningIcon") private let inactiveIcon = StatusResponse.Icon(name: "MenuBarInactiveIcon") @@ -128,6 +116,10 @@ public final actor Status { authStatus = newStatus broadcast() } + + public func getExtensionStatus() -> ExtensionPermissionStatus { + extensionStatus + } public func getAXStatus() -> ObservedAXStatus { if isXcodeRunning() { @@ -156,13 +148,17 @@ public final actor Status { public func getStatus() -> StatusResponse { let authStatusInfo: AuthStatusInfo = getAuthStatusInfo() let clsStatusInfo: CLSStatusInfo = getCLSStatusInfo() - let extensionStatusInfo: ExtensionStatusInfo = getExtensionStatusInfo() + let extensionStatusIcon = ( + extensionStatus == ExtensionPermissionStatus.failed + ) ? errorIcon : nil + let accessibilityStatusInfo: AccessibilityStatusInfo = getAccessibilityStatusInfo() return .init( - icon: authStatusInfo.authIcon ?? clsStatusInfo.icon ?? extensionStatusInfo.icon ?? okIcon, + icon: authStatusInfo.authIcon ?? clsStatusInfo.icon ?? extensionStatusIcon ?? accessibilityStatusInfo.icon ?? okIcon, inProgress: clsStatus.status == .inProgress, clsMessage: clsStatus.message, - message: extensionStatusInfo.message, - url: extensionStatusInfo.url, + message: accessibilityStatusInfo.message, + extensionStatus: extensionStatus, + url: accessibilityStatusInfo.url, authStatus: authStatusInfo.authStatus, userName: authStatusInfo.userName ) @@ -201,22 +197,12 @@ public final actor Status { return CLSStatusInfo(icon: nil, message: "") } - private func getExtensionStatusInfo() -> ExtensionStatusInfo { - if extensionStatus == .failed { - return ExtensionStatusInfo( - icon: errorIcon, - message: """ - Enable Copilot in Xcode & restart - """, - url: "x-apple.systempreferences:com.apple.ExtensionsPreferences" - ) - } - + private func getAccessibilityStatusInfo() -> AccessibilityStatusInfo { switch getAXStatus() { case .granted: - return ExtensionStatusInfo(icon: nil, message: nil, url: nil) + return AccessibilityStatusInfo(icon: nil, message: nil, url: nil) case .notGranted: - return ExtensionStatusInfo( + return AccessibilityStatusInfo( icon: errorIcon, message: """ Enable accessibility in system preferences @@ -224,7 +210,7 @@ public final actor Status { url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" ) case .unknown: - return ExtensionStatusInfo( + return AccessibilityStatusInfo( icon: errorIcon, message: """ Enable accessibility or restart Copilot diff --git a/Tool/Sources/Status/StatusObserver.swift b/Tool/Sources/Status/StatusObserver.swift index 074b305..4384fd3 100644 --- a/Tool/Sources/Status/StatusObserver.swift +++ b/Tool/Sources/Status/StatusObserver.swift @@ -5,6 +5,7 @@ import Cache 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, message: "") + @Published public private(set) var observedAXStatus = ObservedAXStatus.unknown public static let shared = StatusObserver() @@ -12,6 +13,7 @@ public class StatusObserver: ObservableObject { Task { @MainActor in await observeAuthStatus() await observeCLSStatus() + await observeAXStatus() } } @@ -25,6 +27,11 @@ public class StatusObserver: ObservableObject { setupCLSStatusNotificationObserver() } + private func observeAXStatus() async { + await updateAXStatus() + setupAXStatusNotificationObserver() + } + private func updateAuthStatus() async { let authStatus = await Status.shared.getAuthStatus() let statusInfo = await Status.shared.getStatus() @@ -43,6 +50,10 @@ public class StatusObserver: ObservableObject { self.clsStatus = await Status.shared.getCLSStatus() } + private func updateAXStatus() async { + self.observedAXStatus = await Status.shared.getAXStatus() + } + private func setupAuthStatusNotificationObserver() { NotificationCenter.default.addObserver( forName: .serviceStatusDidChange, @@ -79,4 +90,17 @@ public class StatusObserver: ObservableObject { } } } + + private func setupAXStatusNotificationObserver() { + NotificationCenter.default.addObserver( + forName: .serviceStatusDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + Task { @MainActor [self] in + await self.updateAXStatus() + } + } + } } diff --git a/Tool/Sources/TelemetryService/GithubPanicErrorReporter.swift b/Tool/Sources/TelemetryService/GithubPanicErrorReporter.swift index 33733f8..a7bb076 100644 --- a/Tool/Sources/TelemetryService/GithubPanicErrorReporter.swift +++ b/Tool/Sources/TelemetryService/GithubPanicErrorReporter.swift @@ -1,5 +1,7 @@ import Foundation import TelemetryServiceProvider +import UserDefaultsObserver +import Preferences public class GitHubPanicErrorReporter { private static let panicEndpoint = URL(string: "https://copilot-telemetry.githubusercontent.com/telemetry")! @@ -7,6 +9,30 @@ public class GitHubPanicErrorReporter { private static let standardChannelKey = Bundle.main .object(forInfoDictionaryKey: "STANDARD_TELEMETRY_CHANNEL_KEY") as! String + private static let userDefaultsObserver = UserDefaultsObserver( + object: UserDefaults.shared, + forKeyPaths: [ + UserDefaultPreferenceKeys().gitHubCopilotProxyUrl.key, + UserDefaultPreferenceKeys().gitHubCopilotProxyUsername.key, + UserDefaultPreferenceKeys().gitHubCopilotProxyPassword.key, + UserDefaultPreferenceKeys().gitHubCopilotUseStrictSSL.key, + ], + context: nil + ) + + // Use static initializer to set up the observer + private static let _initializer: Void = { + userDefaultsObserver.onChange = { + urlSession = configuredURLSession() + } + }() + + private static var urlSession: URLSession = { + // Initialize urlSession after observer setup + _ = _initializer + return configuredURLSession() + }() + // Helper: Format current time in ISO8601 style private static func currentTime() -> String { let formatter = DateFormatter() @@ -70,6 +96,81 @@ public class GitHubPanicErrorReporter { ] } + private static func configuredURLSession() -> URLSession { + let proxyURL = UserDefaults.shared.value(for: \.gitHubCopilotProxyUrl) + let strictSSL = UserDefaults.shared.value(for: \.gitHubCopilotUseStrictSSL) + + // If no proxy, use shared session + if proxyURL.isEmpty { + return .shared + } + + let configuration = URLSessionConfiguration.default + + if let url = URL(string: proxyURL) { + var proxyConfig: [String: Any] = [:] + let scheme = url.scheme?.lowercased() + + // Set proxy type based on URL scheme + switch scheme { + case "https": + proxyConfig[kCFProxyTypeKey as String] = kCFProxyTypeHTTPS + proxyConfig[kCFNetworkProxiesHTTPSEnable as String] = true + proxyConfig[kCFNetworkProxiesHTTPSProxy as String] = url.host + proxyConfig[kCFNetworkProxiesHTTPSPort as String] = url.port + case "socks", "socks5": + proxyConfig[kCFProxyTypeKey as String] = kCFProxyTypeSOCKS + proxyConfig[kCFNetworkProxiesSOCKSEnable as String] = true + proxyConfig[kCFNetworkProxiesSOCKSProxy as String] = url.host + proxyConfig[kCFNetworkProxiesSOCKSPort as String] = url.port + default: + proxyConfig[kCFProxyTypeKey as String] = kCFProxyTypeHTTP + proxyConfig[kCFProxyHostNameKey as String] = url.host + proxyConfig[kCFProxyPortNumberKey as String] = url.port + } + + // Add proxy authentication if configured + let username = UserDefaults.shared.value(for: \.gitHubCopilotProxyUsername) + let password = UserDefaults.shared.value(for: \.gitHubCopilotProxyPassword) + if !username.isEmpty { + proxyConfig[kCFProxyUsernameKey as String] = username + proxyConfig[kCFProxyPasswordKey as String] = password + } + + configuration.connectionProxyDictionary = proxyConfig + } + + // Configure SSL verification + if strictSSL { + return URLSession(configuration: configuration) + } + + let sessionDelegate = CustomURLSessionDelegate() + + return URLSession( + configuration: configuration, + delegate: sessionDelegate, + delegateQueue: nil + ) + } + + private class CustomURLSessionDelegate: NSObject, URLSessionDelegate { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + // Accept all certificates when strict SSL is disabled + guard let serverTrust = challenge.protectionSpace.serverTrust else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + let credential = URLCredential(trust: serverTrust) + completionHandler(.useCredential, credential) + } + } + public static func report(_ request: TelemetryExceptionRequest) async { do { var properties: [String : Any] = request.properties ?? [:] @@ -84,12 +185,17 @@ public class GitHubPanicErrorReporter { httpRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") httpRequest.httpBody = jsonData - let (_, response) = try await URLSession.shared.data(for: httpRequest) + // Use the cached URLSession instead of creating a new one + let (_, response) = try await urlSession.data(for: httpRequest) + #if DEBUG guard let httpResp = response as? HTTPURLResponse, httpResp.statusCode == 200 else { throw URLError(.badServerResponse) } + #endif } catch { + #if DEBUG print("Fails to send to Panic Endpoint: \(error)") + #endif } } }