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/.gitignore b/.gitignore index 9aa8393c..2a7f67ef 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,4 @@ Server/dist /releases/ /release/ /appcast.xml + diff --git a/CHANGELOG.md b/CHANGELOG.md index cce07a7d..f03abf8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,78 @@ 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.45.0 - November 14, 2025 +### Added +- New models: GPT-5.1, GPT-5.1-Codex, GPT-5.1-Codex-Mini, Claude Haiku 4.5, and Auto (preview). +- Added support for custom agents (preview). +- Introduced the built-in Plan agent (preview). +- Added support for subagent execution (preview). +- Added support for Next Edit Suggestions (preview). + +### Changed +- MCP servers now support dynamic OAuth setup for third-party authentication providers. +- Added a setting to configure the maximum number of tool requests allowed. + +### Fixed +- Fixed an issue that the terminal view in Agent conversation was clipped +- Fixed an issue that the Chat panel failed to recognize newly created workspaces. + +## 0.44.0 - October 15, 2025 +### Added +- Added support for new models in Chat: Grok Code Fast 1, Claude Sonnet 4.5, Claude Opus 4, Claude Opus 4.1 and GPT-5 mini. +- Added support for restoring to a saved checkpoint snapshot. +- Added support for tool selection in agent mode. +- Added the ability to adjust the chat panel font size. +- Added the ability to edit a previous chat message and resend it. +- Introduced a new setting to disable the Copilot “Fix Error” button. +- Added support for custom instructions in the Code Review feature. + +### Changed +- Switched authentication to a new OAuth app "GitHub Copilot IDE Plugin". +- Updated the chat layout to a messenger-style conversation view (user messages on the right, responses on the left). +- Now shows a clearer, more user-friendly message when Copilot finishes responding. +- Added support for skipping a tool call without ending the conversation. + +### Fixed +- Fixed a command injection vulnerability when opening referenced chat files. +- Resolved display issues in the chat view on macOS 26. + +## 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). +- 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. +- 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/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 844f7d7c..c762e625 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -18,6 +18,8 @@ 5EC511E42C90CE9800632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B1D2938973000C9DCDA /* Assets.xcassets */; }; 5EC511E52C90CFD600632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C861E6142994F6080056CB02 /* Assets.xcassets */; }; 5EC511E62C90CFD700632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C861E6142994F6080056CB02 /* Assets.xcassets */; }; + 7E6CEC912EAB6774005F2076 /* RejectNESSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E6CEC902EAB6774005F2076 /* RejectNESSuggestionCommand.swift */; }; + 7E856FF72E9F6D24005751CB /* AcceptNESSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E856FF62E9F6D1D005751CB /* AcceptNESSuggestionCommand.swift */; }; C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */; }; C8009C032941C576007AA7E8 /* SyncTextSettingsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009C022941C576007AA7E8 /* SyncTextSettingsCommand.swift */; }; C800DBB1294C624D00B04CAC /* PrefetchSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */; }; @@ -188,11 +190,13 @@ /* 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 = ""; }; + 7E6CEC902EAB6774005F2076 /* RejectNESSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejectNESSuggestionCommand.swift; sourceTree = ""; }; + 7E856FF62E9F6D1D005751CB /* AcceptNESSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptNESSuggestionCommand.swift; sourceTree = ""; }; C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRealtimeSuggestionsCommand.swift; sourceTree = ""; }; C8009C022941C576007AA7E8 /* SyncTextSettingsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTextSettingsCommand.swift; sourceTree = ""; }; C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchSuggestionsCommand.swift; sourceTree = ""; }; @@ -330,8 +334,10 @@ C8520300293C4D9000460097 /* Helpers.swift */, C81458952939EFDC00135263 /* GetSuggestionsCommand.swift */, C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */, + 7E856FF62E9F6D1D005751CB /* AcceptNESSuggestionCommand.swift */, C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */, C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */, + 7E6CEC902EAB6774005F2076 /* RejectNESSuggestionCommand.swift */, C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */, C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */, C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */, @@ -719,7 +725,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 */ @@ -734,12 +740,14 @@ C8758E7029F04BFF00D29C1C /* CustomCommand.swift in Sources */, C8758E7229F04CF100D29C1C /* SeparatorCommand.swift in Sources */, C861A6A329E5503F005C41A3 /* PromptToCodeCommand.swift in Sources */, + 7E6CEC912EAB6774005F2076 /* RejectNESSuggestionCommand.swift in Sources */, C8520301293C4D9000460097 /* Helpers.swift in Sources */, C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */, C80FFB962A95F58200704A25 /* AcceptPromptToCodeCommand.swift in Sources */, 427C63282C6E868B000E557C /* OpenSettingsCommand.swift in Sources */, C87B03A5293B261200C77EAE /* AcceptSuggestionCommand.swift in Sources */, C87B03A9293B262600C77EAE /* NextSuggestionCommand.swift in Sources */, + 7E856FF72E9F6D24005751CB /* AcceptNESSuggestionCommand.swift in Sources */, C87B03AB293B262E00C77EAE /* PreviousSuggestionCommand.swift in Sources */, C87B03A7293B261900C77EAE /* RejectSuggestionCommand.swift in Sources */, C8009C032941C576007AA7E8 /* SyncTextSettingsCommand.swift in Sources */, 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"> + + + + + + \ No newline at end of file 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/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/Package.swift b/Core/Package.swift index 966dcaab..faf0c12b 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -93,6 +93,7 @@ let package = Package( .product(name: "ChatAPIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "AXHelper", package: "Tool"), + .product(name: "WorkspaceSuggestionService", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Dependencies", package: "swift-dependencies"), @@ -182,7 +183,9 @@ let package = Package( .product(name: "Terminal", package: "Tool"), .product(name: "SystemUtils", package: "Tool"), .product(name: "AppKitExtension", package: "Tool"), - .product(name: "WebContentExtractor", package: "Tool") + .product(name: "WebContentExtractor", package: "Tool"), + .product(name: "GitHelper", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool") ]), .testTarget( name: "ChatServiceTests", @@ -251,6 +254,7 @@ let package = Package( .target( name: "GitHubCopilotViewModel", dependencies: [ + "Client", .product(name: "GitHubCopilotService", package: "Tool"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Status", package: "Tool"), @@ -262,6 +266,7 @@ let package = Package( .target( name: "KeyBindingManager", dependencies: [ + "SuggestionWidget", .product(name: "Workspace", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "Logger", package: "Tool"), diff --git a/Core/Sources/ChatService/ChatInjector.swift b/Core/Sources/ChatService/ChatInjector.swift index 81a60243..df3d454a 100644 --- a/Core/Sources/ChatService/ChatInjector.swift +++ b/Core/Sources/ChatService/ChatInjector.swift @@ -4,7 +4,7 @@ import XcodeInspector import AXHelper import ApplicationServices import AppActivator - +import LanguageServerProtocol public struct ChatInjector { public init() {} @@ -22,16 +22,15 @@ public struct ChatInjector { var lines = editorContent.content.splitByNewLine( omittingEmptySubsequences: false ).map { String($0) } - // Ensure the line number is within the bounds of the file + guard cursorPosition.line <= lines.count else { return } var modifications: [Modification] = [] - // remove selection - // make sure there is selection exist and valid + // Handle selection deletion if let selection = editorContent.selections.first, - selection.isValid, - selection.start.line < lines.endIndex { + selection.isValid, + selection.start.line < lines.endIndex { let selectionEndLine = min(selection.end.line, lines.count - 1) let deletedSelection = CursorRange( start: selection.start, @@ -39,59 +38,110 @@ public struct ChatInjector { ) modifications.append(.deletedSelection(deletedSelection)) lines = lines.applying([.deletedSelection(deletedSelection)]) - - // update cursorPosition to the start of selection cursorPosition = selection.start } - let targetLine = lines[cursorPosition.line] + let insertionRange = CursorRange( + start: cursorPosition, + end: cursorPosition + ) - // Determine the indention level of the target line - let leadingWhitespace = cursorPosition.character > 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..9b5d1708 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -15,10 +15,26 @@ import Workspace import XcodeInspector import OrderedCollections import SystemUtils +import GitHelper +import LanguageServerProtocol +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, + customChatModeId: String?, + userLanguage: String?, + turnId: String? + ) async throws func stopReceivingMessage() async func upvote(_ id: String, _ rating: ConversationRating) async func downvote(_ id: String, _ rating: ConversationRating) async @@ -33,34 +49,13 @@ struct ToolCallRequest { let completion: (AnyJSONRPCResponse) -> Void } -public struct FileEdit: Equatable { +struct ConversationTurnTrackingState { + var turnParentMap: [String: String] = [:] // Maps subturn ID to parent turn ID + var validConversationIds: Set = [] // Tracks all valid conversation IDs including subagents - public enum Status: String { - case none = "none" - case kept = "kept" - case undone = "undone" - } - - public let fileURL: URL - public let originalContent: String - public var modifiedContent: String - public var status: Status - - /// Different toolName, the different undo logic. Like `insert_edit_into_file` and `create_file` - public var toolName: ToolName - - public init( - fileURL: URL, - originalContent: String, - modifiedContent: String, - status: Status = .none, - toolName: ToolName - ) { - self.fileURL = fileURL - self.originalContent = originalContent - self.modifiedContent = modifiedContent - self.status = status - self.toolName = toolName + mutating func reset() { + turnParentMap.removeAll() + validConversationIds.removeAll() } } @@ -70,7 +65,8 @@ public final class ChatService: ChatServiceType, ObservableObject { @Published public internal(set) var chatHistory: [ChatMessage] = [] @Published public internal(set) var isReceivingMessage = false @Published public internal(set) var fileEditMap: OrderedDictionary = [:] - public let chatTabInfo: ChatTabInfo + public internal(set) var requestType: RequestType? = nil + public private(set) var chatTabInfo: ChatTabInfo private let conversationProvider: ConversationServiceProvider? private let conversationProgressHandler: ConversationProgressHandler private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared @@ -83,6 +79,9 @@ public final class ChatService: ChatServiceType, ObservableObject { private var lastUserRequest: ConversationRequest? private var isRestored: Bool = false private var pendingToolCallRequests: [String: ToolCallRequest] = [:] + // Workaround: toolConfirmation request does not have parent turnId + private var conversationTurnTracking = ConversationTurnTrackingState() + init(provider: any ConversationServiceProvider, memory: ContextAwareAutoManagedChatMemory = ContextAwareAutoManagedChatMemory(), conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared, @@ -111,6 +110,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 @@ -145,7 +149,15 @@ public final class ChatService: ChatServiceType, ObservableObject { private func subscribeToClientToolConfirmationEvent() { ClientToolHandlerImpl.shared.onClientToolConfirmationEvent.sink(receiveValue: { [weak self] (request, completion) in - guard let params = request.params, params.conversationId == self?.conversationId else { return } + guard let params = request.params else { return } + + // Check if this conversationId is valid (main conversation or subagent conversation) + guard let validIds = self?.conversationTurnTracking.validConversationIds, validIds.contains(params.conversationId) else { + return + } + + let parentTurnId = self?.conversationTurnTracking.turnParentMap[params.turnId] + let editAgentRounds: [AgentRound] = [ AgentRound(roundId: params.roundId, reply: "", @@ -154,7 +166,7 @@ public final class ChatService: ChatServiceType, ObservableObject { ] ) ] - self?.appendToolCallHistory(turnId: params.turnId, editAgentRounds: editAgentRounds) + self?.appendToolCallHistory(turnId: params.turnId, editAgentRounds: editAgentRounds, parentTurnId: parentTurnId) self?.pendingToolCallRequests[params.toolCallId] = ToolCallRequest( requestId: request.id, turnId: params.turnId, @@ -166,7 +178,13 @@ public final class ChatService: ChatServiceType, ObservableObject { private func subscribeToClientToolInvokeEvent() { ClientToolHandlerImpl.shared.onClientToolInvokeEvent.sink(receiveValue: { [weak self] (request, completion) in - guard let params = request.params, params.conversationId == self?.conversationId else { return } + guard let params = request.params else { return } + + // Check if this conversationId is valid (main conversation or subagent conversation) + guard let validIds = self?.conversationTurnTracking.validConversationIds, validIds.contains(params.conversationId) else { + return + } + guard let copilotTool = CopilotToolRegistry.shared.getTool(name: params.name) else { completion(AnyJSONRPCResponse(id: request.id, result: JSONValue.array([ @@ -182,40 +200,37 @@ public final class ChatService: ChatServiceType, ObservableObject { return } - copilotTool.invokeTool(request, completion: completion, chatHistoryUpdater: self?.appendToolCallHistory, contextProvider: self) + _ = copilotTool.invokeTool(request, completion: completion, contextProvider: self) }).store(in: &cancellables) } - private func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound]) { + func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = [], parentTurnId: String? = nil) { let chatTabId = self.chatTabInfo.id Task { + let turnStatus: ChatMessage.TurnStatus? = { + guard let round = editAgentRounds.first, let toolCall = round.toolCalls?.first else { + return nil + } + + switch toolCall.status { + case .waitForConfirmation: return .waitForConfirmation + case .accepted, .running, .completed, .error: return .inProgress + case .cancelled: return .cancelled + } + }() + let message = ChatMessage( - id: turnId, + assistantMessageWithId: turnId, chatTabID: chatTabId, - clsTurnID: turnId, - role: .assistant, - content: "", - references: [], - steps: [], - editAgentRounds: editAgentRounds + editAgentRounds: editAgentRounds, + parentTurnId: parentTurnId, + fileEdits: fileEdits, + turnStatus: turnStatus ) await self.memory.appendMessage(message) } } - - public func updateFileEdits(by fileEdit: FileEdit) { - if let existingFileEdit = self.fileEditMap[fileEdit.fileURL] { - self.fileEditMap[fileEdit.fileURL] = .init( - fileURL: fileEdit.fileURL, - originalContent: existingFileEdit.originalContent, - modifiedContent: fileEdit.modifiedContent, - toolName: existingFileEdit.toolName - ) - } else { - self.fileEditMap[fileEdit.fileURL] = fileEdit - } - } public func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws { try await conversationProvider?.notifyChangeTextDocument(fileURL: fileURL, content: content, version: version, workspaceURL: getWorkspaceURL()) @@ -242,77 +257,64 @@ public final class ChatService: ChatServiceType, ObservableObject { self.isRestored = true } + /// Updates the status of a tool call (accepted, cancelled, etc.) and notifies the server + /// + /// This method handles two key responsibilities: + /// 1. Sends confirmation response back to the server when user accepts/cancels + /// 2. Updates the tool call status in chat history UI (including subagent tool calls) public func updateToolCallStatus(toolCallId: String, status: AgentToolCall.ToolCallStatus, payload: Any? = nil) { - if status == .cancelled { - resetOngoingRequest() - return - } - - // Send the tool call result back to the server - if let toolCallRequest = self.pendingToolCallRequests[toolCallId], status == .accepted { + // Capture the pending request info before removing it from the dictionary + let toolCallRequest = self.pendingToolCallRequests[toolCallId] + + // Step 1: Send confirmation response to server (for accept/cancel actions only) + if let toolCallRequest = toolCallRequest, status == .accepted || status == .cancelled { self.pendingToolCallRequests.removeValue(forKey: toolCallId) - let toolResult = LanguageModelToolConfirmationResult(result: .Accept) - let jsonResult = try? JSONEncoder().encode(toolResult) - let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null - toolCallRequest.completion( - AnyJSONRPCResponse( - id: toolCallRequest.requestId, - result: JSONValue.array([ - jsonValue, - JSONValue.null - ]) - ) - ) + sendToolConfirmationResponse(toolCallRequest, accepted: status == .accepted) } - // Update the tool call status in the chat history + // Step 2: Update the tool call status in chat history UI Task { - guard let lastMessage = await memory.history.last, lastMessage.role == .assistant else { + guard let targetMessage = await ToolCallStatusUpdater.findMessageContainingToolCall( + toolCallRequest, + conversationTurnTracking: conversationTurnTracking, + history: await memory.history + ) else { return } - - var updatedAgentRounds: [AgentRound] = [] - for i in 0.. = [], contentImageReferences: Array = [], skillSet: Array, - references: Array, + references: [ConversationAttachedReference], model: String? = nil, + modelProviderName: String? = nil, agentMode: Bool = false, + customChatModeId: String? = nil, userLanguage: String? = nil, turnId: String? = nil ) async throws { @@ -360,9 +364,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 +384,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 +411,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) } @@ -439,7 +440,9 @@ public final class ChatService: ChatServiceType, ObservableObject { activeDoc: activeDoc, references: references, model: model, + modelProviderName: modelProviderName, agentMode: agentMode, + customChatModeId: customChatModeId, userLanguage: userLanguage, turnId: currentTurnId, skillSet: validSkillSet @@ -447,7 +450,9 @@ public final class ChatService: ChatServiceType, ObservableObject { self.lastUserRequest = request self.skillSet = validSkillSet - try await sendConversationRequest(request) + if let response = try await sendConversationRequest(request) { + await handleConversationCreateResponse(response) + } } private func createConversationRequest( @@ -455,9 +460,11 @@ 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, + customChatModeId: String? = nil, userLanguage: String? = nil, turnId: String? = nil, skillSet: [ConversationSkill] @@ -481,11 +488,24 @@ public final class ChatService: ChatServiceType, ObservableObject { ignoredSkills: ignoredSkills, references: references, model: model, + modelProviderName: modelProviderName, agentMode: agentMode, + customChatModeId: customChatModeId, userLanguage: userLanguage, turnId: turnId ) } + + private func handleConversationCreateResponse(_ response: ConversationCreateResponse) async { + await memory.mutateHistory { history in + if let index = history.firstIndex(where: { $0.id == response.turnId && $0.role.isAssistant }) { + history[index].modelName = response.modelName + history[index].billingMultiplier = response.billingMultiplier + + self.saveChatMessageToStorage(history[index]) + } + } + } public func sendAndWait(_ id: String, content: String) async throws -> String { try await send(id, content: content, skillSet: [], references: []) @@ -503,9 +523,10 @@ public final class ChatService: ChatServiceType, ObservableObject { print("Failed to cancel ongoing request with WDT: \(activeRequestId)") } } - resetOngoingRequest() + resetOngoingRequest(with: .cancelled) } + // Not used public func clearHistory() async { let messageIds = await memory.history.map { $0.id } @@ -521,13 +542,20 @@ public final class ChatService: ChatServiceType, ObservableObject { deleteAllChatMessagesFromStorage(messageIds) resetOngoingRequest() } - - public func deleteMessage(id: String) async { - await memory.removeMessage(id) - deleteChatMessageFromStorage(id) + + public func deleteMessages(ids: [String]) async { + let turnIdsFromMessages = await memory.history + .filter { ids.contains($0.id) } + .compactMap { $0.clsTurnID } + .map { String($0) } + let turnIds = Array(Set(turnIdsFromMessages)) + + await memory.removeMessages(ids) + await deleteTurns(turnIds) + deleteAllChatMessagesFromStorage(ids) } - 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,7 +568,9 @@ public final class ChatService: ChatServiceType, ObservableObject { skillSet: skillSet, references: lastUserRequest.references ?? [], model: model != nil ? model : lastUserRequest.model, + modelProviderName: modelProviderName, agentMode: lastUserRequest.agentMode, + customChatModeId: lastUserRequest.customChatModeId, userLanguage: lastUserRequest.userLanguage, turnId: id ) @@ -625,7 +655,15 @@ public final class ChatService: ChatServiceType, ObservableObject { } return URL(fileURLWithPath: chatTabInfo.workspacePath) } - + + public func getProjectRootURL() -> 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()) } @@ -650,8 +688,22 @@ public final class ChatService: ChatServiceType, ObservableObject { private func handleProgressBegin(token: String, progress: ConversationProgressBegin) { guard let workDoneToken = activeRequestId, workDoneToken == token else { return } - conversationId = progress.conversationId + // Only update conversationId for main turns, not subagent turns + // Subagent turns have their own conversation ID which should not replace the parent + if progress.parentTurnId == nil { + conversationId = progress.conversationId + } + + // Track all valid conversation IDs for the current turn (main conversation + its subturns) + conversationTurnTracking.validConversationIds.insert(progress.conversationId) + let turnId = progress.turnId + let parentTurnId = progress.parentTurnId + + // Track parent-subturn relationship + if let parentTurnId = parentTurnId { + conversationTurnTracking.turnParentMap[turnId] = parentTurnId + } Task { if var lastUserMessage = await memory.history.last(where: { $0.role == .user }) { @@ -675,16 +727,17 @@ 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: "" - ) + /// Skip creating a new message for subturns - they will be merged into the parent turn + if parentTurnId == nil { + let message = ChatMessage( + assistantMessageWithId: turnId, + chatTabID: chatTabInfo.id, + turnStatus: .inProgress + ) - // will persist in resetOngoingRequest() - await memory.appendMessage(message) + // will persist in resetOngoingRequest() + await memory.appendMessage(message) + } } } @@ -698,6 +751,7 @@ public final class ChatService: ChatServiceType, ObservableObject { var references: [ConversationReference] = [] var steps: [ConversationProgressStep] = [] var editAgentRounds: [AgentRound] = [] + let parentTurnId = progress.parentTurnId if let reply = progress.reply { content = reply @@ -715,29 +769,28 @@ public final class ChatService: ChatServiceType, ObservableObject { editAgentRounds = progressAgentRounds } - if content.isEmpty && references.isEmpty && steps.isEmpty && editAgentRounds.isEmpty { + if content.isEmpty && references.isEmpty && steps.isEmpty && editAgentRounds.isEmpty && parentTurnId == nil { return } - // create immutable copies let messageContent = content let messageReferences = references let messageSteps = steps let messageAgentRounds = editAgentRounds + let messageParentTurnId = parentTurnId 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, - editAgentRounds: messageAgentRounds + editAgentRounds: messageAgentRounds, + parentTurnId: messageParentTurnId, + turnStatus: .inProgress ) - // will persist in resetOngoingRequest() await memory.appendMessage(message) } } @@ -750,11 +803,23 @@ public final class ChatService: ChatServiceType, ObservableObject { // CLS Error Code 402: reached monthly chat messages limit if CLSError.code == 402 { Task { + let selectedModel = lastUserRequest?.model + let selectedModelProviderName = lastUserRequest?.modelProviderName + + var errorMessageText: String + if let selectedModel = selectedModel, let selectedModelProviderName = selectedModelProviderName { + errorMessageText = "You've reached your quota limit for your BYOK model \(selectedModel). Please check with \(selectedModelProviderName) for more information." + } else { + errorMessageText = CLSError.message + } + 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)]) + .updateCLSStatus(.warning, busy: false, message: errorMessageText) + let errorMessage = ChatMessage( + errorMessageWithId: progress.turnId, + chatTabID: chatTabInfo.id, + panelMessages: [.init(type: .error, title: String(CLSError.code ?? 0), message: errorMessageText, location: .Panel)] + ) // will persist in resetongoingRequest() await memory.appendMessage(errorMessage) @@ -764,35 +829,50 @@ public final class ChatService: ChatServiceType, ObservableObject { guard let fallbackModel = CopilotModelManager.getFallbackLLM( scope: lastUserRequest.agentMode ? .agentPanel : .chatPanel ) else { - resetOngoingRequest() + resetOngoingRequest(with: .error) return } 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() + resetOngoingRequest(with: .error) } return } } } 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) - resetOngoingRequest() + resetOngoingRequest(with: .error) return } } else { Task { - let errorMessage = buildErrorMessage(turnId: progress.turnId, errorMessages: [CLSError.message]) + var clsErrorMessage = CLSError.message + if CLSError.code == ConversationErrorCode.toolRoundExceedError.rawValue { + // TODO: Remove this after `Continue` is supported. + clsErrorMessage = HardCodedToolRoundExceedErrorMessage + } + + let errorMessage = ChatMessage( + errorMessageWithId: progress.turnId, + chatTabID: chatTabInfo.id, + errorMessages: [clsErrorMessage] + ) // will persist in resetOngoingRequest() await memory.appendMessage(errorMessage) - resetOngoingRequest() + resetOngoingRequest(with: .error) return } } @@ -800,39 +880,25 @@ 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 + suggestedTitle: progress.suggestedTitle, + turnStatus: .success ) // will persist in resetOngoingRequest() await memory.appendMessage(message) - resetOngoingRequest() + resetOngoingRequest(with: .success) } } - 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() { + private func resetOngoingRequest(with turnStatus: ChatMessage.TurnStatus = .success) { activeRequestId = nil isReceivingMessage = false + requestType = nil + + // Clear turn tracking data + conversationTurnTracking.reset() // cancel all pending tool call requests for (_, request) in pendingToolCallRequests { @@ -875,7 +941,32 @@ public final class ChatService: ChatServiceType, ObservableObject { history[lastIndex].editAgentRounds[i].toolCalls![j].status = .cancelled } } + + // Cancel tool calls in subagent rounds + if let subAgentRounds = history[lastIndex].editAgentRounds[i].subAgentRounds { + for k in 0.. ConversationCreateResponse? { guard !isReceivingMessage else { throw CancellationError() } isReceivingMessage = true + requestType = .conversation do { if let conversationId = conversationId { - try await conversationProvider? + return try await conversationProvider? .createTurn( with: conversationId, request: request, @@ -911,45 +1003,29 @@ public final class ChatService: ChatServiceType, ObservableObject { requestWithTurns.turns = turns } - try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL()) + return try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL()) } } catch { - resetOngoingRequest() + resetOngoingRequest(with: .error) throw error } } - // MARK: - File Edit - public func undoFileEdit(for fileURL: URL) throws { - guard let fileEdit = self.fileEditMap[fileURL], - fileEdit.status == .none - else { return } - - switch fileEdit.toolName { - case .insertEditIntoFile: - InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent, contextProvider: self) - case .createFile: - try CreateFileTool.undo(for: fileURL) - default: + private func deleteTurns(_ turnIds: [String]) async { + guard !turnIds.isEmpty, let conversationId = conversationId else { return } - self.fileEditMap[fileURL]!.status = .undone - } - - public func keepFileEdit(for fileURL: URL) { - guard let fileEdit = self.fileEditMap[fileURL], fileEdit.status == .none - else { return } - self.fileEditMap[fileURL]!.status = .kept - } - - public func resetFileEdits() { - self.fileEditMap = [:] - } - - public func discardFileEdit(for fileURL: URL) throws { - try self.undoFileEdit(for: fileURL) - self.fileEditMap.removeValue(forKey: fileURL) + let workspaceURL = getWorkspaceURL() + + for turnId in turnIds { + do { + try await conversationProvider? + .deleteTurn(with: conversationId, turnId: turnId, workspaceURL: workspaceURL) + } catch { + Logger.client.error("Failed to delete turn: \(error)") + } + } } } @@ -957,6 +1033,7 @@ public final class ChatService: ChatServiceType, ObservableObject { public final class SharedChatService { public var chatTemplates: [ChatTemplate]? = nil public var chatAgents: [ChatAgent]? = nil + public var conversationModes: [ConversationMode]? = nil private let conversationProvider: ConversationServiceProvider? public static let shared = SharedChatService.service() @@ -973,8 +1050,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 @@ -987,6 +1062,19 @@ public final class SharedChatService { return nil } + public func loadConversationModes() async -> [ConversationMode]? { + do { + if let modes = (try await conversationProvider?.modes()) { + self.conversationModes = modes + return modes + } + } catch { + // handle error if desired + } + + return nil + } + public func copilotModels() async -> [CopilotModel] { guard let models = try? await conversationProvider?.models() else { return [] } return models @@ -1051,21 +1139,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 @@ -1104,3 +1206,176 @@ 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 + let turnId = UUID().uuidString + + do { + await CodeReviewService.shared.resetComments() + + await addCodeReviewUserMessage(id: UUID().uuidString, turnId: turnId, group: group) + + let initialBotMessage = ChatMessage( + assistantMessageWithId: turnId, + chatTabID: chatTabInfo.id, + turnStatus: .inProgress, + requestType: .codeReview + ) + await memory.appendMessage(initialBotMessage) + + guard let projectRootURL = getProjectRootURL() + else { + let round = CodeReviewRound.fromError(turnId: turnId, error: "Invalid git repository.") + await appendCodeReviewRound(round) + resetOngoingRequest(with: .error) + 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, turnStatus: .waitForConfirmation) + } catch { + resetOngoingRequest(with: .error) + 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, + turnStatus: ChatMessage.TurnStatus? = nil + ) async { + let message = ChatMessage( + assistantMessageWithId: round.turnId, + chatTabID: chatTabInfo.id, + codeReviewRound: round, + turnStatus: turnStatus + ) + + 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, turnStatus: .inProgress) + + 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(with: .error) + 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(with: .cancelled) + } + + 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, + requestType: .codeReview + ) + 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..c41eb61b --- /dev/null +++ b/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift @@ -0,0 +1,57 @@ +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( + 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/Extensions/ChatService+FileEdit.swift b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift new file mode 100644 index 00000000..df1509ff --- /dev/null +++ b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift @@ -0,0 +1,55 @@ +import Foundation +import ConversationServiceProvider +import ChatAPIService + +extension ChatService { + // MARK: - File Edit + + public func updateFileEdits(by fileEdit: FileEdit) { + if let existingFileEdit = self.fileEditMap[fileEdit.fileURL] { + self.fileEditMap[fileEdit.fileURL] = .init( + fileURL: fileEdit.fileURL, + originalContent: existingFileEdit.originalContent, + modifiedContent: fileEdit.modifiedContent, + toolName: existingFileEdit.toolName + ) + } else { + self.fileEditMap[fileEdit.fileURL] = fileEdit + } + } + + public func undoFileEdit(for fileURL: URL) throws { + guard var fileEdit = self.fileEditMap[fileURL], + fileEdit.status == .none + else { return } + + switch fileEdit.toolName { + case .insertEditIntoFile: + InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent, contextProvider: self) + case .createFile: + try CreateFileTool.undo(for: fileURL) + default: + return + } + + fileEdit.status = .undone + self.fileEditMap[fileURL] = fileEdit + } + + public func keepFileEdit(for fileURL: URL) { + guard var fileEdit = self.fileEditMap[fileURL], fileEdit.status == .none + else { return } + + fileEdit.status = .kept + self.fileEditMap[fileURL] = fileEdit + } + + public func resetFileEdits() { + self.fileEditMap = [:] + } + + public func discardFileEdit(for fileURL: URL) throws { + try self.undoFileEdit(for: fileURL) + self.fileEditMap.removeValue(forKey: fileURL) + } +} diff --git a/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift index 5800820a..19f4aa8d 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 + let response: JSONValue + + if let fileSelection = currentFile.selection { + let start = fileSelection.start + let end = fileSelection.end + 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 ?? "")]), - JSONValue.null - ])) + AnyJSONRPCResponse( + id: request.id, + result: JSONValue.array([response, JSONValue.null])) ) } } diff --git a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift index 08343963..702ade22 100644 --- a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift @@ -1,7 +1,9 @@ import JSONRPC +import AppKit import ConversationServiceProvider import Foundation import Logger +import ChatAPIService public class CreateFileTool: ICopilotTool { public static let name = ToolName.createFile @@ -9,7 +11,6 @@ public class CreateFileTool: ICopilotTool { public func invokeTool( _ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, - chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: (any ToolContextProvider)? ) -> Bool { guard let params = request.params, @@ -49,14 +50,16 @@ public class CreateFileTool: ICopilotTool { return true } - contextProvider?.updateFileEdits(by: .init( + let fileEdit: FileEdit = .init( fileURL: URL(fileURLWithPath: filePath), originalContent: "", modifiedContent: writtenContent, toolName: CreateFileTool.name - )) + ) + + contextProvider?.updateFileEdits(by: fileEdit) - 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)") } @@ -77,9 +80,7 @@ public class CreateFileTool: ICopilotTool { ) ] - if let chatHistoryUpdater { - chatHistoryUpdater(params.turnId, editAgentRounds) - } + contextProvider?.updateChatHistory(params.turnId, editAgentRounds: editAgentRounds, fileEdits: [fileEdit]) completeResponse( request, diff --git a/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift index 5ff5f6b9..c9f95260 100644 --- a/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift +++ b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift @@ -14,7 +14,6 @@ public class FetchWebPageTool: ICopilotTool { public func invokeTool( _ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, - chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: (any ToolContextProvider)? ) -> Bool { guard let params = request.params, diff --git a/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift index f95625dc..f41aa524 100644 --- a/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift +++ b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift @@ -8,7 +8,6 @@ public class GetErrorsTool: ICopilotTool { public func invokeTool( _ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, - chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: ToolContextProvider? ) -> Bool { guard let params = request.params, diff --git a/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift b/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift index 1d298711..69a76689 100644 --- a/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift +++ b/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift @@ -4,7 +4,7 @@ import JSONRPC import Terminal public class GetTerminalOutputTool: ICopilotTool { - public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: (any ToolContextProvider)?) -> Bool { + public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, contextProvider: (any ToolContextProvider)?) -> Bool { var result: String = "" if let input = request.params?.input as? [String: AnyCodable], let terminalId = input["id"]?.value as? String{ let session = TerminalSessionManager.shared.getSession(for: terminalId) diff --git a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift index cbe9e2ec..8e10fbfa 100644 --- a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift +++ b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift @@ -2,15 +2,16 @@ import ChatTab import ConversationServiceProvider import Foundation import JSONRPC +import ChatAPIService 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 + func updateChatHistory(_ turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit]) } -public typealias ChatHistoryUpdater = (String, [AgentRound]) -> Void public protocol ICopilotTool { /** @@ -18,7 +19,6 @@ public protocol ICopilotTool { * - Parameters: * - request: The tool invocation request. * - completion: Closure called with JSON-RPC response when tool execution completes. - * - chatHistoryUpdater: Optional closure to update chat history during tool execution. * - contextProvider: Optional provider that supplies additional context information * needed for tool execution, such as chat tab data and file editing capabilities. * - Returns: Boolean indicating if the tool call has completed. True if the tool call is completed, false otherwise. @@ -26,7 +26,6 @@ public protocol ICopilotTool { func invokeTool( _ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, - chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: ToolContextProvider? ) -> Bool } @@ -85,4 +84,8 @@ extension ICopilotTool { } } -extension ChatService: ToolContextProvider { } +extension ChatService: ToolContextProvider { + public func updateChatHistory(_ turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = []) { + appendToolCallHistory(turnId: turnId, editAgentRounds: editAgentRounds, fileEdits: fileEdits) + } +} diff --git a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift index 935b81bc..2b90f61f 100644 --- a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift @@ -6,6 +6,7 @@ import Foundation import JSONRPC import Logger import XcodeInspector +import ChatAPIService public class InsertEditIntoFileTool: ICopilotTool { public static let name = ToolName.insertEditIntoFile @@ -13,7 +14,6 @@ public class InsertEditIntoFileTool: ICopilotTool { public func invokeTool( _ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, - chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: (any ToolContextProvider)? ) -> Bool { guard let params = request.params, @@ -47,9 +47,8 @@ public class InsertEditIntoFileTool: ICopilotTool { return } - contextProvider.updateFileEdits( - by: .init(fileURL: fileURL, originalContent: originalContent, modifiedContent: code, toolName: InsertEditIntoFileTool.name) - ) + let fileEdit: FileEdit = .init(fileURL: fileURL, originalContent: originalContent, modifiedContent: code, toolName: InsertEditIntoFileTool.name) + contextProvider.updateFileEdits(by: fileEdit) let editAgentRounds: [AgentRound] = [ .init( @@ -66,9 +65,8 @@ public class InsertEditIntoFileTool: ICopilotTool { ) ] - if let chatHistoryUpdater { - chatHistoryUpdater(params.turnId, editAgentRounds) - } + contextProvider + .updateChatHistory(params.turnId, editAgentRounds: editAgentRounds, fileEdits: [fileEdit]) self.completeResponse(request, response: newContent, completion: completion) } @@ -98,7 +96,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 +165,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 } @@ -243,8 +194,6 @@ public class InsertEditIntoFileTool: ICopilotTool { ) 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 { diff --git a/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift b/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift index fba3e4a0..1fc8306b 100644 --- a/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift +++ b/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift @@ -4,7 +4,7 @@ import XcodeInspector import JSONRPC public class RunInTerminalTool: ICopilotTool { - public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: (any ToolContextProvider)?) -> Bool { + public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, contextProvider: (any ToolContextProvider)?) -> Bool { let params = request.params! Task { diff --git a/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift b/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift new file mode 100644 index 00000000..d6d86386 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift @@ -0,0 +1,102 @@ +import ChatAPIService +import ConversationServiceProvider +import Foundation + +/// Helper methods for updating tool call status in chat history +/// Handles both main turn tool calls and subagent tool calls +struct ToolCallStatusUpdater { + /// Finds the message containing the tool call, handling both main turns and subturns + static func findMessageContainingToolCall( + _ toolCallRequest: ToolCallRequest?, + conversationTurnTracking: ConversationTurnTrackingState, + history: [ChatMessage] + ) async -> ChatMessage? { + guard let request = toolCallRequest else { return nil } + + // If this is a subturn, find the parent turn; otherwise use the request's turnId + let turnIdToFind = conversationTurnTracking.turnParentMap[request.turnId] ?? request.turnId + + return history.first(where: { $0.id == turnIdToFind && $0.role == .assistant }) + } + + /// Searches for a tool call in agent rounds (including nested subagent rounds) and creates an update + /// + /// Note: Parent turns can have multiple sequential subturns, but they don't appear simultaneously. + /// Subturns are merged into the parent's last round's subAgentRounds array by ChatMemory. + static func findAndUpdateToolCall( + toolCallId: String, + newStatus: AgentToolCall.ToolCallStatus, + in agentRounds: [AgentRound] + ) -> AgentRound? { + // First, search in main rounds (for regular tool calls) + for round in agentRounds { + if let toolCalls = round.toolCalls { + for toolCall in toolCalls where toolCall.id == toolCallId { + return AgentRound( + roundId: round.roundId, + reply: "", + toolCalls: [ + AgentToolCall( + id: toolCallId, + name: toolCall.name, + status: newStatus + ), + ] + ) + } + } + } + + // If not found in main rounds, search in subagent rounds (for subturn tool calls) + // Subturns are nested under the parent round's subAgentRounds + for round in agentRounds { + guard let subAgentRounds = round.subAgentRounds else { continue } + + for subRound in subAgentRounds { + guard let toolCalls = subRound.toolCalls else { continue } + + for toolCall in toolCalls where toolCall.id == toolCallId { + // Create an update that will be merged into the parent's subAgentRounds + // ChatMemory.appendMessage will handle the merging logic + let subagentRound = AgentRound( + roundId: subRound.roundId, + reply: "", + toolCalls: [ + AgentToolCall( + id: toolCallId, + name: toolCall.name, + status: newStatus + ), + ] + ) + return AgentRound( + roundId: round.roundId, + reply: "", + toolCalls: [], + subAgentRounds: [subagentRound] + ) + } + } + } + + return nil + } + + /// Creates a message update with the new tool call status + static func createMessageUpdate( + targetMessage: ChatMessage, + updatedRound: AgentRound + ) -> ChatMessage { + return ChatMessage( + id: targetMessage.id, + chatTabID: targetMessage.chatTabID, + clsTurnID: targetMessage.clsTurnID, + role: .assistant, + content: "", + references: [], + steps: [], + editAgentRounds: [updatedRound], + turnStatus: .inProgress + ) + } +} 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..fc3e2c58 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -10,6 +10,8 @@ import GitHubCopilotService import Logger import OrderedCollections import SwiftUI +import GitHelper +import SuggestionBasic public struct DisplayedChatMessage: Equatable { public enum Role: Equatable { @@ -28,7 +30,14 @@ public struct DisplayedChatMessage: Equatable { public var errorMessages: [String] = [] public var steps: [ConversationProgressStep] = [] public var editAgentRounds: [AgentRound] = [] + public var parentTurnId: String? = nil public var panelMessages: [CopilotShowMessageParams] = [] + public var codeReviewRound: CodeReviewRound? = nil + public var fileEdits: [FileEdit] = [] + public var turnStatus: ChatMessage.TurnStatus? = nil + public let requestType: RequestType + public var modelName: String? = nil + public var billingMultiplier: Float? = nil public init( id: String, @@ -41,7 +50,14 @@ public struct DisplayedChatMessage: Equatable { errorMessages: [String] = [], steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], - panelMessages: [CopilotShowMessageParams] = [] + parentTurnId: String? = nil, + panelMessages: [CopilotShowMessageParams] = [], + codeReviewRound: CodeReviewRound? = nil, + fileEdits: [FileEdit] = [], + turnStatus: ChatMessage.TurnStatus? = nil, + requestType: RequestType, + modelName: String? = nil, + billingMultiplier: Float? = nil ) { self.id = id self.role = role @@ -53,7 +69,14 @@ public struct DisplayedChatMessage: Equatable { self.errorMessages = errorMessages self.steps = steps self.editAgentRounds = editAgentRounds + self.parentTurnId = parentTurnId self.panelMessages = panelMessages + self.codeReviewRound = codeReviewRound + self.fileEdits = fileEdits + self.turnStatus = turnStatus + self.requestType = requestType + self.modelName = modelName + self.billingMultiplier = billingMultiplier } } @@ -61,32 +84,480 @@ 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 empty() -> ChatContext { + .init(typedMessage: "", attachedReferences: [], 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 + public enum EditorMode: Hashable { + case input // Default input mode + case editUserMessage(MessageID) + + var isDefault: Bool { self == .input } + + var isEditingUserMessage: Bool { + switch self { + case .input: false + case .editUserMessage: true + } + } + + var editingUserMessageId: String? { + switch self { + case .input: nil + case .editUserMessage(let messageID): messageID + } + } + } @ObservableState - struct State: Equatable { - // 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 history: [DisplayedChatMessage] = [] - var isReceivingMessage = false - var chatMenu = ChatMenu.State() - var focusedField: Field? - var currentEditor: FileReference? = nil - var selectedFiles: [FileReference] = [] - var attachedImages: [ImageReference] = [] - /// Cache the original content - var fileEditMap: OrderedDictionary = [:] - var diffViewerController: DiffViewWindowController? = nil - var isAgentMode: Bool = AppState.shared.isAgentModeEnabled() - var workspaceURL: URL? = nil + struct EditorState: Equatable { enum Field: String, Hashable { case textField case fileSearchBar } + + var codeReviewState = ConversationCodeReviewFeature.State() + + var mode: EditorMode + var contexts: [EditorMode: ChatContext] + var contextProvider: ChatContextProvider + var focusedField: Field? + var currentEditor: ConversationFileReference? + var handOffClicked: Bool = false + + init( + mode: EditorMode = .input, + contexts: [EditorMode: ChatContext] = [.input: .empty()], + contextProvider: ChatContextProvider = .init(), + focusedField: Field? = nil, + currentEditor: ConversationFileReference? = nil, + handOffClicked: Bool = false + ) { + self.mode = mode + self.contexts = contexts + self.contextProvider = contextProvider + self.focusedField = focusedField + self.currentEditor = currentEditor + self.handOffClicked = handOffClicked + } + + func context(for mode: EditorMode) -> ChatContext { + contexts[mode] ?? .empty() + } + + mutating func setContext(_ context: ChatContext, for mode: EditorMode) { + contexts[mode] = context + } + + mutating func updateCurrentContext(_ update: (inout ChatContext) -> Void) { + var context = self.context(for: mode) + update(&context) + setContext(context, for: mode) + } + + mutating func keepOnlyInputContext() { + let inputContext = context(for: .input) + contexts = [.input: inputContext] + } + + mutating func clearAttachedImages() { + updateCurrentContext { $0.attachedImages.removeAll() } + } + + mutating func addReference(_ reference: ConversationAttachedReference) { + updateCurrentContext { context in + guard !context.attachedReferences.contains(reference) else { return } + context.attachedReferences.append(reference) + } + } + + mutating func removeReference(_ reference: ConversationAttachedReference) { + updateCurrentContext { context in + guard let index = context.attachedReferences.firstIndex(of: reference) else { return } + context.attachedReferences.remove(at: index) + } + } + + mutating func addImage(_ image: ImageReference) { + updateCurrentContext { context in + guard !context.attachedImages.contains(image) else { return } + context.attachedImages.append(image) + } + } + + mutating func removeImage(_ image: ImageReference) { + updateCurrentContext { context in + guard let index = context.attachedImages.firstIndex(of: image) else { return } + context.attachedImages.remove(at: index) + } + } + + mutating func pushContext(_ context: ChatContext) { + contextProvider.pushContext(context) + } + + mutating func resetContextProvider() { + contextProvider.reset() + } + + mutating func popNextContext() -> ChatContext? { + contextProvider.getNextContext() + } + + func previousContext(from history: [DisplayedChatMessage], projectURL: URL) -> ChatContext? { + contextProvider.getPreviousContext(from: history, projectURL: projectURL) + } + } + + @ObservableState + struct ConversationState: Equatable { + var history: [DisplayedChatMessage] + var isReceivingMessage: Bool + var requestType: RequestType? + + init( + history: [DisplayedChatMessage] = [], + isReceivingMessage: Bool = false, + requestType: RequestType? = nil + ) { + self.history = history + self.isReceivingMessage = isReceivingMessage + self.requestType = requestType + } + + func subsequentMessages(after messageId: MessageID) -> [DisplayedChatMessage] { + guard let index = history.firstIndex(where: { $0.id == messageId }) else { + return [] + } + return Array(history[(index + 1)...]) + } + + func editUserMessageEffectedMessages(for mode: EditorMode) -> [DisplayedChatMessage] { + guard case .editUserMessage(let messageId) = mode else { + return [] + } + return subsequentMessages(after: messageId) + } + } + + struct AgentEditingState: Equatable { + var fileEditMap: OrderedDictionary + var diffViewerController: DiffViewWindowController? + + init( + fileEditMap: OrderedDictionary = [:], + diffViewerController: DiffViewWindowController? = nil + ) { + self.fileEditMap = fileEditMap + self.diffViewerController = diffViewerController + } + + static func == (lhs: AgentEditingState, rhs: AgentEditingState) -> Bool { + lhs.fileEditMap == rhs.fileEditMap && lhs.diffViewerController === rhs.diffViewerController + } + } + + struct EnvironmentState: Equatable { + var isAgentMode: Bool + var workspaceURL: URL? + var selectedAgent: ConversationMode + + init( + isAgentMode: Bool = AppState.shared.isAgentModeEnabled(), + workspaceURL: URL? = nil, + selectedAgent: ConversationMode = .defaultAgent + ) { + self.isAgentMode = isAgentMode + self.workspaceURL = workspaceURL + self.selectedAgent = selectedAgent + } + } + + @ObservableState + struct State: Equatable { + typealias Field = EditorState.Field + + // Not use anymore. the title of history tab will get from chat tab info + // Keep this var as `ChatTabItemView` reference this + var title: String + var editor: EditorState + var conversation: ConversationState + var agentEditing: AgentEditingState + var environment: EnvironmentState + var chatMenu: ChatMenu.State + var codeReviewState: ConversationCodeReviewFeature.State + + init( + title: String = "New Chat", + editor: EditorState = .init(), + conversation: ConversationState = .init(), + agentEditing: AgentEditingState = .init(), + environment: EnvironmentState = .init(), + chatMenu: ChatMenu.State = .init(), + codeReviewState: ConversationCodeReviewFeature.State = .init() + ) { + self.title = title + self.editor = editor + self.conversation = conversation + self.agentEditing = agentEditing + self.environment = environment + self.chatMenu = chatMenu + self.codeReviewState = codeReviewState + } + + init( + title: String = "New Chat", + editorMode: EditorMode = .input, + editorModeContexts: [EditorMode: ChatContext] = [.input: .empty()], + focusedField: Field? = nil, + history: [DisplayedChatMessage] = [], + isReceivingMessage: Bool = false, + requestType: RequestType? = nil, + fileEditMap: OrderedDictionary = [:], + diffViewerController: DiffViewWindowController? = nil, + isAgentMode: Bool = AppState.shared.isAgentModeEnabled(), + workspaceURL: URL? = nil, + selectedAgent: ConversationMode = .defaultAgent, + chatMenu: ChatMenu.State = .init(), + codeReviewState: ConversationCodeReviewFeature.State = .init() + ) { + self.init( + title: title, + editor: EditorState( + mode: editorMode, + contexts: editorModeContexts, + focusedField: focusedField + ), + conversation: ConversationState( + history: history, + isReceivingMessage: isReceivingMessage, + requestType: requestType + ), + agentEditing: AgentEditingState( + fileEditMap: fileEditMap, + diffViewerController: diffViewerController + ), + environment: EnvironmentState( + isAgentMode: isAgentMode, + workspaceURL: workspaceURL, + selectedAgent: selectedAgent + ), + chatMenu: chatMenu, + codeReviewState: codeReviewState + ) + } + + var editorMode: EditorMode { + get { editor.mode } + set { + editor.mode = newValue + if editor.contexts[newValue] == nil { + editor.contexts[newValue] = .empty() + } + } + } + + var chatContext: ChatContext { + get { editor.context(for: editor.mode) } + set { editor.setContext(newValue, for: editor.mode) } + } + + var history: [DisplayedChatMessage] { + get { conversation.history } + set { conversation.history = newValue } + } + + var isReceivingMessage: Bool { + get { conversation.isReceivingMessage } + set { conversation.isReceivingMessage = newValue } + } + + var requestType: RequestType? { + get { conversation.requestType } + set { conversation.requestType = newValue } + } + + var handOffClicked: Bool { + get { editor.handOffClicked } + set { editor.handOffClicked = newValue } + } + + var focusedField: Field? { + get { editor.focusedField } + set { editor.focusedField = newValue } + } + + var currentEditor: ConversationFileReference? { + get { editor.currentEditor } + set { editor.currentEditor = newValue } + } + + var attachedReferences: [ConversationAttachedReference] { + chatContext.attachedReferences + } + + var attachedImages: [ImageReference] { + chatContext.attachedImages + } + + var typedMessage: String { + get { chatContext.typedMessage } + set { + editor.updateCurrentContext { $0.typedMessage = newValue } + editor.resetContextProvider() + } + } + + var fileEditMap: OrderedDictionary { + get { agentEditing.fileEditMap } + set { agentEditing.fileEditMap = newValue } + } + + var diffViewerController: DiffViewWindowController? { + get { agentEditing.diffViewerController } + set { agentEditing.diffViewerController = newValue } + } + + var isAgentMode: Bool { + get { environment.isAgentMode } + set { environment.isAgentMode = newValue } + } + + var workspaceURL: URL? { + get { environment.workspaceURL } + set { environment.workspaceURL = newValue } + } + + var selectedAgent: ConversationMode { + get { environment.selectedAgent } + set { environment.selectedAgent = newValue } + } + + /// Not including the one being edited + var editUserMessageEffectedMessages: [DisplayedChatMessage] { + conversation.editUserMessageEffectedMessages(for: editor.mode) + } + + // The following messages after check point message will hide on ChatPanel + var pendingCheckpointMessageId: String? = nil + // The chat context before the first restoring + var pendingCheckpointContext: ChatContext? = nil + var messagesAfterCheckpoint: [DisplayedChatMessage] { + guard let pendingCheckpointMessageId, let index = history.firstIndex(where: { $0.id == pendingCheckpointMessageId }) else { + return [] + } + + let nextIndex = index + 1 + guard nextIndex < history.count else { + return [] + } + + // The order matters for restoring / redoing file edits + return Array(history[nextIndex...]) + } + + func getMessages(after afterMessageId: String, through throughMessageId: String?) -> [DisplayedChatMessage] { + guard let afterMessageIdIndex = history.firstIndex(where: { $0.id == afterMessageId }) else { + return [] + } + + let startIndex = afterMessageIdIndex + 1 + + let endIndex: Int + if let throughMessageId = throughMessageId, + let throughMessageIdIndex = history.firstIndex(where: { $0.id == throughMessageId }) { + endIndex = throughMessageIdIndex + 1 + } else { + endIndex = history.count + } + + guard startIndex < endIndex, startIndex < history.count else { + return [] + } + + return Array(history[startIndex..) case agentModeChanged(Bool) + case selectedAgentChanged(ConversationMode) + + // Code Review + case codeReview(ConversationCodeReviewFeature.Action) + + // Chat Context + case reloadNextContext + case reloadPreviousContext + case resetContextProvider + + // External Action + case observeFixErrorNotification + case fixEditorErrorIssue(EditorErrorIssue) + + // Check Point + case restoreCheckPoint(String) + case restoreFileEdits + case undoCheckPoint // Revert the restore + case discardCheckPoint + case reloadWorkingset(DisplayedChatMessage) } let service: ChatService @@ -153,6 +647,7 @@ struct Chat { case observeIsReceivingMessageChange(UUID) case sendMessage(UUID) case observeFileEditChange(UUID) + case observeFixErrorNotification(UUID) } @Dependency(\.openURL) var openURL @@ -165,6 +660,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 { @@ -176,11 +675,24 @@ struct Chat { await send(.isReceivingMessageChanged) await send(.focusOnTextField) await send(.refresh) + await send(.observeFixErrorNotification) + + let selectedAgentSubModeId = AppState.shared.getSelectedAgentSubMode() + if let modes = await SharedChatService.shared.loadConversationModes(), + let currentMode = modes.first(where: { $0.id == selectedAgentSubModeId }) { + await send(.selectedAgentChanged(currentMode)) + } let publisher = NotificationCenter.default.publisher(for: .gitHubCopilotChatModeDidChange) for await _ in publisher.values { let isAgentMode = AppState.shared.isAgentModeEnabled() await send(.agentModeChanged(isAgentMode)) + + let selectedAgentSubModeId = AppState.shared.getSelectedAgentSubMode() + if let modes = await SharedChatService.shared.loadConversationModes(), + let currentMode = modes.first(where: { $0.id == selectedAgentSubModeId }) { + await send(.selectedAgentChanged(currentMode)) + } } } @@ -197,23 +709,48 @@ 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 selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() + let shouldAttachImages = selectedModel?.supportVision ?? CopilotModelManager.getDefaultChatModel( + scope: AppState.shared.modelScope() + )?.supportVision ?? false let attachedImages: [ImageReference] = shouldAttachImages ? state.attachedImages : [] - state.attachedImages = [] - return .run { _ in + + let references = state.attachedReferences + state.editor.clearAttachedImages() + + let toDeleteMessageIds: [String] = { + var messageIds: [String] = [] + if state.editorMode.isEditingUserMessage { + messageIds.append(contentsOf: state.editUserMessageEffectedMessages.map { $0.id }) + if let editingUserMessageId = state.editorMode.editingUserMessageId { + messageIds.append(editingUserMessageId) + } + } + return messageIds + }() + + return .run { send in + await send(.resetContextProvider) + await send(.discardCheckPoint) + await service.deleteMessages(ids: toDeleteMessageIds) + await send(.setEditorMode(.input)) + try await service .send( id, content: message, contentImageReferences: attachedImages, skillSet: skillSet, - references: selectedFiles, + references: references, model: selectedModelFamily, + modelProviderName: selectedModel?.providerName, agentMode: agentMode, + customChatModeId: selectedAgentSubMode, userLanguage: chatResponseLocale ) }.cancellable(id: CancelID.sendMessage(self.id)) @@ -240,16 +777,92 @@ 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.attachedReferences + let agentMode = AppState.shared.isAgentModeEnabled() + let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() - return .run { _ in - try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles, model: selectedModelFamily, userLanguage: chatResponseLocale) + return .run { send in + await send(.resetContextProvider) + await send(.discardCheckPoint) + + try await service + .send( + id, + content: message, + skillSet: skillSet, + references: references, + model: selectedModelFamily, + modelProviderName: selectedModel?.providerName, + agentMode: agentMode, + customChatModeId: selectedAgentSubMode, + userLanguage: chatResponseLocale + ) }.cancellable(id: CancelID.sendMessage(self.id)) + + case let .handOffButtonClicked(handOff): + state.handOffClicked = true + let agent = handOff.agent + let prompt = handOff.prompt + let shouldSend = handOff.send ?? false + + return .run { send in + // Find and switch to the target agent + let modes = await SharedChatService.shared.loadConversationModes() ?? [] + if let targetAgent = modes.first(where: { $0.name.lowercased() == agent.lowercased() }) { + await send(.selectedAgentChanged(targetAgent)) + } + + // If send is true, send the prompt message + if shouldSend && !prompt.isEmpty { + await send(.updateTypedMessage(prompt)) + let id = UUID().uuidString + await send(.sendButtonTapped(id)) + } else if !prompt.isEmpty { + // Just populate the message field + await send(.updateTypedMessage(prompt)) + } + } case .returnButtonTapped: state.typedMessage += "\n" return .none + + case let .updateTypedMessage(message): + state.typedMessage = message + return .none + + case let .setEditorMode(mode): + + switch mode { + case .input: + state.editorMode = mode + // remove all edit contexts except input mode + state.editor.keepOnlyInputContext() + case .editUserMessage(let messageID): + guard let message = state.history.first(where: { $0.id == messageID }), + message.role == .user, + let projectURL = service.getProjectRootURL() + else { + return .none + } + + let chatContext: ChatContext = .from(message, projectURL: projectURL) + state.editor.setContext(chatContext, for: mode) + state.editorMode = mode + let isReceivingMessage = service.isReceivingMessage + + return .run { send in + if isReceivingMessage { + await send(.stopRespondingButtonTapped) + } + } + } + + return .none case .stopRespondingButtonTapped: return .merge( @@ -266,7 +879,7 @@ struct Chat { case let .deleteMessageButtonTapped(id): return .run { _ in - await service.deleteMessage(id: id) + await service.deleteMessages(ids: [id]) } case let .resendMessageButtonTapped(id): @@ -291,9 +904,11 @@ struct Chat { "/bin/bash", arguments: [ "-c", - "xed -l 0 \"\(reference.filePath)\"", + "xed -l 0 \"${TARGET_CHAT_FILE}\"", ], - environment: [:] + environment: [ + "TARGET_CHAT_FILE": reference.filePath + ] ) } catch { print(error) @@ -389,7 +1004,8 @@ struct Chat { .init( uri: $0.uri, status: $0.status, - kind: $0.kind + kind: $0.kind, + referenceType: $0.referenceType ) }, followUp: message.followUp, @@ -397,7 +1013,14 @@ struct Chat { errorMessages: message.errorMessages, steps: message.steps, editAgentRounds: message.editAgentRounds, - panelMessages: message.panelMessages + parentTurnId: message.parentTurnId, + panelMessages: message.panelMessages, + codeReviewRound: message.codeReviewRound, + fileEdits: message.fileEdits, + turnStatus: message.turnStatus, + requestType: message.requestType, + modelName: message.modelName, + billingMultiplier: message.billingMultiplier )) return all @@ -407,6 +1030,7 @@ struct Chat { case .isReceivingMessageChanged: state.isReceivingMessage = service.isReceivingMessage + state.requestType = service.requestType return .none case .fileEditChanged: @@ -467,31 +1091,30 @@ 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): + state.editor.addReference(ref) + return .none + + case let .removeReference(ref): + state.editor.removeReference(ref) + return .none // MARK: - Image Context case let .addSelectedImage(imageReference): guard !state.attachedImages.contains(imageReference) else { return .none } - state.attachedImages.append(imageReference) - return .none + state.editor.addImage(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 + guard let _ = state.attachedImages.firstIndex(of: imageReference) else { return .none } + state.editor.removeImage(imageReference) + return .run { send in await send(.resetContextProvider) } // MARK: - Agent Edits @@ -540,6 +1163,258 @@ struct Chat { case let .agentModeChanged(isAgentMode): state.isAgentMode = isAgentMode return .none + + case let .selectedAgentChanged(mode): + state.selectedAgent = mode + state.handOffClicked = false + return .none + + // MARK: - Code Review + case let .codeReview(.request(group)): + return .run { send in + await send(.discardCheckPoint) + } + + case .codeReview: + return .none + + // MARK: Chat Context + case .reloadNextContext: + guard let context = state.editor.popNextContext() else { + return .none + } + + state.chatContext = context + + return .run { send in + await send(.focusOnTextField) + } + + case .reloadPreviousContext: + guard let projectURL = service.getProjectRootURL(), + let context = state.editor.previousContext( + from: state.history, + projectURL: projectURL) + else { + return .none + } + + let currentContext = state.chatContext + state.chatContext = context + state.editor.pushContext(currentContext) + + return .run { send in + await send(.focusOnTextField) + } + + case .resetContextProvider: + state.editor.resetContextProvider() + 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() + // TODO: if we need to switch to agent mode or keep the current mode + let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() + + return .run { _ in + try await service.send( + UUID().uuidString, + content: message, + skillSet: skillSet, + references: references, + model: selectedModelFamily, + modelProviderName: selectedModel?.providerName, + agentMode: agentMode, + customChatModeId: selectedAgentSubMode, + userLanguage: chatResponseLocale + ) + }.cancellable(id: CancelID.sendMessage(self.id)) + + // MARK: - Check Point + + case let .restoreCheckPoint(messageId): + guard let message = state.history.first(where: { $0.id == messageId }) else { + return .none + } + + if state.pendingCheckpointContext == nil { + state.pendingCheckpointContext = state.chatContext + } + state.pendingCheckpointMessageId = messageId + + // Reload the chat context + let messagesAfterCheckpoint = state.messagesAfterCheckpoint + if !messagesAfterCheckpoint.isEmpty, + let userMessage = messagesAfterCheckpoint.first, + userMessage.role == .user, + let projectURL = service.getProjectRootURL() + { + state.chatContext = .from(userMessage, projectURL: projectURL) + } + + let isReceivingMessage = state.isReceivingMessage + return .run { send in + await send(.restoreFileEdits) + await send(.reloadWorkingset(message)) + if isReceivingMessage { + await send(.stopRespondingButtonTapped) + } + } + + case .restoreFileEdits: + // Revert file edits in messages after checkpoint + let messagesAfterCheckpoint = state.messagesAfterCheckpoint + guard !messagesAfterCheckpoint.isEmpty else { + return .none + } + + return .run { _ in + var restoredURLs = Set() + let fileManager = FileManager.default + + // Revert the file edit. From the oldest to newest + for message in messagesAfterCheckpoint { + let fileEdits = message.fileEdits + guard !fileEdits.isEmpty else { + continue + } + + for fileEdit in fileEdits { + guard !restoredURLs.contains(fileEdit.fileURL) else { + continue + } + restoredURLs.insert(fileEdit.fileURL) + + do { + switch fileEdit.toolName { + case .createFile: + try fileManager.removeItem(at: fileEdit.fileURL) + case .insertEditIntoFile: + try fileEdit.originalContent.write(to: fileEdit.fileURL, atomically: true, encoding: .utf8) + default: + break + } + } catch { + Logger.client.error(">>> Failed to restore file Edit: \(error)") + } + } + } + } + + case .undoCheckPoint: + if let context = state.pendingCheckpointContext { + state.chatContext = context + state.pendingCheckpointContext = nil + } + let reversedMessagesAfterCheckpoint = Array(state.messagesAfterCheckpoint.reversed()) + + state.pendingCheckpointMessageId = nil + + // Redo file edits in messages after checkpoint + guard !reversedMessagesAfterCheckpoint.isEmpty else { + return .none + } + + return .run { send in + var redoedURL = Set() + let lastMessage = reversedMessagesAfterCheckpoint.first + + for message in reversedMessagesAfterCheckpoint { + let fileEdits = message.fileEdits + guard !fileEdits.isEmpty else { + continue + } + + for fileEdit in fileEdits { + guard !redoedURL.contains(fileEdit.fileURL) else { + continue + } + redoedURL.insert(fileEdit.fileURL) + + do { + switch fileEdit.toolName { + case .createFile, .insertEditIntoFile: + try fileEdit.modifiedContent.write(to: fileEdit.fileURL, atomically: true, encoding: .utf8) + default: + break + } + } catch { + Logger.client.error(">>> failed to undo fileEdit: \(error)") + } + } + } + + // Recover fileEdits working set + if let lastMessage { + await send(.reloadWorkingset(lastMessage)) + } + } + + case .discardCheckPoint: + let messagesAfterCheckpoint = state.messagesAfterCheckpoint + state.pendingCheckpointMessageId = nil + state.pendingCheckpointContext = nil + return .run { _ in + if !messagesAfterCheckpoint.isEmpty { + await service.deleteMessages(ids: messagesAfterCheckpoint.map { $0.id }) + } + } + + case let .reloadWorkingset(message): + return .run { _ in + service.resetFileEdits() + for fileEdit in message.fileEdits { + service.updateFileEdits(by: fileEdit) + } + } } } } @@ -613,3 +1488,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/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/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/ChatExtension.swift b/Core/Sources/ConversationTab/ChatExtension.swift index 27220a96..f5e2573f 100644 --- a/Core/Sources/ConversationTab/ChatExtension.swift +++ b/Core/Sources/ConversationTab/ChatExtension.swift @@ -6,12 +6,21 @@ 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()] } + + func getChatContext(of mode: Chat.EditorMode) -> ChatContext { + return editor.context(for: mode) + } + + func getSubsequentMessages(after messageId: String) -> [DisplayedChatMessage] { + conversation.subsequentMessages(after: messageId) + } } diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index 5b11637b..80c3d5a2 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -13,6 +13,10 @@ import ChatTab import Workspace import Persist import UniformTypeIdentifiers +import Status +import GitHubCopilotService +import GitHubCopilotViewModel +import LanguageServerProtocol private let r: Double = 4 @@ -31,30 +35,38 @@ public struct ChatPanel: View { Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .padding(.trailing, 16) } else { ChatPanelMessages(chat: chat) .accessibilityElement(children: .combine) .accessibilityLabel("Chat Messages Group") - - if let _ = chat.history.last?.followUp { + + if chat.isAgentMode, let handOffs = chat.selectedAgent.handOffs, !handOffs.isEmpty, + chat.history.contains(where: { $0.role == .assistant && $0.turnStatus != .inProgress }), + !chat.handOffClicked { + ChatHandOffs(chat: chat) + .scaledPadding(.vertical, 8) + .scaledPadding(.horizontal, 16) + .dimWithExitEditMode(chat) + } else if let _ = chat.history.last?.followUp { ChatFollowUp(chat: chat) - .padding(.trailing, 16) - .padding(.vertical, 8) + .scaledPadding(.vertical, 8) + .scaledPadding(.horizontal, 16) + .dimWithExitEditMode(chat) } } if chat.fileEditMap.count > 0 { WorkingSetView(chat: chat) - .padding(.trailing, 16) + .dimWithExitEditMode(chat) + .scaledPadding(.horizontal, 16) } - ChatPanelInputArea(chat: chat) - .padding(.trailing, 16) + ChatPanelInputArea(chat: chat, r: r, editorMode: .input) + .dimWithExitEditMode(chat) + .scaledPadding(.horizontal, 16) } - .padding(.leading, 16) - .padding(.bottom, 16) - .background(Color(nsColor: .windowBackgroundColor)) + .scaledPadding(.vertical, 12) + .background(Color.chatWindowBackgroundColor) .onAppear { chat.send(.appear) } @@ -65,6 +77,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 @@ -78,16 +92,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)))) + } } } } @@ -128,6 +148,7 @@ struct ChatPanelMessages: View { @State var didScrollToBottomOnAppearOnce = false @State var isBottomHidden = true @Environment(\.isEnabled) var isEnabled + @AppStorage(\.fontScale) private var fontScale: Double var body: some View { WithPerceptionTracking { @@ -137,12 +158,13 @@ struct ChatPanelMessages: View { Group { ChatHistory(chat: chat) - .listItemTint(.clear) + .fixedSize(horizontal: false, vertical: true) ExtraSpacingInResponding(chat: chat) Spacer(minLength: 12) .id(bottomID) + .listRowInsets(EdgeInsets()) .onAppear { isBottomHidden = false if !didScrollToBottomOnAppearOnce { @@ -170,8 +192,8 @@ struct ChatPanelMessages: View { } } } - .padding(.leading, -8) .listStyle(.plain) + .scaledPadding(.leading, 8) .listRowBackground(EmptyView()) .modify { view in if #available(macOS 13.0, *) { @@ -195,6 +217,7 @@ struct ChatPanelMessages: View { } .overlay(alignment: .bottomTrailing) { scrollToBottomButton(proxy: proxy) + .scaledPadding(4) } .background { PinToBottomHandler( @@ -241,12 +264,21 @@ struct ChatPanelMessages: View { .store(in: &cancellable) } + private let listRowSpacing: CGFloat = 32 + private let scrollButtonBuffer: CGFloat = 32 + @MainActor func updatePinningState() { // where does the 32 come from? withAnimation(.linear(duration: 0.1)) { - isScrollToBottomButtonDisplayed = scrollOffset > listHeight + 32 + 20 - || scrollOffset <= 0 + // Ensure listHeight is greater than 0 to avoid invalid calculations or division by zero. + // This guard clause prevents unnecessary updates when the list height is not yet determined. + guard listHeight > 0 else { + isScrollToBottomButtonDisplayed = false + return + } + + isScrollToBottomButtonDisplayed = scrollOffset > listHeight + (listRowSpacing + scrollButtonBuffer) * fontScale } } @@ -259,19 +291,18 @@ struct ChatPanelMessages: View { } }) { Image(systemName: "chevron.down") - .padding(8) + .scaledFrame(width: 12, height: 12) + .scaledPadding(4) .background { Circle() - .fill(.thickMaterial) - .shadow(color: .black.opacity(0.2), radius: 2) + .fill(Color.chatWindowBackgroundColor) } .overlay { Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1) } .foregroundStyle(.secondary) } - .buttonStyle(HoverButtonStyle(padding: 0)) - .padding(4) + .buttonStyle(.plain) .keyboardShortcut(.downArrow, modifiers: [.command]) .opacity(isScrollToBottomButtonDisplayed ? 1 : 0) .help("Scroll Down") @@ -279,11 +310,13 @@ struct ChatPanelMessages: View { struct ExtraSpacingInResponding: View { let chat: StoreOf + + @AppStorage(\.fontScale) private var fontScale: Double var body: some View { WithPerceptionTracking { if chat.isReceivingMessage { - Spacer(minLength: 12) + Spacer(minLength: 12 * fontScale) } } } @@ -310,7 +343,16 @@ struct ChatPanelMessages: View { } } } else { - Task { pinnedToBottom = false } + Task { + // Scoll to bottom when `isReceiving` changes to `false` + if pinnedToBottom { + await Task.yield() + withAnimation(.easeInOut(duration: 0.1)) { + scrollToBottom() + } + } + pinnedToBottom = false + } } } .onChange(of: chat.history.last) { _ in @@ -320,8 +362,10 @@ struct ChatPanelMessages: View { } Task { await Task.yield() - withAnimation(.easeInOut(duration: 0.1)) { - scrollToBottom() + if !chat.editorMode.isEditingUserMessage { + withAnimation(.easeInOut(duration: 0.1)) { + scrollToBottom() + } } } } @@ -339,21 +383,63 @@ struct ChatPanelMessages: View { struct ChatHistory: View { let chat: StoreOf + + var filteredHistory: [DisplayedChatMessage] { + guard let pendingCheckpointMessageId = chat.pendingCheckpointMessageId else { + return chat.history + } + + if let checkPointMessageIndex = chat.history.firstIndex(where: { $0.id == pendingCheckpointMessageId }) { + return Array(chat.history.prefix(checkPointMessageIndex + 1)) + } + + return chat.history + } + + var editUserMessageEffectedMessageIds: Set { + Set(chat.editUserMessageEffectedMessages.map { $0.id }) + } var body: some View { WithPerceptionTracking { - ForEach(Array(chat.history.enumerated()), id: \.element.id) { index, message in - VStack(spacing: 0) { - WithPerceptionTracking { - ChatHistoryItem(chat: chat, message: message) - .id(message.id) - .padding(.top, 4) - .padding(.bottom, 12) + let currentFilteredHistory = filteredHistory + let pendingCheckpointMessageId = chat.pendingCheckpointMessageId + + VStack(spacing: 16) { + ForEach(Array(currentFilteredHistory.enumerated()), id: \.element.id) { index, message in + VStack(spacing: 8) { + WithPerceptionTracking { + ChatHistoryItem(chat: chat, message: message) + .id(message.id) + } + + if message.role != .ignored && index < currentFilteredHistory.count - 1 { + if message.role == .assistant && message.parentTurnId == nil { + let nextMessage = currentFilteredHistory[index + 1] + let hasContent = !message.text.isEmpty || !message.editAgentRounds.isEmpty + let nextIsNotSubturn = nextMessage.parentTurnId != message.id + + if hasContent && nextIsNotSubturn { + CheckPoint(chat: chat, messageId: message.id) + .padding(.vertical, 8) + .padding(.trailing, 8) + } + } + } + + // Show up check point for redo + if message.id == pendingCheckpointMessageId { + CheckPoint(chat: chat, messageId: message.id) + .padding(.vertical, 8) + .padding(.trailing, 8) + } } - - // add divider between messages - if message.role != .ignored && index < chat.history.count - 1 { - Divider() } + .dimWithExitEditMode( + chat, + applyTo: message.id, + isDimmed: editUserMessageEffectedMessageIds.contains(message.id), + allowTapToExit: chat.editorMode.isEditingUserMessage && chat.editorMode.editingUserMessageId != message.id + ) } } } @@ -373,20 +459,18 @@ struct ChatHistoryItem: View { id: message.id, text: text, imageReferences: message.imageReferences, - chat: chat + chat: chat, + editorCornerRadius: r, + requestType: message.requestType ) + .scaledPadding(.leading, chat.editorMode.isEditingUserMessage && chat.editorMode.editingUserMessageId == message.id ? 0 : 20) + .scaledPadding(.trailing, 8) case .assistant: BotMessage( - id: message.id, - text: text, - references: message.references, - followUp: message.followUp, - errorMessages: message.errorMessages, - chat: chat, - steps: message.steps, - editAgentRounds: message.editAgentRounds, - panelMessages: message.panelMessages + message: message, + chat: chat ) + .scaledPadding(.trailing, 20) case .ignored: EmptyView() } @@ -407,21 +491,65 @@ 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) } } .buttonStyle(.plain) .onHover { isHovered in - if isHovered { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() + DispatchQueue.main.async { + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .onDisappear { + NSCursor.pop() + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +struct ChatHandOffs: View { + let chat: StoreOf + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading) { + Text("PROCEED FROM \(chat.selectedAgent.name.uppercased())") + .foregroundStyle(.secondary) + .scaledPadding(.horizontal, 4) + .scaledPadding(.bottom, -4) + + FlowLayout(mode: .vstack, items: chat.selectedAgent.handOffs ?? [], itemSpacing: 4) { item in + Button(action: { + chat.send(.handOffButtonClicked(item)) + }) { + Text(item.label) + } + .buttonStyle(.bordered) + .onHover { isHovered in + DispatchQueue.main.async { + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } } } + .onDisappear { + NSCursor.pop() + } } } .frame(maxWidth: .infinity, alignment: .leading) @@ -458,391 +586,17 @@ struct ChatCLSError: View { } } -struct ChatPanelInputArea: View { - let chat: StoreOf - @FocusState var focusedField: Chat.State.Field? - - var body: some View { - HStack { - InputAreaTextEditor(chat: chat, focusedField: $focusedField) - } - .background(Color.clear) - } - - @MainActor - var clearButton: some View { - Button(action: { - chat.send(.clearButtonTap) - }) { - Group { - if #available(macOS 13.0, *) { - Image(systemName: "eraser.line.dashed.fill") - } else { - Image(systemName: "trash.fill") - } - } - .padding(6) - .background { - Circle().fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - } - .buttonStyle(.plain) - } - - enum ShowingType { case template, agent } - - struct InputAreaTextEditor: View { - @Perception.Bindable var chat: StoreOf - var focusedField: FocusState.Binding - @State var cancellable = Set() - @State private var isFilePickerPresented = false - @State private var allFiles: [FileReference]? = nil - @State private var filteredTemplates: [ChatTemplate] = [] - @State private var filteredAgent: [ChatAgent] = [] - @State private var showingTemplates = false - @State private var dropDownShowingType: ShowingType? = nil - - @AppStorage(\.enableCurrentEditorContext) var enableCurrentEditorContext: Bool - @State private var isCurrentEditorContextEnabled: Bool = UserDefaults.shared.value( - for: \.enableCurrentEditorContext - ) - - var body: some View { - WithPerceptionTracking { - VStack(spacing: 0) { - chatContextView - - if isFilePickerPresented { - FilePicker( - allFiles: $allFiles, - workspaceURL: chat.workspaceURL, - onSubmit: { file in - chat.send(.addSelectedFile(file)) - }, - onExit: { - isFilePickerPresented = false - focusedField.wrappedValue = .textField - } - ) - .onAppear() { - allFiles = ContextUtils.getFilesFromWorkspaceIndex(workspaceURL: chat.workspaceURL) - } - } - - if !chat.state.attachedImages.isEmpty { - ImagesScrollView(chat: chat) - } - - ZStack(alignment: .topLeading) { - if chat.typedMessage.isEmpty { - Group { - chat.isAgentMode ? - Text("Edit files in your workspace in agent mode") : - Text("Ask Copilot or type / for commands") - } - .font(.system(size: 14)) - .foregroundColor(Color(nsColor: .placeholderTextColor)) - .padding(8) - .padding(.horizontal, 4) - } - - HStack(spacing: 0) { - AutoresizingCustomTextEditor( - text: $chat.typedMessage, - font: .systemFont(ofSize: 14), - isEditable: true, - maxHeight: 400, - onSubmit: { - if (dropDownShowingType == nil) { - submitChatMessage() - } - dropDownShowingType = nil - } - ) - .focused(focusedField, equals: .textField) - .bind($chat.focusedField, to: focusedField) - .padding(8) - .fixedSize(horizontal: false, vertical: true) - .onChange(of: chat.typedMessage) { newValue in - Task { - await onTypedMessageChanged(newValue: newValue) - } - } - /// When chat mode changed, the chat tamplate and agent need to be reloaded - .onChange(of: chat.isAgentMode) { _ in - Task { - await onTypedMessageChanged(newValue: chat.typedMessage) - } - } - } - .frame(maxWidth: .infinity) - } - .padding(.top, 4) - - HStack(spacing: 0) { - ModelPicker() - - Spacer() - - Group { - if chat.isReceivingMessage { stopButton } - else { sendButton } - } - .buttonStyle(HoverButtonStyle(padding: 0)) - } - .padding(8) - .padding(.top, -4) - } - .overlay(alignment: .top) { - dropdownOverlay - } - .onAppear() { - subscribeToActiveDocumentChangeEvent() - } - .background { - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - .background { - Button(action: { - chat.send(.returnButtonTapped) - }) { - EmptyView() - } - .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) - .accessibilityHidden(true) - - Button(action: { - focusedField.wrappedValue = .textField - }) { - EmptyView() - } - .keyboardShortcut("l", modifiers: [.command]) - .accessibilityHidden(true) - } - } - } - - private var sendButton: some View { - Button(action: { - submitChatMessage() - }) { - Image(systemName: "paperplane.fill") - .padding(4) - } - .keyboardShortcut(KeyEquivalent.return, modifiers: []) - .help("Send") - } - - private var stopButton: some View { - Button(action: { - chat.send(.stopRespondingButtonTapped) - }) { - Image(systemName: "stop.circle") - .padding(4) - } - .help("Stop") - } - - private var dropdownOverlay: some View { - Group { - if dropDownShowingType != nil { - if dropDownShowingType == .template { - ChatDropdownView(items: $filteredTemplates, prefixSymbol: "/") { template in - chat.typedMessage = "/" + template.id + " " - if template.id == "releaseNotes" { - submitChatMessage() - } - } - } else if dropDownShowingType == .agent { - ChatDropdownView(items: $filteredAgent, prefixSymbol: "@") { agent in - chat.typedMessage = "@" + agent.id + " " - } - } - } - } - } - - func onTypedMessageChanged(newValue: String) async { - if newValue.hasPrefix("/") { - filteredTemplates = await chatTemplateCompletion(text: newValue) - dropDownShowingType = filteredTemplates.isEmpty ? nil : .template - } else if newValue.hasPrefix("@") && !chat.isAgentMode { - filteredAgent = await chatAgentCompletion(text: newValue) - dropDownShowingType = filteredAgent.isEmpty ? nil : .agent - } else { - dropDownShowingType = nil - } - } - - enum ChatContextButtonType { case imageAttach, contextAttach} +extension URL { + func getPathRelativeToHome() -> String { + let filePath = self.path + guard !filePath.isEmpty else { return "" } - private var chatContextView: some View { - let buttonItems: [ChatContextButtonType] = [.contextAttach, .imageAttach] - let currentEditorItem: [FileReference] = [chat.state.currentEditor].compactMap { - $0 - } - let selectedFileItems = chat.state.selectedFiles - let chatContextItems: [Any] = buttonItems.map { - $0 as ChatContextButtonType - } + currentEditorItem + selectedFileItems - return FlowLayout(mode: .scrollable, items: chatContextItems, itemSpacing: 4) { item in - if let buttonType = item as? ChatContextButtonType { - if buttonType == .imageAttach { - VisionMenuView(chat: chat) - } else if buttonType == .contextAttach { - // File picker button - Button(action: { - withAnimation { - isFilePickerPresented.toggle() - if !isFilePickerPresented { - focusedField.wrappedValue = .textField - } - } - }) { - 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) - } - } 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 { - Button(action: { chat.send(.removeSelectedFile(select)) }) { - Image(systemName: "xmark") - .resizable() - .frame(width: 8, height: 8) - .foregroundColor(.primary.opacity(0.85)) - .padding(4) - } - .buttonStyle(HoverButtonStyle()) - } - } - .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) - ) - } - } - .padding(.horizontal, 8) - .padding(.top, 8) - } - - func chatTemplateCompletion(text: String) async -> [ChatTemplate] { - guard text.count >= 1 && text.first == "/" else { return [] } - - let prefix = text.dropFirst() - var promptTemplates: [ChatTemplate] = [] - 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) && - !skippedTemplates.contains($0.id) - } - } - - func chatAgentCompletion(text: String) async -> [ChatAgent] { - guard text.count >= 1 && text.first == "@" else { return [] } - let prefix = text.dropFirst() - var chatAgents = await SharedChatService.shared.loadChatAgents() ?? [] - - if let index = chatAgents.firstIndex(where: { $0.slug == "project" }) { - let projectAgent = chatAgents[index] - chatAgents[index] = .init(slug: "workspace", name: "workspace", description: "Ask about your workspace", avatarUrl: projectAgent.avatarUrl) - } - - /// only enable the @workspace - let includedAgents = ["workspace"] - - return chatAgents.filter { $0.slug.hasPrefix(prefix) && includedAgents.contains($0.slug) } - } - - func subscribeToActiveDocumentChangeEvent() { - Publishers.CombineLatest( - XcodeInspector.shared.$latestActiveXcode, - XcodeInspector.shared.$activeDocumentURL - .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)) - } - } - } - .store(in: &cancellable) + let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path + if !homeDirectory.isEmpty { + return filePath.replacingOccurrences(of: homeDirectory, with: "~") } - func submitChatMessage() { - chat.send(.sendButtonTapped(UUID().uuidString)) - } + return filePath } } // MARK: - Previews @@ -853,7 +607,8 @@ struct ChatPanel_Preview: PreviewProvider { id: "1", role: .user, text: "**Hello**", - references: [] + references: [], + requestType: .conversation ), .init( id: "2", @@ -868,27 +623,32 @@ struct ChatPanel_Preview: PreviewProvider { .init( uri: "Hi Hi Hi Hi", status: .included, - kind: .class + kind: .class, + referenceType: .file ), - ] + ], + requestType: .conversation ), .init( id: "7", role: .ignored, text: "Ignored", - references: [] + references: [], + requestType: .conversation ), .init( id: "5", role: .assistant, text: "Yooo", - references: [] + references: [], + requestType: .conversation ), .init( id: "4", role: .user, text: "Yeeeehh", - references: [] + references: [], + requestType: .conversation ), .init( id: "3", @@ -908,7 +668,8 @@ struct ChatPanel_Preview: PreviewProvider { ``` """#, references: [], - followUp: .init(message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.", id: "3", type: "type") + followUp: .init(message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.", id: "3", type: "type"), + requestType: .conversation ), ] @@ -953,8 +714,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.", - + editorModeContexts: [Chat.EditorMode.input: ChatContext( + 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/CodeBlockHighlighter.swift b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift index 553f5976..3cecf903 100644 --- a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift +++ b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift @@ -86,13 +86,13 @@ struct AsyncCodeBlockView: View { Group { if let highlighted = storage.highlighted { Text(highlighted) - .frame(maxWidth: .infinity, alignment: .leading) } else { Text(content).font(.init(font)) - .frame(maxWidth: .infinity, alignment: .leading) } } - .frame(maxWidth: .infinity) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) .onAppear { storage.highlight(debounce: false, for: self) } 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/Controller/DiffViewWindowController.swift b/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift index e4da4784..4a52af45 100644 --- a/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift +++ b/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift @@ -2,6 +2,7 @@ import SwiftUI import ChatService import ComposableArchitecture import WebKit +import ChatAPIService enum Style { /// default diff view frame. Same as the `ChatPanel` diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift index 50ebe68f..2884f332 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()) { @@ -273,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/DiffViews/DiffView.swift b/Core/Sources/ConversationTab/DiffViews/DiffView.swift index c857528e..ee66ec8b 100644 --- a/Core/Sources/ConversationTab/DiffViews/DiffView.swift +++ b/Core/Sources/ConversationTab/DiffViews/DiffView.swift @@ -5,6 +5,7 @@ import Logger import ConversationServiceProvider import ChatService import ChatTab +import ChatAPIService extension FileEdit { var originalContentByStatus: String { diff --git a/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift b/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift index cc42af5d..36c952a5 100644 --- a/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift +++ b/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift @@ -3,6 +3,7 @@ import ChatService import SwiftUI import WebKit import Logger +import ChatAPIService struct DiffWebView: NSViewRepresentable { @Perception.Bindable var chat: StoreOf diff --git a/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift new file mode 100644 index 00000000..d55acc6d --- /dev/null +++ b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift @@ -0,0 +1,92 @@ +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) \"${TARGET_REVIEW_FILE}\"" + ], + environment: [ + "TARGET_REVIEW_FILE": fileURL.path + ] + ) + } catch { + print(error) + } + } + + Task { @MainActor in + CodeReviewStateService.shared.notifyFileClicked() + } + } + + } + } + } +} diff --git a/Core/Sources/ConversationTab/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift index 8ae83e10..ca2e3494 100644 --- a/Core/Sources/ConversationTab/FilePicker.swift +++ b/Core/Sources/ConversationTab/FilePicker.swift @@ -5,22 +5,42 @@ 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]? { + @AppStorage(\.chatFontSize) var chatFontSize + + // 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) } } @@ -57,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) @@ -73,6 +94,7 @@ public struct FilePicker: View { } }) { Image(systemName: "xmark.circle.fill") + .scaledFont(.body) .foregroundColor(.secondary) } .buttonStyle(HoverButtonStyle()) @@ -90,17 +112,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 +130,7 @@ public struct FilePicker: View { } } } - .id(filteredFiles?.hashValue) + .id(filteredReferences?.hashValue) } .frame(maxHeight: 200) .padding(.horizontal, 4) @@ -154,50 +176,56 @@ public struct FilePicker: View { RoundedRectangle(cornerRadius: 8) .stroke(Color(nsColor: .separatorColor), lineWidth: 1) ) - .padding(.horizontal, 12) } } 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) + .scaledFrame(width: 16, height: 16) + .hoverSecondaryForeground(isHovered: selectedId == id) .padding(.leading, 4) - VStack(alignment: .leading) { - Text(doc.fileName ?? doc.url.lastPathComponent) - .font(.body) + HStack(spacing: 4) { + Text(ref.displayName) + .scaledFont(.body) .hoverPrimaryForeground(isHovered: selectedId == id) .lineLimit(1) .truncationMode(.middle) - Text(doc.relativePath ?? doc.url.path) - .font(.caption) - .foregroundColor(.secondary) + .layoutPriority(1) + + Text(ref.relativePath) + .scaledFont(.caption) + .hoverSecondaryForeground(isHovered: selectedId == id) .lineLimit(1) .truncationMode(.middle) + // Ensure relative path remains visible even when display name is very long + .frame(minWidth: 80, alignment: .leading) } Spacer() @@ -209,7 +237,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/ModeAndModelPicker/ModeAndModelPickerPicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift new file mode 100644 index 00000000..5b815e59 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift @@ -0,0 +1,491 @@ +import SwiftUI +import ChatService +import Persist +import ComposableArchitecture +import GitHubCopilotService +import Combine +import HostAppActivator +import SharedUIComponents +import ConversationServiceProvider + +struct ModeAndModelPicker: View { + let projectRootURL: URL? + @Binding var selectedAgent: ConversationMode + + @State private var selectedModel: LLMModel? + @State private var isHovered = false + @State private var isPressed = false + @ObservedObject private var modelManager = CopilotModelManagerObservable.shared + static var lastRefreshModelsTime: Date = .init(timeIntervalSince1970: 0) + + @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() + + @State var isMCPFFEnabled: Bool + @State var isBYOKFFEnabled: Bool + @State var isEditorPreviewEnabled: Bool + @State private var cancellables = Set() + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + let attributes: [NSAttributedString.Key: NSFont] = ModelMenuItemFormatter.attributes + + init(projectRootURL: URL?, selectedAgent: Binding) { + self.projectRootURL = projectRootURL + self._selectedAgent = selectedAgent + let initialModel = AppState.shared.getSelectedModel() ?? + CopilotModelManager.getDefaultChatModel() + self._selectedModel = State(initialValue: initialModel) + self.isMCPFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.mcp + self.isBYOKFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.byok + self.isEditorPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures + updateAgentPicker() + } + + private func subscribeToFeatureFlagsDidChangeEvent() { + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in + isMCPFFEnabled = featureFlags.mcp + isBYOKFFEnabled = featureFlags.byok + isEditorPreviewEnabled = featureFlags.editorPreviewFeatures + }) + .store(in: &cancellables) + } + + var copilotModels: [LLMModel] { + AppState.shared.isAgentModeEnabled() ? + modelManager.availableAgentModels : modelManager.availableChatModels + } + + var byokModels: [LLMModel] { + AppState.shared.isAgentModeEnabled() ? + modelManager.availableAgentBYOKModels : modelManager.availableChatBYOKModels + } + + var defaultModel: LLMModel? { + 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.availableAgentBYOKModels : + modelManager.availableChatModels + modelManager.availableChatBYOKModels + 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 = ModelMenuItemFormatter.getMultiplierText(for: model) + newCache[model.modelName.appending(model.providerName ?? "")] = multiplierText + + 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 + ModelMenuItemFormatter.minimumPaddingWidth + multiplierWidth + maxWidth = max(maxWidth, totalWidth) + } + + if maxWidth == 0, let selectedModel = selectedModel { + maxWidth = (selectedModel.displayName ?? selectedModel.modelName).size(withAttributes: attributes).width + } + + return ScopeCache( + modelMultiplierCache: newCache, + cachedMaxWidth: maxWidth, + lastModelsHash: currentHash + ) + } + + func updateCurrentModel() { + let currentModel = AppState.shared.getSelectedModel() + var allAvailableModels = copilotModels + if isBYOKFFEnabled { + allAvailableModels += byokModels + } + + // If editor preview is disabled and current model is auto, switch away from it + if !isEditorPreviewEnabled && currentModel?.isAutoModel == true { + // Try default model first + if let defaultModel = defaultModel, !defaultModel.isAutoModel { + AppState.shared.setSelectedModel(defaultModel) + selectedModel = defaultModel + return + } + // If default is also auto, use first non-auto available model + if let firstNonAuto = allAvailableModels.first(where: { !$0.isAutoModel }) { + AppState.shared.setSelectedModel(firstNonAuto) + selectedModel = firstNonAuto + return + } + } + + // 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() { + self.chatMode = AppState.shared.getSelectedChatMode() + } + + func switchModelsForScope(_ scope: PromptTemplateScope, model: String?) { + let newModeModels = CopilotModelManager.getAvailableChatLLMs( + scope: scope + ) + BYOKModelManager.getAvailableChatLLMs(scope: scope) + + // If a model string is provided, try to parse and find it + if let modelString = model { + if let parsedModel = parseModelString(modelString, from: newModeModels) { + // Model exists in the scope, set it + AppState.shared.setSelectedModel(parsedModel) + self.updateCurrentModel() + updateModelCacheIfNeeded(for: scope) + return + } + // If model doesn't exist in scope, fall through to default behavior + } + + 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) + } else { + AppState.shared.setSelectedModel(newModeModels[0]) + } + } + } + + self.updateCurrentModel() + updateModelCacheIfNeeded(for: scope) + } + + // Parse model string in format "{Model DisplayName} ({providerName or copilot})" + // If no parentheses, defaults to Copilot model + private func parseModelString(_ modelString: String, from availableModels: [LLMModel]) -> LLMModel? { + var displayName: String + var isCopilotModel: Bool + var provider: String = "" + + // Extract display name and provider from the format: "DisplayName (provider)" + if let openParenIndex = modelString.lastIndex(of: "("), + let closeParenIndex = modelString.lastIndex(of: ")"), + openParenIndex < closeParenIndex { + + let displayNameEndIndex = modelString.index(before: openParenIndex) + displayName = String(modelString[.. 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.displayName ?? model.modelName, + isSelected: selectedModel == model, + cachedMultiplierText: currentCache.modelMultiplierCache[model.modelName.appending(model.providerName ?? "")] ?? "" + )) + } + .help( + model.isAutoModel + ? "Auto selects the best model for your request based on capacity and performance." + : model.displayName ?? model.modelName) + } + + private var mcpButton: some View { + Group { + if isMCPFFEnabled { + Button(action: { + let currentSubMode = AppState.shared.getSelectedAgentSubMode() + try? launchHostAppToolsSettings(currentAgentSubMode: currentSubMode) + }) { + 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.") + } + } + .cornerRadius(6) + } + + private var mcpIcon: some View { + Image(systemName: "wrench.and.screwdriver") + .resizable() + .scaledToFit() + .scaledFrame(width: 16, height: 16) + .padding(4) + .font(Font.system(size: 11, weight: .semibold)) + } + + // Main view body + var body: some View { + WithPerceptionTracking { + HStack(spacing: 0) { + // Custom segmented control with color change + ChatModePicker( + projectRootURL: projectRootURL, + chatMode: $chatMode, + selectedAgent: $selectedAgent, + onScopeChange: switchModelsForScope + ) + .onAppear { + updateAgentPicker() + } + .onReceive( + NotificationCenter.default.publisher(for: .gitHubCopilotChatModeDidChange)) { _ in + updateAgentPicker() + } + + if chatMode == "Agent" { + mcpButton + } + + // Model Picker + Group { + if !copilotModels.isEmpty && selectedModel != nil { + modelPickerMenu + } else { + EmptyView() + } + } + } + .onAppear() { + updateCurrentModel() + // Initialize both caches + updateModelCacheIfNeeded(for: .chatPanel) + updateModelCacheIfNeeded(for: .agentPanel) + Task { + await refreshModels() + } + } + .onChange(of: defaultModel) { _ in + updateCurrentModel() + } + .onChange(of: modelManager.availableChatModels) { _ in + updateCurrentModel() + updateModelCacheIfNeeded(for: .chatPanel) + } + .onChange(of: modelManager.availableAgentModels) { _ in + 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() + } + .onChange(of: isBYOKFFEnabled) { _ in + updateCurrentModel() + } + .onChange(of: isEditorPreviewEnabled) { _ in + updateCurrentModel() + } + .onReceive(NotificationCenter.default.publisher(for: .gitHubCopilotSelectedModelDidChange)) { _ in + updateCurrentModel() + } + .task { + subscribeToFeatureFlagsDidChangeEvent() + } + } + } + + func labelWidth() -> CGFloat { + guard let selectedModel = selectedModel else { return 100 } + let displayName = selectedModel.displayName ?? selectedModel.modelName + let width = displayName.size( + withAttributes: attributes + ).width + return CGFloat(width * fontScale + 20) + } + + @MainActor + func refreshModels() async { + let now = Date() + if now.timeIntervalSince(Self.lastRefreshModelsTime) < 60 { + return + } + + Self.lastRefreshModelsTime = now + let copilotModels = await SharedChatService.shared.copilotModels() + if !copilotModels.isEmpty { + CopilotModelManager.updateLLMs(copilotModels) + } + } + + private func createModelMenuItemAttributedString( + modelName: String, + isSelected: Bool, + cachedMultiplierText: String + ) -> AttributedString { + return ModelMenuItemFormatter.createModelMenuItemAttributedString( + modelName: modelName, + isSelected: isSelected, + multiplierText: cachedMultiplierText, + targetWidth: currentCache.cachedMaxWidth + ) + } +} + +struct ModelPicker_Previews: PreviewProvider { + @State static var agent: ConversationMode = .defaultAgent + + static var previews: some View { + ModeAndModelPicker(projectRootURL: nil, selectedAgent: $agent) + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButton.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButton.swift new file mode 100644 index 00000000..683f8091 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButton.swift @@ -0,0 +1,372 @@ +import AppKit +import ConversationServiceProvider +import Persist +import SharedUIComponents +import SwiftUI + +// MARK: - Custom NSButton that accepts clicks anywhere within its bounds +class ClickThroughButton: NSButton { + override func hitTest(_ point: NSPoint) -> NSView? { + // If the point is within our bounds, return self (the button) + // This ensures clicks on subviews are handled by the button + if self.bounds.contains(point) { + return self + } + return super.hitTest(point) + } +} + +// MARK: - Agent Mode Button + +struct AgentModeButton: NSViewRepresentable { + @StateObject private var fontScaleManager = FontScaleManager.shared + + private var fontScale: Double { + fontScaleManager.currentScale + } + + let title: String + let isSelected: Bool + let activeBackground: Color + let activeTextColor: Color + let inactiveTextColor: Color + let chatMode: String + let builtInAgentModes: [ConversationMode] + let customAgents: [ConversationMode] + let selectedAgent: ConversationMode + let selectedIconName: String? + let isCustomAgentEnabled: Bool + let onSelectAgent: (ConversationMode) -> Void + let onEditAgent: (ConversationMode) -> Void + let onDeleteAgent: (ConversationMode) -> Void + let onCreateAgent: () -> Void + + func makeNSView(context: Context) -> NSView { + let containerView = NSView() + containerView.translatesAutoresizingMaskIntoConstraints = false + + let button = ClickThroughButton() + button.title = "" + button.bezelStyle = .inline + button.setButtonType(.momentaryPushIn) + button.isBordered = false + button.target = context.coordinator + button.action = #selector(Coordinator.buttonClicked(_:)) + button.translatesAutoresizingMaskIntoConstraints = false + + // Create icon for agent mode + let iconImageView = NSImageView() + iconImageView.translatesAutoresizingMaskIntoConstraints = false + iconImageView.imageScaling = .scaleProportionallyDown + + // Create chevron icon + let chevronView = NSImageView() + let chevronImage = NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil) + let symbolConfig = NSImage.SymbolConfiguration(pointSize: 9 * fontScale, weight: .bold) + chevronView.image = chevronImage?.withSymbolConfiguration(symbolConfig) + chevronView.translatesAutoresizingMaskIntoConstraints = false + chevronView.isHidden = !isCustomAgentEnabled + + // Create title label + let titleLabel = NSTextField(labelWithString: title) + titleLabel.font = NSFont.systemFont(ofSize: 12 * fontScale) + titleLabel.isEditable = false + titleLabel.isBordered = false + titleLabel.backgroundColor = .clear + titleLabel.drawsBackground = false + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLabel.alignment = .center + titleLabel.usesSingleLineMode = true + titleLabel.lineBreakMode = .byClipping + + // Create horizontal stack with icon, title, and chevron + let stackView = NSStackView(views: [iconImageView, titleLabel, chevronView]) + stackView.orientation = .horizontal + stackView.spacing = 0 + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.alignment = .centerY + stackView.setHuggingPriority(.required, for: .horizontal) + stackView.setContentCompressionResistancePriority(.required, for: .horizontal) + + // Set custom spacing between title and chevron + stackView.setCustomSpacing(3 * fontScale, after: titleLabel) + + button.addSubview(stackView) + containerView.addSubview(button) + + let stackLeadingConstraint = stackView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 6 * fontScale) + let stackTrailingConstraint = stackView.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -6 * fontScale) + let stackTopConstraint = stackView.topAnchor.constraint(equalTo: button.topAnchor, constant: 2 * fontScale) + let stackBottomConstraint = stackView.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -2 * fontScale) + let iconWidthConstraint = iconImageView.widthAnchor.constraint(equalToConstant: 16 * fontScale) + let iconHeightConstraint = iconImageView.heightAnchor.constraint(equalToConstant: 16 * fontScale) + let chevronWidthConstraint = chevronView.widthAnchor.constraint(equalToConstant: 9 * fontScale) + let chevronHeightConstraint = chevronView.heightAnchor.constraint(equalToConstant: 9 * fontScale) + + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + button.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + button.topAnchor.constraint(equalTo: containerView.topAnchor), + button.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + + stackLeadingConstraint, + stackTrailingConstraint, + stackTopConstraint, + stackBottomConstraint, + + iconWidthConstraint, + iconHeightConstraint, + + chevronWidthConstraint, + chevronHeightConstraint, + ]) + + context.coordinator.button = button + context.coordinator.titleLabel = titleLabel + context.coordinator.iconImageView = iconImageView + context.coordinator.chevronView = chevronView + context.coordinator.stackView = stackView + context.coordinator.stackLeadingConstraint = stackLeadingConstraint + context.coordinator.stackTrailingConstraint = stackTrailingConstraint + context.coordinator.stackTopConstraint = stackTopConstraint + context.coordinator.stackBottomConstraint = stackBottomConstraint + context.coordinator.iconWidthConstraint = iconWidthConstraint + context.coordinator.iconHeightConstraint = iconHeightConstraint + context.coordinator.chevronWidthConstraint = chevronWidthConstraint + context.coordinator.chevronHeightConstraint = chevronHeightConstraint + + return containerView + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let button = context.coordinator.button, + let titleLabel = context.coordinator.titleLabel, + let iconImageView = context.coordinator.iconImageView, + let chevronView = context.coordinator.chevronView, + let stackView = context.coordinator.stackView else { return } + + titleLabel.stringValue = title + titleLabel.font = NSFont.systemFont(ofSize: 12 * fontScale) + context.coordinator.chatMode = chatMode + context.coordinator.builtInAgentModes = builtInAgentModes + context.coordinator.customAgents = customAgents + context.coordinator.selectedAgent = selectedAgent + context.coordinator.isSelected = isSelected + context.coordinator.isCustomAgentEnabled = isCustomAgentEnabled + context.coordinator.fontScale = fontScale + + // Update constraints for scaling + context.coordinator.stackLeadingConstraint?.constant = 6 * fontScale + context.coordinator.stackTrailingConstraint?.constant = -6 * fontScale + context.coordinator.stackTopConstraint?.constant = 2 * fontScale + context.coordinator.stackBottomConstraint?.constant = -2 * fontScale + context.coordinator.iconWidthConstraint?.constant = 16 * fontScale + context.coordinator.iconHeightConstraint?.constant = 16 * fontScale + context.coordinator.chevronWidthConstraint?.constant = 9 * fontScale + context.coordinator.chevronHeightConstraint?.constant = 9 * fontScale + stackView.spacing = 0 + + // Update custom spacing between title and chevron + stackView.setCustomSpacing(3 * fontScale, after: titleLabel) + + // Update chevron visibility based on feature flag and policy + chevronView.isHidden = !isCustomAgentEnabled + + // Update icon based on selected agent mode + if let iconName = selectedIconName { + iconImageView.isHidden = false + iconImageView.image = createIconImage(named: iconName, pointSize: 16 * fontScale) + } else { + // No icon for custom agents + iconImageView.isHidden = true + iconImageView.image = nil + } + + // Update chevron icon with scaled size + chevronView.image = createSFSymbolImage(named: "chevron.down", pointSize: 9 * fontScale, weight: .bold) + + // Update button appearance based on selection + if isSelected { + button.layer?.backgroundColor = NSColor(activeBackground).cgColor + titleLabel.textColor = NSColor(activeTextColor) + iconImageView.contentTintColor = NSColor(activeTextColor) + chevronView.contentTintColor = NSColor(activeTextColor) + + // Remove existing shadows before adding new ones + button.layer?.shadowOpacity = 0 + + // Add shadows + button.shadow = { + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.05) + shadow.shadowOffset = NSSize(width: 0, height: -1) + shadow.shadowBlurRadius = 0.375 + return shadow + }() + + // For the second shadow, we can add a sublayer or just use one. + // For simplicity, we will just use one for now. A second shadow can be added with a sublayer if needed. + + // Add overlay + button.layer?.borderColor = NSColor.black.withAlphaComponent(0.02).cgColor + button.layer?.borderWidth = 0.5 + + } else { + button.layer?.backgroundColor = NSColor.clear.cgColor + titleLabel.textColor = NSColor(inactiveTextColor) + iconImageView.contentTintColor = NSColor(inactiveTextColor) + chevronView.contentTintColor = NSColor(inactiveTextColor) + button.shadow = nil + button.layer?.borderColor = NSColor.clear.cgColor + button.layer?.borderWidth = 0 + } + button.wantsLayer = true + button.layer?.cornerRadius = 10 * fontScale + button.layer?.cornerCurve = .continuous + } + + func makeCoordinator() -> Coordinator { + Coordinator( + chatMode: chatMode, + builtInAgentModes: builtInAgentModes, + customAgents: customAgents, + selectedAgent: selectedAgent, + isSelected: isSelected, + isCustomAgentEnabled: isCustomAgentEnabled, + fontScale: fontScale, + onSelectAgent: onSelectAgent, + onEditAgent: onEditAgent, + onDeleteAgent: onDeleteAgent, + onCreateAgent: onCreateAgent + ) + } + + // MARK: - Helper Methods for Image Creation + + /// Creates an icon image - either a custom asset or SF Symbol + private func createIconImage(named iconName: String, pointSize: CGFloat) -> NSImage? { + if iconName == AgentModeIcon.agent { + return createResizedCustomImage(named: iconName, targetSize: pointSize) + } else { + return createSFSymbolImage(named: iconName, pointSize: pointSize, weight: .bold) + } + } + + /// Creates a resized custom image (non-SF Symbol) with template rendering + private func createResizedCustomImage(named imageName: String, targetSize: CGFloat) -> NSImage? { + guard let image = NSImage(named: imageName) else { return nil } + + let size = NSSize(width: targetSize, height: targetSize) + let resizedImage = NSImage(size: size) + resizedImage.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + image.draw( + in: NSRect(origin: .zero, size: size), + from: NSRect(origin: .zero, size: image.size), + operation: .sourceOver, + fraction: 1.0 + ) + resizedImage.unlockFocus() + resizedImage.isTemplate = true + return resizedImage + } + + /// Creates an SF Symbol image with the specified configuration + private func createSFSymbolImage(named symbolName: String, pointSize: CGFloat, weight: NSFont.Weight) -> NSImage? { + let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: weight) + return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? + .withSymbolConfiguration(config) + } + + class Coordinator: NSObject { + var chatMode: String + var builtInAgentModes: [ConversationMode] + var customAgents: [ConversationMode] + var selectedAgent: ConversationMode + var isSelected: Bool + var isCustomAgentEnabled: Bool + var fontScale: Double + var button: NSButton? + var titleLabel: NSTextField? + var iconImageView: NSImageView? + var chevronView: NSImageView? + var stackView: NSStackView? + var stackLeadingConstraint: NSLayoutConstraint? + var stackTrailingConstraint: NSLayoutConstraint? + var stackTopConstraint: NSLayoutConstraint? + var stackBottomConstraint: NSLayoutConstraint? + var iconWidthConstraint: NSLayoutConstraint? + var iconHeightConstraint: NSLayoutConstraint? + var chevronWidthConstraint: NSLayoutConstraint? + var chevronHeightConstraint: NSLayoutConstraint? + let onSelectAgent: (ConversationMode) -> Void + let onEditAgent: (ConversationMode) -> Void + let onDeleteAgent: (ConversationMode) -> Void + let onCreateAgent: () -> Void + + init( + chatMode: String, + builtInAgentModes: [ConversationMode], + customAgents: [ConversationMode], + selectedAgent: ConversationMode, + isSelected: Bool, + isCustomAgentEnabled: Bool, + fontScale: Double, + onSelectAgent: @escaping (ConversationMode) -> Void, + onEditAgent: @escaping (ConversationMode) -> Void, + onDeleteAgent: @escaping (ConversationMode) -> Void, + onCreateAgent: @escaping () -> Void + ) { + self.chatMode = chatMode + self.builtInAgentModes = builtInAgentModes + self.customAgents = customAgents + self.selectedAgent = selectedAgent + self.isSelected = isSelected + self.isCustomAgentEnabled = isCustomAgentEnabled + self.fontScale = fontScale + self.onSelectAgent = onSelectAgent + self.onEditAgent = onEditAgent + self.onDeleteAgent = onDeleteAgent + self.onCreateAgent = onCreateAgent + } + + @objc func buttonClicked(_ sender: NSButton) { + // If in Ask mode, switch to agent mode + if chatMode == ChatMode.Ask.rawValue { + // Restore the previously selected agent from AppState + let savedSubMode = AppState.shared.getSelectedAgentSubMode() + + // Try to find the saved agent + let agent = builtInAgentModes.first(where: { $0.id == savedSubMode }) + ?? customAgents.first(where: { $0.id == savedSubMode }) + ?? builtInAgentModes.first + + if let agent = agent { + onSelectAgent(agent) + } + } else { + // If in Agent mode and custom agent is enabled, show the menu + // If custom agent is disabled, do nothing + if isCustomAgentEnabled { + showMenu(sender) + } + } + } + + @objc func showMenu(_ sender: NSButton) { + let menuBuilder = AgentModeMenu( + builtInAgentModes: builtInAgentModes, + customAgents: customAgents, + selectedAgent: selectedAgent, + fontScale: fontScale, + onSelectAgent: onSelectAgent, + onEditAgent: onEditAgent, + onDeleteAgent: onDeleteAgent, + onCreateAgent: onCreateAgent + ) + menuBuilder.showMenu(relativeTo: sender) + } + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift new file mode 100644 index 00000000..322bac6d --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift @@ -0,0 +1,522 @@ +import AppKit +import ConversationServiceProvider +import SwiftUI + +// MARK: - Agent Menu Item View + +class AgentModeButtonMenuItem: NSView { + // Layout constants + private let fontScale: Double + + private lazy var scaledConstants = ScaledLayoutConstants(fontScale: fontScale) + + private struct ScaledLayoutConstants { + let fontScale: Double + + var menuHeight: CGFloat { 22 * fontScale } + var checkmarkLeftEdge: CGFloat { 9 * fontScale } + var checkmarkSize: CGFloat { 13 * fontScale } + var iconSize: CGFloat { 16 * fontScale } + var iconTextSpacing: CGFloat { 5 * fontScale } + var checkmarkIconSpacing: CGFloat { 5 * fontScale } + var hoverEdgeInset: CGFloat { 5 * fontScale } + var buttonSpacing: CGFloat { -4 * fontScale } + var deleteButtonRightEdge: CGFloat { 12 * fontScale } + var buttonSize: CGFloat { 24 * fontScale } + var buttonIconSize: CGFloat { 10 * fontScale } + var buttonBackgroundSize: CGFloat { 17 * fontScale } + var buttonBackgroundEdgeInset: CGFloat { 3 * fontScale } + var minWidth: CGFloat { 180 * fontScale } + var maxWidth: CGFloat { 320 * fontScale } + var fontSize: CGFloat { 13 * fontScale } + var fontWeight: NSFont.Weight { .regular } + + // MARK: - Computed Properties for Repeated Calculations + + /// Starting X position for checkmark and icons without selection + var checkmarkStartX: CGFloat { checkmarkLeftEdge } + + /// Starting X position for icons when menu has selection + var iconStartXWithSelection: CGFloat { + checkmarkLeftEdge + checkmarkSize + checkmarkIconSpacing + } + + /// Icon X position based on selection state + func iconX(isSelected: Bool, menuHasSelection: Bool) -> CGFloat { + isSelected || menuHasSelection ? iconStartXWithSelection : checkmarkLeftEdge + } + + /// Helper to vertically center an element within the menu height + func centeredY(for elementSize: CGFloat) -> CGFloat { + (menuHeight - elementSize) / 2 + } + + /// Starting X position for label text based on icon presence + func labelStartX(hasIcon: Bool, iconName: String?, isSelected: Bool, menuHasSelection: Bool) -> CGFloat { + if hasIcon { + let iconX: CGFloat + let iconWidth: CGFloat + if iconName == AgentModeIcon.plus { + iconX = checkmarkLeftEdge + iconWidth = checkmarkSize + } else { + iconX = isSelected ? iconStartXWithSelection : (menuHasSelection ? iconStartXWithSelection : checkmarkLeftEdge) + iconWidth = iconSize + } + return iconX + iconWidth + iconTextSpacing + } else { + return menuHasSelection ? iconStartXWithSelection : checkmarkLeftEdge + } + } + } + + private let name: String + private let iconName: String? + private let isSelected: Bool + private let menuHasSelection: Bool + private let onSelect: () -> Void + private let onEdit: (() -> Void)? + private let onDelete: (() -> Void)? + + private var isHovered = false + private var isEditButtonHovered = false + private var isDeleteButtonHovered = false + private var trackingArea: NSTrackingArea? + + private var hasEditDeleteButtons: Bool { + onEdit != nil && onDelete != nil + } + + private let nameLabel = NSTextField(labelWithString: "") + private let iconImageView = NSImageView() + private let checkmarkImageView = NSImageView() + private let editButton = NSButton() + private let deleteButton = NSButton() + private let editButtonBackground = NSView() + private let deleteButtonBackground = NSView() + + init( + name: String, + iconName: String?, + isSelected: Bool, + menuHasSelection: Bool, + fontScale: Double = 1.0, + fixedWidth: CGFloat? = nil, + onSelect: @escaping () -> Void, + onEdit: (() -> Void)? = nil, + onDelete: (() -> Void)? = nil + ) { + self.name = name + self.iconName = iconName + self.isSelected = isSelected + self.menuHasSelection = menuHasSelection + self.fontScale = fontScale + self.onSelect = onSelect + self.onEdit = onEdit + self.onDelete = onDelete + + // Use fixed width if provided, otherwise calculate dynamically + let calculatedWidth = fixedWidth ?? Self.calculateMenuItemWidth( + name: name, + hasIcon: iconName != nil, + isSelected: isSelected, + menuHasSelection: menuHasSelection, + hasEditDelete: onEdit != nil && onDelete != nil, + fontScale: fontScale + ) + + let constants = ScaledLayoutConstants(fontScale: fontScale) + super.init(frame: NSRect(x: 0, y: 0, width: calculatedWidth, height: constants.menuHeight)) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + static func calculateMenuItemWidth( + name: String, + hasIcon: Bool, + isSelected: Bool, + menuHasSelection: Bool, + hasEditDelete: Bool, + fontScale: Double = 1.0 + ) -> CGFloat { + // Create scaled constants + let constants = ScaledLayoutConstants(fontScale: fontScale) + + // Calculate text width + let font = NSFont.systemFont(ofSize: constants.fontSize, weight: constants.fontWeight) + let textAttributes = [NSAttributedString.Key.font: font] + let textSize = (name as NSString).size(withAttributes: textAttributes) + + // Calculate label X position using computed property + let iconName = hasIcon ? (name == "Create an agent" ? AgentModeIcon.plus : nil) : nil + let labelX = constants.labelStartX(hasIcon: hasIcon, iconName: iconName, isSelected: isSelected, menuHasSelection: menuHasSelection) + + // Calculate required width + var width = labelX + textSize.width + 10 * fontScale // 10pt padding after text + + if hasEditDelete { + // Add space for edit and delete buttons + width = max(width, labelX + textSize.width + 20 * fontScale) // Ensure some space before buttons + width += (constants.buttonSize * 2) + constants.buttonSpacing + constants.deleteButtonRightEdge + } else { + width += 10 * fontScale // Extra padding for items without buttons + } + + // Clamp to min/max width + return min(max(width, constants.minWidth), constants.maxWidth) + } + + private func setupView() { + wantsLayer = true + layer?.masksToBounds = true + + setupCheckmark() + setupIcon() + setupNameLabel() + + let showEditDeleteButtons = onEdit != nil && onDelete != nil + if showEditDeleteButtons { + setupEditDeleteButtons() + } + + setupTrackingArea() + } + + // MARK: - View Setup Helpers + + private func setupCheckmark() { + let checkmarkConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.checkmarkSize, weight: .medium) + if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil)? + .withSymbolConfiguration(checkmarkConfig) { + checkmarkImageView.image = image + } + checkmarkImageView.contentTintColor = .labelColor + let checkmarkY = scaledConstants.centeredY(for: scaledConstants.checkmarkSize) + checkmarkImageView.frame = NSRect( + x: scaledConstants.checkmarkStartX, + y: checkmarkY, + width: scaledConstants.checkmarkSize, + height: scaledConstants.checkmarkSize + ) + checkmarkImageView.isHidden = !isSelected + addSubview(checkmarkImageView) + } + + private func setupIcon() { + guard let iconName = iconName else { return } + + if iconName == AgentModeIcon.agent { + setupCustomAgentIcon() + } else if iconName == AgentModeIcon.plus { + setupPlusIcon() + } else { + setupSFSymbolIcon(iconName) + } + + iconImageView.contentTintColor = .labelColor + iconImageView.isHidden = false + + // Calculate and set icon position + let (iconX, iconSize, iconY) = calculateIconPosition(for: iconName) + iconImageView.frame = NSRect(x: iconX, y: iconY, width: iconSize, height: iconSize) + addSubview(iconImageView) + } + + private func setupCustomAgentIcon() { + guard let image = NSImage(named: AgentModeIcon.agent) else { return } + + let targetSize = NSSize(width: scaledConstants.iconSize, height: scaledConstants.iconSize) + let resizedImage = NSImage(size: targetSize) + resizedImage.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + image.draw( + in: NSRect(origin: .zero, size: targetSize), + from: NSRect(origin: .zero, size: image.size), + operation: .sourceOver, + fraction: 1.0 + ) + resizedImage.unlockFocus() + resizedImage.isTemplate = true + iconImageView.image = resizedImage + } + + private func setupPlusIcon() { + let plusConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.checkmarkSize, weight: .medium) + if let image = NSImage(systemSymbolName: AgentModeIcon.plus, accessibilityDescription: nil) { + iconImageView.image = image.withSymbolConfiguration(plusConfig) + } + } + + private func setupSFSymbolIcon(_ iconName: String) { + let symbolConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.iconSize, weight: .medium) + if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) { + iconImageView.image = image.withSymbolConfiguration(symbolConfig) + } + } + + private func calculateIconPosition(for iconName: String) -> (x: CGFloat, size: CGFloat, y: CGFloat) { + if iconName == AgentModeIcon.plus { + let size = scaledConstants.checkmarkSize + return ( + scaledConstants.checkmarkStartX, + size, + scaledConstants.centeredY(for: size) + ) + } else { + let size = scaledConstants.iconSize + return ( + scaledConstants.iconX(isSelected: isSelected, menuHasSelection: menuHasSelection), + size, + scaledConstants.centeredY(for: size) + ) + } + } + + private func setupNameLabel() { + let labelX = scaledConstants.labelStartX( + hasIcon: iconName != nil, + iconName: iconName, + isSelected: isSelected, + menuHasSelection: menuHasSelection + ) + + nameLabel.stringValue = name + nameLabel.font = NSFont.systemFont(ofSize: scaledConstants.fontSize, weight: scaledConstants.fontWeight) + nameLabel.textColor = .labelColor + nameLabel.frame = NSRect(x: labelX, y: 3 * fontScale, width: 160 * fontScale, height: 16 * fontScale) + nameLabel.isEditable = false + nameLabel.isBordered = false + nameLabel.backgroundColor = .clear + nameLabel.drawsBackground = false + addSubview(nameLabel) + } + + private func setupEditDeleteButtons() { + let viewWidth = frame.width + let buttonIconConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.buttonIconSize, weight: .medium) + + // Calculate button positions from the right edge + let deleteButtonX = viewWidth - scaledConstants.deleteButtonRightEdge - scaledConstants.buttonSize + let editButtonX = deleteButtonX - scaledConstants.buttonSpacing - scaledConstants.buttonSize + let backgroundY = (frame.height - scaledConstants.buttonBackgroundSize) / 2 + + // Setup edit button and background + setupEditButton(at: editButtonX, backgroundY: backgroundY, config: buttonIconConfig) + + // Setup delete button and background + setupDeleteButton(at: deleteButtonX, backgroundY: backgroundY, config: buttonIconConfig) + } + + private func setupButtonWithBackground( + button: NSButton, + background: NSView, + at x: CGFloat, + backgroundY: CGFloat, + iconName: String, + accessibilityDescription: String, + action: Selector, + config: NSImage.SymbolConfiguration + ) { + // Setup background + let backgroundX = x + scaledConstants.buttonBackgroundEdgeInset + background.wantsLayer = true + background.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.15).cgColor + background.layer?.cornerRadius = scaledConstants.buttonBackgroundSize / 2 + background.frame = NSRect( + x: backgroundX, + y: backgroundY, + width: scaledConstants.buttonBackgroundSize, + height: scaledConstants.buttonBackgroundSize + ) + background.isHidden = true + addSubview(background) + + // Setup button + button.image = NSImage(systemSymbolName: iconName, accessibilityDescription: accessibilityDescription)?.withSymbolConfiguration(config) + button.bezelStyle = .roundRect + button.isBordered = false + button.frame = NSRect( + x: x, + y: scaledConstants.centeredY(for: scaledConstants.buttonSize), + width: scaledConstants.buttonSize, + height: scaledConstants.buttonSize + ) + button.target = self + button.action = action + button.isHidden = true + button.alphaValue = 1.0 + addSubview(button) + } + + private func setupEditButton(at x: CGFloat, backgroundY: CGFloat, config: NSImage.SymbolConfiguration) { + setupButtonWithBackground( + button: editButton, + background: editButtonBackground, + at: x, + backgroundY: backgroundY, + iconName: "pencil", + accessibilityDescription: "Edit", + action: #selector(editTapped), + config: config + ) + } + + private func setupDeleteButton(at x: CGFloat, backgroundY: CGFloat, config: NSImage.SymbolConfiguration) { + setupButtonWithBackground( + button: deleteButton, + background: deleteButtonBackground, + at: x, + backgroundY: backgroundY, + iconName: "trash", + accessibilityDescription: "Delete", + action: #selector(deleteTapped), + config: config + ) + } + + private func setupTrackingArea() { + // Use .zero rect with .inVisibleRect to automatically track the visible bounds + // This avoids accessing bounds during layout cycles + trackingArea = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .mouseMoved, .activeInActiveApp, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea!) + } + + override func mouseEntered(with event: NSEvent) { + isHovered = true + updateButtonVisibility() + updateColors() + needsDisplay = true + } + + override func mouseExited(with event: NSEvent) { + isHovered = false + isEditButtonHovered = false + isDeleteButtonHovered = false + updateButtonVisibility() + editButtonBackground.isHidden = true + deleteButtonBackground.isHidden = true + updateColors() + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + let location = convert(event.locationInWindow, from: nil) + + if hasEditDeleteButtons { + if editButton.frame.contains(location) || deleteButton.frame.contains(location) { + return + } + } + + onSelect() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea = trackingArea { + removeTrackingArea(trackingArea) + } + setupTrackingArea() + } + + private func updateButtonVisibility() { + if hasEditDeleteButtons { + editButton.isHidden = !isHovered + deleteButton.isHidden = !isHovered + } + } + + private func updateColors() { + if isHovered { + nameLabel.textColor = .white + iconImageView.contentTintColor = .white + checkmarkImageView.contentTintColor = .white + if hasEditDeleteButtons { + editButton.contentTintColor = .white + deleteButton.contentTintColor = .white + } + } else { + nameLabel.textColor = .labelColor + iconImageView.contentTintColor = .labelColor + checkmarkImageView.contentTintColor = .labelColor + if hasEditDeleteButtons { + editButton.contentTintColor = nil + deleteButton.contentTintColor = nil + } + } + } + + @objc private func editTapped() { + onEdit?() + } + + @objc private func deleteTapped() { + onDelete?() + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + if isHovered { + NSGraphicsContext.saveGraphicsState() + + let hoverColor = NSColor(.accentColor) + hoverColor.setFill() + + let cornerRadius: CGFloat + if #available(macOS 26.0, *) { + cornerRadius = 8.0 * fontScale + } else { + cornerRadius = 4.0 * fontScale + } + + // Use frame dimensions instead of bounds to avoid layout recursion + let viewWidth = frame.width + let viewHeight = frame.height + let hoverWidth = viewWidth - (scaledConstants.hoverEdgeInset * 2) + let insetRect = NSRect(x: scaledConstants.hoverEdgeInset, y: 0, width: hoverWidth, height: viewHeight) + let path = NSBezierPath(roundedRect: insetRect, xRadius: cornerRadius, yRadius: cornerRadius) + path.fill() + + NSGraphicsContext.restoreGraphicsState() + } + } + + override func mouseMoved(with event: NSEvent) { + guard hasEditDeleteButtons else { return } + + let location = convert(event.locationInWindow, from: nil) + + if editButton.frame.contains(location) && !editButton.isHidden { + updateButtonHoverState(editHovered: true, deleteHovered: false, trashFilled: false) + } else if deleteButton.frame.contains(location) && !deleteButton.isHidden { + updateButtonHoverState(editHovered: false, deleteHovered: true, trashFilled: true) + } else { + updateButtonHoverState(editHovered: false, deleteHovered: false, trashFilled: false) + } + + if isHovered { + editButton.contentTintColor = .white + deleteButton.contentTintColor = .white + } + } + + private func updateButtonHoverState(editHovered: Bool, deleteHovered: Bool, trashFilled: Bool) { + isEditButtonHovered = editHovered + isDeleteButtonHovered = deleteHovered + editButtonBackground.isHidden = !editHovered + deleteButtonBackground.isHidden = !deleteHovered + + let buttonIconConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.buttonIconSize, weight: .medium) + let trashIcon = trashFilled ? "trash.fill" : "trash" + deleteButton.image = NSImage(systemSymbolName: trashIcon, accessibilityDescription: "Delete")?.withSymbolConfiguration(buttonIconConfig) + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeIconConstants.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeIconConstants.swift new file mode 100644 index 00000000..3461a0f4 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeIconConstants.swift @@ -0,0 +1,21 @@ +import Foundation + +// MARK: - Agent Mode Icon Constants + +enum AgentModeIcon { + /// Icon for Plan mode (SF Symbol: checklist) + static let plan = "checklist" + + /// Icon for Agent mode (Custom asset: Agent) + static let agent = "Agent" + + /// Icon for create/add actions (SF Symbol: plus) + static let plus = "plus" + + /// Returns the appropriate icon name for a given agent mode name + /// - Parameter modeName: The name of the agent mode + /// - Returns: The icon name to use, or nil for custom agents + static func icon(for modeName: String) -> String { + return modeName.lowercased() == "plan" ? plan : agent + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeMenu.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeMenu.swift new file mode 100644 index 00000000..81e76aef --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeMenu.swift @@ -0,0 +1,165 @@ +import AppKit +import ConversationServiceProvider + +// MARK: - Agent Mode Menu Builder + +struct AgentModeMenu { + let builtInAgentModes: [ConversationMode] + let customAgents: [ConversationMode] + let selectedAgent: ConversationMode + let fontScale: Double + let onSelectAgent: (ConversationMode) -> Void + let onEditAgent: (ConversationMode) -> Void + let onDeleteAgent: (ConversationMode) -> Void + let onCreateAgent: () -> Void + + func createMenu() -> NSMenu { + let menu = NSMenu() + + let menuHasSelection = true // Always show checkmarks for clarity + + // Calculate the maximum width needed across all items + let maxWidth = calculateMaxMenuItemWidth(menuHasSelection: menuHasSelection) + + // Add built-in agent modes + addBuiltInModes(to: menu, menuHasSelection: menuHasSelection, width: maxWidth) + + // Add custom agents if any + if !customAgents.isEmpty { + menu.addItem(.separator()) + addCustomAgents(to: menu, menuHasSelection: menuHasSelection, width: maxWidth) + } + + // Add create option + menu.addItem(.separator()) + addCreateOption(to: menu, menuHasSelection: menuHasSelection, width: maxWidth) + + return menu + } + + private func calculateMaxMenuItemWidth(menuHasSelection: Bool) -> CGFloat { + var maxWidth: CGFloat = 0 + + // Check built-in modes + for mode in builtInAgentModes { + let width = AgentModeButtonMenuItem.calculateMenuItemWidth( + name: mode.name, + hasIcon: true, + isSelected: selectedAgent.id == mode.id, + menuHasSelection: menuHasSelection, + hasEditDelete: false, + fontScale: fontScale + ) + maxWidth = max(maxWidth, width) + } + + // Check custom agents + for agent in customAgents { + let width = AgentModeButtonMenuItem.calculateMenuItemWidth( + name: agent.name, + hasIcon: false, + isSelected: selectedAgent.id == agent.id, + menuHasSelection: menuHasSelection, + hasEditDelete: true, + fontScale: fontScale + ) + maxWidth = max(maxWidth, width) + } + + // Check create option + let createWidth = AgentModeButtonMenuItem.calculateMenuItemWidth( + name: "Create an agent", + hasIcon: true, + isSelected: false, + menuHasSelection: menuHasSelection, + hasEditDelete: false, + fontScale: fontScale + ) + maxWidth = max(maxWidth, createWidth) + + return maxWidth + } + + private func addBuiltInModes(to menu: NSMenu, menuHasSelection: Bool, width: CGFloat) { + for mode in builtInAgentModes { + let agentItem = NSMenuItem() + // Determine icon: use checklist for Plan, Agent icon for others + let iconName = AgentModeIcon.icon(for: mode.name) + let agentView = AgentModeButtonMenuItem( + name: mode.name, + iconName: iconName, + isSelected: selectedAgent.id == mode.id, + menuHasSelection: menuHasSelection, + fontScale: fontScale, + fixedWidth: width, + onSelect: { [onSelectAgent] in + onSelectAgent(mode) + menu.cancelTracking() + } + ) + agentView.toolTip = mode.description + agentItem.view = agentView + menu.addItem(agentItem) + } + } + + private func addCustomAgents(to menu: NSMenu, menuHasSelection: Bool, width: CGFloat) { + for agent in customAgents { + let agentItem = NSMenuItem() + agentItem.representedObject = agent + + // Create custom view for the menu item + let customView = AgentModeButtonMenuItem( + name: agent.name, + iconName: nil, + isSelected: selectedAgent.id == agent.id, + menuHasSelection: menuHasSelection, + fontScale: fontScale, + fixedWidth: width, + onSelect: { [onSelectAgent] in + onSelectAgent(agent) + menu.cancelTracking() + }, + onEdit: { [onEditAgent] in + onEditAgent(agent) + menu.cancelTracking() + }, + onDelete: { [onDeleteAgent] in + onDeleteAgent(agent) + menu.cancelTracking() + } + ) + + customView.toolTip = agent.description + agentItem.view = customView + menu.addItem(agentItem) + } + } + + private func addCreateOption(to menu: NSMenu, menuHasSelection: Bool, width: CGFloat) { + let createItem = NSMenuItem() + let createView = AgentModeButtonMenuItem( + name: "Create an agent", + iconName: AgentModeIcon.plus, + isSelected: false, + menuHasSelection: menuHasSelection, + fontScale: fontScale, + fixedWidth: width, + onSelect: { [onCreateAgent] in + onCreateAgent() + menu.cancelTracking() + } + ) + createItem.view = createView + menu.addItem(createItem) + } + + func showMenu(relativeTo button: NSButton) { + let menu = createMenu() + + // Show menu aligned to the button's edge, positioned below the button + let buttonFrame = button.frame + let menuOrigin = NSPoint(x: buttonFrame.minX, y: buttonFrame.maxY) + menu.popUp(positioning: menu.items.first, at: menuOrigin, in: button.superview) + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift new file mode 100644 index 00000000..97268560 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift @@ -0,0 +1,304 @@ +import AppKit +import AppKitExtension +import ChatService +import Combine +import ConversationServiceProvider +import GitHubCopilotService +import Persist +import SharedUIComponents +import SwiftUI +import SystemUtils +import Workspace +import XcodeInspector + +public extension Notification.Name { + static let gitHubCopilotChatModeDidChange = Notification + .Name("com.github.CopilotForXcode.ChatModeDidChange") +} + +public struct ChatModePicker: View { + @Binding var chatMode: String + @Binding var selectedAgent: ConversationMode + + let projectRootURL: URL? + @Environment(\.colorScheme) var colorScheme + @State var isAgentModeFFEnabled: Bool + @State var isEditorPreviewFFEnabled: Bool + @State var isCustomAgentPolicyEnabled: Bool + @State private var cancellables = Set() + @State private var builtInAgents: [ConversationMode] = [] + @State private var customAgents: [ConversationMode] = [] + @State private var isCreateSheetPresented = false + @State private var agentToDelete: ConversationMode? + @State private var showDeleteConfirmation = false + var onScopeChange: (PromptTemplateScope, String?) -> Void + + public init( + projectRootURL: URL?, + chatMode: Binding, + selectedAgent: Binding, + onScopeChange: @escaping (PromptTemplateScope, String?) -> Void = { _, _ in } + ) { + _chatMode = chatMode + _selectedAgent = selectedAgent + self.projectRootURL = projectRootURL + self.onScopeChange = onScopeChange + isAgentModeFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.agentMode + isEditorPreviewFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures + isCustomAgentPolicyEnabled = CopilotPolicyNotifierImpl.shared.copilotPolicy.customAgentEnabled + } + + private func setAskMode() { + chatMode = ChatMode.Ask.rawValue + AppState.shared.setSelectedChatMode(ChatMode.Ask.rawValue) + onScopeChange(.chatPanel, nil) + NotificationCenter.default.post( + name: .gitHubCopilotChatModeDidChange, + object: nil + ) + } + + private func setAgentMode(_ agent: ConversationMode) { + chatMode = ChatMode.Agent.rawValue + selectedAgent = agent + AppState.shared.setSelectedChatMode(ChatMode.Agent.rawValue) + AppState.shared.setSelectedAgentSubMode(agent.id) + + // Load agents if switching from Ask mode + Task { + await loadCustomAgentsAsync() + } + onScopeChange(.agentPanel, agent.model) + NotificationCenter.default.post( + name: .gitHubCopilotChatModeDidChange, + object: nil + ) + } + + private func subscribeToFeatureFlagsDidChangeEvent() { + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in + isAgentModeFFEnabled = featureFlags.agentMode + isEditorPreviewFFEnabled = featureFlags.editorPreviewFeatures + }) + .store(in: &cancellables) + } + + private func subscribeToPolicyDidChangeEvent() { + CopilotPolicyNotifierImpl.shared.policyDidChange.sink(receiveValue: { policy in + isCustomAgentPolicyEnabled = policy.customAgentEnabled + }) + .store(in: &cancellables) + } + + private func loadCustomAgents() { + Task { + await loadCustomAgentsAsync() + + // Only restore if we're in Agent mode + if chatMode == ChatMode.Agent.rawValue { + loadSelectedAgentSubMode() + } + } + } + + private func loadCustomAgentsAsync() async { + guard let modes = await SharedChatService.shared.loadConversationModes() else { + // Fallback: create default built-in modes when server returns nil + builtInAgents = [.defaultAgent] + customAgents = [] + return + } + + // Filter built-in modes (exclude Edit) + builtInAgents = modes.filter { $0.isBuiltIn && $0.kind == .Agent } + + // Filter for custom agent modes (non-built-in) + customAgents = modes.filter { !$0.isBuiltIn && $0.kind == .Agent } + } + + private func deleteCustomAgent(_ agent: ConversationMode) { + agentToDelete = agent + showDeleteConfirmation = true + } + + private func performDelete() { + guard let agent = agentToDelete, + let uriString = agent.uri, + let fileURL = URL(string: uriString) else { + return + } + + do { + try FileManager.default.removeItem(at: fileURL) + loadCustomAgents() + } catch { + // Error handling + } + agentToDelete = nil + } + + private func openAgentFileInXcode(_ agent: ConversationMode) { + guard let uriString = agent.uri, let fileURL = URL(string: uriString) else { + return + } + + NSWorkspace.openFileInXcode(fileURL: fileURL) + } + + private func createNewAgent() { + isCreateSheetPresented = true + } + + private var displayName: String { + return selectedAgent.name + } + + private var displayIconName: String? { + // Custom agents don't have icons + if !selectedAgent.isBuiltIn { + return nil + } + // Use checklist icon for Plan, Agent icon for others + return AgentModeIcon.icon(for: selectedAgent.name) + } + + public var body: some View { + VStack { + if isAgentModeFFEnabled { + HStack(spacing: -1) { + ModeButton( + title: "Ask", + isSelected: chatMode == ChatMode.Ask.rawValue, + activeBackground: colorScheme == .dark ? Color.white.opacity(0.25) : Color.white, + activeTextColor: Color.primary, + inactiveTextColor: Color.primary.opacity(0.5), + action: { + setAskMode() + } + ) + + AgentModeButton( + title: displayName, + isSelected: chatMode == ChatMode.Agent.rawValue, + activeBackground: Color.accentColor, + activeTextColor: Color.white, + inactiveTextColor: Color.primary.opacity(0.5), + chatMode: chatMode, + builtInAgentModes: builtInAgents, + customAgents: customAgents, + selectedAgent: selectedAgent, + selectedIconName: displayIconName, + isCustomAgentEnabled: isEditorPreviewFFEnabled && isCustomAgentPolicyEnabled, + onSelectAgent: { setAgentMode($0) }, + onEditAgent: { openAgentFileInXcode($0) }, + onDeleteAgent: { deleteCustomAgent($0) }, + onCreateAgent: { createNewAgent() } + ) + } + .scaledPadding(1) + .scaledFrame(height: 22, alignment: .topLeading) + .background(.primary.opacity(0.1)) + .cornerRadius(16) + .padding(4) + .help("Set Agent") + } else { + EmptyView() + } + } + .task { + subscribeToFeatureFlagsDidChangeEvent() + subscribeToPolicyDidChangeEvent() + await loadCustomAgentsAsync() + loadSelectedAgentSubMode() + if !isAgentModeFFEnabled { + setAskMode() + } + } + .onChange(of: isAgentModeFFEnabled) { newAgentModeFFEnabled in + if !newAgentModeFFEnabled { + setAskMode() + } + } + .onChange(of: isEditorPreviewFFEnabled) { newValue in + // If editor preview is disabled and current agent is not the default agent, reset to default + if !newValue && chatMode == ChatMode.Agent.rawValue && !selectedAgent.isDefaultAgent { + let defaultAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent + setAgentMode(defaultAgent) + } + } + .onChange(of: isCustomAgentPolicyEnabled) { newValue in + // If custom agent policy is disabled and current agent is not the default agent, reset to default + if !newValue && chatMode == ChatMode.Agent.rawValue && !selectedAgent.isDefaultAgent { + let defaultAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent + setAgentMode(defaultAgent) + } + } + // Minimal refresh: when app becomes active (e.g. user returns from editing an agent file in Xcode) + // Reload custom agents to pick up external changes without adding complex file monitoring. + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + loadCustomAgents() + } + .onChange(of: selectedAgent) { newAgent in + // When selectedAgent changes externally (e.g., from handoff), + // call setAgentMode to trigger all side effects + // Guard: only trigger if we're not already in the correct state to avoid redundant work + guard chatMode != ChatMode.Agent.rawValue || + AppState.shared.getSelectedAgentSubMode() != newAgent.id else { + return + } + setAgentMode(newAgent) + } + .sheet(isPresented: $isCreateSheetPresented) { + CreateCustomCopilotFileView( + promptType: .agent, + editorPluginVersion: SystemUtils.editorPluginVersionString, + getCurrentProjectURL: { projectRootURL }, + onSuccess: { _ in + loadCustomAgents() + }, + onError: { _ in + // Handle error silently or log it + } + ) + } + .confirmationDialog( + // `agentToDelete` should always be non-nil, adding fallback for compilation safety + "Are you sure you want to delete '\(agentToDelete?.name ?? "Agent")'?", + isPresented: $showDeleteConfirmation + ) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { performDelete() } + } + } + + private func loadSelectedAgentSubMode() { + let subMode = AppState.shared.getSelectedAgentSubMode() + + // Try to find the agent + if let agent = findAgent(byId: subMode) { + // If it's not the default agent and custom agents are disabled, reset to default + if !agent.isDefaultAgent && (!isEditorPreviewFFEnabled || !isCustomAgentPolicyEnabled) { + selectedAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent + AppState.shared.setSelectedAgentSubMode("Agent") + return + } + selectedAgent = agent + return + } + + // Default to Agent mode if nothing matches + selectedAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent + } + + private func findAgent(byId id: String) -> ConversationMode? { + // Check built-in agents first + if let builtIn = builtInAgents.first(where: { $0.id == id }) { + return builtIn + } + // Check custom agents + if let custom = customAgents.first(where: { $0.id == id }) { + return custom + } + return nil + } +} diff --git a/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift similarity index 83% rename from Core/Sources/ConversationTab/ModelPicker/ModeButton.swift rename to Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift index b204e04c..7964d448 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedUIComponents public struct ModeButton: View { let title: String @@ -11,12 +12,13 @@ public struct ModeButton: View { public var body: some View { Button(action: action) { Text(title) - .padding(.horizontal, 6) - .padding(.vertical, 0) + .scaledFont(size: 12) + .scaledPadding(.horizontal, 6) + .scaledPadding(.vertical, 2) .frame(maxHeight: .infinity, alignment: .center) .background(isSelected ? activeBackground : Color.clear) .foregroundColor(isSelected ? activeTextColor : inactiveTextColor) - .cornerRadius(5) + .cornerRadius(16) .shadow(color: .black.opacity(0.05), radius: 0.375, x: 0, y: 1) .shadow(color: .black.opacity(0.15), radius: 0.125, x: 0, y: 0.25) .overlay( diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift new file mode 100644 index 00000000..184bfe83 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift @@ -0,0 +1,276 @@ +import Foundation +import Combine +import Persist +import GitHubCopilotService +import ConversationServiceProvider + +public let SELECTED_LLM_KEY = "selectedLLM" +public let SELECTED_CHATMODE_KEY = "selectedChatMode" +public let SELECTED_AGENT_SUBMODE_KEY = "selectedAgentSubMode" + +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) + DispatchQueue.main.async { + 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" + } + + func getSelectedAgentSubMode() -> String { + if let savedSubMode = get(key: SELECTED_AGENT_SUBMODE_KEY), + let subMode = savedSubMode.stringValue { + return subMode + } + // Default to "Agent" + return "Agent" + } + + func setSelectedAgentSubMode(_ subMode: String) { + update(key: SELECTED_AGENT_SUBMODE_KEY, value: subMode) + } + + 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 && !$0.isAutoModel }) + // 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(where: { !$0.isAutoModel }) { + 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 { + public let displayName: String? + public let modelName: String + public let modelFamily: String + public let billing: CopilotModelBilling? + public let providerName: String? + public 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 extension LLMModel { + /// Apply to `Copilot Models` + var isPremiumModel: Bool { billing?.isPremium == true } + /// Apply to `Copilot Models` + var isStandardModel: Bool { !isPremiumModel || billing == nil } + /// Apply to `Copilot Models` + var isAutoModel: Bool { isStandardModel && modelName == "Auto" } +} + +extension CopilotModel { + var isAutoModel: Bool { modelName == "Auto" } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift new file mode 100644 index 00000000..7b32efc8 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift @@ -0,0 +1,87 @@ +import AppKit +import Foundation + +public struct ScopeCache { + var modelMultiplierCache: [String: String] = [:] + var cachedMaxWidth: CGFloat = 0 + var lastModelsHash: Int = 0 +} + +// MARK: - Model Menu Item Formatting +public struct ModelMenuItemFormatter { + public static let minimumPadding: Int = 48 + + public static let attributes: [NSAttributedString.Key: NSFont] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] + + public static var spaceWidth: CGFloat { + "\u{200A}".size(withAttributes: attributes).width + } + + public static var minimumPaddingWidth: CGFloat { + spaceWidth * CGFloat(minimumPadding) + } + + /// Creates an attributed string for model menu items with proper spacing and formatting + public static func createModelMenuItemAttributedString( + modelName: String, + isSelected: Bool, + multiplierText: String, + targetWidth: CGFloat? = nil + ) -> AttributedString { + let displayName = isSelected ? "✓ \(modelName)" : " \(modelName)" + + var fullString = displayName + var attributedString = AttributedString(fullString) + + if !multiplierText.isEmpty { + let displayNameWidth = displayName.size(withAttributes: attributes).width + let multiplierTextWidth = multiplierText.size(withAttributes: attributes).width + + // Calculate padding needed + let neededPaddingWidth: CGFloat + + if let targetWidth = targetWidth { + neededPaddingWidth = targetWidth - displayNameWidth - multiplierTextWidth + } else { + neededPaddingWidth = minimumPaddingWidth + } + + let finalPaddingWidth = max(neededPaddingWidth, minimumPaddingWidth) + let numberOfSpaces = Int(round(finalPaddingWidth / spaceWidth)) + let padding = String(repeating: "\u{200A}", count: max(minimumPadding, numberOfSpaces)) + fullString = "\(displayName)\(padding)\(multiplierText)" + + attributedString = AttributedString(fullString) + + if let range = attributedString.range( + of: multiplierText, + options: .backwards + ) { + attributedString[range].foregroundColor = .secondary + } + } + + return attributedString + } + + /// Gets the multiplier text for a model (e.g., "2x", "Included", provider name, or "Variable") + public static func getMultiplierText(for model: LLMModel) -> String { + if model.isAutoModel { + return "Variable" + } else if let billing = model.billing { + let multiplier = billing.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" + } + } else if let providerName = model.providerName, !providerName.isEmpty { + return providerName + } else { + return "" + } + } +} diff --git a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift b/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift deleted file mode 100644 index 94cd8051..00000000 --- a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift +++ /dev/null @@ -1,95 +0,0 @@ -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.agentMode - } - - 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.agentMode - }) - .store(in: &cancellables) - } - - public var body: some View { - 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: { - 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) - } - } - } -} diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift deleted file mode 100644 index 0f76adea..00000000 --- a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift +++ /dev/null @@ -1,522 +0,0 @@ -import SwiftUI -import ChatService -import Persist -import ComposableArchitecture -import GitHubCopilotService -import Combine -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 isHovered = false - @State private var isPressed = false - @ObservedObject private var modelManager = CopilotModelManagerObservable.shared - static var lastRefreshModelsTime: Date = .init(timeIntervalSince1970: 0) - - @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() - - @State var isMCPFFEnabled: Bool - @State private var cancellables = Set() - - 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 ?? "" - 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 - } - - var defaultModel: LLMModel? { - 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 ?? "" - } - - func updateAgentPicker() { - self.chatMode = AppState.shared.getSelectedChatMode() - } - - func switchModelsForScope(_ scope: PromptTemplateScope) { - let newModeModels = CopilotModelManager.getAvailableChatLLMs(scope: scope) - - if let currentModel = AppState.shared.getSelectedModelName() { - if !newModeModels.isEmpty && !newModeModels.contains(where: { $0.modelName == currentModel }) { - let defaultModel = CopilotModelManager.getDefaultChatModel(scope: scope) - if let defaultModel = defaultModel { - AppState.shared.setSelectedModel(defaultModel) - } else { - AppState.shared.setSelectedModel(newModeModels[0]) - } - } - } - - self.updateCurrentModel() - updateModelCacheIfNeeded(for: scope) - } - - // 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) - - if standardModels.isEmpty { - Link("Add Premium Models", destination: URL(string: "https://aka.ms/github-copilot-upgrade-plan")!) - } - } - .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, - cachedMultiplierText: currentCache.modelMultiplierCache[model.modelName] ?? "" - )) - } - } - - private var mcpButton: some View { - 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.") - } - } - .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 { - HStack(spacing: 0) { - // Custom segmented control with color change - ChatModePicker(chatMode: $chatMode, onScopeChange: switchModelsForScope) - .onAppear() { - updateAgentPicker() - } - - if chatMode == "Agent" { - mcpButton - } - - // Model Picker - Group { - if !models.isEmpty && !selectedModel.isEmpty { - modelPickerMenu - } else { - EmptyView() - } - } - } - .onAppear() { - updateCurrentModel() - // Initialize both caches - updateModelCacheIfNeeded(for: .chatPanel) - updateModelCacheIfNeeded(for: .agentPanel) - Task { - await refreshModels() - } - } - .onChange(of: defaultModel) { _ in - updateCurrentModel() - } - .onChange(of: modelManager.availableChatModels) { _ in - updateCurrentModel() - updateModelCacheIfNeeded(for: .chatPanel) - } - .onChange(of: modelManager.availableAgentModels) { _ in - updateCurrentModel() - updateModelCacheIfNeeded(for: .agentPanel) - } - .onChange(of: chatMode) { _ in - updateCurrentModel() - } - .onReceive(NotificationCenter.default.publisher(for: .gitHubCopilotSelectedModelDidChange)) { _ in - updateCurrentModel() - } - .task { - subscribeToFeatureFlagsDidChangeEvent() - } - } - } - - func labelWidth() -> CGFloat { - let width = selectedModel.size(withAttributes: attributes).width - return CGFloat(width + 20) - } - - @MainActor - func refreshModels() async { - let now = Date() - if now.timeIntervalSince(Self.lastRefreshModelsTime) < 60 { - return - } - - Self.lastRefreshModelsTime = now - let copilotModels = await SharedChatService.shared.copilotModels() - if !copilotModels.isEmpty { - CopilotModelManager.updateLLMs(copilotModels) - } - } - - private func createModelMenuItemAttributedString( - modelName: String, - isSelected: Bool, - cachedMultiplierText: String - ) -> AttributedString { - let displayName = isSelected ? "✓ \(modelName)" : " \(modelName)" - - var fullString = displayName - var attributedString = AttributedString(fullString) - - if !cachedMultiplierText.isEmpty { - let displayNameWidth = displayName.size(withAttributes: attributes).width - let multiplierTextWidth = cachedMultiplierText.size(withAttributes: attributes).width - let neededPaddingWidth = currentCache.cachedMaxWidth - displayNameWidth - multiplierTextWidth - let finalPaddingWidth = max(neededPaddingWidth, minimumPaddingWidth) - - 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: cachedMultiplierText) { - attributedString[range].foregroundColor = .secondary - } - } - - return attributedString - } -} - -struct ModelPicker_Previews: PreviewProvider { - static var previews: some View { - ModelPicker() - } -} diff --git a/Core/Sources/ConversationTab/Styles.swift b/Core/Sources/ConversationTab/Styles.swift index 0306e4c7..eca980d5 100644 --- a/Core/Sources/ConversationTab/Styles.swift +++ b/Core/Sources/ConversationTab/Styles.swift @@ -36,6 +36,7 @@ extension NSAppearance { extension View { var messageBubbleCornerRadius: Double { 8 } var hoverableImageCornerRadius: Double { 4 } + var inputAreaTextEditorCornerRadius: Double { 12 } func codeBlockLabelStyle() -> some View { relativeLineSpacing(.em(0.225)) @@ -52,7 +53,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 +72,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) + } } } } @@ -181,9 +184,33 @@ 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( + 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 + +struct CodeReviewCardBackground: View { + var body: some View { + RoundedRectangle(cornerRadius: 12) + .stroke(.black.opacity(0.17), lineWidth: 1) + .background(Color.gray.opacity(0.05)) + } +} + +struct CodeReviewHeaderBackground: View { + var body: some View { + RoundedRectangle(cornerRadius: 12) + .stroke(.black.opacity(0.17), lineWidth: 1) + .background(Color.gray.opacity(0.1)) + } +} diff --git a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift index c75e864e..777b2cfc 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(size: chatFontSize, weight: .semibold) .foregroundStyle(.primary) .background(Color.clear) .frame(maxWidth: .infinity, alignment: .leading) @@ -83,7 +82,7 @@ struct RunInTerminalToolView: View { toolView } - .padding(8) + .scaledPadding(8) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) @@ -119,13 +118,15 @@ struct RunInTerminalToolView: View { if command != nil { HStack(spacing: 4) { statusIcon - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) Text(command!) + .lineLimit(nil) .textSelection(.enabled) - .font(.system(size: chatFontSize, design: .monospaced)) - .padding(8) + .scaledFont(size: chatFontSize, design: .monospaced) + .scaledPadding(8) .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) .foregroundStyle(codeForegroundColor) .background(codeBackgroundColor) .clipShape(RoundedRectangle(cornerRadius: 6)) @@ -142,23 +143,29 @@ struct RunInTerminalToolView: View { terminalSession: terminalSession, onTerminalInput: terminalSession.handleTerminalInput ) - .frame(minHeight: 200, maxHeight: 400) + .scaledFrame(minHeight: 200, maxHeight: 400) } else if tool.status == .waitForConfirmation { ThemedMarkdownText(text: explanation ?? "", chat: chat) .frame(maxWidth: .infinity, alignment: .leading) HStack { - Button("Cancel") { + Button(action: { chat.send(.toolCallCancelled(tool.id)) + }) { + Text("Skip") + .scaledFont(.body) } - - Button("Continue") { + + Button(action: { chat.send(.toolCallAccepted(tool.id)) + }) { + Text("Allow") + .scaledFont(.body) } .buttonStyle(BorderedProminentButtonStyle()) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) + .scaledPadding(.top, 4) } } } diff --git a/Core/Sources/ConversationTab/ViewExtension.swift b/Core/Sources/ConversationTab/ViewExtension.swift index 912c9687..181f3cbd 100644 --- a/Core/Sources/ConversationTab/ViewExtension.swift +++ b/Core/Sources/ConversationTab/ViewExtension.swift @@ -1,4 +1,5 @@ import SwiftUI +import ComposableArchitecture let ITEM_SELECTED_COLOR = Color("ItemSelectedColor") @@ -82,4 +83,58 @@ extension View { public func hoverSecondaryForeground(isHovered: Bool) -> some View { self.hoverForeground(isHovered: isHovered, defaultColor: .secondary) } + + // MARK: - Editor Mode + + /// Dims the view when in edit mode and provides tap/keyboard exit functionality + /// - Parameters: + /// - chat: The chat store + /// - messageId: Optional message ID to determine if this specific message should be dimmed + /// - isDimmed: Whether this view should be dimmed (defaults to true when editing affects this view) + /// - allowTapToExit: Whether tapping on this view should exit edit mode (defaults to true) + func dimWithExitEditMode( + _ chat: StoreOf, + applyTo messageId: String? = nil, + isDimmed: Bool? = nil, + allowTapToExit: Bool = true + ) -> some View { + let editUserMessageEffectedMessageIds = chat.editUserMessageEffectedMessages.map { $0.id } + let shouldDim = isDimmed ?? { + guard chat.editorMode.isEditingUserMessage else { return false } + guard let messageId else { return true } + return editUserMessageEffectedMessageIds.contains(messageId) + }() + + let isInEditMode = chat.editorMode.isEditingUserMessage + let shouldAllowTapExit = allowTapToExit && isInEditMode + + return self + .opacity(shouldDim && isInEditMode ? 0.5 : 1) + .overlay( + Group { + if shouldAllowTapExit { + Color.clear + .contentShape(Rectangle()) // Ensure the entire area is tappable + .onTapGesture { + if shouldAllowTapExit { + chat.send(.setEditorMode(.input)) + } + } + } + } + ) + .background( + // Global escape key handler - only add once per view hierarchy + Group { + if isInEditMode { + Button("") { + chat.send(.setEditorMode(.input)) + } + .keyboardShortcut(.escape, modifiers: []) + .opacity(0) + .accessibilityHidden(true) + } + } + ) + } } diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index 2f0bf835..65bbc537 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -7,50 +7,29 @@ import SwiftUI import ConversationServiceProvider import ChatTab import ChatAPIService +import HostAppActivator struct BotMessage: View { var r: Double { messageBubbleCornerRadius } - let id: String - let text: String - let references: [ConversationReference] - let followUp: ConversationFollowUp? - let errorMessages: [String] + let message: DisplayedChatMessage let chat: StoreOf - let steps: [ConversationProgressStep] - let editAgentRounds: [AgentRound] - let panelMessages: [CopilotShowMessageParams] + var id: String { + message.id + } + var text: String { message.text } + var references: [ConversationReference] { message.references } + var followUp: ConversationFollowUp? { message.followUp } + var errorMessages: [String] { message.errorMessages } + var steps: [ConversationProgressStep] { message.steps } + var editAgentRounds: [AgentRound] { message.editAgentRounds } + var panelMessages: [CopilotShowMessageParams] { message.panelMessages } + var codeReviewRound: CodeReviewRound? { message.codeReviewRound } @Environment(\.colorScheme) var colorScheme @AppStorage(\.chatFontSize) var chatFontSize @State var isReferencesPresented = false - - struct ResponseToolBar: View { - let id: String - let chat: StoreOf - let text: String - - var body: some View { - HStack(spacing: 4) { - - UpvoteButton { rating in - chat.send(.upvote(id, rating)) - } - - DownvoteButton { rating in - chat.send(.downvote(id, rating)) - } - - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - chat.send(.copyCode(id)) - } - - Spacer() // Pushes the buttons to the left - } - } - } + @State var isHovering = false struct ReferenceButton: View { var r: Double { messageBubbleCornerRadius } @@ -73,144 +52,212 @@ struct BotMessage: View { return title } + var referenceIcon: some View { + Group { + if !isReferencesPresented { + HStack(alignment: .center, spacing: 0) { + Image(systemName: "chevron.right") + } + .scaledPadding(.leading, 4) + .scaledPadding(.trailing, 3) + .scaledPadding(.vertical, 1.5) + } else { + HStack(alignment: .center, spacing: 0) { + Image(systemName: "chevron.down") + } + .scaledPadding(.top, 4) + .scaledPadding(.bottom, 3) + .scaledPadding(.horizontal, 1.5) + + } + } + .scaledFont(size: chatFontSize - 1, weight: .medium) + .scaledFrame(width: 16, height: 16, alignment: .center) + } + var body: some View { - VStack(alignment: .leading, spacing: 8) { - Button(action: { - isReferencesPresented.toggle() - }, label: { - HStack(spacing: 4) { - Image(systemName: isReferencesPresented ? "chevron.down" : "chevron.right") + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + Button(action: { + isReferencesPresented.toggle() + }, label: { + HStack(spacing: 4) { + referenceIcon - Text(MakeReferenceTitle(references: references)) - .font(.system(size: chatFontSize)) - } + Text(MakeReferenceTitle(references: references)) + .scaledFont(size: chatFontSize - 1) + } + .foregroundStyle(.secondary) + }) + .buttonStyle(.plain) + .padding(.vertical, 4) + .padding(.trailing, 4) .background { RoundedRectangle(cornerRadius: r - 4) - .fill(isReferencesHovered ? Color.gray.opacity(0.1) : Color.clear) + .fill(isReferencesHovered ? Color.gray.opacity(0.2) : Color.clear) } - .foregroundStyle(.secondary) - }) - .buttonStyle(HoverButtonStyle()) - .accessibilityValue(isReferencesPresented ? "Collapse" : "Expand") - - if isReferencesPresented { - ReferenceList(references: references, chat: chat) - .background( - RoundedRectangle(cornerRadius: 5) - .stroke(Color.gray, lineWidth: 0.2) - ) + .accessibilityValue(isReferencesPresented ? "Collapse" : "Expand") + + if isReferencesPresented { + ReferenceList(references: references, chat: chat) + .background( + RoundedRectangle(cornerRadius: 5) + .stroke(Color.gray, lineWidth: 0.2) + ) + } + } + .onHover { + isReferencesHovered = $0 } + + Spacer() } } } - - private var agentWorkingStatus: some View { - HStack(spacing: 4) { - ProgressView() - .controlSize(.small) - .frame(width: 20, height: 16) - .scaleEffect(0.7) - - Text("Working...") - .font(.system(size: chatFontSize)) - .foregroundColor(.secondary) - } - } var body: some View { - HStack { - VStack(alignment: .leading, spacing: 8) { - CopilotMessageHeader() - .padding(.leading, 6) - - if !references.isEmpty { - WithPerceptionTracking { - ReferenceButton( - references: references, - chat: chat, - isReferencesPresented: $isReferencesPresented + WithPerceptionTracking { + HStack { + VStack(alignment: .leading, spacing: 8) { + if !references.isEmpty { + WithPerceptionTracking { + ReferenceButton( + references: references, + chat: chat, + isReferencesPresented: $isReferencesPresented + ) + } + } + + // progress step + if steps.count > 0 { + 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) + } + + if !text.isEmpty { + Group{ + ThemedMarkdownText(text: text, chat: chat) + } + .scaledPadding(.leading, 2) + .scaledPadding(.vertical, 4) + } + + if let codeReviewRound = codeReviewRound { + CodeReviewMainView( + store: chat, round: codeReviewRound ) + .frame(maxWidth: .infinity) } - } - - // progress step - if steps.count > 0 { - ProgressStep(steps: steps) - } - - if !panelMessages.isEmpty { - WithPerceptionTracking { - ForEach(panelMessages.indices, id: \.self) { index in - FunctionMessage(text: panelMessages[index].message, chat: chat) + + if !errorMessages.isEmpty { + buildErrorMessageView() + } + + HStack { + if shouldShowTurnStatus() { + TurnStatusView(message: message) } + + Spacer() + + ResponseToolBar( + id: id, + chat: chat, + text: text, + message: message + ) + .conditionalFontWeight(.medium) + .opacity(shouldShowToolBar() ? 1 : 0) + .scaledPadding(.trailing, -20) } } - - if editAgentRounds.count > 0 { - ProgressAgentRound(rounds: editAgentRounds, chat: chat) + .padding(.leading, message.parentTurnId != nil ? 4 : 0) + .shadow(color: .black.opacity(0.05), radius: 6) + .contextMenu { + Button("Copy") { + 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) } - - if !text.isEmpty { - ThemedMarkdownText(text: text, chat: chat) + .onHover { + isHovering = $0 } - - 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) + } + } + } + + @ViewBuilder + private func buildErrorMessageView() -> some View { + VStack(spacing: 4) { + ForEach(errorMessages.indices, id: \.self) { index in + if let attributedString = try? AttributedString(markdown: errorMessages[index]) { + NotificationBanner(style: .warning) { + VStack(alignment: .leading, spacing: 4) { + Text(attributedString) + + if errorMessages[index] == HardCodedToolRoundExceedErrorMessage { + Button(action: { + Task { + try? launchHostAppAdvancedSettings() + } + }) { + Text("Open Settings") } + .buttonStyle(.link) } } } } - - if shouldShowWorkingStatus() { - agentWorkingStatus - } - - if shouldShowToolBar() { - ResponseToolBar(id: id, chat: chat, text: text) - } - } - .shadow(color: .black.opacity(0.05), radius: 6) - .contextMenu { - Button("Copy") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - - Button("Set as Extra System Prompt") { - chat.send(.setAsExtraPromptButtonTapped(id)) - } - - Divider() - - Button("Delete") { - chat.send(.deleteMessageButtonTapped(id)) - } } } + .scaledPadding(.vertical, 4) } - private func shouldShowWorkingStatus() -> Bool { - let hasRunningStep: Bool = steps.contains(where: { $0.status == .running }) - let hasRunningRound: Bool = editAgentRounds.contains(where: { round in - return round.toolCalls?.contains(where: { $0.status == .running }) ?? false - }) - - if hasRunningStep || hasRunningRound { + private func shouldShowTurnStatus() -> Bool { + guard isLatestAssistantMessage() else { return false } - // Only show working status for the current bot message being received - return chat.isReceivingMessage && isLatestAssistantMessage() + if steps.isEmpty && editAgentRounds.isEmpty { + return true + } + + if !steps.isEmpty { + return !message.text.isEmpty + } + + return true } private func shouldShowToolBar() -> Bool { // Always show toolbar for historical messages - if !isLatestAssistantMessage() { return true } + if !isLatestAssistantMessage() { return isHovering } // For current message, only show toolbar when message is complete return !chat.isReceivingMessage @@ -247,15 +294,15 @@ 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) + .scaledFrame(width: 14, height: 14) Text(reference.fileName) .truncationMode(.middle) .lineLimit(1) .layoutPriority(1) - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize - 1) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -293,6 +340,74 @@ struct ReferenceList: View { } } +private struct TurnStatusView: View { + + let message: DisplayedChatMessage + + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + HStack(spacing: 0) { + if let turnStatus = message.turnStatus { + switch turnStatus { + case .inProgress: + inProgressStatus + case .success: + completedStatus + case .cancelled: + cancelStatus + case .error: + EmptyView() + case .waitForConfirmation: + waitForConfirmationStatus + } + } + } + } + + private var inProgressStatus: some View { + HStack(spacing: 4) { + ProgressView() + .controlSize(.small) + .scaledFont(size: chatFontSize - 1) + .conditionalFontWeight(.medium) + + Text("Generating...") + .scaledFont(size: chatFontSize - 1) + .foregroundColor(.secondary) + } + } + + private var completedStatus: some View { + statusView(icon: "checkmark.circle.fill", iconColor: .successLightGreen, text: "Completed") + } + + private var waitForConfirmationStatus: some View { + statusView(icon: "clock.fill", iconColor: .brown, text: "Waiting for your response") + } + + private var cancelStatus: some View { + statusView(icon: "slash.circle", iconColor: .secondary, text: "Stopped") + } + + private var errorStatus: some View { + statusView(icon: "xmark.circle.fill", iconColor: .red, text: "Error Occurred") + } + + private func statusView(icon: String, iconColor: Color, text: String) -> some View { + HStack(spacing: 4) { + Image(systemName: icon) + .scaledFont(size: chatFontSize) + .foregroundColor(iconColor) + .conditionalFontWeight(.medium) + + Text(text) + .scaledFont(size: chatFontSize - 1) + .foregroundColor(.secondary) + } + } +} + struct BotMessage_Previews: PreviewProvider { static let steps: [ConversationProgressStep] = [ .init(id: "001", title: "running step", description: "this is running step", status: .running, error: nil), @@ -322,29 +437,38 @@ struct BotMessage_Previews: PreviewProvider { static var previews: some View { let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") BotMessage( - id: "1", - text: """ - **Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you? - ```swift - func foo() {} - ``` - """, - references: .init(repeating: .init( - uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", - status: .included, - kind: .class - ), count: 2), - followUp: ConversationFollowUp(message: "followup question", id: "id", type: "type"), - errorMessages: ["Sorry, an error occurred while generating a response."], + message: .init( + id: "1", + role: .assistant, + text: """ + **Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you? + ```swift + func foo() {} + ``` + """, + references: .init( + repeating: .init( + uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", + status: .included, + kind: .class, + referenceType: .file), + count: 2 + ), + followUp: ConversationFollowUp(message: "followup question", id: "id", type: "type"), + errorMessages: ["Sorry, an error occurred while generating a response."], + steps: steps, + editAgentRounds: agentRounds, + panelMessages: [], + codeReviewRound: nil, + requestType: .conversation + ), chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }), - steps: steps, - editAgentRounds: agentRounds, - panelMessages: [] ) .padding() .fixedSize(horizontal: true, vertical: true) } } + struct ReferenceList_Previews: PreviewProvider { static var previews: some View { @@ -353,32 +477,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/BotMessage/ResponseToolBar.swift b/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift new file mode 100644 index 00000000..108f2f64 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift @@ -0,0 +1,65 @@ +import SwiftUI +import ComposableArchitecture +import SharedUIComponents + +struct ResponseToolBar: View { + let id: String + let chat: StoreOf + let text: String + let message: DisplayedChatMessage + @AppStorage(\.chatFontSize) var chatFontSize + + var billingMultiplier: String? { + guard let multiplier = message.billingMultiplier else { + return nil + } + let rounded = (multiplier * 100).rounded() / 100 + let formatter = NumberFormatter() + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 2 + formatter.numberStyle = .decimal + let formattedMultiplier = formatter.string(from: NSNumber(value: rounded)) ?? "\(rounded)" + return "\(formattedMultiplier)x" + } + + var modelNameAndMultiplierText: String? { + guard let modelName = message.modelName else { + return nil + } + + var text = modelName + + if let billingMultiplier = billingMultiplier { + text += " • \(billingMultiplier)" + } + + return text + } + + var body: some View { + HStack(spacing: 8) { + + if let modelNameAndMultiplierText = modelNameAndMultiplierText { + Text(modelNameAndMultiplierText) + .scaledFont(size: chatFontSize - 1) + .lineLimit(1) + .foregroundColor(.secondary) + .help(modelNameAndMultiplierText) + } + + UpvoteButton { rating in + chat.send(.upvote(id, rating)) + } + + DownvoteButton { rating in + chat.send(.downvote(id, rating)) + } + + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + chat.send(.copyCode(id)) + } + } + } +} diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift new file mode 100644 index 00000000..251f4022 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift @@ -0,0 +1,41 @@ +import SwiftUI +import ComposableArchitecture + +struct ChatPanelInputArea: View { + let chat: StoreOf + let r: Double + let editorMode: Chat.EditorMode + @FocusState var focusedField: Chat.State.Field? + + var body: some View { + HStack { + InputAreaTextEditor(chat: chat, r: r, focusedField: $focusedField, editorMode: editorMode) + } + .background(Color.clear) + } + + @MainActor + var clearButton: some View { + Button(action: { + chat.send(.clearButtonTap) + }) { + Group { + if #available(macOS 13.0, *) { + Image(systemName: "eraser.line.dashed.fill") + .scaledFont(.body) + } else { + Image(systemName: "trash.fill") + .scaledFont(.body) + } + } + .padding(6) + .background { + Circle().fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1) + } + } + .buttonStyle(.plain) + } +} diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift new file mode 100644 index 00000000..f3c7c019 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift @@ -0,0 +1,652 @@ +import ChatService +import ComposableArchitecture +import Combine +import ConversationServiceProvider +import SwiftUIFlowLayout +import GitHubCopilotService +import GitHubCopilotViewModel +import LanguageServerProtocol +import Preferences +import SharedUIComponents +import Status +import SwiftUI +import Workspace +import XcodeInspector + +enum ShowingType { case template, agent } + +struct InputAreaTextEditor: View { + @Perception.Bindable var chat: StoreOf + let r: Double + var focusedField: FocusState.Binding + let editorMode: Chat.EditorMode + @State var cancellable = Set() + @State private var isFilePickerPresented = false + @State private var allFiles: [ConversationAttachedReference]? = nil + @State private var filteredTemplates: [ChatTemplate] = [] + @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 + ) + @ObservedObject private var status: StatusObserver = .shared + @State private var isCCRFFEnabled: Bool + @State private var cancellables = Set() + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + init( + chat: StoreOf, + r: Double, + focusedField: FocusState.Binding, + editorMode: Chat.EditorMode + ) { + self.chat = chat + self.r = r + self.focusedField = focusedField + self.editorMode = editorMode + self.isCCRFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.ccr + } + + var isEditorActive: Bool { + editorMode == chat.editorMode + } + + 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 projectRootURL: URL? { + WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: chat.workspaceURL, + documentURL: chat.state.currentEditor?.url + ) + } + + var body: some View { + WithPerceptionTracking { + let typedMessage = chat.state.getChatContext(of: editorMode).typedMessage + VStack(spacing: 0) { + chatContextView + + if isFilePickerPresented { + FilePicker( + allFiles: $allFiles, + workspaceURL: chat.workspaceURL, + onSubmit: { ref in + chat.send(.addReference(ref)) + }, + onExit: { + isFilePickerPresented = false + focusedField.wrappedValue = .textField + } + ) + .onAppear() { + allFiles = ContextUtils.getFilesFromWorkspaceIndex(workspaceURL: chat.workspaceURL) + } + } + + if !chat.state.attachedImages.isEmpty { + ImagesScrollView(chat: chat, editorMode: editorMode) + } + + ZStack(alignment: .topLeading) { + if typedMessage.isEmpty { + Group { + chat.isAgentMode ? + Text("Edit files in your workspace in agent mode") : + Text("Ask Copilot or type / for commands") + } + .scaledFont(size: 14) + .foregroundColor(Color(nsColor: .placeholderTextColor)) + .padding(8) + .padding(.horizontal, 4) + } + + HStack(spacing: 0) { + AutoresizingCustomTextEditor( + text: Binding( + get: { typedMessage }, + set: { newValue in chat.send(.updateTypedMessage(newValue)) } + ), + font: .systemFont(ofSize: 14 * fontScale), + isEditable: true, + maxHeight: 400, + onSubmit: { + if (dropDownShowingType == nil) { + submitChatMessage() + } + dropDownShowingType = nil + }, + onTextEditorStateChanged: { (state: TextEditorState?) in + DispatchQueue.main.async { + textEditorState = state + } + } + ) + .focused(focusedField, equals: isEditorActive ? .textField : nil) + .bind($chat.focusedField, to: focusedField) + .padding(8) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: typedMessage) { newValue in + Task { + await onTypedMessageChanged(newValue: newValue) + } + } + /// When chat mode changed, the chat tamplate and agent need to be reloaded + .onChange(of: chat.isAgentMode) { _ in + guard isEditorActive else { return } + Task { + await onTypedMessageChanged(newValue: typedMessage) + } + } + } + .frame(maxWidth: .infinity) + } + .padding(.top, 4) + + HStack(spacing: 0) { + ModeAndModelPicker(projectRootURL: projectRootURL, selectedAgent: $chat.selectedAgent) + + Spacer() + + if chat.editorMode.isDefault { + 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) + } + .overlay(alignment: .top) { + dropdownOverlay + } + .onAppear() { + guard editorMode.isDefault else { return } + 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) + .fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(.quaternary, lineWidth: 1) + } + .background { + if isEditorActive { + Button(action: { + chat.send(.returnButtonTapped) + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) + .accessibilityHidden(true) + + Button(action: { + focusedField.wrappedValue = .textField + }) { + EmptyView() + } + .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() + }) { + Image(systemName: "paperplane.fill") + .scaledFont(.body) + .padding(4) + } + .keyboardShortcut(KeyEquivalent.return, modifiers: []) + .help("Send") + } + + private var stopButton: some View { + Button(action: { + chat.send(.stopRespondingButtonTapped) + }) { + Image(systemName: "stop.circle") + .scaledFont(.body) + .padding(4) + } + } + + private var isFreeUser: Bool { + guard let quotaInfo = status.quotaInfo else { return true } + + return quotaInfo.isFreeUser + } + + private var ccrDisabledTooltip: String { + 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") + .resizable() + .scaledToFit() + .scaledFrame(width: 14, height: 14) + .padding(6) + } + + private var codeReviewButton: some View { + Group { + if isFreeUser { + // Show nothing + } else if isCCRFFEnabled { + 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 + } + .scaledFont(.body) + .opacity(isRequestingCodeReview ? 0 : 1) + .help("Code Review") + } + .buttonStyle(HoverButtonStyle(padding: 0)) + } else { + codeReviewIcon + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + .help(ccrDisabledTooltip) + } + } + } + + private func subscribeToFeatureFlagsDidChangeEvent() { + FeatureFlagNotifierImpl.shared.featureFlagsDidChange + .sink(receiveValue: { isCCRFFEnabled = $0.ccr }) + .store(in: &cancellables) + } + + private var dropdownOverlay: some View { + Group { + if dropDownShowingType != nil { + if dropDownShowingType == .template { + ChatDropdownView(items: $filteredTemplates, prefixSymbol: "/") { template in + chat.send(.updateTypedMessage("/" + template.id + " ")) + if template.id == "releaseNotes" { + submitChatMessage() + } + } + } else if dropDownShowingType == .agent { + ChatDropdownView(items: $filteredAgent, prefixSymbol: "@") { agent in + chat.send(.updateTypedMessage("@" + agent.id + " ")) + } + } + } + } + } + + func onTypedMessageChanged(newValue: String) async { + guard chat.editorMode.isDefault else { return } + if newValue.hasPrefix("/") { + filteredTemplates = await chatTemplateCompletion(text: newValue) + dropDownShowingType = filteredTemplates.isEmpty ? nil : .template + } else if newValue.hasPrefix("@") && !chat.isAgentMode { + filteredAgent = await chatAgentCompletion(text: newValue) + dropDownShowingType = filteredAgent.isEmpty ? nil : .agent + } else { + dropDownShowingType = nil + } + } + + enum ChatContextButtonType { case imageAttach, contextAttach} + + private var chatContextView: some View { + let buttonItems: [ChatContextButtonType] = [.contextAttach, .imageAttach] + // Always use the latest current editor from state + let currentEditorItem: [ConversationFileReference] = [chat.state.currentEditor].compactMap { + $0 + } + let references = chat.state.getChatContext(of: editorMode).attachedReferences + let chatContextItems: [Any] = buttonItems.map { + $0 as ChatContextButtonType + } + currentEditorItem + references + return FlowLayout(mode: .scrollable, items: chatContextItems, itemSpacing: 4) { item in + if let buttonType = item as? ChatContextButtonType { + if buttonType == .imageAttach { + VisionMenuView(chat: chat) + } else if buttonType == .contextAttach { + // File picker button + Button(action: { + withAnimation { + isFilePickerPresented.toggle() + if !isFilePickerPresented { + focusedField.wrappedValue = .textField + } + } + }) { + Image(systemName: "paperclip") + .resizable() + .aspectRatio(contentMode: .fill) + .scaledFrame(width: 16, height: 16) + .scaledPadding(4) + .foregroundColor(.primary.opacity(0.85)) + .scaledFont(size: 11, weight: .semibold) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .help("Add Context") + .cornerRadius(6) + } + } 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 { + let toggleTrailingPadding: CGFloat = { + if #available(macOS 26.0, *) { + return 8 + } else { + return 4 + } + }() + + HStack(spacing: 0) { + makeContextFileNameView(url: ref.url, isCurrentEditor: true, selection: ref.selection) + + Toggle("", isOn: $isCurrentEditorContextEnabled) + .toggleStyle(SwitchToggleStyle(tint: .blue)) + .controlSize(.mini) + .frame(width: 34) + .padding(.trailing, toggleTrailingPadding) + .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() + .scaledFrame(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() + .scaledFrame(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 { + Text(String(format: ":%d-%d", selection.start.line + 1, selection.end.line + 1)) + } + } + } + .foregroundColor(.secondary) + } + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor( + isCurrentEditor && !isCurrentEditorContextEnabled + ? .secondary + : .primary.opacity(0.85) + ) + .scaledFont(.body) + .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) + .help(url.getPathRelativeToHome()) + } + + func chatTemplateCompletion(text: String) async -> [ChatTemplate] { + guard text.count >= 1 && text.first == "/" else { return [] } + + 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] + ) + + let templates = promptTemplates + [releaseNotesTemplate] + let skippedTemplates = [ "feedback", "help" ] + + return templates.filter { + $0.scopes.contains(chat.isAgentMode ? .agentPanel : .chatPanel) && + $0.id.lowercased().hasPrefix(prefix) && + !skippedTemplates.contains($0.id) + } + } + + func chatAgentCompletion(text: String) async -> [ChatAgent] { + guard text.count >= 1 && text.first == "@" else { return [] } + let prefix = text.dropFirst() + var chatAgents = await SharedChatService.shared.loadChatAgents() ?? [] + + if let index = chatAgents.firstIndex(where: { $0.slug == "project" }) { + let projectAgent = chatAgents[index] + chatAgents[index] = .init(slug: "workspace", name: "workspace", description: "Ask about your workspace", avatarUrl: projectAgent.avatarUrl) + } + + /// only enable the @workspace + let includedAgents = ["workspace"] + + return chatAgents.filter { $0.slug.hasPrefix(prefix) && includedAgents.contains($0.slug) } + } + + func subscribeToActiveDocumentChangeEvent() { + 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, 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) + } + + 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() { + chat.send(.sendButtonTapped(UUID().uuidString)) + } +} diff --git a/Core/Sources/ConversationTab/Views/CheckPoint.swift b/Core/Sources/ConversationTab/Views/CheckPoint.swift new file mode 100644 index 00000000..5c6e4a86 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CheckPoint.swift @@ -0,0 +1,238 @@ +import SwiftUI +import ComposableArchitecture +import SharedUIComponents +import AppKit + +struct CheckPoint: View { + let chat: StoreOf + let messageId: String + + @State private var isHovering: Bool = false + @State private var window: NSWindow? + @AppStorage(\.chatFontSize) var chatFontSize + @AppStorage(\.suppressRestoreCheckpointConfirmation) var suppressRestoreCheckpointConfirmation + @Environment(\.colorScheme) var colorScheme + + private var isPendingCheckpoint: Bool { + chat.pendingCheckpointMessageId == messageId + } + + var body: some View { + WithPerceptionTracking { + HStack(spacing: 4) { + checkpointIcon + + checkpointLine + .overlay(alignment: .leading) { + checkpointContent + } + } + .scaledFrame(height: chatFontSize) + .onHover { isHovering = $0 } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .background(WindowAccessor { window in + // Store window reference for later use + self.window = window + }) + } + } + + var checkpointIcon: some View { + Image(systemName: "bookmark") + .resizable() + .scaledToFit() + .scaledFrame(width: chatFontSize, height: chatFontSize) + .foregroundStyle(.secondary) + } + + var checkpointLine: some View { + DashedLine() + .stroke(style: StrokeStyle(dash: [3])) + .foregroundStyle(.gray) + .scaledFrame(height: 1) + } + + @ViewBuilder + var checkpointContent: some View { + HStack(spacing: 12) { + if isPendingCheckpoint { + HStack(spacing: 12) { + undoButton + + Text("Checkpoint Restored") + .scaledFont(size: chatFontSize) + .foregroundStyle(.secondary) + .scaledPadding(.horizontal, 2) + .background(Color.chatWindowBackgroundColor) + } + } else if isHovering { + restoreButton + .transition(.opacity.combined(with: .move(edge: .leading))) + } + + Spacer() + } + } + + var hasSubsequentFileEdit: Bool { + for message in chat.state.getMessages(after: messageId, through: chat.pendingCheckpointMessageId) { + if !message.fileEdits.isEmpty { + return true + } + } + + return false + } + + var restoreButton: some View { + ActionButton( + title: "Restore Checkpoint", + helpText: "Restore workspace and chat to this point", + action: { + if !suppressRestoreCheckpointConfirmation && hasSubsequentFileEdit { + showRestoreAlert() + } else { + handleRestore() + } + } + ) + } + + func handleRestore() { + Task { @MainActor in + await chat.send(.restoreCheckPoint(messageId)).finish() + } + } + + var undoButton: some View { + ActionButton( + title: "Undo", + helpText: "Reapply discarded workspace changes and chat", + action: { + Task { @MainActor in + await chat.send(.undoCheckPoint).finish() + } + } + ) + } + + var accessibilityLabel: String { + if isPendingCheckpoint { + "Checkpoint restored. Tap to redo changes." + } else { + "Checkpoint. Tap to restore to this point." + } + } + + func showRestoreAlert() { + let alert = NSAlert() + alert.messageText = "Restore Checkpoint" + alert.informativeText = "This will remove all subsequent requests and edits. Do you want to proceed?" + + alert.addButton(withTitle: "Restore") + alert.addButton(withTitle: "Cancel") + + alert.showsSuppressionButton = true + alert.suppressionButton?.title = "Don't ask again" + + alert.alertStyle = .warning + + let targetWindow = window ?? NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first { + $0.isVisible + } + + if let targetWindow = targetWindow { + alert.beginSheetModal(for: targetWindow) { response in + self.handleAlertResponse(response, alert: alert) + } + } else { + let response = alert.runModal() + handleAlertResponse(response, alert: alert) + } + } + + private func handleAlertResponse(_ response: NSApplication.ModalResponse, alert: NSAlert) { + if response == .alertFirstButtonReturn { + handleRestore() + } + + suppressRestoreCheckpointConfirmation = alert.suppressionButton?.state == .on + } +} + +private struct ActionButton: View { + let title: String + let helpText: String + let action: () -> Void + + @Environment(\.colorScheme) private var colorScheme + @AppStorage(\.chatFontSize) private var chatFontSize + + private var adaptiveTextColor: Color { + colorScheme == .light ? .black.opacity(0.75) : .white.opacity(0.75) + } + + var body: some View { + Button(action: action) { + Text(title) + .scaledFont(.footnote) + .scaledPadding(4) + .foregroundStyle(adaptiveTextColor) + } + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color(nsColor: .windowBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(.gray, lineWidth: 0.5) + ) + ) + .buttonStyle(HoverButtonStyle(padding: 0)) + .scaledPadding(.leading, 8) + .help(helpText) + .accessibilityLabel(title) + .accessibilityHint(helpText) + } +} + +private struct DashedLine: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.midY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) + return path + } +} + +struct WindowAccessor: NSViewRepresentable { + var callback: (NSWindow?) -> Void + + func makeNSView(context: Context) -> NSView { + return WindowTrackingView(callback: callback) + } + + func updateNSView(_ nsView: NSView, context: Context) { + if let windowTrackingView = nsView as? WindowTrackingView { + windowTrackingView.callback = callback + } + } +} + +private class WindowTrackingView: NSView { + var callback: (NSWindow?) -> Void + + init(callback: @escaping (NSWindow?) -> Void) { + self.callback = callback + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + callback(window) + } +} diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift new file mode 100644 index 00000000..27256b87 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift @@ -0,0 +1,69 @@ +import ComposableArchitecture +import ConversationServiceProvider +import LanguageServerProtocol +import SwiftUI +import SharedUIComponents + +// 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) + .scaledFont(.system(size: chatFontSize)) + } + + var shouldShowHelloMessage: Bool { round.statusHistory.contains(.waitForConfirmation) } + + 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 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..9686afd3 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift @@ -0,0 +1,278 @@ +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("codeReview") + .resizable() + .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.") + .scaledFont(.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) + .scaledFont(.body) + + Button("Continue") { + store.send(.codeReview(.accept(id: roundId, selectedFiles: selectedFileUris))) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .scaledFont(.body) + } + } +} + +// MARK: - File Selection List + +private struct FileSelectionList: View { + let store: StoreOf + 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 + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + 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) { + // Select All checkbox for all files + selectedAllCheckbox + .disabled(reviewStatus != .waitForConfirmation) + .scaledFrame(maxHeight: 16) + + FileToggleList( + fileUris: visibleFileUris, + reviewStatus: reviewStatus, + selectedFileUris: $selectedFileUris, + onSelectionChange: updateMixedState + ) + .padding(.leading, 16) + + if hasMoreFiles { + if !isExpanded { + ExpandFilesButton(isExpanded: $isExpanded) + } + + if isExpanded { + FileToggleList( + fileUris: additionalFileUris, + reviewStatus: reviewStatus, + 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))" + let font: NSFont = .systemFont(ofSize: chatFontSize * fontScale) + + return MixedStateCheckbox( + title: title, + font: font, + 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 + } + } +} + +private struct ExpandFilesButton: View { + @Binding var isExpanded: Bool + @AppStorage(\.chatFontSize) private var chatFontSize + + var body: some View { + HStack(spacing: 2) { + Image("chevron.down") + .resizable() + .scaledToFit() + .scaledFrame(width: 16, height: 16) + + Button(action: { isExpanded = true }) { + Text("Show more") + .underline() + .scaledFont(.system(size: chatFontSize)) + .lineSpacing(20) + } + .buttonStyle(PlainButtonStyle()) + } + .foregroundColor(.blue) + } +} + +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 + 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 } + } + + onSelectionChange() + } + ) + } +} + +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() + .scaledFrame(width: 16, height: 16) + + Text(fileURL?.lastPathComponent ?? fileUri) + .scaledFont(.body) + .lineLimit(1) + .truncationMode(.middle) + } + } + .toggleStyle(CheckboxToggleStyle()) + .disabled(!isInteractionEnabled) + + Spacer() + } + } +} diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift new file mode 100644 index 00000000..67dcf282 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift @@ -0,0 +1,184 @@ +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") + .scaledFont(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() + .scaledToFit() + .scaledFrame(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() + .scaledToFit() + .scaledFrame(width: 16, height: 16) + + Button(action: { + if hasComments { + store.send(.codeReview(.onFileClicked(fileURL, comments[0].range.end.line))) + } + }) { + Text(fileURL.lastPathComponent) + .scaledFont(.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) + .scaledFont(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..76fcbf6d --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift @@ -0,0 +1,49 @@ +import SwiftUI +import ConversationServiceProvider +import SharedUIComponents + +struct ReviewSummarySection: View { + var round: CodeReviewRound + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + HStack { + if round.status == .error, let errorMessage = round.error { + Text(errorMessage) + .scaledFont(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)) + } + + Spacer() + } + } +} + +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.") + } + + } + .scaledFont(size: chatFontSize) + } +} diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift index 02330454..bbbf6d58 100644 --- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift @@ -1,10 +1,10 @@ - import SwiftUI import ConversationServiceProvider import ComposableArchitecture import Combine import ChatTab import ChatService +import SharedUIComponents struct ProgressAgentRound: View { let rounds: [AgentRound] @@ -12,13 +12,15 @@ struct ProgressAgentRound: View { var body: some View { WithPerceptionTracking { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 8) { ForEach(rounds, id: \.roundId) { round in - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 8) { ThemedMarkdownText(text: round.reply, chat: chat) if let toolCalls = round.toolCalls, !toolCalls.isEmpty { ProgressToolCalls(tools: toolCalls, chat: chat) - .padding(.vertical, 8) + } + if let subAgentRounds = round.subAgentRounds, !subAgentRounds.isEmpty { + SubAgentRounds(rounds: subAgentRounds, chat: chat) } } } @@ -28,6 +30,32 @@ struct ProgressAgentRound: View { } } +struct SubAgentRounds: View { + let rounds: [AgentRound] + let chat: StoreOf + + @Environment(\.colorScheme) var colorScheme + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 8) { + ForEach(rounds, id: \.roundId) { round in + VStack(alignment: .leading, spacing: 8) { + ThemedMarkdownText(text: round.reply, chat: chat) + if let toolCalls = round.toolCalls, !toolCalls.isEmpty { + ProgressToolCalls(tools: toolCalls, chat: chat) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .scaledPadding(.horizontal, 16) + .scaledPadding(.vertical, 12) + .background(RoundedRectangle(cornerRadius: 8).fill(Color("SubagentTurnBackground"))) + } + } +} + struct ProgressToolCalls: View { let tools: [AgentToolCall] let chat: StoreOf @@ -64,19 +92,26 @@ struct ToolConfirmationView: View { .frame(maxWidth: .infinity, alignment: .leading) HStack { - Button("Cancel") { + Button(action: { chat.send(.toolCallCancelled(tool.id)) + }) { + Text("Skip") + .scaledFont(.body) } - - Button("Continue") { + + Button(action: { chat.send(.toolCallAccepted(tool.id)) + }) { + Text("Allow") + .scaledFont(.body) } .buttonStyle(BorderedProminentButtonStyle()) + } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) + .scaledPadding(.top, 4) } - .padding(8) + .scaledPadding(8) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) @@ -97,15 +132,15 @@ 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) + .scaledPadding(.vertical, 2) + .scaledPadding(.horizontal, 4) .background(Color("ToolTitleHighlightBgColor")) .cornerRadius(4) .overlay( @@ -130,10 +165,10 @@ struct ToolStatusItemView: View { case .running: ProgressView() .controlSize(.small) - .scaleEffect(0.7) + .scaledScaleEffect(0.7) case .completed: Image(systemName: "checkmark") - .foregroundColor(.green.opacity(0.5)) + .foregroundColor(Color.successLightGreen) case .error: Image(systemName: "xmark.circle") .foregroundColor(.red.opacity(0.5)) @@ -146,6 +181,7 @@ struct ToolStatusItemView: View { EmptyView() } } + .scaledFont(size: chatFontSize - 1, weight: .medium) } var progressTitleText: some View { @@ -185,14 +221,15 @@ 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() } + .help(tool.progressMessage ?? "") } } } diff --git a/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift index 7b6c845e..739b126a 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,11 +31,10 @@ 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) + .foregroundColor(Color.successLightGreen) case .failed: Image(systemName: "xmark.circle") .foregroundColor(.red) @@ -43,28 +43,29 @@ struct StatusItemView: View { .foregroundColor(.gray) } } + .scaledFont(size: chatFontSize - 1, weight: .medium) } - var statusTitle: some View { - var title = step.title + var statusTitleText: String { if step.id == ProjectContextSkill.ProgressID && step.status == .failed { - title = step.error?.message ?? step.title + return step.error?.message ?? step.title } - return Text(title) + return step.title } var body: some View { WithPerceptionTracking { HStack(spacing: 4) { statusIcon - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) - statusTitle - .font(.system(size: chatFontSize)) + Text(statusTitleText) + .scaledFont(size: chatFontSize - 1) .lineLimit(1) Spacer() } + .help(statusTitleText) } } } diff --git a/Core/Sources/ConversationTab/Views/FunctionMessage.swift b/Core/Sources/ConversationTab/Views/FunctionMessage.swift index 8fbd6ac9..0523a44e 100644 --- a/Core/Sources/ConversationTab/Views/FunctionMessage.swift +++ b/Core/Sources/ConversationTab/Views/FunctionMessage.swift @@ -19,6 +19,10 @@ struct FunctionMessage: View { private var isOrgUser: Bool { text.contains("reach out to your organization's Copilot admin") } + + private var isBYOKUser: Bool { + text.contains("You've reached your quota limit for your BYOK model") + } private var switchToFallbackModelText: String { if let fallbackModelName = CopilotModelManager.getFallbackLLM( @@ -31,25 +35,33 @@ struct FunctionMessage: View { } private var errorContent: Text { - switch (isFreePlanUser, isOrgUser) { - case (true, _): + switch (isFreePlanUser, isOrgUser, isBYOKUser) { + 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): + 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)) + + case (_, _, true): + let sentences = splitBYOKQuotaMessage(text) + + guard sentences.count == 2 else { fallthrough } - default: let parts = [ - "You have exceeded your premium request allowance.", + sentences[0], switchToFallbackModelText, - "[Enable additional paid premium requests](https://aka.ms/github-copilot-manage-overage) to continue using premium models." + sentences[1] ].filter { !$0.isEmpty } return Text(attributedString(from: parts)) + + default: + let parts = [text, switchToFallbackModelText].filter { !$0.isEmpty } + return Text(attributedString(from: parts)) } } @@ -61,6 +73,21 @@ struct FunctionMessage: View { } } + private func splitBYOKQuotaMessage(_ message: String) -> [String] { + // Fast path: find the first period followed by a space + capital P (for "Please") + let boundary = ". Please check with" + if let range = message.range(of: boundary) { + // First sentence ends at the period just before " Please" + let firstSentence = String(message[.. String { switch item.source { @@ -22,41 +24,32 @@ struct ImageReferenceItemView: View { } var body: some View { + // The HStack arranges its child views horizontally with a right-to-left layout direction applied via `.environment(\.layoutDirection, .rightToLeft)`. + // This ensures the views are displayed in reverse order to match the desired layout for FlowLayout. 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)) + .scaledFont(size: 12) .truncationMode(.middle) - .frame(width: textWidth, alignment: .leading) + .scaledFrame(maxWidth: 105, alignment: .center) + .fixedSize(horizontal: true, vertical: false) + + Image(systemName: "photo") + .resizable() + .scaledToFit() + .scaledPadding(.vertical, 2) + .scaledFrame(width: 16, height: 16) } - .padding(4) - .background( - Color(nsColor: .windowBackgroundColor).opacity(0.5) - ) - .cornerRadius(4) + .foregroundColor(.primary.opacity(0.85)) + .scaledPadding(.horizontal, 4) + .scaledPadding(.vertical, 1) + .cornerRadius(6) .overlay( - RoundedRectangle(cornerRadius: 4) + RoundedRectangle(cornerRadius: 6) .inset(by: 0.5) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + .stroke(Color(nsColor: .quaternaryLabelColor), lineWidth: 1 * fontScale) ) .popover(isPresented: $showPopover, arrowEdge: .bottom) { PopoverImageView(data: item.data) diff --git a/Core/Sources/ConversationTab/Views/NotificationBanner.swift b/Core/Sources/ConversationTab/Views/NotificationBanner.swift index 68c40d57..f5047793 100644 --- a/Core/Sources/ConversationTab/Views/NotificationBanner.swift +++ b/Core/Sources/ConversationTab/Views/NotificationBanner.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedUIComponents public enum BannerStyle { case warning @@ -19,26 +20,26 @@ public enum BannerStyle { struct NotificationBanner: View { var style: BannerStyle @ViewBuilder var content: () -> Content + @AppStorage(\.chatFontSize) var chatFontSize 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() } } + .scaledFont(size: chatFontSize - 1) } .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(.vertical, 10) - .padding(.horizontal, 12) + .scaledPadding(.vertical, 10) + .scaledPadding(.horizontal, 12) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color(nsColor: .separatorColor), lineWidth: 1) ) - .padding(.vertical, 4) } } diff --git a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift index d08d7abc..f00d76e4 100644 --- a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift +++ b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift @@ -5,31 +5,63 @@ import ChatService import ComposableArchitecture import SuggestionBasic import ChatTab +import SharedUIComponents -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 @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 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( - fontSize: chatFontSize, - codeFont: chatCodeFont.value.nsFont, + fontSize: scaledChatFontSize, + codeFont: scaledChatCodeFont, codeBlockBackgroundColor: { if syncCodeHighlightTheme { if colorScheme == .light, let color = codeBackgroundColorLight.value { @@ -53,7 +85,7 @@ struct ThemedMarkdownText: View { } return Color.secondary.opacity(0.7) }(), - chat: chat + context: context )) } } @@ -66,7 +98,7 @@ extension MarkdownUI.Theme { codeFont: NSFont, codeBlockBackgroundColor: Color, codeBlockLabelColor: Color, - chat: StoreOf + context: MarkdownActionProvider ) -> MarkdownUI.Theme { .gitHub.text { ForegroundColor(.primary) @@ -79,7 +111,7 @@ extension MarkdownUI.Theme { codeFont: codeFont, codeBlockBackgroundColor: codeBlockBackgroundColor, codeBlockLabelColor: codeBlockLabelColor, - chat: chat + context: context ) } } @@ -90,11 +122,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,8 +138,10 @@ struct MarkdownCodeBlockView: View { codeBlockConfiguration, backgroundColor: codeBlockBackgroundColor, labelColor: codeBlockLabelColor, - insertAction: insertCode + context: context ) + // Force recreation when font size changes + .id("code-block-\(codeFont.pointSize)") } else { ScrollView(.horizontal) { AsyncCodeBlockView( @@ -126,8 +156,10 @@ struct MarkdownCodeBlockView: View { codeBlockConfiguration, backgroundColor: codeBlockBackgroundColor, labelColor: codeBlockLabelColor, - insertAction: insertCode + context: context ) + // Force recreation when font size changes + .id("code-block-\(codeFont.pointSize)") } } } @@ -143,7 +175,6 @@ 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..4b8a22e3 100644 --- a/Core/Sources/ConversationTab/Views/UserMessage.swift +++ b/Core/Sources/ConversationTab/Views/UserMessage.swift @@ -9,6 +9,9 @@ import Cache import ChatTab import ConversationServiceProvider import SwiftUIFlowLayout +import ChatAPIService + +private let MAX_TEXT_LENGTH = 10000 // Maximum characters to prevent crashes struct UserMessage: View { var r: Double { messageBubbleCornerRadius } @@ -16,51 +19,99 @@ struct UserMessage: View { let text: String let imageReferences: [ImageReference] let chat: StoreOf + let editorCornerRadius: Double + let requestType: RequestType @Environment(\.colorScheme) var colorScheme - @ObservedObject private var statusObserver = StatusObserver.shared + @State var isMessageHovering: Bool = false + + // 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 + } - struct AvatarView: View { - @ObservedObject private var avatarViewModel = AvatarViewModel.shared - - var body: some View { - if let avatarImage = avatarViewModel.avatarImage { - avatarImage - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 24, height: 24) - .clipShape(Circle()) - } else { - Image(systemName: "person.circle") - .resizable() - .frame(width: 24, height: 24) - } + private var isEditing: Bool { + if case .editUserMessage(let editId) = chat.state.editorMode { + return editId == id } + return false } + + private var editorMode: Chat.EditorMode { .editUserMessage(id) } + + private var isConversationMessage: Bool { requestType == .conversation } var body: some View { + if !isEditing { + messageView + } else { + MessageInputArea(editorMode: editorMode, chat: chat, editorCornerRadius: editorCornerRadius) + } + } + + var messageView: some View { HStack { VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 4) { - AvatarView() - - Text(statusObserver.authStatus.username ?? "") - .chatMessageHeaderTextStyle() - .padding(2) - - Spacer() - } - - ThemedMarkdownText(text: text, chat: chat) - .frame(maxWidth: .infinity, alignment: .leading) + textView + .scaledPadding(.vertical, 8) + .scaledPadding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: r) + .fill(isMessageHovering ? Color("DarkBlue") : Color("LightBlue")) + ) + .overlay( + Group { + if isConversationMessage { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + chat.send(.setEditorMode(.editUserMessage(id))) + } + .allowsHitTesting(true) + } + } + ) + .onHover { isHovered in + if isConversationMessage { + isMessageHovering = isHovered + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .frame(maxWidth: .infinity, alignment: .trailing) if !imageReferences.isEmpty { FlowLayout(mode: .scrollable, items: imageReferences, itemSpacing: 4) { item in ImageReferenceItemView(item: item) } + .environment(\.layoutDirection, .rightToLeft) } } } - .shadow(color: .black.opacity(0.05), radius: 6) + } + + var textView: some View { + ThemedMarkdownText(text: displayText, chat: chat) + } +} + +private struct MessageInputArea: View { + let editorMode: Chat.EditorMode + let chat: StoreOf + let editorCornerRadius: Double + + var body: some View { + ChatPanelInputArea( + chat: chat, + r: editorCornerRadius, + editorMode: editorMode + ) + .frame(maxWidth: .infinity) } } @@ -86,7 +137,9 @@ struct UserMessage_Previews: PreviewProvider { chat: .init( initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) } - ) + ), + editorCornerRadius: 4, + requestType: .conversation ) .padding() .fixedSize(horizontal: true, vertical: true) diff --git a/Core/Sources/ConversationTab/Views/WorkingSetView.swift b/Core/Sources/ConversationTab/Views/WorkingSetView.swift index 677c44dc..7cb5eb73 100644 --- a/Core/Sources/ConversationTab/Views/WorkingSetView.swift +++ b/Core/Sources/ConversationTab/Views/WorkingSetView.swift @@ -7,6 +7,7 @@ import JSONRPC import SharedUIComponents import OrderedCollections import ConversationServiceProvider +import ChatAPIService struct WorkingSetView: View { let chat: StoreOf @@ -18,19 +19,18 @@ struct WorkingSetView: View { VStack(spacing: 4) { WorkingSetHeader(chat: chat) - .frame(height: 24) - .padding(.leading, 12) - .padding(.trailing, 5) + .scaledFrame(height: 24) + .scaledPadding(.leading, 7) VStack(spacing: 0) { ForEach(chat.fileEditMap.elements, id: \.key.path) { element in FileEditView(chat: chat, fileEdit: element.value) } } - .padding(.horizontal, 5) } - .padding(.top, 8) - .padding(.bottom, 10) + .scaledPadding(.horizontal, 5) + .scaledPadding(.top, 8) + .scaledPadding(.bottom, 10) .frame(maxWidth: .infinity) .background( RoundedCorners(tl: r, tr: r, bl: 0, br: 0) @@ -62,8 +62,9 @@ struct WorkingSetHeader: View { ) -> some View { Button(action: action) { Text(text) + .scaledFont(.body) .foregroundColor(textForegroundColor) - .padding(.horizontal, 6) + .scaledPadding(.horizontal, 6) .padding(.vertical, 2) .background(textBackgroundColor) .cornerRadius(2) @@ -71,7 +72,7 @@ struct WorkingSetHeader: View { RoundedRectangle(cornerRadius: 2) .stroke(Color.white.opacity(0.07), lineWidth: 1) ) - .frame(width: 60, height: 15, alignment: .center) + .scaledFrame(width: 60, height: 15, alignment: .center) } .buttonStyle(PlainButtonStyle()) } @@ -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(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")) } @@ -210,8 +211,8 @@ struct FileEditView: View { .onHover { hovering in isHovering = hovering } - .padding(.leading, 7) - .frame(height: 24) + .scaledPadding(.leading, 7) + .scaledFrame(height: 24) .hoverRadiusBackground( isHovered: isHovering, hoverColor: Color.blue, 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/ImagesScrollView.swift b/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift index 87e7179a..c2e3d6b8 100644 --- a/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift +++ b/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift @@ -3,9 +3,10 @@ import ComposableArchitecture public struct ImagesScrollView: View { let chat: StoreOf + let editorMode: Chat.EditorMode public var body: some View { - let attachedImages = chat.state.attachedImages.reversed() + let attachedImages = chat.state.getChatContext(of: editorMode).attachedImages.reversed() return ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 2) { ForEach(attachedImages, id: \.self) { image in @@ -13,7 +14,5 @@ public struct ImagesScrollView: View { } } } - .padding(.horizontal, 8) - .padding(.top, 8) } } 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/GitHubCopilotViewModel/GitHubCopilotViewModel.swift b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift index e310f5d5..39d298c0 100644 --- a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift +++ b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Status import SwiftUI import Cache +import Client public struct SignInResponse { public let status: SignInInitiateStatus @@ -17,7 +18,6 @@ public class GitHubCopilotViewModel: ObservableObject { public static let shared = GitHubCopilotViewModel() @Dependency(\.toast) var toast - @Dependency(\.openURL) var openURL @AppStorage("username") var username: String = "" @@ -127,7 +127,7 @@ public class GitHubCopilotViewModel: ObservableObject { waitingForSignIn = false } - public func copyAndOpen() { + public func copyAndOpen(fromHostApp: Bool = false) { waitingForSignIn = true guard let signInResponse else { toast("Missing sign in details.", .error) @@ -137,13 +137,11 @@ 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(fromHostApp: fromHostApp) } - public func waitForSignIn() { + public func waitForSignIn(fromHostApp: Bool = false) { Task { do { guard waitingForSignIn else { return } @@ -158,14 +156,19 @@ public class GitHubCopilotViewModel: ObservableObject { self.status = status await Status.shared.updateAuthStatus(.loggedIn, username: username) broadcastStatusChange() - let models = try? await service.models() - if let models = models, !models.isEmpty { - CopilotModelManager.updateLLMs(models) + if !fromHostApp { + let models = try? await service.models() + if let models = models, !models.isEmpty { + CopilotModelManager.updateLLMs(models) + } + } else { + let xpcService = try getService() + _ = try? await xpcService.updateCopilotModels() } } catch let error as GitHubCopilotError { switch error { case .languageServerError(.timeout): - waitForSignIn() + waitForSignIn(fromHostApp: fromHostApp) return case .languageServerError( .serverError( @@ -243,9 +246,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/HostApp/AdvancedSettings/ChatSection.swift b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift index a71e2aa3..988a086e 100644 --- a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift @@ -1,31 +1,105 @@ +import AppKitExtension import Client import ComposableArchitecture +import ConversationServiceProvider import SwiftUI import Toast import XcodeInspector +import SharedUIComponents +import Logger +import SystemUtils struct ChatSection: View { @AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode - + @AppStorage(\.enableFixError) var enableFixError + @AppStorage(\.enableSubagent) var enableSubagent + @ObservedObject private var featureFlags = FeatureFlagManager.shared + @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared + var body: some View { SettingsSection(title: "Chat Settings") { + // Copilot instructions - .github/copilot-instructions.md + CopilotInstructionSetting() + .padding(SettingsToggle.defaultPadding) + + Divider() + + // Custom Instructions - .github/instructions/*.instructions.md + PromptFileSetting(promptType: .instructions) + .padding(SettingsToggle.defaultPadding) + + Divider() + + if featureFlags.isEditorPreviewEnabled { + // Custom Prompts - .github/prompts/*.prompt.md + PromptFileSetting(promptType: .prompt) + .padding(SettingsToggle.defaultPadding) + + Divider() + + if featureFlags.isAgentModeEnabled && copilotPolicy.isCustomAgentEnabled { + // Custom Agents - .github/agents/*.agent.md + AgentFileSetting(promptType: .agent) + .padding(SettingsToggle.defaultPadding) + + Divider() + + // SubAgent toggle + SettingsToggle( + title: "Enable Subagent", + subtitle: "Allows Copilot Agent mode to call custom agents as subagent. Requires GitHub Copilot for Xcode restart to take effect.", + isOn: Binding( + get: { enableSubagent && copilotPolicy.isSubagentEnabled }, + set: { if copilotPolicy.isSubagentEnabled { enableSubagent = $0 } } + ), + badge: copilotPolicy.isSubagentEnabled + ? nil + : BadgeItem( + text: "Disabled by organization policy", + level: .warning, + icon: "exclamationmark.triangle.fill", + tooltip: "Subagents are disabled by your organization's policy. Please contact your administrator to enable them." + ) + ) + .disabled(!copilotPolicy.isSubagentEnabled) + + Divider() + } + } + // Auto Attach toggle SettingsToggle( - title: "Auto-attach Chat Window to Xcode", + 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() - // Custom instructions - CustomInstructionSetting() + // Font Size + FontSizeSetting() .padding(SettingsToggle.defaultPadding) + + if featureFlags.isAgentModeEnabled { + Divider() + + // Agent Max Tool Calling Requests + AgentMaxToolCallLoopSetting() + .padding(SettingsToggle.defaultPadding) + } } } } @@ -50,14 +124,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 +147,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) } @@ -84,13 +158,190 @@ struct ResponseLanguageSetting: View { Text(option.displayName).tag(option.localeCode) } } - .frame(maxWidth: 200, alignment: .leading) + .frame(maxWidth: 200, alignment: .trailing) + } + } + } +} + +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() + 6) + .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 AgentMaxToolCallLoopSetting: View { + @AppStorage(\.agentMaxToolCallingLoop) var agentMaxToolCallingLoop + @State private var numberInput: String = "" + @State private var debounceTimer: Timer? + + private static let debounceDelay: TimeInterval = 0.5 + + var body: some View { + WithPerceptionTracking { + HStack { + VStack(alignment: .leading) { + Text("Agent Max Requests") + .font(.body) + Text("Sets the maximum number of tool call requests Copilot can make in a single agent turn.") + .font(.footnote) + } + + Spacer() + + TextField("", text: $numberInput) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 40, maxWidth: 120) + .fixedSize(horizontal: true, vertical: false) + .onChange(of: numberInput) { newValue in + if newValue.isEmpty { return } + + guard let number = Int(newValue.filter { $0.isNumber }), number > 0 else { + numberInput = "" + return + } + + numberInput = "\(number)" + + debounceTimer?.invalidate() + debounceTimer = Timer.scheduledTimer( + withTimeInterval: Self.debounceDelay, + repeats: false + ) { _ in + agentMaxToolCallingLoop = number + DistributedNotificationCenter + .default() + .post(name: .githubCopilotAgentMaxToolCallingLoopDidChange, object: nil) + } + } + } + .onAppear { + numberInput = "\(agentMaxToolCallingLoop)" + } + .onDisappear { + // Flush before invalidating + if let timer = debounceTimer, timer.isValid { + timer.fire() + } + + debounceTimer?.invalidate() + debounceTimer = nil } } } } -struct CustomInstructionSetting: View { +struct CopilotInstructionSetting: View { @State var isGlobalInstructionsViewOpen = false @Environment(\.toast) var toast @@ -98,9 +349,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,38 +373,192 @@ 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) - } - - // 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 + guard let projectURL = await getCurrentProjectURL() else { + toast("No active workspace found", .error) + return + } + + 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) + } + } + } +} + +struct PromptFileSetting: View { + let promptType: PromptType + @State private var isCreateSheetPresented = false + @Environment(\.toast) var toast + + 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 + } + + Button("Open \(promptType.directoryName.capitalized) Folder") { + openDirectory() + } + } + .sheet(isPresented: $isCreateSheetPresented) { + CreateCustomCopilotFileView( + promptType: promptType, + editorPluginVersion: SystemUtils.editorPluginVersionString, + getCurrentProjectURL: { await getCurrentProjectURL() }, + onSuccess: { message in + toast(message, .info) + }, + onError: { message in + toast(message, .error) } + ) + } + } + } + + 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) + } + } + } +} + +struct AgentFileSetting: View { + let promptType: PromptType + @State private var isCreateSheetPresented = false + @Environment(\.toast) var toast + + var body: some View { + WithPerceptionTracking { + HStack { + VStack(alignment: .leading) { + Text(promptType.settingTitle) + .font(.body) + Text( + (try? AttributedString(markdown: promptType.description)) ?? AttributedString( + promptType.description + ) + ) + .font(.footnote) } - if FileManager.default.fileExists(atPath: configFile.path) { - NSWorkspace.shared.open(configFile) + Spacer() + + Button("Create") { + isCreateSheetPresented = true + } + + Button("Browse \(promptType.displayName)s") { + openDirectory() + } + } + .sheet(isPresented: $isCreateSheetPresented) { + CreateCustomCopilotFileView( + promptType: promptType, + editorPluginVersion: SystemUtils.editorPluginVersionString, + getCurrentProjectURL: { await getCurrentProjectURL() }, + onSuccess: { message in + toast(message, .info) + }, + onError: { message in + toast(message, .error) + } + ) + } + } + } + + 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) + + // Open file picker for .agent.md files + await MainActor.run { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.init(filenameExtension: "agent.md") ?? .plainText] + panel.allowsMultipleSelection = false + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.level = .modalPanel + panel.directoryURL = directory + panel.message = "Select an existing agent file" + panel.prompt = "Select" + panel.showsHiddenFiles = false + + panel.allowsOtherFileTypes = false + panel.isExtensionHidden = false + + panel.begin { response in + if response == .OK, let selectedURL = panel.url { + // If the file doesn't exist, create it + if !FileManager.default.fileExists(atPath: selectedURL.path) { + do { + // Create empty agent file with basic structure + let template = promptType.defaultTemplate + try template.write(to: selectedURL, atomically: true, encoding: .utf8) + } catch { + toast("Failed to create agent file: \(error)", .error) + return + } + } + + // Open the file in Xcode + NSWorkspace.openFileInXcode(fileURL: selectedURL) + } + } } + } catch { + toast("Failed to create \(promptType.directoryName) directory: \(error)", .error) } } } diff --git a/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift new file mode 100644 index 00000000..d93ae8d9 --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift @@ -0,0 +1,64 @@ +import AppKit +import Client +import Foundation +import SwiftUI +import Toast +import XcodeInspector +import SystemUtils +import SharedUIComponents +import Workspace +import LanguageServerProtocol + +// MARK: - Workspace URL Helpers + +private func getCurrentWorkspaceURL() async -> URL? { + guard let service = try? getService(), + let inspectorData = try? await service.getXcodeInspectorData() else { + return nil + } + + if let url = inspectorData.realtimeActiveWorkspaceURL, + let workspaceURL = URL(string: url), + workspaceURL.path != "/" { + return workspaceURL + } else if let url = inspectorData.latestNonRootWorkspaceURL { + return URL(string: url) + } + + return nil +} + +func getCurrentProjectURL() async -> URL? { + guard let workspaceURL = await getCurrentWorkspaceURL(), + let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: workspaceURL, + documentURL: nil + ) else { + return nil + } + + return projectURL +} + +// MARK: - Workspace Folders + +func getWorkspaceFolders() async -> [WorkspaceFolder]? { + guard let workspaceURL = await getCurrentWorkspaceURL(), + let workspaceInfo = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) else { + return nil + } + + let projects = WorkspaceFile.getProjects(workspace: workspaceInfo) + return projects.map { project in + WorkspaceFolder(uri: project.uri, name: project.name) + } +} + +// MARK: - File System Helpers + +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/AdvancedSettings/SuggestionSection.swift b/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift index cb86bde3..689ccaa5 100644 --- a/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift @@ -4,8 +4,10 @@ struct SuggestionSection: View { @AppStorage(\.realtimeSuggestionToggle) var realtimeSuggestionToggle @AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList @AppStorage(\.acceptSuggestionWithTab) var acceptSuggestionWithTab + @AppStorage(\.realtimeNESToggle) var realtimeNESToggle @State var isSuggestionFeatureDisabledLanguageListViewOpen = false @State private var shouldPresentTurnoffSheet = false + @ObservedObject private var featureFlags = FeatureFlagManager.shared var realtimeSuggestionBinding : Binding { Binding( @@ -23,9 +25,18 @@ struct SuggestionSection: View { var body: some View { SettingsSection(title: "Suggestion Settings") { SettingsToggle( - title: "Request suggestions while typing", + title: "Enable completions while typing", isOn: realtimeSuggestionBinding ) + + if featureFlags.isEditorPreviewEnabled { + Divider() + SettingsToggle( + title: "Enable Next Edit Suggestions (NES)", + isOn: $realtimeNESToggle + ) + } + Divider() SettingsToggle( title: "Accept suggestions with Tab", diff --git a/Core/Sources/HostApp/BYOKConfigView.swift b/Core/Sources/HostApp/BYOKConfigView.swift new file mode 100644 index 00000000..50d569da --- /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] ?? false }, + 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..4f93eee0 --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift @@ -0,0 +1,153 @@ +import GitHubCopilotService +import SwiftUI +import SharedUIComponents + +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() + AdaptiveHelpLink(action: openHelpLink) + } + + 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..fa0bff5f --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift @@ -0,0 +1,243 @@ +import Client +import GitHubCopilotService +import Logger +import SwiftUI +import XPCShared +import SystemUtils + +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 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) + 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..4ce44c91 --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift @@ -0,0 +1,171 @@ +import GitHubCopilotService +import SwiftUI +import SharedUIComponents + +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() + AdaptiveHelpLink(action: openHelpLink) + } + + 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 + ) + ) + + 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() + } 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..937086f5 --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift @@ -0,0 +1,319 @@ +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 { + 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 { + 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 } + } + }, + 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) + } + ) + } + + @ViewBuilder + private var ConfiguredProviderActions: some View { + HStack(spacing: 8) { + if provider.authType == .GlobalApiKey && isExpanded { + CollapsibleSearchField(searchText: $searchText, isExpanded: $isSearchBarVisible) + + 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/CopilotPolicyManager.swift b/Core/Sources/HostApp/CopilotPolicyManager.swift new file mode 100644 index 00000000..cc769117 --- /dev/null +++ b/Core/Sources/HostApp/CopilotPolicyManager.swift @@ -0,0 +1,105 @@ +import Client +import Combine +import Foundation +import GitHubCopilotService +import Logger +import SwiftUI + +/// Centralized manager for GitHub Copilot policies in the HostApp +/// Use as @StateObject or @ObservedObject in SwiftUI views +@MainActor +public class CopilotPolicyManager: ObservableObject { + public static let shared = CopilotPolicyManager() + + // MARK: - Published Properties + + @Published public private(set) var isMCPContributionPointEnabled = true + @Published public private(set) var isCustomAgentEnabled = true + @Published public private(set) var isSubagentEnabled = true + @Published public private(set) var isCVERemediatorAgentEnabled = true + + // MARK: - Private Properties + + private var cancellables = Set() + private var lastUpdateTime: Date? + private let updateThrottle: TimeInterval = 1.0 // Prevent excessive updates + + // MARK: - Initialization + + private init() { + setupNotificationObserver() + Task { + await updatePolicy() + } + } + + // MARK: - Public Methods + + /// Manually refresh policies from the service + public func refresh() async { + await updatePolicy() + } + + // MARK: - Private Methods + + private func setupNotificationObserver() { + DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotPolicyDidChange) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + await self?.updatePolicy() + } + } + .store(in: &cancellables) + } + + private func updatePolicy() async { + // Throttle updates to prevent excessive calls + if let lastUpdate = lastUpdateTime, + Date().timeIntervalSince(lastUpdate) < updateThrottle { + return + } + + lastUpdateTime = Date() + + do { + let service = try getService() + guard let policy = try await service.getCopilotPolicy() else { + Logger.client.info("Copilot policy returned nil, using defaults") + return + } + + // Update all policies at once + isMCPContributionPointEnabled = policy.mcpContributionPointEnabled + isCustomAgentEnabled = policy.customAgentEnabled + isSubagentEnabled = policy.subagentEnabled + isCVERemediatorAgentEnabled = policy.cveRemediatorAgentEnabled + + Logger.client.info("Copilot policy updated: customAgent=\(policy.customAgentEnabled), mcp=\(policy.mcpContributionPointEnabled), subagent=\(policy.subagentEnabled)") + } catch { + Logger.client.error("Failed to update copilot policy: \(error.localizedDescription)") + } + } +} + +// MARK: - Environment Key + +private struct CopilotPolicyManagerKey: EnvironmentKey { + static let defaultValue = CopilotPolicyManager.shared +} + +public extension EnvironmentValues { + var copilotPolicyManager: CopilotPolicyManager { + get { self[CopilotPolicyManagerKey.self] } + set { self[CopilotPolicyManagerKey.self] = newValue } + } +} + +// MARK: - View Extension + +public extension View { + /// Inject the copilot policy manager into the environment + func withCopilotPolicyManager(_ manager: CopilotPolicyManager = .shared) -> some View { + self.environment(\.copilotPolicyManager, manager) + } +} diff --git a/Core/Sources/HostApp/FeatureFlagManager.swift b/Core/Sources/HostApp/FeatureFlagManager.swift new file mode 100644 index 00000000..e996a8db --- /dev/null +++ b/Core/Sources/HostApp/FeatureFlagManager.swift @@ -0,0 +1,109 @@ +import Client +import Combine +import Foundation +import GitHubCopilotService +import Logger +import SwiftUI + +/// Centralized manager for GitHub Copilot feature flags in the HostApp +/// Use as @StateObject or @ObservedObject in SwiftUI views +@MainActor +public class FeatureFlagManager: ObservableObject { + public static let shared = FeatureFlagManager() + + // MARK: - Published Properties + + @Published public private(set) var isAgentModeEnabled = true + @Published public private(set) var isBYOKEnabled = true + @Published public private(set) var isMCPEnabled = true + @Published public private(set) var isEditorPreviewEnabled = true + @Published public private(set) var isChatEnabled = true + @Published public private(set) var isCodeReviewEnabled = true + + // MARK: - Private Properties + + private var cancellables = Set() + private var lastUpdateTime: Date? + private let updateThrottle: TimeInterval = 1.0 // Prevent excessive updates + + // MARK: - Initialization + + private init() { + setupNotificationObserver() + Task { + await updateFeatureFlags() + } + } + + // MARK: - Public Methods + + /// Manually refresh feature flags from the service + public func refresh() async { + await updateFeatureFlags() + } + + // MARK: - Private Methods + + private func setupNotificationObserver() { + DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotFeatureFlagsDidChange) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + await self?.updateFeatureFlags() + } + } + .store(in: &cancellables) + } + + private func updateFeatureFlags() async { + // Throttle updates to prevent excessive calls + if let lastUpdate = lastUpdateTime, + Date().timeIntervalSince(lastUpdate) < updateThrottle { + return + } + + lastUpdateTime = Date() + + do { + let service = try getService() + guard let featureFlags = try await service.getCopilotFeatureFlags() else { + Logger.client.info("Feature flags returned nil, using defaults") + return + } + + // Update all flags at once + isAgentModeEnabled = featureFlags.agentMode + isBYOKEnabled = featureFlags.byok + isMCPEnabled = featureFlags.mcp + isEditorPreviewEnabled = featureFlags.editorPreviewFeatures + isChatEnabled = featureFlags.chat + isCodeReviewEnabled = featureFlags.ccr + + Logger.client.info("Feature flags updated: agentMode=\(featureFlags.agentMode), byok=\(featureFlags.byok), mcp=\(featureFlags.mcp), editorPreview=\(featureFlags.editorPreviewFeatures)") + } catch { + Logger.client.error("Failed to update feature flags: \(error.localizedDescription)") + } + } +} + +// MARK: - Environment Key + +private struct FeatureFlagManagerKey: EnvironmentKey { + static let defaultValue = FeatureFlagManager.shared +} + +public extension EnvironmentValues { + var featureFlagManager: FeatureFlagManager { + get { self[FeatureFlagManagerKey.self] } + set { self[FeatureFlagManagerKey.self] = newValue } + } +} + +// MARK: - View Extension + +public extension View { + /// Inject the feature flag manager into the environment + func withFeatureFlagManager(_ manager: FeatureFlagManager = .shared) -> some View { + self.environment(\.featureFlagManager, manager) + } +} diff --git a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift index 5a454b7a..3f0dc34c 100644 --- a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift +++ b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift @@ -57,7 +57,10 @@ struct CopilotConnectionView: View { isPresented: $viewModel.isSignInAlertPresented, presenting: viewModel.signInResponse) { _ in Button("Cancel", role: .cancel, action: {}) - Button("Copy Code and Open", action: viewModel.copyAndOpen) + Button( + "Copy Code and Open", + action: { viewModel.copyAndOpen(fromHostApp: true) } + ) } message: { response in Text(""" Please enter the above code in the \ diff --git a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift index 6c264821..19418245 100644 --- a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift +++ b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import SwiftUI +import SharedUIComponents struct GeneralSettingsView: View { @AppStorage(\.extensionPermissionShown) var extensionPermissionShown: Bool @@ -19,7 +20,7 @@ struct GeneralSettingsView: View { return "" } } - + var extensionPermissionSubtitle: any View { switch store.isExtensionPermissionGranted { case .notGranted: @@ -40,8 +41,7 @@ struct GeneralSettingsView: View { return Text("") } } - - + var extensionPermissionBadge: BadgeItem? { switch store.isExtensionPermissionGranted { case .notGranted: @@ -52,8 +52,8 @@ struct GeneralSettingsView: View { return nil } } - - var extensionPermissionAction: ()->Void { + + var extensionPermissionAction: () -> Void { switch store.isExtensionPermissionGranted { case .disabled: return { shouldShowRestartXcodeAlert = true } @@ -89,12 +89,9 @@ struct GeneralSettingsView: View { } footer: { HStack { Spacer() - Button("?") { - NSWorkspace.shared.open( - URL(string: "https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md")! - ) - } - .clipShape(Circle()) + AdaptiveHelpLink(action: { NSWorkspace.shared.open( + URL(string: "https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md")! + )}) } } .alert( @@ -102,10 +99,10 @@ struct GeneralSettingsView: View { isPresented: $shouldPresentExtensionPermissionAlert ) { Button( - "Open System Preferences", - action: { - NSWorkspace.openXcodeExtensionsPreferences() - }).keyboardShortcut(.defaultAction) + "Open System Preferences", + action: { + NSWorkspace.openXcodeExtensionsPreferences() + }).keyboardShortcut(.defaultAction) Button("View How-to Guide", action: { let url = "https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md#extension-permission" NSWorkspace.shared.open(URL(string: url)!) @@ -126,7 +123,7 @@ struct GeneralSettingsView: View { Button("Restart Now") { NSWorkspace.restartXcode() }.keyboardShortcut(.defaultAction) - + Button("Cancel", role: .cancel) {} } message: { Text("Quit and restart Xcode to enable Github Copilot for Xcode extension.") diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index 93c8725a..e9d2253e 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 tools = 2 + case byok = 3 + + var title: String { + switch self { + case .general: return "General" + case .advanced: return "Advanced" + case .tools: return "Tools" + case .byok: return "Models" + } + } + + var image: String { + switch self { + case .general: return "CopilotLogo" + case .advanced: return "gearshape.2.fill" + case .tools: return "wrench.and.screwdriver.fill" + case .byok: return "Model" + } + } + + var isSystemImage: Bool { + switch self { + case .general, .byok: 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/MCPConfigView.swift b/Core/Sources/HostApp/MCPConfigView.swift deleted file mode 100644 index df80423a..00000000 --- a/Core/Sources/HostApp/MCPConfigView.swift +++ /dev/null @@ -1,207 +0,0 @@ -import Client -import Foundation -import Logger -import SharedUIComponents -import SwiftUI -import Toast -import ConversationServiceProvider -import GitHubCopilotService -import ComposableArchitecture - -struct MCPConfigView: View { - @State private var mcpConfig: String = "" - @Environment(\.toast) var toast - @State private var configFilePath: String = mcpConfigFilePath - @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 - - 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() - } - .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)") - } - } - - private func setupConfigFilePath() { - let fileManager = FileManager.default - - if !fileManager.fileExists(atPath: configDirectory.path) { - try? fileManager.createDirectory(at: configDirectory, withIntermediateDirectories: true) - } - - // If the file doesn't exist, create one with a proper structure - let configFileURL = URL(fileURLWithPath: configFilePath) - if !fileManager.fileExists(atPath: configFilePath) { - try? """ - { - "servers": { - - } - } - """.write(to: configFileURL, atomically: true, encoding: .utf8) - } - - // Read the current content from file and ensure it's valid JSON - mcpConfig = readAndValidateJSON(from: configFileURL) ?? "{}" - - // Get initial modification date - lastModificationDate = getFileModificationDate(url: configFileURL) - } - - /// Reads file content and validates it as JSON, returning only the "servers" object - private func readAndValidateJSON(from url: URL) -> String? { - guard let data = try? Data(contentsOf: url) else { - return nil - } - - // Try to parse as JSON to validate - do { - // First verify it's valid JSON - let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] - - // Extract the "servers" object - guard let servers = jsonObject?["servers"] as? [String: Any] else { - Logger.client.info("No 'servers' key found in MCP configuration") - toast("No 'servers' key found in MCP configuration", .error) - // Return empty object if no servers section - return "{}" - } - - // Convert the servers object back to JSON data - let serversData = try JSONSerialization.data( - withJSONObject: servers, options: [.prettyPrinted]) - - // Return as a string - return String(data: serversData, encoding: .utf8) - } catch { - // If parsing fails, return nil - Logger.client.info("Parsing MCP JSON error: \(error)") - toast("Invalid JSON in MCP configuration file", .error) - return nil - } - } - - private func getFileModificationDate(url: URL) -> Date? { - let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) - return attributes?[.modificationDate] as? Date - } - - private func startMonitoringConfigFile() { - stopMonitoringConfigFile() // Stop existing monitoring if any - - isMonitoring = true - - fileMonitorTask = Task { - let configFileURL = URL(fileURLWithPath: configFilePath) - - // Check for file changes periodically - while isMonitoring { - try? await Task.sleep(nanoseconds: 3_000_000_000) // Check every 3 seconds - - let currentDate = getFileModificationDate(url: configFileURL) - - if let currentDate = currentDate, currentDate != lastModificationDate { - // File modification date has changed, update our record - lastModificationDate = currentDate - - // Read and validate the updated content - if let validJson = readAndValidateJSON(from: configFileURL) { - await MainActor.run { - mcpConfig = validJson - refreshConfiguration(validJson) - toast("MCP configuration file updated", .info) - } - } else { - // If JSON is invalid, show error - await MainActor.run { - toast("Invalid JSON in MCP configuration file", .error) - } - } - } - } - } - } - - private func stopMonitoringConfigFile() { - isMonitoring = false - fileMonitorTask?.cancel() - fileMonitorTask = nil - } - - 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) - } - - Task { - do { - let service = try getService() - try await service.postNotification( - name: Notification.Name - .gitHubCopilotShouldRefreshEditorInformation.rawValue - ) - toast("MCP configuration updated", .info) - } catch { - toast(error.localizedDescription, .error) - } - } - } -} - -#Preview { - MCPConfigView() - .frame(width: 800, height: 600) -} diff --git a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift b/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift deleted file mode 100644 index 98e92c76..00000000 --- a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift +++ /dev/null @@ -1,169 +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(.borderedProminentWhite) - .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 - ) - 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/MCPSettings/MCPServerToolsSection.swift b/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift deleted file mode 100644 index 9641a45a..00000000 --- a/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift +++ /dev/null @@ -1,199 +0,0 @@ -import SwiftUI -import Persist -import GitHubCopilotService -import Client -import Logger - -/// Section for a single server's tools -struct MCPServerToolsSection: View { - let serverTools: MCPServerToolsCollection - @Binding var isServerEnabled: Bool - var forceExpand: Bool = false - @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 { - 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) } - ) - } - } - .onChange(of: serverTools) { newValue in - initializeToolStates(server: newValue) - } - } - - - var body: some View { - 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) { - serverToggle.padding(.leading, 12) - divider.padding(.top, 4) - } - } else { - // Regular DisclosureGroup for non-error state - DisclosureGroup(isExpanded: $isExpanded) { - toolsList - } label: { - serverToggle - } - .onAppear { - initializeToolStates(server: serverTools) - if forceExpand { - isExpanded = true - } - } - .onChange(of: forceExpand) { newForceExpand in - if newForceExpand { - isExpanded = true - } - } - - if !isExpanded { - divider - } - } - } - } - - private func extractErrorMessage(_ description: String) -> String { - guard let messageRange = description.range(of: "message:"), - let stackRange = description.range(of: "stack:") else { - return description - } - let start = description.index(messageRange.upperBound, offsetBy: 0) - let end = description.index(stackRange.lowerBound, offsetBy: 0) - return description[start.. Binding { - Binding( - get: { toolEnabledStates[tool.name] ?? (tool._status == .enabled) }, - set: { toolEnabledStates[tool.name] = $0 } - ) - } - - private func handleToolToggleChange(tool: MCPTool, isEnabled: Bool) { - toolEnabledStates[tool.name] = isEnabled - - // Update server state based on tool states - updateServerState() - - // Update only this specific tool status - updateToolStatus(tool: tool, isEnabled: isEnabled) - } - - private func updateServerState() { - // If any tool is enabled, server should be enabled - // If all tools are disabled, server should be disabled - let allToolsDisabled = serverTools.tools.allSatisfy { tool in - !(toolEnabledStates[tool.name] ?? (tool._status == .enabled)) - } - - isServerEnabled = !allToolsDisabled - } - - private func updateToolStatus(tool: MCPTool, isEnabled: Bool) { - let serverUpdate = UpdateMCPToolsStatusServerCollection( - name: serverTools.name, - tools: [UpdatedMCPToolsStatus(name: tool.name, status: isEnabled ? .enabled : .disabled)] - ) - - updateMCPStatus([serverUpdate]) - } - - private func updateAllToolsStatus(enabled: Bool) { - isServerEnabled = enabled - - // Get all tools for this server from the original collection - let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools - .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools - - // Update all tool states - includes both visible and filtered-out tools - for tool in allServerTools { - toolEnabledStates[tool.name] = enabled - } - - // Create status update for all tools - let serverUpdate = UpdateMCPToolsStatusServerCollection( - name: serverTools.name, - tools: allServerTools.map { - UpdatedMCPToolsStatus(name: $0.name, status: enabled ? .enabled : .disabled) - } - ) - - updateMCPStatus([serverUpdate]) - } - - private func updateMCPStatus(_ serverUpdates: [UpdateMCPToolsStatusServerCollection]) { - // Update status in AppState and CopilotMCPToolManager - AppState.shared.updateMCPToolsStatus(serverUpdates) - - Task { - do { - let service = try getService() - try await service.updateMCPServerToolsStatus(serverUpdates) - } catch { - Logger.client.error("Failed to update MCP status: \(error.localizedDescription)") - } - } - } -} diff --git a/Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift b/Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift deleted file mode 100644 index c4f0f0f2..00000000 --- a/Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift +++ /dev/null @@ -1,159 +0,0 @@ -import SwiftUI -import Combine -import GitHubCopilotService -import Persist - -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() - ) { - let filteredServerTools = filteredMCPServerTools() - if filteredServerTools.isEmpty { - EmptyStateView() - } else { - ToolsListView( - mcpServerTools: filteredServerTools, - serverToggleStates: $serverToggleStates, - searchKey: searchText, - expandedServerNames: expandedServerNames(filteredServerTools: filteredServerTools) - ) - } - } - .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 - 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 } - } - } - - private func filteredMCPServerTools() -> [MCPServerToolsCollection] { - let key = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !key.isEmpty else { return mcpToolManager.availableMCPServerTools } - return mcpToolManager.availableMCPServerTools.compactMap { server in - let filteredTools = server.tools.filter { tool in - tool.name.lowercased().contains(key) || (tool.description?.lowercased().contains(key) ?? false) - } - if filteredTools.isEmpty { return nil } - return MCPServerToolsCollection( - name: server.name, - status: server.status, - tools: filteredTools, - error: server.error - ) - } - } - - private func expandedServerNames(filteredServerTools: [MCPServerToolsCollection]) -> Set { - // Expand all groups that have at least one tool in the filtered list - Set(filteredServerTools.map { $0.name }) - } -} - -/// Empty state view when no tools are available -private struct EmptyStateView: View { - var body: some View { - Text("No MCP tools available. Make sure your MCP server is configured correctly and running.") - .foregroundColor(.secondary) - } -} - -// 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/SharedComponents/Badge.swift b/Core/Sources/HostApp/SharedComponents/Badge.swift index d3a9dd6e..011b5fed 100644 --- a/Core/Sources/HostApp/SharedComponents/Badge.swift +++ b/Core/Sources/HostApp/SharedComponents/Badge.swift @@ -4,55 +4,88 @@ 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 + let tooltip: String? + + init(text: String, level: Level, icon: String? = nil, isSelected: Bool = false, tooltip: String? = nil) { self.text = text self.level = level self.icon = icon + self.isSelected = isSelected + self.tooltip = tooltip } } struct Badge: View { let text: String + let attributedText: AttributedString? let level: BadgeItem.Level let icon: String? - + let isSelected: Bool + let tooltip: String? + init(badgeItem: BadgeItem) { - self.text = badgeItem.text - self.level = badgeItem.level - self.icon = badgeItem.icon + text = badgeItem.text + attributedText = nil + level = badgeItem.level + icon = badgeItem.icon + isSelected = badgeItem.isSelected + tooltip = badgeItem.tooltip } - - init(text: String, level: BadgeItem.Level, icon: String? = nil) { + + init(text: String, level: BadgeItem.Level, icon: String? = nil, isSelected: Bool = false, tooltip: String? = nil) { self.text = text + self.attributedText = nil self.level = level self.icon = icon + self.isSelected = isSelected + self.tooltip = tooltip } + init(attributedText: AttributedString, level: BadgeItem.Level, icon: String? = nil, isSelected: Bool = false, tooltip: String? = nil) { + self.text = String(attributedText.characters) + self.attributedText = attributedText + self.level = level + self.icon = icon + self.isSelected = isSelected + self.tooltip = tooltip + } + 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) + } + if let attributedText = attributedText, attributedText.characters.count > 0 { + Text(attributedText) + .fontWeight(.semibold) + .font(.caption2) + .lineLimit(1) + .truncationMode(.middle) + } else if !text.isEmpty { + Text(text) + .fontWeight(.semibold) + .font(.caption2) + .lineLimit(1) } - Text(text) - .fontWeight(.semibold) - .font(.system(size: 11)) - .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 +96,12 @@ 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 + ) ) + .help(tooltip ?? text) } } diff --git a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift index 85205d04..7ab60d87 100644 --- a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift +++ b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift @@ -1,23 +1,30 @@ import SwiftUI +import SharedUIComponents 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) { configuration.label.foregroundColor(.primary) configuration.content.foregroundColor(.primary) } - .padding(8) + .padding(.vertical, 12) + .padding(.horizontal, 20) .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..b033c1dc --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift @@ -0,0 +1,78 @@ +import SwiftUI +import SharedUIComponents + +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..a3dc805d 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift @@ -4,14 +4,38 @@ struct SettingsToggle: View { static let defaultPadding: CGFloat = 10 let title: String + let subtitle: String? let isOn: Binding + let badge: BadgeItem? + + init(title: String, subtitle: String? = nil, isOn: Binding, badge: BadgeItem? = nil) { + self.title = title + self.subtitle = subtitle + self.isOn = isOn + self.badge = badge + } var body: some View { HStack(alignment: .center) { - Text(title) + VStack(alignment: .leading) { + HStack(spacing: 6) { + Text(title).font(.body) + + if let badge = badge { + Badge(badgeItem: badge) + .allowsHitTesting(true) + } + } + + if let subtitle = subtitle { + Text(subtitle).font(.footnote) + } + } Spacer() Toggle(isOn: isOn) {} + .controlSize(.mini) .toggleStyle(.switch) + .padding(.vertical, 4) } .padding(SettingsToggle.defaultPadding) } diff --git a/Core/Sources/HostApp/SharedComponents/SplitButton.swift b/Core/Sources/HostApp/SharedComponents/SplitButton.swift new file mode 100644 index 00000000..3f9897fb --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/SplitButton.swift @@ -0,0 +1,116 @@ +import SwiftUI +import AppKit + +// MARK: - SplitButton Menu Item + +public struct SplitButtonMenuItem: Identifiable { + public let id = UUID() + public let title: String + public let action: () -> Void + + public init(title: String, action: @escaping () -> Void) { + self.title = title + self.action = action + } +} + +// MARK: - SplitButton using NSComboButton + +@available(macOS 13.0, *) +public struct SplitButton: NSViewRepresentable { + let title: String + let primaryAction: () -> Void + let isDisabled: Bool + let menuItems: [SplitButtonMenuItem] + + public init( + title: String, + isDisabled: Bool = false, + primaryAction: @escaping () -> Void, + menuItems: [SplitButtonMenuItem] = [] + ) { + self.title = title + self.isDisabled = isDisabled + self.primaryAction = primaryAction + self.menuItems = menuItems + } + + public func makeNSView(context: Context) -> NSComboButton { + let button = NSComboButton() + + button.title = title + button.target = context.coordinator + button.action = #selector(Coordinator.handlePrimaryAction) + button.isEnabled = !isDisabled + + + context.coordinator.button = button + context.coordinator.updateMenu(with: menuItems) + + return button + } + + public func updateNSView(_ nsView: NSComboButton, context: Context) { + nsView.title = title + nsView.isEnabled = !isDisabled + context.coordinator.updateMenu(with: menuItems) + } + + public func makeCoordinator() -> Coordinator { + Coordinator(primaryAction: primaryAction) + } + + public class Coordinator: NSObject { + let primaryAction: () -> Void + weak var button: NSComboButton? + private var menuItemActions: [UUID: () -> Void] = [:] + + init(primaryAction: @escaping () -> Void) { + self.primaryAction = primaryAction + } + + @objc func handlePrimaryAction() { + primaryAction() + } + + @objc func handleMenuItemAction(_ sender: NSMenuItem) { + if let itemId = sender.representedObject as? UUID, + let action = menuItemActions[itemId] { + action() + } + } + + func updateMenu(with items: [SplitButtonMenuItem]) { + let menu = NSMenu() + menuItemActions.removeAll() + + // Add fixed menu title if there are items + if !items.isEmpty { + if #available(macOS 14.0, *) { + let headerItem = NSMenuItem.sectionHeader(title: "Install Server With") + menu.addItem(headerItem) + } else { + let headerItem = NSMenuItem() + headerItem.title = "Install Server With" + headerItem.isEnabled = false + menu.addItem(headerItem) + } + + // Add menu items + for item in items { + let menuItem = NSMenuItem( + title: item.title, + action: #selector(handleMenuItemAction(_:)), + keyEquivalent: "" + ) + menuItem.target = self + menuItem.representedObject = item.id + menuItemActions[item.id] = item.action + menu.addItem(menuItem) + } + } + + button?.menu = menu + } + } +} diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 0aa3b008..c4a372cd 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -15,9 +15,9 @@ public let hostAppStore: StoreOf = .init(initialState: .init(), reducer public struct TabContainer: View { let store: StoreOf @ObservedObject var toastController: ToastController + @ObservedObject private var featureFlags = FeatureFlagManager.shared @State private var tabBarItems = [TabBarItem]() - @State private var isAgentModeFFEnabled = true - @Binding var tag: Int + @Binding var tag: TabIndex public init() { toastController = ToastControllerDependencyKey.liveValue @@ -36,19 +36,6 @@ 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?.agentMode ?? 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 { @@ -56,24 +43,13 @@ 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" - ) - if isAgentModeFFEnabled { - MCPConfigView().tabBarItem( - tag: 2, - title: "MCP", - image: "wrench.and.screwdriver.fill" - ) + GeneralView(store: store.scope(state: \.general, action: \.general)).tabBarItem(for: .general) + AdvancedSettings().tabBarItem(for: .advanced) + if featureFlags.isAgentModeEnabled { + MCPConfigView().tabBarItem(for: .tools) + } + if featureFlags.isBYOKEnabled { + BYOKConfigView().tabBarItem(for: .byok) } } .environment(\.tabBarTabTag, tag) @@ -81,30 +57,30 @@ 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 } .onAppear { store.send(.appear) - Task { - await updateAgentModeFeatureFlag() + } + .onChange(of: featureFlags.isAgentModeEnabled) { isEnabled in + if hostAppStore.state.activeTabIndex == .tools && !isEnabled { + hostAppStore.send(.setActiveTab(.general)) } } - .onReceive(DistributedNotificationCenter.default() - .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in - Task { - await updateAgentModeFeatureFlag() - } + .onChange(of: featureFlags.isBYOKEnabled) { isEnabled in + if hostAppStore.state.activeTabIndex == .byok && !isEnabled { + hostAppStore.send(.setActiveTab(.general)) } + } } } } struct TabBar: View { - @Binding var tag: Int + @Binding var tag: TabIndex fileprivate var tabBarItems: [TabBarItem] var body: some View { @@ -123,9 +99,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 +132,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 +153,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 +175,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 +202,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/HostApp/ToolsConfigView.swift b/Core/Sources/HostApp/ToolsConfigView.swift new file mode 100644 index 00000000..6308b5b4 --- /dev/null +++ b/Core/Sources/HostApp/ToolsConfigView.swift @@ -0,0 +1,272 @@ +import Client +import ComposableArchitecture +import ConversationServiceProvider +import Foundation +import GitHubCopilotService +import Logger +import Persist +import SharedUIComponents +import SwiftUI +import SystemUtils +import Toast + +struct MCPConfigView: View { + @State private var mcpConfig: String = "" + @Environment(\.toast) var toast + @ObservedObject private var featureFlags = FeatureFlagManager.shared + @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared + @State private var configFilePath: String = mcpConfigFilePath + @State private var isMonitoring: Bool = false + @State private var lastModificationDate: Date? = nil + @State private var fileMonitorTask: Task? = nil + @State private var selectedOption = ToolType.MCP + @State private var selectedMode: ConversationMode = .defaultAgent + @Environment(\.colorScheme) var colorScheme + + private var isCustomAgentEnabled: Bool { + featureFlags.isEditorPreviewEnabled && copilotPolicy.isCustomAgentEnabled + } + + private static var lastSyncTimestamp: Date? = nil + @State private var debounceTimer: Timer? + private static let refreshDebounceInterval: TimeInterval = 1.0 // 1.0 second debounce + + enum ToolType: String, CaseIterable, Identifiable { + case MCP, BuiltIn + var id: Self { self } + } + + var body: some View { + WithPerceptionTracking { + ScrollView { + Picker("", selection: $selectedOption) { + if #available(macOS 26.0, *) { + Text("MCP".padded(centerTo: 24, with: "\u{2002}")).tag(ToolType.MCP) + Text("Built-In".padded(centerTo: 24, with: "\u{2002}")).tag(ToolType.BuiltIn) + } else { + Text("MCP").tag(ToolType.MCP) + Text("Built-In").tag(ToolType.BuiltIn) + } + } + .frame(width: 400) + .labelsHidden() + .pickerStyle(.segmented) + .padding(.top, 12) + .padding(.bottom, 4) + + Group { + if selectedOption == .MCP { + VStack(alignment: .leading, spacing: 8) { + MCPIntroView(isMCPFFEnabled: featureFlags.isMCPEnabled) + if featureFlags.isMCPEnabled { + MCPManualInstallView() + + if featureFlags.isEditorPreviewEnabled && ( SystemUtils.isPrereleaseBuild || SystemUtils.isDeveloperMode ) { + MCPRegistryURLView() + } + + MCPToolsListView( + selectedMode: $selectedMode, + isCustomAgentEnabled: isCustomAgentEnabled + ) + + HStack { + Spacer() + AdaptiveHelpLink(action: { NSWorkspace.shared.open( + URL(string: "https://modelcontextprotocol.io/introduction")! + ) }) + } + } + } + .onAppear { + setupConfigFilePath() + if featureFlags.isMCPEnabled { + startMonitoringConfigFile() + } + } + .onDisappear { + stopMonitoringConfigFile() + } + .onChange(of: featureFlags.isMCPEnabled) { newMCPFFEnabled in + if newMCPFFEnabled { + startMonitoringConfigFile() + refreshConfiguration() + } else { + stopMonitoringConfigFile() + } + } + .onChange(of: isCustomAgentEnabled) { isEnabled in + if !isEnabled && !selectedMode.isDefaultAgent { + selectedMode = .defaultAgent + } + } + } else { + BuiltInToolsListView( + selectedMode: $selectedMode, + isCustomAgentEnabled: isCustomAgentEnabled + ) + } + } + .padding(.horizontal, 20) + } + } + } + + private func setupConfigFilePath() { + let fileManager = FileManager.default + + if !fileManager.fileExists(atPath: configDirectory.path) { + try? fileManager.createDirectory(at: configDirectory, withIntermediateDirectories: true) + } + + // If the file doesn't exist, create one with a proper structure + let configFileURL = URL(fileURLWithPath: configFilePath) + if !fileManager.fileExists(atPath: configFilePath) { + try? """ + { + "servers": { + + } + } + """.write(to: configFileURL, atomically: true, encoding: .utf8) + } + + // Read the current content from file and ensure it's valid JSON + mcpConfig = readAndValidateJSON(from: configFileURL) ?? "{}" + + // Get initial modification date + lastModificationDate = getFileModificationDate(url: configFileURL) + } + + /// Reads file content and validates it as JSON, returning only the "servers" object + private func readAndValidateJSON(from url: URL) -> String? { + guard let data = try? Data(contentsOf: url) else { + return nil + } + + // Try to parse as JSON to validate + do { + // First verify it's valid JSON + let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + // Extract the "servers" object + guard let servers = jsonObject?["servers"] as? [String: Any] else { + Logger.client.info("No 'servers' key found in MCP configuration") + toast("No 'servers' key found in MCP configuration", .error) + // Return empty object if no servers section + return "{}" + } + + // Convert the servers object back to JSON data + let serversData = try JSONSerialization.data( + withJSONObject: servers, options: [.prettyPrinted]) + + // Return as a string + return String(data: serversData, encoding: .utf8) + } catch { + // If parsing fails, return nil + Logger.client.info("Parsing MCP JSON error: \(error)") + toast("Invalid JSON in MCP configuration file", .error) + return nil + } + } + + private func getFileModificationDate(url: URL) -> Date? { + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + return attributes?[.modificationDate] as? Date + } + + private func startMonitoringConfigFile() { + stopMonitoringConfigFile() // Stop existing monitoring if any + + isMonitoring = true + Logger.client.info("Starting MCP config file monitoring") + + fileMonitorTask = Task { + let configFileURL = URL(fileURLWithPath: configFilePath) + + // Check for file changes periodically + while isMonitoring { + try? await Task.sleep(nanoseconds: 3_000_000_000) // Check every 1 second for better responsiveness + + guard isMonitoring else { break } // Extra check after sleep + + let currentDate = getFileModificationDate(url: configFileURL) + + if let currentDate = currentDate, currentDate != lastModificationDate { + // File modification date has changed, update our record + Logger.client.info("MCP config file change detected") + lastModificationDate = currentDate + + // Read and validate the updated content + if let validJson = readAndValidateJSON(from: configFileURL) { + await MainActor.run { + mcpConfig = validJson + refreshConfiguration() + toast("MCP configuration file updated", .info) + } + } else { + // If JSON is invalid, show error + await MainActor.run { + toast("Invalid JSON in MCP configuration file", .error) + Logger.client.info("Invalid JSON detected during monitoring") + } + } + } + } + Logger.client.info("Stopped MCP config file monitoring") + } + } + + private func stopMonitoringConfigFile() { + guard isMonitoring else { return } + Logger.client.info("Stopping MCP config file monitoring") + isMonitoring = false + fileMonitorTask?.cancel() + fileMonitorTask = nil + } + + func refreshConfiguration() { + 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) + } + + // Debounce the refresh notification to avoid sending too frequently + debounceTimer?.invalidate() + debounceTimer = Timer.scheduledTimer(withTimeInterval: MCPConfigView.refreshDebounceInterval, repeats: false) { _ in + Task { + do { + let service = try getService() + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + await MainActor.run { + toast("Fetching MCP tools...", .info) + } + } catch { + await MainActor.run { + toast(error.localizedDescription, .error) + } + } + } + } + } +} + +extension String { + func padded(centerTo total: Int, with pad: Character = " ") -> String { + guard count < total else { return self } + let deficit = total - count + let left = deficit / 2 + let right = deficit - left + return String(repeating: pad, count: left) + self + String(repeating: pad, count: right) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AgentModeDescriptionView.swift b/Core/Sources/HostApp/ToolsSettings/AgentModeDescriptionView.swift new file mode 100644 index 00000000..3ae5239e --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AgentModeDescriptionView.swift @@ -0,0 +1,34 @@ +import SwiftUI +import ConversationServiceProvider + +struct AgentModeDescription { + static func descriptionText(for mode: ConversationMode) -> String { + // Check if it's the built-in "Agent" mode + if mode.isDefaultAgent { + return "The selected tools will be applied globally for all chat sessions that use the default agent." + } + + // Check if it's a custom mode + if !mode.isBuiltIn { + return "The selected tools are configured by the '\(mode.name)' custom agent. Changes to the tools will be applied to the custom agent file as well." + } + + // Other built-in modes (like Plan, etc.) + return "The selected tools are configured by the '\(mode.name)' agent. Changes to the tools are not allowed for now." + } +} + +/// Shared description view for agent modes +struct AgentModeDescriptionView: View { + let selectedMode: ConversationMode + let isLoadingMode: Bool + + var body: some View { + if !isLoadingMode { + Text(AgentModeDescription.descriptionText(for: selectedMode)) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AgentModeDropdownView.swift b/Core/Sources/HostApp/ToolsSettings/AgentModeDropdownView.swift new file mode 100644 index 00000000..69a028d9 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AgentModeDropdownView.swift @@ -0,0 +1,87 @@ +import Client +import ConversationServiceProvider +import HostAppActivator +import Logger +import Persist +import SwiftUI + +struct AgentModeDropdown: View { + @Binding var modes: [ConversationMode] + @Binding var selectedMode: ConversationMode + + public init(modes: Binding<[ConversationMode]>, selectedMode: Binding) { + _modes = modes + _selectedMode = selectedMode + } + + var builtInModes: [ConversationMode] { + modes.filter { $0.isBuiltIn } + } + + var customModes: [ConversationMode] { + modes.filter { !$0.isBuiltIn } + } + + var body: some View { + Picker(selection: Binding( + get: { selectedMode.id }, + set: { newId in + if let mode = modes.first(where: { $0.id == newId }) { + selectedMode = mode + } + } + )) { + ForEach(builtInModes, id: \.id) { mode in + Text(mode.name).tag(mode.id) + } + + if !customModes.isEmpty { + Divider() + ForEach(customModes, id: \.id) { mode in + Text(mode.name).tag(mode.id) + } + } + } label: { + Text("Applied for").fontWeight(.bold) + } + .pickerStyle(.menu) + .frame(maxWidth: 300, alignment: .leading) + .padding(.leading, -4) + .onAppear { + loadModes() + } + .onReceive(DistributedNotificationCenter.default().publisher(for: .selectedAgentSubModeDidChange)) { notification in + if let userInfo = notification.userInfo as? [String: String], + let newModeId = userInfo["agentSubMode"], + newModeId != selectedMode.id, + let mode = modes.first(where: { $0.id == newModeId }) { + Logger.client.info("AgentModeDropdown: Mode changed to: \(newModeId)") + selectedMode = mode + } + } + } + + // MARK: - Helper Methods + + private func loadModes() { + Task { + do { + let service = try getService() + let workspaceFolders = await getWorkspaceFolders() + if let fetchedModes = try await service.getModes(workspaceFolders: workspaceFolders) { + Logger.client.info("AgentModeDropdown: Fetched \(fetchedModes.count) modes") + await MainActor.run { + modes = fetchedModes.filter { $0.kind == .Agent } + + if !modes.contains(where: { $0.id == selectedMode.id }), + let firstMode = modes.first { + selectedMode = firstMode + } + } + } + } catch { + Logger.client.error("AgentModeDropdown: Failed to load modes: \(error.localizedDescription)") + } + } + } +} 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..6cdd7a0c --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift @@ -0,0 +1,222 @@ +import Client +import Combine +import ConversationServiceProvider +import GitHubCopilotService +import Logger +import Persist +import SwiftUI +import SharedUIComponents + +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] = [:] + @State private var modes: [ConversationMode] = [] + @Binding var selectedMode: ConversationMode + let isCustomAgentEnabled: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + GroupBox(label: headerView) { + contentView + } + .groupBoxStyle(CardGroupBoxStyle()) + } + .onAppear { + initializeToolStates() + // Refresh client tools to get any late-arriving server tools + Task { + do { + let service = try getService() + _ = try await service.refreshClientTools() + } catch { + Logger.client.error("Failed to refresh client tools: \(error)") + } + } + } + .onChange(of: builtInToolManager.availableLanguageModelTools) { _ in + initializeToolStates() + } + .onChange(of: selectedMode) { _ in + toolEnabledStates = [:] // Clear state immediately + initializeToolStates() + } + .onReceive(DistributedNotificationCenter.default().publisher(for: .gitHubCopilotCustomAgentToolsDidChange)) { _ in + Logger.client.info("Custom agent tools change notification received in BuiltInToolsListView") + if !selectedMode.isDefaultAgent { + Task { + await reloadModesAndUpdateStates() + } + } + } + } + + // MARK: - Header View + + private var headerView: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center) { + Text("Built-In Tools").fontWeight(.bold) + if isCustomAgentEnabled { + AgentModeDropdown(modes: $modes, selectedMode: $selectedMode) + } + Spacer() + CollapsibleSearchField(searchText: $searchText, isExpanded: $isSearchBarVisible) + } + .clipped() + + AgentModeDescriptionView(selectedMode: selectedMode, isLoadingMode: false) + } + } + + // 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), + isInteractionAllowed: isInteractionAllowed(), + onToolToggleChanged: { isEnabled in + handleToolToggleChange(tool: tool, isEnabled: isEnabled) + } + ) + } + } + } + + // MARK: - Helper Methods + + private func initializeToolStates() { + // When mode changes, recalculate everything from scratch + var map: [String: Bool] = [:] + for tool in builtInToolManager.availableLanguageModelTools { + map[tool.name] = isToolEnabledInMode(tool) + } + toolEnabledStates = map + } + + private func toolBindingFor(_ tool: LanguageModelTool) -> Binding { + Binding( + get: { + toolEnabledStates[tool.name] ?? isToolEnabledInMode(tool) + }, + 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) { + let toolUpdate = ToolStatusUpdate(name: tool.name, status: isEnabled ? .enabled : .disabled) + updateToolStatus([toolUpdate]) + } + + private func updateToolStatus(_ toolUpdates: [ToolStatusUpdate]) { + Task { + do { + let service = try getService() + + if !selectedMode.isDefaultAgent { + let chatMode = selectedMode.kind + let customChatModeId = selectedMode.isBuiltIn == false ? selectedMode.id : nil + let workspaceFolders = await getWorkspaceFolders() + + let updatedTools = try await service + .updateToolsStatus( + toolUpdates, + chatAgentMode: chatMode, + customChatModeId: customChatModeId, + workspaceFolders: workspaceFolders + ) + + if updatedTools == nil { + Logger.client.error("Failed to update built-in tool status: No updated tools returned") + } + + await reloadModesAndUpdateStates() + } else { + let updatedTools = try await service.updateToolsStatus(toolUpdates) + if updatedTools == nil { + Logger.client.error("Failed to update built-in tool status: No updated tools returned") + } + } + } catch { + Logger.client.error("Failed to update built-in tool status: \(error.localizedDescription)") + } + } + } + + @MainActor + private func reloadModesAndUpdateStates() async { + do { + let service = try getService() + let workspaceFolders = await getWorkspaceFolders() + if let fetchedModes = try await service.getModes(workspaceFolders: workspaceFolders) { + modes = fetchedModes.filter { $0.kind == .Agent } + + if let updatedMode = modes.first(where: { $0.id == selectedMode.id }) { + selectedMode = updatedMode + + for tool in builtInToolManager.availableLanguageModelTools { + if let customTools = updatedMode.customTools { + toolEnabledStates[tool.name] = customTools.contains(tool.name) + } else { + toolEnabledStates[tool.name] = false + } + } + } + } + } catch { + Logger.client.error("Failed to reload modes: \(error.localizedDescription)") + } + } + + private func isToolEnabledInMode(_ tool: LanguageModelTool) -> Bool { + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: tool.name, + currentStatus: tool.status, + selectedMode: selectedMode + ) + } + + private func isInteractionAllowed() -> Bool { + return AgentModeToolHelpers.isInteractionAllowed(selectedMode: selectedMode) + } +} + +/// 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 76% rename from Core/Sources/HostApp/MCPSettings/CopilotMCPToolManagerObservable.swift rename to Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift index d493b8be..cc6e745d 100644 --- a/Core/Sources/HostApp/MCPSettings/CopilotMCPToolManagerObservable.swift +++ b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift @@ -17,6 +17,7 @@ class CopilotMCPToolManagerObservable: ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } + Logger.client.info("MCP tools change notification received") Task { await self.refreshMCPServerTools() } @@ -31,6 +32,7 @@ class CopilotMCPToolManagerObservable: ObservableObject { @MainActor private func refreshMCPServerTools() async { + Logger.client.info("Refreshing MCP server tools...") do { let service = try getService() let mcpTools = try await service.getAvailableMCPServerToolsCollections() @@ -43,9 +45,14 @@ class CopilotMCPToolManagerObservable: ObservableObject { private func refreshTools(tools: [MCPServerToolsCollection]?) { guard let tools = tools else { // nil means the tools data is ready, and skip it first. + Logger.client.info("MCP tools data not ready yet, skipping refresh") return } + let totalToolsCount = tools.reduce(0) { $0 + $1.tools.count } + let serverNames = tools.map { $0.name }.joined(separator: ", ") + Logger.client.info("Refreshed MCP tools - Servers: \(tools.count), Total tools: \(totalToolsCount), Server names: [\(serverNames)]") + AppState.shared.cleanupMCPToolsStatus(availableTools: tools) AppState.shared.createMCPToolsStatus(tools) self.availableMCPServerTools = tools diff --git a/Core/Sources/HostApp/MCPSettings/MCPAppState.swift b/Core/Sources/HostApp/ToolsSettings/MCPAppState.swift similarity index 98% rename from Core/Sources/HostApp/MCPSettings/MCPAppState.swift rename to Core/Sources/HostApp/ToolsSettings/MCPAppState.swift index f6d16d98..05b6ad84 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPAppState.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPAppState.swift @@ -95,10 +95,10 @@ extension AppState { // Get all available server names and their respective tool names let availableServerMap = Dictionary( - uniqueKeysWithValues: availableTools.map { collection in + availableTools.map { collection in (collection.name, Set(collection.tools.map { $0.name })) } - ) + ) { first, _ in first } // Remove servers that don't exist in available tools existingServers.removeAll { !availableServerMap.keys.contains($0.name) } 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..ac84bcce --- /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 { + let isMCPFFEnabled: Bool + + public init(isMCPFFEnabled: Bool) { + 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) + ) + ) + } + } + } +} + +#Preview { + MCPIntroView(isMCPFFEnabled: true) + .frame(width: 800) +} + +#Preview { + MCPIntroView(isMCPFFEnabled: 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..80ea7589 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift @@ -0,0 +1,153 @@ +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 MCP configuration section" : "Expand MCP configuration section" }, + title: { Text("MCP Configuration").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) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift new file mode 100644 index 00000000..61aa5885 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift @@ -0,0 +1,392 @@ +import Client +import Foundation +import GitHubCopilotService +import Logger +import SwiftUI + +// MARK: - Installation Option + +public struct InstallationOption { + public let displayName: String + public let description: String + public let config: [String: Any] + public let isDefault: Bool + + public init(displayName: String, description: String, config: [String: Any], isDefault: Bool = false) { + self.displayName = displayName + self.description = description + self.config = config + self.isDefault = isDefault + } +} + +// MARK: - Registry Types + +private struct RegistryType { + let displayName: String + let commandName: String + + func buildArguments(for package: Package) -> [String] { + let identifier = package.identifier ?? "" + let version = package.version ?? "" + + switch package.registryType { + case "npm": + return ["-y", version.isEmpty ? identifier : "\(identifier)@\(version)"] + case "pypi": + return [version.isEmpty ? identifier : "\(identifier)==\(version)"] + case "oci": + return ["run", "-i", "--rm", version.isEmpty ? identifier : "\(identifier):\(version)"] + case "nuget": + var args = [version.isEmpty ? identifier : "\(identifier)@\(version)", "--yes"] + if package.packageArguments?.isEmpty == false { args.append("--") } + return args + default: + return [version.isEmpty ? identifier : "\(identifier)@\(version)"] + } + } +} + +private let registryTypes: [String: RegistryType] = [ + "npm": RegistryType(displayName: "NPM", commandName: "npx"), + "pypi": RegistryType(displayName: "PyPI", commandName: "uvx"), + "oci": RegistryType(displayName: "OCI", commandName: "docker"), + "nuget": RegistryType(displayName: "NuGet", commandName: "dnx") +] + +// MARK: - MCP Registry Service + +@MainActor +public class MCPRegistryService: ObservableObject { + public static let shared = MCPRegistryService() + @AppStorage(\.mcpRegistryURL) var mcpRegistryURL + + private init() {} + + public static func getServerId(from serverDetail: MCPRegistryServerDetail) -> String? { + return serverDetail.meta?.official?.id + } + + public func getRegistryURL() throws -> String { + let url = mcpRegistryURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !url.isEmpty else { + throw MCPRegistryError.registryURLNotConfigured + } + return url + } + + // MARK: - Installation Options + + public func getAllInstallationOptions(for serverDetail: MCPRegistryServerDetail) -> [InstallationOption] { + var options: [InstallationOption] = [] + + // Add remote options + serverDetail.remotes?.enumerated().forEach { index, remote in + let config = createServerConfig(for: serverDetail, remote: remote) + options.append(InstallationOption( + displayName: "\(remote.transportType.displayText): \(remote.url)", + description: "Connect to remote server at \(remote.url)", + config: config, + isDefault: index == 0 && options.isEmpty + )) + } + + // Add package options + serverDetail.packages?.enumerated().forEach { index, package in + let config = createServerConfig(for: serverDetail, package: package) + let registryDisplay = package.registryType?.registryDisplayText ?? "Unknown" + let identifier = package.identifier.map { " : \($0)" } ?? "" + + options.append(InstallationOption( + displayName: "\(registryDisplay)\(identifier)", + description: "Install \(package.identifier ?? "") from \(registryDisplay)", + config: config, + isDefault: index == 0 && options.isEmpty + )) + } + + return options + } + + public func createServerConfiguration(for serverDetail: MCPRegistryServerDetail) throws -> [String: Any] { + let options = getAllInstallationOptions(for: serverDetail) + guard let defaultOption = options.first(where: { $0.isDefault }) ?? options.first else { + throw MCPRegistryError.noInstallationOptionsAvailable(serverName: serverDetail.name) + } + return defaultOption.config + } + + // MARK: - Install/Uninstall Operations + + public func installMCPServer(_ serverDetail: MCPRegistryServerDetail, installationOption: InstallationOption? = nil) async throws { + Logger.client.info("Installing MCP Server '\(serverDetail.name)'...") + + let serverConfig: [String: Any] + if let option = installationOption { + serverConfig = option.config + } else { + serverConfig = try createServerConfiguration(for: serverDetail) + } + + var currentConfig = loadConfiguration() ?? [:] + if currentConfig["servers"] == nil { + currentConfig["servers"] = [String: Any]() + } + + guard var serversDict = currentConfig["servers"] as? [String: Any] else { + throw MCPRegistryError.invalidConfigurationStructure + } + + serversDict[serverDetail.name] = serverConfig + currentConfig["servers"] = serversDict + + try saveConfiguration(currentConfig) + Logger.client.info("Successfully installed MCP Server '\(serverDetail.name)'") + } + + public func uninstallMCPServer(_ serverDetail: MCPRegistryServerDetail) async throws { + Logger.client.info("Uninstalling MCP Server '\(serverDetail.name)'...") + + var currentConfig = loadConfiguration() ?? [:] + guard var serversDict = currentConfig["servers"] as? [String: Any] else { + throw MCPRegistryError.serverNotFound(serverName: serverDetail.name) + } + + guard serversDict[serverDetail.name] != nil else { + throw MCPRegistryError.serverNotFound(serverName: serverDetail.name) + } + + serversDict.removeValue(forKey: serverDetail.name) + currentConfig["servers"] = serversDict + + try saveConfiguration(currentConfig) + Logger.client.info("Successfully uninstalled MCP Server '\(serverDetail.name)'") + } + + // MARK: - Configuration Creation + + public func createServerConfig(for serverDetail: MCPRegistryServerDetail, remote: Remote) -> [String: Any] { + var config: [String: Any] = [ + "type": "http", + "url": remote.url + ] + + // Add headers if present + if let headers = remote.headers, !headers.isEmpty { + let headersDict = Dictionary(headers.map { ($0.name, $0.value ?? "") }) { first, _ in first } + config["requestInit"] = ["headers": headersDict] + } + + addMetadata(to: &config, serverDetail: serverDetail) + return config + } + + public func createServerConfig(for serverDetail: MCPRegistryServerDetail, package: Package) -> [String: Any] { + let registryType = registryTypes[package.registryType ?? ""] + let command = package.runtimeHint ?? registryType?.commandName ?? (package.registryType ?? "unknown") + + var config: [String: Any] = [ + "type": "stdio", + "command": command + ] + + // Build arguments + var args: [String] = [] + + // Runtime arguments + package.runtimeArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) } + + // Default arguments if no runtime arguments + if package.runtimeArguments?.isEmpty != false { + args.append(contentsOf: registryType?.buildArguments(for: package) ?? [package.identifier ?? ""]) + } + + // Package arguments + package.packageArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) } + + config["args"] = args + + // Environment variables + if let envVars = package.environmentVariables, !envVars.isEmpty { + config["env"] = Dictionary(envVars.map { ($0.name, $0.value ?? "") }) { first, _ in first } + } + + addMetadata(to: &config, serverDetail: serverDetail) + return config + } + + private func addMetadata(to config: inout [String: Any], serverDetail: MCPRegistryServerDetail) { + var registry: [String: Any] = [:] + + if let url = try? getRegistryURL() { + registry["url"] = url + } + + if let serverId = Self.getServerId(from: serverDetail) { + registry["serverId"] = serverId + } + + config["x-metadata"] = ["registry": registry] + } + + private func extractArgumentValues(from argument: Argument) -> [String] { + switch argument { + case let .positional(positionalArg): + return (positionalArg.value ?? positionalArg.valueHint).map { [$0] } ?? [] + case let .named(namedArg): + return [namedArg.name ?? ""] + (namedArg.value.map { [$0] } ?? []) + } + } + + // MARK: - Configuration File Management + + private func loadConfiguration() -> [String: Any]? { + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + guard FileManager.default.fileExists(atPath: mcpConfigFilePath), + let data = try? Data(contentsOf: configFileURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return jsonObject + } + + private func saveConfiguration(_ config: [String: Any]) throws { + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + + // Ensure directory exists + let configDirectory = configFileURL.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: configDirectory.path) { + try FileManager.default.createDirectory(at: configDirectory, withIntermediateDirectories: true) + } + + // Save configuration + let jsonData = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted]) + try jsonData.write(to: configFileURL) + + // Note: UserDefaults update and notification will be handled by ToolsConfigView's file monitor + // with debouncing to prevent duplicate notifications + } + + // MARK: - Server Installation Status + + public func isServerInstalled(_ serverDetail: MCPRegistryServerDetail) -> Bool { + guard let config = loadConfiguration(), + let serversDict = config["servers"] as? [String: Any], + let expectedKey = expectedRegistryKey(for: serverDetail) else { return false } + return serversDict.values.contains { (value) -> Bool in + guard let serverConfigDict = value as? [String: Any], + let key = registryKey(from: serverConfigDict) else { return false } + return key == expectedKey + } + } + + // MARK: - Option Installed Helpers + + public func isPackageOptionInstalled(serverDetail: MCPRegistryServerDetail, package: Package) -> Bool { + guard isServerInstalled(serverDetail), + let config = loadConfiguration(), + let serversDict = config["servers"] as? [String: Any], + let expectedKey = expectedRegistryKey(for: serverDetail) else { return false } + + let command = package.runtimeHint ?? registryTypes[package.registryType ?? ""]?.commandName ?? (package.registryType ?? "unknown") + let expectedArgsFirst: String? = { + var args: [String] = [] + package.runtimeArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) } + if package.runtimeArguments?.isEmpty != false { + args.append(contentsOf: registryTypes[package.registryType ?? ""]?.buildArguments(for: package) ?? [package.identifier ?? ""]) + } + package.packageArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) } + return args.first + }() + + return serversDict.values.contains { value in + guard let cfg = value as? [String: Any], + let key = registryKey(from: cfg), + key == expectedKey, + (cfg["type"] as? String)?.lowercased() == "stdio", + let c = cfg["command"] as? String, + let args = cfg["args"] as? [String] else { return false } + return c == command && args.first == expectedArgsFirst + } + } + + public func isRemoteOptionInstalled(serverDetail: MCPRegistryServerDetail, remote: Remote) -> Bool { + guard isServerInstalled(serverDetail), + let config = loadConfiguration(), + let serversDict = config["servers"] as? [String: Any], + let expectedKey = expectedRegistryKey(for: serverDetail) else { return false } + + return serversDict.values.contains { value in + guard let cfg = value as? [String: Any], + let key = registryKey(from: cfg), + key == expectedKey, + (cfg["type"] as? String)?.lowercased() == "http", + let url = cfg["url"] as? String else { return false } + return url == remote.url + } + } + + public func createRegistryServerKey(registryURL: String, serverId: String) -> String { + let baseURL = normalizeRegistryURL(registryURL) + return "\(baseURL)|\(serverId)" + } + + // MARK: - Registry Key Helpers + + private func normalizeRegistryURL(_ url: String) -> String { + // Remove trailing /v0/servers, /v0.1/servers or similar version paths + var normalized = url.trimmingCharacters(in: .whitespacesAndNewlines) + if let range = normalized.range(of: "/v\\d+(\\.\\d+)?/servers$", options: .regularExpression) { + normalized = String(normalized[.. String? { + guard let serverId = Self.getServerId(from: serverDetail), + let registryURL = try? getRegistryURL() else { return nil } + return createRegistryServerKey(registryURL: registryURL, serverId: serverId) + } + + private func registryKey(from serverConfig: [String: Any]) -> String? { + guard let metadata = serverConfig["x-metadata"] as? [String: Any], + let registry = metadata["registry"] as? [String: Any], + let url = registry["url"] as? String, + let serverId = registry["serverId"] as? String else { return nil } + return createRegistryServerKey(registryURL: url, serverId: serverId) + } +} + +// MARK: - Error Types + +public enum MCPRegistryError: LocalizedError { + case registryURLNotConfigured + case noInstallationOptionsAvailable(serverName: String) + case invalidConfigurationStructure + case serverNotFound(serverName: String) + case configurationFileError(String) + + public var errorDescription: String? { + switch self { + case .registryURLNotConfigured: + return "MCP Registry URL is not configured. Please configure the registry URL in Settings > Tools > GitHub Copilot > MCP to browse and install servers from the registry." + case let .noInstallationOptionsAvailable(serverName): + return "Cannot create server configuration for '\(serverName)' - no installation options available" + case .invalidConfigurationStructure: + return "Invalid MCP configuration file structure" + case let .serverNotFound(serverName): + return "MCP Server '\(serverName)' not found in configuration" + case let .configurationFileError(message): + return "Configuration file error: \(message)" + } + } +} + +// MARK: - Extensions + +extension String { + var registryDisplayText: String { + return registryTypes[self]?.displayName ?? self.capitalized + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift new file mode 100644 index 00000000..0f2101a6 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift @@ -0,0 +1,160 @@ +import GitHubCopilotService +import SwiftUI +import SharedUIComponents + +struct MCPRegistryURLInputField: View { + @Binding var urlText: String + @AppStorage(\.mcpRegistryURLHistory) private var urlHistory + @State private var showHistory: Bool = false + @FocusState private var isFocused: Bool + + let defaultMCPRegistryURL = "https://api.mcp.github.com/2025-09-15/v0/servers" + let maxURLLength: Int + let isSheet: Bool + let mcpRegistryEntry: MCPRegistryEntry? + let onValidationChange: ((Bool) -> Void)? + let onCommit: (() -> Void)? + + private var isRegistryOnly: Bool { + mcpRegistryEntry?.registryAccess == .registryOnly + } + + init( + urlText: Binding, + maxURLLength: Int = 2048, + isSheet: Bool = false, + mcpRegistryEntry: MCPRegistryEntry? = nil, + onValidationChange: ((Bool) -> Void)? = nil, + onCommit: (() -> Void)? = nil + ) { + self._urlText = urlText + self.maxURLLength = maxURLLength + self.isSheet = isSheet + self.mcpRegistryEntry = mcpRegistryEntry + self.onValidationChange = onValidationChange + self.onCommit = onCommit + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + if isSheet { + TextFieldsContainer { + TextField("MCP Registry URL", text: $urlText) + .focused($isFocused) + .disabled(isRegistryOnly) + .onChange(of: urlText) { newValue in + handleURLChange(newValue) + } + .onSubmit { + onCommit?() + } + } + } else { + TextField("MCP Registry URL:", text: $urlText) + .textFieldStyle(.roundedBorder) + .focused($isFocused) + .disabled(isRegistryOnly) + .onChange(of: urlText) { newValue in + handleURLChange(newValue) + } + .onSubmit { + onCommit?() + } + } + + Menu { + ForEach(urlHistory, id: \.self) { url in + Button(url) { + urlText = url + isFocused = false + onCommit?() + } + } + + Divider() + + Button("Reset to Default") { + urlText = defaultMCPRegistryURL + onCommit?() + } + + if !urlHistory.isEmpty { + Button("Clear History") { + urlHistory = [] + } + } + } label: { + Image(systemName: "chevron.down") + .resizable() + .scaledToFit() + .frame(width: 11, height: 11) + .padding(isSheet ? 9 : 3) + } + .labelStyle(.iconOnly) + .menuIndicator(.hidden) + .buttonStyle( + HoverButtonStyle( + hoverColor: SecondarySystemFillColor, + backgroundColor: SecondarySystemFillColor, + cornerRadius: isSheet ? 12 : 6 + ) + ) + .opacity(isRegistryOnly ? 0.5 : 1) + .disabled(isRegistryOnly) + } + + if isRegistryOnly { + Badge( + text: "This URL is managed by \(mcpRegistryEntry!.owner.login) and cannot be modified", + level: .info, + icon: "info.circle.fill" + ) + } + } + .onAppear { + if isRegistryOnly, let entryURL = mcpRegistryEntry?.url { + urlText = entryURL + } + } + .onChange(of: mcpRegistryEntry) { newEntry in + if newEntry?.registryAccess == .registryOnly, let entryURL = newEntry?.url { + urlText = entryURL + } + } + } + + private func handleURLChange(_ newValue: String) { + // If registryOnly, force the URL back to the registry entry URL + if isRegistryOnly, let entryURL = mcpRegistryEntry?.url { + urlText = entryURL + return + } + + let limitedText = String(newValue.prefix(maxURLLength)) + if limitedText != newValue { + urlText = limitedText + } + + let isValid = limitedText.isEmpty || isValidURL(limitedText) + onValidationChange?(isValid) + } + + private func isValidURL(_ string: String) -> Bool { + guard !string.isEmpty else { return true } + return URL(string: string) != nil && (string.hasPrefix("http://") || string.hasPrefix("https://")) + } +} + +extension Array where Element == String { + mutating func addToHistory(_ url: String, maxItems: Int = 10) { + // Remove if already exists + removeAll { $0 == url } + // Add to beginning + insert(url, at: 0) + // Keep only maxItems + if count > maxItems { + removeLast(count - maxItems) + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift new file mode 100644 index 00000000..8e1c9954 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift @@ -0,0 +1,73 @@ +import GitHubCopilotService +import SwiftUI +import SharedUIComponents + +struct MCPRegistryURLSheet: View { + @AppStorage(\.mcpRegistryURL) private var mcpRegistryURL + @AppStorage(\.mcpRegistryURLHistory) private var mcpRegistryURLHistory + @Environment(\.dismiss) private var dismiss + @State private var originalMcpRegistryURL: String = "" + @State private var isFormValid: Bool = true + + let mcpRegistryEntry: MCPRegistryEntry? + let onURLUpdated: (() -> Void)? + + init(mcpRegistryEntry: MCPRegistryEntry? = nil, onURLUpdated: (() -> Void)? = nil) { + self.mcpRegistryEntry = mcpRegistryEntry + self.onURLUpdated = onURLUpdated + } + + var body: some View { + Form { + VStack(alignment: .center, spacing: 20) { + HStack(alignment: .center) { + Spacer() + Text("MCP Registry URL").font(.headline) + Spacer() + AdaptiveHelpLink(action: openHelpLink) + } + + VStack(alignment: .leading, spacing: 4) { + MCPRegistryURLInputField( + urlText: $originalMcpRegistryURL, + isSheet: true, + mcpRegistryEntry: mcpRegistryEntry, + onValidationChange: { isValid in + isFormValid = isValid + } + ) + } + + HStack(spacing: 8) { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button("Update") { + // Check if URL changed before updating + originalMcpRegistryURL = originalMcpRegistryURL.trimmingCharacters(in: .whitespacesAndNewlines) + if originalMcpRegistryURL != mcpRegistryURL { + mcpRegistryURL = originalMcpRegistryURL + onURLUpdated?() + } + dismiss() + } + .buttonStyle(.borderedProminent) + .disabled(!isFormValid || mcpRegistryEntry?.registryAccess == .registryOnly) + } + } + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + .padding(20) + } + .onAppear { + loadExistingURL() + } + } + + private func loadExistingURL() { + originalMcpRegistryURL = mcpRegistryURL + } + + private func openHelpLink() { + NSWorkspace.shared.open(URL(string: "https://docs.github.com/en/copilot/how-tos/provide-context/use-mcp/select-an-mcp-registry")!) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift new file mode 100644 index 00000000..c0241951 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift @@ -0,0 +1,241 @@ +import AppKit +import Logger +import SharedUIComponents +import SwiftUI +import Client +import XPCShared +import GitHubCopilotService +import ComposableArchitecture + +struct MCPRegistryURLView: View { + @State private var isExpanded: Bool = false + @AppStorage(\.mcpRegistryURL) var mcpRegistryURL + @AppStorage(\.mcpRegistryURLHistory) private var mcpRegistryURLHistory + @State private var isLoading: Bool = false + @State private var tempURLText: String = "" + @State private var errorMessage: String = "" + @State private var mcpRegistry: [MCPRegistryEntry]? = nil + + private let maxURLLength = 2048 + private let mcpRegistryUrlVersion = "/v0/servers" + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + DisclosureSettingsRow( + isExpanded: $isExpanded, + accessibilityLabel: { $0 ? "Collapse mcp registry URL section" : "Expand mcp registry URL section" }, + title: { Text("MCP Registry URL").font(.headline) + Text(" (Optional)") }, + subtitle: { Text("Connect to available MCP servers for your AI workflows using the Registry URL.") }, + actions: { + HStack(spacing: 8) { + if isLoading { + ProgressView().controlSize(.small) + } + + Button { + isExpanded = true + } label: { + HStack(spacing: 0) { + Image(systemName: "square.and.pencil") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Edit URL") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .help("Configure your MCP Registry URL") + .disabled(mcpRegistry?.first?.registryAccess == .registryOnly) + + Button { Task{ await loadMCPServers() } } label: { + HStack(spacing: 0) { + Image(systemName: "square.grid.2x2") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Browse MCP Servers...") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .help("Browse MCP Servers") + } + .padding(.vertical, 12) + } + ) + + if isExpanded { + VStack(alignment: .leading, spacing: 8) { + MCPRegistryURLInputField( + urlText: $tempURLText, + maxURLLength: maxURLLength, + isSheet: false, + mcpRegistryEntry: mcpRegistry?.first, + onValidationChange: { _ in + // Only validate, don't update mcpRegistryURL here + }, + onCommit: { + // Update mcpRegistryURL when user presses Enter + tempURLText = tempURLText.trimmingCharacters(in: .whitespacesAndNewlines) + if tempURLText != mcpRegistryURL { + mcpRegistryURL = tempURLText + } + } + ) + + if !errorMessage.isEmpty { + Badge(text: errorMessage, level: .danger, icon: "xmark.circle.fill") + } + } + .padding(.leading, 36) + .padding([.trailing, .bottom], 20) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + .onAppear { + tempURLText = mcpRegistryURL + } + } + } + .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) + .onAppear { + tempURLText = mcpRegistryURL + Task { await getMCPRegistryAllowlist() } + } + .onReceive(DistributedNotificationCenter.default().publisher(for: .authStatusDidChange)) { _ in + Task { await getMCPRegistryAllowlist() } + } + .onChange(of: mcpRegistryURL) { newValue in + // Update the temp text to reflect the new URL + tempURLText = newValue + Task { await updateGalleryWindowIfOpen() } + } + .onChange(of: mcpRegistry) { _ in + Task { await updateGalleryWindowIfOpen() } + } + } + } + + private func loadMCPServers() async { + // Update mcpRegistryURL with current tempURLText before loading + tempURLText = tempURLText.trimmingCharacters(in: .whitespacesAndNewlines) + if tempURLText != mcpRegistryURL { + mcpRegistryURL = tempURLText + } + + isLoading = true + defer { isLoading = false } + do { + let service = try getService() + let serverList = try await service.listMCPRegistryServers( + .init(baseUrl: mcpRegistryURL, limit: 30) + ) + + guard let serverList = serverList, !serverList.servers.isEmpty else { + Logger.client.info("No MCP servers found at registry URL: \(mcpRegistryURL)") + return + } + + // Add to history on successful load + mcpRegistryURLHistory.addToHistory(mcpRegistryURL) + errorMessage = "" + + MCPServerGalleryWindow.open(serverList: serverList, mcpRegistryEntry: mcpRegistry?.first) + } catch { + Logger.client.error("Failed to load MCP servers from registry: \(error.localizedDescription)") + if let serviceError = error as? XPCExtensionServiceError { + errorMessage = serviceError.underlyingError?.localizedDescription ?? serviceError.localizedDescription + } else { + errorMessage = error.localizedDescription + } + isExpanded = true + } + } + + private func getMCPRegistryAllowlist() async { + isLoading = true + defer { isLoading = false } + do { + let service = try getService() + + // Only fetch allowlist if user is logged in + let authStatus = try await service.getXPCServiceAuthStatus() + guard authStatus?.status == .loggedIn else { + Logger.client.info("User not logged in, skipping MCP registry allowlist fetch") + return + } + + let result = try await service.getMCPRegistryAllowlist() + + guard let result = result, !result.mcpRegistries.isEmpty else { + if result == nil { + Logger.client.error("Failed to get allowlist result") + } else { + mcpRegistry = [] + } + return + } + + if let firstRegistry = result.mcpRegistries.first { + let baseUrl = firstRegistry.url.hasSuffix("/") + ? String(firstRegistry.url.dropLast()) + : firstRegistry.url + let entry = MCPRegistryEntry( + url: baseUrl + mcpRegistryUrlVersion, + registryAccess: firstRegistry.registryAccess, + owner: firstRegistry.owner + ) + mcpRegistry = [entry] + Logger.client.info("Current MCP Registry Entry: \(entry)") + + // If registryOnly, force the URL to be the registry URL + if entry.registryAccess == .registryOnly { + mcpRegistryURL = entry.url + tempURLText = entry.url + } + } + } catch { + Logger.client.error("Failed to get MCP allowlist from registry: \(error)") + } + } + + private func updateGalleryWindowIfOpen() async { + // Only update if the gallery window is currently open + guard MCPServerGalleryWindow.isOpen() else { + return + } + + isLoading = true + defer { isLoading = false } + + // Let the view model handle the entire update flow including clearing and fetching + if let error = await MCPServerGalleryWindow.refreshFromURL(mcpRegistryEntry: mcpRegistry?.first) { + // Display error in the URL view + if let serviceError = error as? XPCExtensionServiceError { + errorMessage = serviceError.underlyingError?.localizedDescription ?? serviceError.localizedDescription + } else { + errorMessage = error.localizedDescription + } + isExpanded = true + } else { + errorMessage = "" + } + } +} + +#Preview { + MCPRegistryURLView() + .padding() + .frame(width: 900) +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift new file mode 100644 index 00000000..986e9e90 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift @@ -0,0 +1,651 @@ +import SwiftUI +import AppKit +import GitHubCopilotService +import SharedUIComponents +import Foundation + +@available(macOS 13.0, *) +struct MCPServerDetailSheet: View { + let server: MCPRegistryServerDetail + @State private var selectedTab = TabType.Packages + @State private var expandedPackages: Set = [] + @State private var expandedRemotes: Set = [] + @State private var packageConfigs: [Int: [String: Any]] = [:] + @State private var remoteConfigs: [Int: [String: Any]] = [:] + // Track installation progress per item so we can disable buttons / show feedback + @State private var installingPackages: Set = [] + @State private var installingRemotes: Set = [] + // Track whether the server (any option) is already installed + @State private var isInstalled: Bool + // Overwrite confirmation alert + @State private var showOverwriteAlert: Bool = false + @State private var pendingInstallAction: (() -> Void)? = nil + + @Environment(\.dismiss) private var dismiss + + enum TabType: String, CaseIterable, Identifiable { + case Packages, Remotes, Metadata + var id: Self { self } + } + + init(server: MCPRegistryServerDetail) { + self.server = server + // Determine installed status using registry service (same logic as gallery view) + _isInstalled = State(initialValue: MCPRegistryService.shared.isServerInstalled(server)) + } + + // Shared visual constants + private let labelColumnWidth: CGFloat = 80 + private let detailTopPadding: CGFloat = 6 + + var body: some View { + VStack(spacing: 0) { + // Header + headerSection + + // Tab selector + tabSelector + + // Content + OverlayScrollView { + VStack(alignment: .leading, spacing: 16) { + switch selectedTab { + case .Packages: + packagesTab + case .Remotes: + remotesTab + case .Metadata: + metadataTab + } + } + .padding(28) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 400) + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(action: { dismiss() }) { Text("Close") } + } + ToolbarItem(placement: .secondaryAction) { + if isInstalled { + Button("Open Config") { openConfig() } + .help("Open mcp.json") + } + } + } + .toolbarRole(.automatic) + .frame(width: 600, height: 450) + .background(Color(nsColor: .controlBackgroundColor)) + .onAppear { + isInstalled = MCPRegistryService.shared.isServerInstalled(server) + } + .alert("Overwrite Existing Installation?", isPresented: $showOverwriteAlert) { + Button("Cancel", role: .cancel) { pendingInstallAction = nil } + Button("Overwrite", role: .destructive) { + pendingInstallAction?() + pendingInstallAction = nil + } + } message: { + Text("Installing this option will replace the currently installed variant of this server.") + } + } + + // MARK: - Header Section + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center) { + Text(server.name) + .font(.system(size: 18, weight: .semibold)) + + if let status = server.status, status == .deprecated { + statusBadge(status) + } + + Spacer() + } + + HStack(spacing: 24) { + HStack(spacing: 6) { + Image(systemName: "tag") + Text(server.version) + } + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + + if let publishedAt = server.createdAt ?? server.meta?.official?.publishedAt { + dateMetadataTag(title: "Published ", dateString: publishedAt, image: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + + if let updatedAt = server.updatedAt ?? server.meta?.official?.updatedAt { + dateMetadataTag(title: "Updated ", dateString: updatedAt, image: "icloud.and.arrow.up") + } + + if let repo = server.repository, !repo.url.isEmpty, !repo.source.isEmpty { + if let repoURL = URL(string: repo.url) { + HStack(spacing: 6) { + Image(systemName: "link") + Link(destination: repoURL) { + Text("Repository") + } + .onHover { hovering in + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + } + + Text(server.description) + .font(.system(size: 13)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(2) + .padding(.top, 4) + } + .padding(28) + .background(Color(nsColor: .windowBackgroundColor)) + } + + private func dateMetadataTag(title: String, dateString: String, image: String) -> some View { + HStack(spacing: 6) { + Image(systemName: image) + if let date = parseDate(dateString) { + (Text("\(title)\(relativeDateString(date))")) + .help(formatExactDate(date)) + } else { + Text("\(title) \(dateString)").help(dateString) + } + } + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + + // MARK: - Tab Selector + + private var tabSelector: some View { + HStack(spacing: 0) { + Picker("", selection: $selectedTab) { + Text("Packages (\(server.packages?.count ?? 0))") + .tag(TabType.Packages) + Text("Remotes (\(server.remotes?.count ?? 0))") + .tag(TabType.Remotes) + Text("Metadata") + .tag(TabType.Metadata) + } + .pickerStyle(.segmented) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.3)) + .overlay( + Rectangle() + .fill(Color(nsColor: .separatorColor)) + .frame(height: 1), + alignment: .bottom + ) + } + + // MARK: - Packages Tab + + private var packagesTab: some View { + Group { + if let packages = server.packages, !packages.isEmpty { + ForEach(Array(packages.enumerated()), id: \.offset) { index, package in + packageItem(package, index: index) + } + } else { + EmptyStateView(message: "No packages available for this server", type: .Packages) + } + } + } + + private func packageItem(_ package: Package, index: Int) -> some View { + let isExpanded = expandedPackages.contains(index) + let optionInstalled = MCPRegistryService.shared.isPackageOptionInstalled(serverDetail: server, package: package) + let metadata: [ServerInstallationOptionView.Metadata] = { + var rows: [ServerInstallationOptionView.Metadata] = [] + if let identifier = package.identifier { + rows.append(.init(label: "ID", value: identifier, monospaced: true)) + } + if let registryURL = package.registryBaseURL { + rows.append(.init(label: "Registry", value: registryURL)) + } + if let runtime = package.runtimeHint { rows.append(.init(label: "Runtime", value: runtime)) } + return rows + }() + return ServerInstallationOptionView( + title: package.registryType?.registryDisplayText ?? "Package", + iconSystemName: "shippingbox", + versionTag: package.version, + metadata: metadata, + isExpanded: isExpanded, + isInstalled: isInstalled, // overall server installed + isInstalling: installingPackages.contains(index), + showUninstall: optionInstalled, + labelColumnWidth: labelColumnWidth, + onToggleExpand: { + if isExpanded { + expandedPackages.remove(index) + } else { + expandedPackages.insert(index) + if packageConfigs[index] == nil { packageConfigs[index] = generateServerConfig(for: package) } + } + }, + onInstall: { handlePackageInstallButton(package, index: index, optionInstalled: optionInstalled) }, + onUninstall: { uninstallServer() }, + config: packageConfigs[index] + ) + } + + // MARK: - Remotes Tab + + private var remotesTab: some View { + Group { + if let remotes = server.remotes, !remotes.isEmpty { + ForEach(Array(remotes.enumerated()), id: \.offset) { index, remote in + remoteItem(remote, index: index) + } + } else { + EmptyStateView( + message: "No remote endpoints configured for this server", + type: .Remotes + ) + } + } + } + + private func remoteItem(_ remote: Remote, index: Int) -> some View { + let isExpanded = expandedRemotes.contains(index) + let optionInstalled = MCPRegistryService.shared.isRemoteOptionInstalled(serverDetail: server, remote: remote) + let metadata: [ServerInstallationOptionView.Metadata] = [ + .init(label: "URL", value: remote.url, monospaced: true) + ] + return ServerInstallationOptionView( + title: remote.transportType.displayText, + iconSystemName: "globe", + versionTag: nil, + metadata: metadata, + isExpanded: isExpanded, + isInstalled: isInstalled, + isInstalling: installingRemotes.contains(index), + showUninstall: optionInstalled, + labelColumnWidth: labelColumnWidth, + onToggleExpand: { + if isExpanded { + expandedRemotes.remove(index) + } else { + expandedRemotes.insert(index) + if remoteConfigs[index] == nil { remoteConfigs[index] = generateServerConfig(for: remote) } + } + }, + onInstall: { handleRemoteInstallButton(remote, index: index, optionInstalled: optionInstalled) }, + onUninstall: { uninstallServer() }, + config: remoteConfigs[index] + ) + } + + // MARK: - Metadata Tab + + private var metadataTab: some View { + VStack(alignment: .leading, spacing: 16) { + if let meta = server.meta { + if let official = meta.official { + officialMetadataSection(official) + } + + } + + if server.meta == nil { + EmptyStateView( + message: "No metadata available", + type: .Metadata + ) + } + } + } + + private func repositorySection(_ repo: Repository) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Repository") + .font(.system(size: 14, weight: .medium)) + + VStack(alignment: .leading, spacing: 8) { + metadataRow(label: "Source", value: repo.source) + metadataRow(label: "URL", value: repo.url, isLink: true) + if let id = repo.id { + metadataRow(label: "ID", value: id) + } + if let subfolder = repo.subfolder { + metadataRow(label: "Subfolder", value: subfolder) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.5)) + ) + } + } + + private func officialMetadataSection(_ official: OfficialMeta) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Official Registry") + .font(.system(size: 14, weight: .medium)) + } + + VStack(alignment: .leading, spacing: 8) { + metadataRow(label: "Server ID", value: official.id) + metadataRow( + label: "Published", + value: parseDate(official.publishedAt) != nil ? formatExactDate( + parseDate(official.publishedAt)! + ) : official.publishedAt + ) + metadataRow( + label: "Updated", + value: parseDate(official.updatedAt) != nil ? formatExactDate( + parseDate(official.updatedAt)! + ) : official.updatedAt + ) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + ) + } + + private func publisherMetadataSection(_ publisher: PublisherProvidedMeta) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Build Information") + .font(.system(size: 14, weight: .medium)) + + VStack(alignment: .leading, spacing: 8) { + if let tool = publisher.tool { + metadataRow(label: "Tool", value: tool) + } + if let version = publisher.version { + metadataRow(label: "Version", value: version) + } + if let buildInfo = publisher.buildInfo { + if let commit = buildInfo.commit { + metadataRow(label: "Commit", value: String(commit.prefix(8))) + } + if let timestamp = buildInfo.timestamp { + metadataRow( + label: "Built", + value: parseDate(timestamp) != nil ? formatExactDate( + parseDate(timestamp)! + ) : timestamp + ) + } + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.5)) + ) + } + } + + private func metadataRow(label: String, value: String, isLink: Bool = false) -> some View { + HStack(spacing: 8) { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .leading) + + if isLink, let url = URL(string: value) { + Link(value, destination: url) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.blue) + } else { + Text(value) + .font(.system(size: 12, design: label.contains("ID") || label.contains("Commit") ? .monospaced : .default)) + .foregroundColor(.primary) + .textSelection(.enabled) + } + } + } + + private func serverConfigView(_ config: [String: Any]) -> some View { + ZStack(alignment: .topTrailing) { + VStack(alignment: .leading, spacing: 8) { + Text(formatConfigAsJSON(config)) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 2) + } + .padding(12) + + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(formatConfigAsJSON(config), forType: .string) + } + .padding(6) + .help("Copy configuration to clipboard") + } + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .textBackgroundColor).opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + + + private func formatConfigAsJSON(_ config: [String: Any]) -> String { + do { + let jsonData = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) + return String(data: jsonData, encoding: .utf8) ?? "{}" + } catch { + return "{}" + } + } + + // MARK: - Configuration Generation Helpers + + private func generateServerConfig(for package: Package) -> [String: Any] { + return MCPRegistryService.shared.createServerConfig(for: server, package: package) + } + + private func generateServerConfig(for remote: Remote) -> [String: Any] { + return MCPRegistryService.shared.createServerConfig(for: server, remote: remote) + } + + // MARK: - Install Helpers + + private func performPackageInstall(_ package: Package, index: Int) { + guard !installingPackages.contains(index) else { return } + installingPackages.insert(index) + Task { + let config = packageConfigs[index] ?? generateServerConfig(for: package) + // Cache generated config for preview if needed later + if packageConfigs[index] == nil { packageConfigs[index] = config } + let option = InstallationOption( + displayName: package.registryType?.registryDisplayText ?? "Package", + description: "Install \(package.identifier ?? server.name)", + config: config + ) + do { + try await MCPRegistryService.shared.installMCPServer(server, installationOption: option) + // Mark installed locally so UI reflects the state immediately + isInstalled = true + } catch { + // Silently fail for now; could surface error UI later + } + installingPackages.remove(index) + } + } + + private func handlePackageInstallButton(_ package: Package, index: Int, optionInstalled: Bool) { + if isInstalled && !optionInstalled { + // Show overwrite confirmation + pendingInstallAction = { performPackageInstall(package, index: index) } + showOverwriteAlert = true + } else { + performPackageInstall(package, index: index) + } + } + + private func performRemoteInstall(_ remote: Remote, index: Int) { + guard !installingRemotes.contains(index) else { return } + installingRemotes.insert(index) + Task { + let config = remoteConfigs[index] ?? generateServerConfig(for: remote) + if remoteConfigs[index] == nil { remoteConfigs[index] = config } + let option = InstallationOption( + displayName: "\(remote.transportType.rawValue)", + description: "Install remote endpoint \(remote.url)", + config: config + ) + do { + try await MCPRegistryService.shared.installMCPServer(server, installationOption: option) + isInstalled = true + } catch { + // Silently fail for now + } + installingRemotes.remove(index) + } + } + + private func handleRemoteInstallButton(_ remote: Remote, index: Int, optionInstalled: Bool) { + if isInstalled && !optionInstalled { + pendingInstallAction = { performRemoteInstall(remote, index: index) } + showOverwriteAlert = true + } else { + performRemoteInstall(remote, index: index) + } + } + + private func uninstallServer() { + Task { + do { + try await MCPRegistryService.shared.uninstallMCPServer(server) + isInstalled = false + } catch { + // TODO: Consider surfacing error to user + } + } + } + + // MARK: - Helper Views + + private func statusBadge(_ status: ServerStatus) -> some View { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.orange) + .padding(.horizontal, 6) + .help("The server is deprecated.") + } + + private struct EmptyStateView: View { + let message: String + let type: PackageType + + enum PackageType: String { + case Packages, Remotes, Metadata + } + + var Logo: some View { + switch type { + case .Packages: + return Image(systemName: "shippingbox") + case .Remotes: + return Image(systemName: "globe") + case .Metadata: + return Image(systemName: "info.circle") + } + } + + var body: some View { + VStack(spacing: 12) { + Logo.font(.system(size: 32)) + + Text(message) + .font(.system(size: 13)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + } + + // MARK: - Utilities + + private func parseDate(_ dateString: String) -> Date? { + // Try multiple ISO8601 formatters in order of specificity + let formatters: [ISO8601DateFormatter] = [ + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }(), + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }(), + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime, .withTimeZone] + return formatter + }(), + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime] + return formatter + }() + ] + + // Try each formatter until one succeeds + for formatter in formatters { + if let date = formatter.date(from: dateString) { + return date + } + } + + return nil + } + + private func formatExactDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .full + formatter.timeStyle = .medium + return formatter.string(from: date) + } + + private func relativeDateString(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: date, relativeTo: Date()) + } + + // MARK: - Open Config / Selection Support + + private func openConfig() { + // Simplified to just open the MCP config file, mirroring manual install behavior. + let url = URL(fileURLWithPath: mcpConfigFilePath) + NSWorkspace.shared.open(url) + } +} + diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift new file mode 100644 index 00000000..59d59cd7 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift @@ -0,0 +1,345 @@ +import AppKit +import Client +import CryptoKit +import GitHubCopilotService +import Logger +import SharedUIComponents +import SwiftUI +import XPCShared + +enum MCPServerGalleryWindow { + static let identifier = "MCPServerGalleryWindow" + private static weak var currentViewModel: MCPServerGalleryViewModel? + + @MainActor static func open( + serverList: MCPRegistryServerList, + mcpRegistryEntry: MCPRegistryEntry? = nil + ) { + if let existing = NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) { + // Update existing window with new data + update(serverList: serverList, mcpRegistryEntry: mcpRegistryEntry) + existing.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let viewModel = MCPServerGalleryViewModel( + initialList: serverList, + mcpRegistryEntry: mcpRegistryEntry + ) + currentViewModel = viewModel + + let controller = NSHostingController( + rootView: MCPServerGalleryView( + viewModel: viewModel + ) + ) + + let window = NSWindow(contentViewController: controller) + window.title = "MCP Servers Marketplace" + window.identifier = NSUserInterfaceItemIdentifier(identifier) + window.setContentSize(NSSize(width: 800, height: 600)) + window.minSize = NSSize(width: 600, height: 400) + window.styleMask.insert([.titled, .closable, .resizable, .miniaturizable]) + window.isReleasedWhenClosed = false + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + @MainActor static func update( + serverList: MCPRegistryServerList, + mcpRegistryEntry: MCPRegistryEntry? = nil + ) { + currentViewModel?.updateData(serverList: serverList, mcpRegistryEntry: mcpRegistryEntry) + } + + @MainActor static func refreshFromURL(mcpRegistryEntry: MCPRegistryEntry? = nil) async -> Error? { + return await currentViewModel?.refreshFromURL(mcpRegistryEntry: mcpRegistryEntry) + } + + static func isOpen() -> Bool { + return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) != nil + } +} + +// MARK: - Stable ID helper + +extension MCPRegistryServerDetail { + var stableID: String { + meta?.official?.id ?? repository?.id ?? name + } +} + +private struct IdentifiableServer: Identifiable { + let server: MCPRegistryServerDetail + var id: String { server.stableID } +} + +struct MCPServerGalleryView: View { + @ObservedObject var viewModel: MCPServerGalleryViewModel + @State private var isShowingURLSheet = false + + init(viewModel: MCPServerGalleryViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + + var body: some View { + VStack(spacing: 0) { + if let error = viewModel.lastError { + if let serviceError = error as? XPCExtensionServiceError { + Badge(text: serviceError.underlyingError?.localizedDescription ?? serviceError.localizedDescription, level: .danger, icon: "xmark.circle.fill") + } else { + Badge(text: error.localizedDescription, level: .danger, icon: "xmark.circle.fill") + } + } + + tableHeaderView + serverListView + } + .padding(20) + .background(Color(nsColor: .controlBackgroundColor)) + .background(.ultraThinMaterial) + .onAppear { + viewModel.loadInstalledServers() + } + .sheet(isPresented: $isShowingURLSheet) { + urlSheet + } + .sheet(isPresented: Binding( + get: { viewModel.infoSheetServer != nil }, + set: { isPresented in + if !isPresented { + viewModel.dismissInfo() + } + } + )) { + if let server = viewModel.infoSheetServer { + infoSheet(server) + } + } + .searchable(text: $viewModel.searchText, prompt: "Search") + .toolbar { + ToolbarItem { + Button(action: { viewModel.refresh() }) { + Image(systemName: "arrow.clockwise") + } + .help("Refresh") + } + + ToolbarItem { + Button(action: { isShowingURLSheet = true }) { + Image(systemName: "square.and.pencil") + } + .help("Configure your MCP Registry URL") + } + } + } + + private var tableHeaderView: some View { + VStack(spacing: 0) { + HStack { + Text("Name") + .font(.system(size: 11, weight: .bold)) + .padding(.horizontal, 8) + .frame(width: 220, alignment: .leading) + + Divider().frame(height: 20) + + Text("Description") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Text("Actions") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + } + .padding(.trailing, 8) + .frame(width: 120, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.clear) + + Divider() + } + } + + private var serverListView: some View { + ZStack { + ScrollView { + LazyVStack(spacing: 0) { + serverRows + + if viewModel.shouldShowLoadMoreSentinel { + Color.clear + .frame(height: 1) + .onAppear { viewModel.loadMoreIfNeeded() } + .accessibilityHidden(true) + } + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + .padding(.vertical, 12) + Spacer() + } + } + } + } + + if viewModel.isRefreshing { + VStack(spacing: 12) { + ProgressView() + Text("Loading servers...") + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.95)) + } + } + } + + private var serverRows: some View { + ForEach(Array(viewModel.filteredServers.enumerated()), id: \.element.stableID) { index, server in + let isInstalled = viewModel.isServerInstalled(serverId: server.stableID) + row(for: server, index: index, isInstalled: isInstalled) + .background(rowBackground(for: index)) + .cornerRadius(8) + .onAppear { + handleRowAppear(index: index) + } + } + } + + private var urlSheet: some View { + MCPRegistryURLSheet( + mcpRegistryEntry: viewModel.mcpRegistryEntry, + onURLUpdated: { + viewModel.refresh() + } + ) + .frame(width: 500, height: 200) + } + + private func rowBackground(for index: Int) -> Color { + index.isMultiple(of: 2) ? Color.clear : Color.primary.opacity(0.03) + } + + private func handleRowAppear(index: Int) { + let currentFilteredCount = viewModel.filteredServers.count + let totalServerCount = viewModel.servers.count + + // Prefetch when approaching the end of filtered results + if index >= currentFilteredCount - 5 { + // If we're filtering and the filtered results are small compared to total servers, + // or if we're near the end of all available data, try to load more + if currentFilteredCount < 20 || index >= totalServerCount - 5 { + viewModel.loadMoreIfNeeded() + } + } + } + + // MARK: - Subviews + + private func row(for server: MCPRegistryServerDetail, index: Int, isInstalled: Bool) -> some View { + HStack { + Text(server.name) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 8) + .frame(width: 220, alignment: .leading) + + Divider().frame(height: 20).foregroundColor(Color.clear) + + Text(server.description) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 8) { + if isInstalled { + Button("Uninstall") { + Task { + await viewModel.uninstallServer(server) + } + } + .buttonStyle(DestructiveButtonStyle()) + .help("Uninstall") + } else { + if #available(macOS 13.0, *) { + SplitButton( + title: "Install", + isDisabled: viewModel.hasNoDeployments(server), + primaryAction: { + // Install with default configuration + Task { + await viewModel.installServer(server) + } + }, + menuItems: viewModel.getInstallationOptions(for: server).map { option in + SplitButtonMenuItem(title: option.displayName) { + Task { + await viewModel.installServer(server, configuration: option.displayName) + } + } + } + ) + .help("Install") + } else { + Button("Install") { + Task { + await viewModel.installServer(server) + } + } + .disabled(viewModel.hasNoDeployments(server)) + .help("Install") + } + } + + Button { + viewModel.showInfo(server) + } label: { + Image(systemName: "info.circle") + .font(.system(size: 13)) + .foregroundColor(.primary) + .multilineTextAlignment(.trailing) + } + .buttonStyle(.plain) + .help("View Details") + } + .padding(.horizontal, 8) + .frame(width: 120, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + private func infoSheet(_ server: MCPRegistryServerDetail) -> some View { + if #available(macOS 13.0, *) { + return AnyView(MCPServerDetailSheet(server: server)) + } else { + return AnyView(EmptyView()) + } + } +} + +func defaultInstallation(for server: MCPRegistryServerDetail) -> String { + // Get the first available type from remotes or packages + if let firstRemote = server.remotes?.first { + return firstRemote.transportType.rawValue + } + if let firstPackage = server.packages?.first { + return firstPackage.registryType ?? "" + } + return "" +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift new file mode 100644 index 00000000..7bdaf1ac --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift @@ -0,0 +1,303 @@ +import Client +import CryptoKit +import Foundation +import GitHubCopilotService +import Logger +import SwiftUI + +@MainActor +final class MCPServerGalleryViewModel: ObservableObject { + // Input invariants + private let pageSize: Int + + // User / UI state + @Published var searchText: String = "" + + // Data + @Published private(set) var servers: [MCPRegistryServerDetail] + @Published private(set) var installedServers: Set = [] + @Published private(set) var registryMetadata: MCPRegistryServerListMetadata? + + // Loading flags + @Published private(set) var isInitialLoading: Bool = false + @Published private(set) var isLoadingMore: Bool = false + @Published private(set) var isRefreshing: Bool = false + + // Transient presentation state + @Published var pendingServer: MCPRegistryServerDetail? + @Published var infoSheetServer: MCPRegistryServerDetail? + @Published var mcpRegistryEntry: MCPRegistryEntry? + @Published private(set) var lastError: Error? + + @AppStorage(\.mcpRegistryURL) var mcpRegistryURL + @AppStorage(\.mcpRegistryURLHistory) private var mcpRegistryURLHistory + + // Service integration + private let registryService = MCPRegistryService.shared + + init( + initialList: MCPRegistryServerList, + mcpRegistryEntry: MCPRegistryEntry? = nil, + pageSize: Int = 30 + ) { + self.pageSize = pageSize + servers = initialList.servers + registryMetadata = initialList.metadata + self.mcpRegistryEntry = mcpRegistryEntry + } + + // MARK: - Derived Data + + var filteredServers: [MCPRegistryServerDetail] { + // First filter for only latest official servers + let latestServers = servers.filter { server in + server.meta?.official?.isLatest == true + } + + // Then apply search filter if search text is present + let key = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !key.isEmpty else { return latestServers } + + return latestServers.filter { + $0.name.lowercased().contains(key) || + $0.description.lowercased().contains(key) + } + } + + var shouldShowLoadMoreSentinel: Bool { + // Show load more sentinel if there's more data available + if let next = registryMetadata?.nextCursor, !next.isEmpty { + return true + } + return false + } + + func isServerInstalled(serverId: String) -> Bool { + // Find the server by ID and check installation status using the service + if let server = servers.first(where: { $0.stableID == serverId }) { + return registryService.isServerInstalled(server) + } + + // Fallback to the existing key-based check for backwards compatibility + let key = createRegistryServerKey(registryURL: mcpRegistryURL, serverId: serverId) + return installedServers.contains(key) + } + + func hasNoDeployments(_ server: MCPRegistryServerDetail) -> Bool { + return server.remotes?.isEmpty ?? true && server.packages?.isEmpty ?? true + } + + // MARK: - User Intents (Updated with Service Integration) + + func requestInstall(_ server: MCPRegistryServerDetail) { + Task { + await installServer(server) + } + } + + func requestInstallWithConfiguration(_ server: MCPRegistryServerDetail, configuration: String) { + Task { + await installServer(server, configuration: configuration) + } + } + + func installServer(_ server: MCPRegistryServerDetail, configuration: String? = nil) async { + do { + let installationOption: InstallationOption? + + if let configName = configuration { + // Find the specific installation option + let options = registryService.getAllInstallationOptions(for: server) + installationOption = options.first { option in + option.displayName.contains(configName) || + option.description.contains(configName) + } + } else { + installationOption = nil + } + + try await registryService.installMCPServer(server, installationOption: installationOption) + + // Refresh installed servers list + loadInstalledServers() + + Logger.client.info("Successfully installed MCP Server '\(server.name)'") + + } catch { + Logger.client.error("Failed to install server '\(server.name)': \(error)") + // TODO: Consider adding error handling UI feedback here + } + } + + func uninstallServer(_ server: MCPRegistryServerDetail) async { + do { + try await registryService.uninstallMCPServer(server) + + // Refresh installed servers list + loadInstalledServers() + + Logger.client.info("Successfully uninstalled MCP Server '\(server.name)'") + + } catch { + Logger.client.error("Failed to uninstall server '\(server.name)': \(error)") + // TODO: Consider adding error handling UI feedback here + } + } + + func refresh() { + Task { + isRefreshing = true + defer { isRefreshing = false } + + // Clear the current server list + servers = [] + registryMetadata = nil + searchText = "" + + // Load servers from the base URL + _ = await loadServerList(resetToFirstPage: true) + } + } + + // Called from Settings view to refresh with optional new registry entry + func refreshFromURL(mcpRegistryEntry: MCPRegistryEntry? = nil) async -> Error? { + isRefreshing = true + defer { isRefreshing = false } + + // Clear the current server list immediately + servers = [] + registryMetadata = nil + searchText = "" + self.mcpRegistryEntry = mcpRegistryEntry + Logger.client.info("Cleared gallery view model data for refresh") + + // Load servers from the base URL + let error = await loadServerList(resetToFirstPage: true) + + // Reload installed servers after fetching new data + loadInstalledServers() + + return error + } + + func updateData(serverList: MCPRegistryServerList, mcpRegistryEntry: MCPRegistryEntry? = nil) { + servers = serverList.servers + registryMetadata = serverList.metadata + self.mcpRegistryEntry = mcpRegistryEntry + searchText = "" + loadInstalledServers() + Logger.client.info("Updated gallery view model with \(serverList.servers.count) servers and registry entry: \(String(describing: mcpRegistryEntry))") + } + + func clearData() { + servers = [] + registryMetadata = nil + searchText = "" + Logger.client.info("Cleared gallery view model data") + } + + func showInfo(_ server: MCPRegistryServerDetail) { + infoSheetServer = server + } + + func dismissInfo() { + infoSheetServer = nil + } + + // MARK: - Data Loading + + func loadMoreIfNeeded() { + guard !isLoadingMore, + !isInitialLoading, + let nextCursor = registryMetadata?.nextCursor, + !nextCursor.isEmpty + else { return } + + Task { + await loadServerList(resetToFirstPage: false) + } + } + + private func loadServerList(resetToFirstPage: Bool) async -> Error? { + if resetToFirstPage { + isInitialLoading = true + } else { + isLoadingMore = true + } + + defer { + isInitialLoading = false + isLoadingMore = false + } + + lastError = nil + + do { + let service = try getService() + let cursor = resetToFirstPage ? nil : registryMetadata?.nextCursor + + let serverList = try await service.listMCPRegistryServers( + .init( + baseUrl: mcpRegistryURL, + cursor: cursor, + limit: pageSize + ) + ) + + if resetToFirstPage { + // Replace all servers when refreshing or resetting + servers = serverList?.servers ?? [] + registryMetadata = serverList?.metadata + } else { + // Append when loading more + servers.append(contentsOf: serverList?.servers ?? []) + registryMetadata = serverList?.metadata + } + + mcpRegistryURLHistory.addToHistory(mcpRegistryURL) + + return nil + } catch { + Logger.client.error("Failed to load MCP servers: \(error)") + lastError = error + return error + } + } + + func loadInstalledServers() { + // Clear the set and rebuild it + installedServers.removeAll() + + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + guard FileManager.default.fileExists(atPath: mcpConfigFilePath), + let data = try? Data(contentsOf: configFileURL), + let currentConfig = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let serversDict = currentConfig["servers"] as? [String: Any] else { + return + } + + for (_, serverConfig) in serversDict { + guard + let serverConfigDict = serverConfig as? [String: Any], + let metadata = serverConfigDict["x-metadata"] as? [String: Any], + let registry = metadata["registry"] as? [String: Any], + let registryUrl = registry["url"] as? String, + let serverId = registry["serverId"] as? String + else { continue } + + installedServers.insert( + createRegistryServerKey(registryURL: registryUrl, serverId: serverId) + ) + } + } + + private func createRegistryServerKey(registryURL: String, serverId: String) -> String { + return registryService.createRegistryServerKey(registryURL: registryURL, serverId: serverId) + } + + // MARK: - Installation Options Helper + + func getInstallationOptions(for server: MCPRegistryServerDetail) -> [InstallationOption] { + return registryService.getAllInstallationOptions(for: server) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/ServerInstallationOptionView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/ServerInstallationOptionView.swift new file mode 100644 index 00000000..fcc129e4 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/ServerInstallationOptionView.swift @@ -0,0 +1,170 @@ +import SwiftUI +import AppKit +import Foundation +import SharedUIComponents + +struct ServerInstallationOptionView: View { + struct Metadata: Identifiable { + let id = UUID() + let label: String + let value: String + var monospaced: Bool = false + var isLink: Bool = false + } + + let title: String + let iconSystemName: String + let versionTag: String? + let metadata: [Metadata] + + // State/control flags passed from parent + let isExpanded: Bool + let isInstalled: Bool + let isInstalling: Bool + let showUninstall: Bool + + // Layout constants + let labelColumnWidth: CGFloat + + // Behavior closures supplied by parent + let onToggleExpand: () -> Void + let onInstall: () -> Void + let onUninstall: () -> Void + + // Optional configuration JSON (already generated by parent) shown when expanded + let config: [String: Any]? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + header + if isExpanded, let config { + configSection(config) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + ) + .animation(.easeInOut(duration: 0.2), value: isExpanded) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 8) { + Label(title, systemImage: iconSystemName) + .font(.system(size: 14, weight: .medium)) + + if let versionTag { + Text(versionTag) + .font(.system(size: 12, design: .monospaced)) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Capsule().fill(Color.green.opacity(0.15))) + } + + Spacer() + + Button(isExpanded ? "Hide" : "Preview") { onToggleExpand() } + .buttonStyle(.bordered) + .help(isExpanded ? "Hide configuration details" : "Preview configuration details") + + if showUninstall { + Button("Uninstall") { onUninstall() } + .buttonStyle(DestructiveButtonStyle()) + .help("Uninstall this installed option") + } else { + Button(action: onInstall) { + if isInstalling { + ProgressView().controlSize(.mini) + } else { + Text("Install") + } + } + .disabled(isInstalling) + .buttonStyle(.borderedProminent) + .help("Install this server using the selected option") + } + } + + // Metadata rows + Group { + ForEach(metadata) { item in + HStack(spacing: 6) { + Text(item.label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .frame(width: labelColumnWidth, alignment: .leading) + + if item.isLink, let url = URL(string: item.value) { + Link(item.value, destination: url) + .font(.system(size: 12, design: item.monospaced ? .monospaced : .default)) + .foregroundColor(.primary) + .textSelection(.enabled) + } else { + Text(item.value) + .font(.system(size: 12, design: item.monospaced ? .monospaced : .default)) + .foregroundColor(.primary) + .textSelection(.enabled) + } + } + } + } + .padding(.top, 6) + } + } + + private func configSection(_ config: [String: Any]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Divider().padding(.vertical, 4) + HStack { + Text("Server Configuration") + .font(.system(size: 13, weight: .medium)) + Spacer() + } + configView(config) + } + } + + @ViewBuilder + private func configView(_ config: [String: Any]) -> some View { + ZStack(alignment: .topTrailing) { + VStack(alignment: .leading, spacing: 8) { + Text(formatConfigAsJSON(config)) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 2) + } + .padding(12) + + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(formatConfigAsJSON(config), forType: .string) + } + .padding(6) + .help("Copy configuration to clipboard") + } + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .textBackgroundColor).opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + + private func formatConfigAsJSON(_ config: [String: Any]) -> String { + do { + let data = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) + return String(data: data, encoding: .utf8) ?? "{}" + } catch { return "{}" } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift new file mode 100644 index 00000000..93a1cb77 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift @@ -0,0 +1,420 @@ +import SwiftUI +import Persist +import GitHubCopilotService +import Client +import Logger +import Foundation +import SharedUIComponents +import ConversationServiceProvider + +/// Section for a single server's tools +struct MCPServerToolsSection: View { + let serverTools: MCPServerToolsCollection + @Binding var isServerEnabled: Bool + var forceExpand: Bool = false + var isInteractionAllowed: Bool = true + @Binding var modes: [ConversationMode] + @Binding var selectedMode: ConversationMode + @State private var toolEnabledStates: [String: Bool] = [:] + @State private var isExpanded: Bool = true + @State private var checkboxMixedState: CheckboxMixedState = .off + private var originalServerName: String { serverTools.name } + + private var serverToggleLabel: some View { + HStack(spacing: 8) { + Text("MCP Server: \(serverTools.name)") + .fontWeight(.medium) + .foregroundStyle( + serverTools.status == .running ? .primary : .tertiary + ) + if serverTools.status == .error || serverTools.status == .blocked { + let message = extractErrorMessage(serverTools.error?.description ?? "") + if serverTools.status == .error { + Badge( + attributedText: createErrorMessage(message), + level: .danger, + icon: "xmark.circle.fill" + ) + .environment((\.openURL), OpenURLAction { url in + if url.absoluteString == "mcp://open-config" { + openMCPConfigFile() + return .handled + } + return .systemAction + }) + } else if serverTools.status == .blocked { + Badge(text: serverTools.registryInfo ?? "Blocked", level: .warning, icon: "exclamationmark.triangle.fill") + } + } else if let registryInfo = serverTools.registryInfo { + Text(registryInfo) + .foregroundStyle(.secondary) + .font(.system(size: 11)) + } + Spacer() + } + } + + private func openMCPConfigFile() { + let url = URL(fileURLWithPath: mcpConfigFilePath) + NSWorkspace.shared.open(url) + } + + private func createErrorMessage(_ baseMessage: String) -> AttributedString { + if hasServerConfigPlaceholders() { + let prefix = baseMessage.isEmpty ? "" : baseMessage + ". " + var attributedString = AttributedString(prefix + "You may need to update placeholders in ") + + var mcpLink = AttributedString("mcp.json") + mcpLink.link = URL(string: "mcp://open-config") + mcpLink.underlineStyle = .single + + attributedString.append(mcpLink) + attributedString.append(AttributedString(".")) + + return attributedString + } else { + return AttributedString(baseMessage) + } + } + + private var serverToggle: some View { + HStack(spacing: 8) { + MixedStateCheckbox( + title: "", + font: .systemFont(ofSize: 13), + state: $checkboxMixedState + ) { + switch checkboxMixedState { + case .off, .mixed: + // Enable all tools + updateAllToolsStatus(enabled: true) + case .on: + // Disable all tools + updateAllToolsStatus(enabled: false) + } + updateMixedState() + } + .disabled(serverTools.status == .error || serverTools.status == .blocked || !isInteractionAllowed) + + serverToggleLabel + .contentShape(Rectangle()) + .onTapGesture { + if serverTools.status != .error && serverTools.status != .blocked { + withAnimation { + isExpanded.toggle() + } + } + } + } + .padding(.leading, 4) + } + + 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 + ToolRow( + toolName: tool.name, + toolDescription: tool.description, + toolStatus: tool._status, + isServerEnabled: isServerEnabled, + isToolEnabled: toolBindingFor(tool), + isInteractionAllowed: isInteractionAllowed, + onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) } + ) + .padding(.leading, 36) + } + } + .onChange(of: serverTools) { newValue in + initializeToolStates(server: newValue) + updateMixedState() + } + } + + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Conditional view rendering based on error state + if serverTools.status == .error || serverTools.status == .blocked { + // No disclosure group for error state + VStack(spacing: 0) { + serverToggle.padding(.leading, 12) + divider.padding(.top, 4) + } + } else { + // Regular DisclosureGroup for non-error state + DisclosureGroup(isExpanded: $isExpanded) { + toolsList + } label: { + serverToggle + } + .onAppear { + initializeToolStates(server: serverTools) + updateMixedState() + if forceExpand { + isExpanded = true + } + } + .onChange(of: forceExpand) { newForceExpand in + if newForceExpand { + isExpanded = true + } + } + .onChange(of: selectedMode) { _ in + toolEnabledStates = [:] + initializeToolStates(server: serverTools) + updateMixedState() + } + .onChange(of: selectedMode.customTools) { _ in + Task { + await reloadModesAndUpdateStates() + } + } + .onReceive(DistributedNotificationCenter.default().publisher(for: .gitHubCopilotCustomAgentToolsDidChange)) { _ in + Logger.client.info("Custom agent tools change notification received in MCPServerToolsSection") + if !selectedMode.isDefaultAgent { + Task { + await reloadModesAndUpdateStates() + } + } + } + + if !isExpanded { + divider + } + } + } + } + + private func extractErrorMessage(_ description: String) -> String { + guard let messageRange = description.range(of: "message:"), + let stackRange = description.range(of: "stack:") else { + return description + } + let start = description.index(messageRange.upperBound, offsetBy: 0) + let end = description.index(stackRange.lowerBound, offsetBy: 0) + return description[start.. Bool { + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + + guard FileManager.default.fileExists(atPath: mcpConfigFilePath), + let data = try? Data(contentsOf: configFileURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let servers = jsonObject["servers"] as? [String: Any], + let serverConfig = servers[serverTools.name] else { + return false + } + + // Convert server config to JSON string + guard let serverData = try? JSONSerialization.data(withJSONObject: serverConfig, options: []), + let serverConfigString = String(data: serverData, encoding: .utf8) else { + return false + } + + // Check for placeholder patterns ending with }" + // Matches: "{PLACEHOLDER}", "${PLACEHOLDER}", "key={PLACEHOLDER}", "key=${PLACEHOLDER}", "${prefix:PLACEHOLDER}" + let placeholderPattern = "\"([a-zA-Z0-9_]+=)?\\$?\\{[a-zA-Z0-9_:\\-\\.]+\\}\"" + + guard let regex = try? NSRegularExpression(pattern: placeholderPattern, options: []) else { + return false + } + + let range = NSRange(serverConfigString.startIndex.. Binding { + Binding( + get: { + toolEnabledStates[tool.name] ?? isToolEnabledInMode(tool.name, currentStatus: tool._status) + }, + set: { toolEnabledStates[tool.name] = $0 } + ) + } + + private func handleToolToggleChange(tool: MCPTool, isEnabled: Bool) { + toolEnabledStates[tool.name] = isEnabled + + // Update server state based on tool states + updateServerState() + + // Update mixed state + updateMixedState() + + // Update only this specific tool status + updateToolStatus(tool: tool, isEnabled: isEnabled) + } + + private func updateServerState() { + // If any tool is enabled, server should be enabled + // If all tools are disabled, server should be disabled + let allToolsDisabled = serverTools.tools.allSatisfy { tool in + !(toolEnabledStates[tool.name] ?? (tool._status == .enabled)) + } + + isServerEnabled = !allToolsDisabled + } + + private func updateToolStatus(tool: MCPTool, isEnabled: Bool) { + let serverUpdate = UpdateMCPToolsStatusServerCollection( + name: serverTools.name, + tools: [UpdatedMCPToolsStatus(name: tool.name, status: isEnabled ? .enabled : .disabled)] + ) + + updateMCPStatus([serverUpdate]) + } + + private func updateAllToolsStatus(enabled: Bool) { + isServerEnabled = enabled + + // Get all tools for this server from the original collection + let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools + .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools + + // Update all tool states - includes both visible and filtered-out tools + for tool in allServerTools { + toolEnabledStates[tool.name] = enabled + } + + // Create status update for all tools + let serverUpdate = UpdateMCPToolsStatusServerCollection( + name: serverTools.name, + tools: allServerTools.map { + UpdatedMCPToolsStatus(name: $0.name, status: enabled ? .enabled : .disabled) + } + ) + + updateMCPStatus([serverUpdate]) + } + + private func updateMixedState() { + let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools + .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools + + let enabledCount = allServerTools.filter { tool in + toolEnabledStates[tool.name] ?? (tool._status == .enabled) + }.count + + let totalCount = allServerTools.count + + if enabledCount == 0 { + checkboxMixedState = .off + } else if enabledCount == totalCount { + checkboxMixedState = .on + } else { + checkboxMixedState = .mixed + } + } + + private func updateMCPStatus(_ serverUpdates: [UpdateMCPToolsStatusServerCollection]) { + let isDefaultAgentMode = selectedMode.isDefaultAgent + + if isDefaultAgentMode { + AppState.shared.updateMCPToolsStatus(serverUpdates) + } + + Task { + do { + let service = try getService() + + if !isDefaultAgentMode { + let chatMode = selectedMode.kind + let customChatModeId = selectedMode.isBuiltIn == false ? selectedMode.id : nil + let workspaceFolders = await getWorkspaceFolders() + + try await service + .updateMCPServerToolsStatus( + serverUpdates, + chatAgentMode: chatMode, + customChatModeId: customChatModeId, + workspaceFolders: workspaceFolders + ) + } else { + try await service.updateMCPServerToolsStatus(serverUpdates) + } + } catch { + Logger.client.error("Failed to update MCP status: \(error.localizedDescription)") + } + } + } + + @MainActor + private func reloadModesAndUpdateStates() async { + do { + let service = try getService() + let workspaceFolders = await getWorkspaceFolders() + if let fetchedModes = try await service.getModes(workspaceFolders: workspaceFolders) { + modes = fetchedModes.filter { $0.kind == .Agent } + + if let updatedMode = modes.first(where: { $0.id == selectedMode.id }) { + selectedMode = updatedMode + + let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools + .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools + + for tool in allServerTools { + let toolName = "\(serverTools.name)/\(tool.name)" + if let customTools = updatedMode.customTools { + toolEnabledStates[tool.name] = customTools.contains(toolName) + } else { + toolEnabledStates[tool.name] = false + } + } + + updateMixedState() + updateServerState() + } + } + } catch { + Logger.client.error("Failed to reload modes: \(error.localizedDescription)") + } + } + + private func isToolEnabledInMode(_ toolName: String, currentStatus: ToolStatus) -> Bool { + let configurationKey = "\(serverTools.name)/\(toolName)" + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: configurationKey, + currentStatus: currentStatus, + selectedMode: selectedMode + ) + } +} diff --git a/Core/Sources/HostApp/MCPSettings/MCPToolsListContainerView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift similarity index 72% rename from Core/Sources/HostApp/MCPSettings/MCPToolsListContainerView.swift rename to Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift index 27f2d6cb..ecf30952 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPToolsListContainerView.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift @@ -1,5 +1,6 @@ import SwiftUI import GitHubCopilotService +import ConversationServiceProvider /// Main list view containing all the tools struct MCPToolsListContainerView: View { @@ -7,6 +8,9 @@ struct MCPToolsListContainerView: View { @Binding var serverToggleStates: [String: Bool] let searchKey: String let expandedServerNames: Set + var isInteractionAllowed: Bool = true + @Binding var modes: [ConversationMode] + @Binding var selectedMode: ConversationMode var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -14,11 +18,15 @@ struct MCPToolsListContainerView: View { MCPServerToolsSection( serverTools: serverTools, isServerEnabled: serverToggleBinding(for: serverTools.name), - forceExpand: expandedServerNames.contains(serverTools.name) && !searchKey.isEmpty + forceExpand: expandedServerNames.contains(serverTools.name) && !searchKey.isEmpty, + isInteractionAllowed: isInteractionAllowed, + modes: $modes, + selectedMode: $selectedMode ) } } .padding(.vertical, 4) + .id(selectedMode.id) } private func serverToggleBinding(for serverName: String) -> Binding { diff --git a/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift new file mode 100644 index 00000000..ba8e1b4f --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift @@ -0,0 +1,114 @@ +import Combine +import GitHubCopilotService +import Persist +import SwiftUI +import SharedUIComponents +import ConversationServiceProvider + +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 = "" + @State private var modes: [ConversationMode] = [] + @Binding var selectedMode: ConversationMode + let isCustomAgentEnabled: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + GroupBox( + label: + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center) { + Text("Available MCP Tools").fontWeight(.bold) + if isCustomAgentEnabled { + AgentModeDropdown(modes: $modes, selectedMode: $selectedMode) + } + Spacer() + CollapsibleSearchField(searchText: $searchText, isExpanded: $isSearchBarVisible) + } + .clipped() + + AgentModeDescriptionView(selectedMode: selectedMode, isLoadingMode: false) + } + ) { + let filteredServerTools = filteredMCPServerTools() + if filteredServerTools.isEmpty { + EmptyStateView() + } else { + ToolsListView( + mcpServerTools: filteredServerTools, + serverToggleStates: $serverToggleStates, + searchKey: searchText, + expandedServerNames: expandedServerNames(filteredServerTools: filteredServerTools), + isInteractionAllowed: isInteractionAllowed(), + modes: $modes, + selectedMode: $selectedMode + ) + } + } + .groupBoxStyle(CardGroupBoxStyle()) + } + .onAppear(perform: updateServerToggleStates) + .onChange(of: mcpToolManager.availableMCPServerTools) { _ in + updateServerToggleStates() + } + .onChange(of: selectedMode) { _ in + updateServerToggleStates() + } + } + + private func updateServerToggleStates() { + serverToggleStates = mcpToolManager.availableMCPServerTools.reduce(into: [:]) { result, server in + result[server.name] = !server.tools.isEmpty && !server.tools.allSatisfy { $0._status != .enabled } + } + } + + private func filteredMCPServerTools() -> [MCPServerToolsCollection] { + 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) + } + if filteredTools.isEmpty { return nil } + return MCPServerToolsCollection( + name: server.name, + status: server.status, + tools: filteredTools, + error: server.error + ) + } + } + + private func expandedServerNames(filteredServerTools: [MCPServerToolsCollection]) -> Set { + // Expand all groups that have at least one tool in the filtered list + Set(filteredServerTools.map { $0.name }) + } + + private func isInteractionAllowed() -> Bool { + return AgentModeToolHelpers.isInteractionAllowed(selectedMode: selectedMode) + } +} + +/// Empty state view when no tools are available +private struct EmptyStateView: View { + var body: some View { + Text("No MCP tools available. Make sure your MCP server is configured correctly and running.") + .foregroundColor(.secondary) + } +} + +// Private components now defined in separate files: +// MCPToolsListContainerView - in MCPToolsListContainerView.swift +// MCPServerToolsSection - in MCPServerToolsSection.swift + +/// Private alias for maintaining backward compatibility +private typealias ToolsListView = MCPToolsListContainerView +private typealias ServerToolsSection = MCPServerToolsSection diff --git a/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift similarity index 63% rename from Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift rename to Core/Sources/HostApp/ToolsSettings/ToolRowView.swift index f6a8e20f..d8df5965 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift +++ b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift @@ -1,24 +1,30 @@ 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 + var isInteractionAllowed: Bool = true let onToolToggleChanged: (Bool) -> Void var body: some View { HStack(alignment: .center) { Toggle(isOn: Binding( get: { isToolEnabled }, - set: { onToolToggleChanged($0) } + set: { newValue in + isToolEnabled = newValue + onToolToggleChanged(newValue) + } )) { 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) @@ -30,10 +36,8 @@ struct MCPToolRow: View { Divider().padding(.vertical, 4) } } + .disabled(!isInteractionAllowed) } - .padding(.leading, 36) .padding(.vertical, 0) - .onChange(of: tool._status) { isToolEnabled = $0 == .enabled } - .onChange(of: isServerEnabled) { if !$0 { isToolEnabled = false } } } } diff --git a/Core/Sources/KeyBindingManager/KeyBindingManager.swift b/Core/Sources/KeyBindingManager/KeyBindingManager.swift index 2fcf67fa..e0a22188 100644 --- a/Core/Sources/KeyBindingManager/KeyBindingManager.swift +++ b/Core/Sources/KeyBindingManager/KeyBindingManager.swift @@ -5,16 +5,24 @@ public final class KeyBindingManager { public init( workspacePool: WorkspacePool, acceptSuggestion: @escaping () -> Void, + acceptNESSuggestion: @escaping () -> Void, expandSuggestion: @escaping () -> Void, collapseSuggestion: @escaping () -> Void, - dismissSuggestion: @escaping () -> Void + dismissSuggestion: @escaping () -> Void, + rejectNESSuggestion: @escaping () -> Void, + goToNextEditSuggestion: @escaping () -> Void, + isNESPanelOutOfFrame: @escaping () -> Bool ) { tabToAcceptSuggestion = .init( workspacePool: workspacePool, acceptSuggestion: acceptSuggestion, - dismissSuggestion: dismissSuggestion, + acceptNESSuggestion: acceptNESSuggestion, + dismissSuggestion: dismissSuggestion, expandSuggestion: expandSuggestion, - collapseSuggestion: collapseSuggestion + collapseSuggestion: collapseSuggestion, + rejectNESSuggestion: rejectNESSuggestion, + goToNextEditSuggestion: goToNextEditSuggestion, + isNESPanelOutOfFrame: isNESPanelOutOfFrame ) } diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift index f2d4c147..07568796 100644 --- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -8,6 +8,7 @@ import SuggestionBasic import UserDefaultsObserver import Workspace import XcodeInspector +import SuggestionWidget final class TabToAcceptSuggestion { let hook: CGEventHookType = CGEventHook(eventsOfInterest: [.keyDown]) { message in @@ -16,9 +17,13 @@ final class TabToAcceptSuggestion { let workspacePool: WorkspacePool let acceptSuggestion: () -> Void + let acceptNESSuggestion: () -> Void let expandSuggestion: () -> Void let collapseSuggestion: () -> Void let dismissSuggestion: () -> Void + let rejectNESSuggestion: () -> Void + let goToNextEditSuggestion: () -> Void + let isNESPanelOutOfFrame: () -> Bool private var modifierEventMonitor: Any? private let userDefaultsObserver = UserDefaultsObserver( object: UserDefaults.shared, forKeyPaths: [ @@ -47,16 +52,24 @@ final class TabToAcceptSuggestion { init( workspacePool: WorkspacePool, acceptSuggestion: @escaping () -> Void, + acceptNESSuggestion: @escaping () -> Void, dismissSuggestion: @escaping () -> Void, expandSuggestion: @escaping () -> Void, - collapseSuggestion: @escaping () -> Void + collapseSuggestion: @escaping () -> Void, + rejectNESSuggestion: @escaping () -> Void, + goToNextEditSuggestion: @escaping () -> Void, + isNESPanelOutOfFrame: @escaping () -> Bool ) { _ = ThreadSafeAccessToXcodeInspector.shared self.workspacePool = workspacePool self.acceptSuggestion = acceptSuggestion + self.acceptNESSuggestion = acceptNESSuggestion self.dismissSuggestion = dismissSuggestion + self.rejectNESSuggestion = rejectNESSuggestion self.expandSuggestion = expandSuggestion self.collapseSuggestion = collapseSuggestion + self.goToNextEditSuggestion = goToNextEditSuggestion + self.isNESPanelOutOfFrame = isNESPanelOutOfFrame hook.add( .init( @@ -121,18 +134,48 @@ final class TabToAcceptSuggestion { } func handleEvent(_ event: CGEvent) -> CGEventManipulation.Result { - let (accept, reason) = Self.shouldAcceptSuggestion( - event: event, - workspacePool: workspacePool, - xcodeInspector: ThreadSafeAccessToXcodeInspector.shared - ) - if let reason = reason { - Logger.service.debug("TabToAcceptSuggestion: \(accept ? "" : "not") accepting due to: \(reason)") - } - if accept { - acceptSuggestion() - return .discarded + let keycode = Int(event.getIntegerValueField(.keyboardEventKeycode)) + let tab = 48 + let escape = 53 + + if keycode == tab { + let (accept, reason, codeSuggestionType) = Self.shouldAcceptSuggestion( + event: event, + workspacePool: workspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspector.shared + ) + if let reason = reason { + Logger.service.debug("TabToAcceptSuggestion: \(accept ? "" : "not") accepting due to: \(reason)") + } + if accept, let codeSuggestionType { + switch codeSuggestionType { + case .codeCompletion: + acceptSuggestion() + case .nes: + if isNESPanelOutOfFrame() { + goToNextEditSuggestion() + } else { + acceptNESSuggestion() + } + } + return .discarded + } + return .unchanged + } else if keycode == escape { + let (shouldReject, reason) = Self.shouldRejectNESSuggestion( + event: event, + workspacePool: workspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspector.shared + ) + if let reason = reason { + Logger.service.debug("ShouldRejectNESSuggestion: \(shouldReject ? "" : "not") rejecting due to: \(reason)") + } + if shouldReject { + rejectNESSuggestion() + return .discarded + } } + return .unchanged } @@ -146,36 +189,93 @@ final class TabToAcceptSuggestion { } extension TabToAcceptSuggestion { + + enum SuggestionAction { + case acceptSuggestion, rejectNESSuggestion + } + /// Returns whether a given keyboard event should be intercepted and trigger /// accepting a suggestion. static func shouldAcceptSuggestion( event: CGEvent, workspacePool: WorkspacePool, xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol + ) -> (accept: Bool, reason: String?, codeSuggestionType: CodeSuggestionType?) { + let (isValidEvent, eventReason) = Self.validateEvent(event) + guard isValidEvent else { return (false, eventReason, nil) } + + let (isValidFilespace, filespaceReason, codeSuggestionType) = Self.validateFilespace( + event, + workspacePool: workspacePool, + xcodeInspector: xcodeInspector, + suggestionAction: .acceptSuggestion + ) + guard isValidFilespace else { return (false, filespaceReason, nil) } + + return (true, nil, codeSuggestionType) + } + + static func shouldRejectNESSuggestion( + event: CGEvent, + workspacePool: WorkspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol ) -> (accept: Bool, reason: String?) { - let keycode = Int(event.getIntegerValueField(.keyboardEventKeycode)) - let tab = 48 - guard keycode == tab else { return (false, nil) } + let (isValidEvent, eventReason) = Self.validateEvent(event) + guard isValidEvent else { return (false, eventReason) } + + let (isValidFilespace, filespaceReason, _) = Self.validateFilespace( + event, + workspacePool: workspacePool, + xcodeInspector: xcodeInspector, + suggestionAction: .rejectNESSuggestion + ) + guard isValidFilespace else { return (false, filespaceReason) } + + return (true, nil) + } + + static private func validateEvent(_ event: CGEvent) -> (Bool, String?) { if event.flags.contains(.maskHelp) { return (false, nil) } if event.flags.contains(.maskShift) { return (false, nil) } if event.flags.contains(.maskControl) { return (false, nil) } if event.flags.contains(.maskCommand) { return (false, nil) } + + return (true, nil) + } + + static private func validateFilespace( + _ event: CGEvent, + workspacePool: WorkspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol, + suggestionAction: SuggestionAction + ) -> (Bool, String?, CodeSuggestionType?) { guard xcodeInspector.hasActiveXcode else { - return (false, "No active Xcode") + return (false, "No active Xcode", nil) } guard xcodeInspector.hasFocusedEditor else { - return (false, "No focused editor") + return (false, "No focused editor", nil) } guard let fileURL = xcodeInspector.activeDocumentURL else { - return (false, "No active document") + return (false, "No active document", nil) } guard let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL) else { - return (false, "No filespace") + return (false, "No filespace", nil) } - if filespace.presentingSuggestion == nil { - return (false, "No suggestion") + + var codeSuggestionType: CodeSuggestionType? = { + if let _ = filespace.presentingSuggestion { return .codeCompletion } + if let _ = filespace.presentingNESSuggestion { return .nes } + return nil + }() + guard let codeSuggestionType = codeSuggestionType else { + return (false, "No suggestion", nil) } - return (true, nil) + + if suggestionAction == .rejectNESSuggestion, codeSuggestionType != .nes { + return (false, "Invalid NES suggestion", nil) + } + + return (true, nil, codeSuggestionType) } } diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift index 77d91bb0..d1837411 100644 --- a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift +++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift @@ -8,6 +8,7 @@ extension ChatMessage { struct TurnItemData: Codable { var content: String + var contentImageReferences: [ImageReference] var rating: ConversationRating var references: [ConversationReference] var followUp: ConversationFollowUp? @@ -15,12 +16,19 @@ extension ChatMessage { var errorMessages: [String] = [] var steps: [ConversationProgressStep] var editAgentRounds: [AgentRound] + var parentTurnId: String? var panelMessages: [CopilotShowMessageParams] + var fileEdits: [FileEdit] + var turnStatus: ChatMessage.TurnStatus? + let requestType: RequestType + var modelName: String? + var billingMultiplier: Float? // Custom decoder to provide default value for steps init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) content = try container.decode(String.self, forKey: .content) + contentImageReferences = try container.decodeIfPresent([ImageReference].self, forKey: .contentImageReferences) ?? [] rating = try container.decode(ConversationRating.self, forKey: .rating) references = try container.decode([ConversationReference].self, forKey: .references) followUp = try container.decodeIfPresent(ConversationFollowUp.self, forKey: .followUp) @@ -28,12 +36,19 @@ extension ChatMessage { errorMessages = try container.decodeIfPresent([String].self, forKey: .errorMessages) ?? [] steps = try container.decodeIfPresent([ConversationProgressStep].self, forKey: .steps) ?? [] editAgentRounds = try container.decodeIfPresent([AgentRound].self, forKey: .editAgentRounds) ?? [] + parentTurnId = try container.decodeIfPresent(String.self, forKey: .parentTurnId) panelMessages = try container.decodeIfPresent([CopilotShowMessageParams].self, forKey: .panelMessages) ?? [] + fileEdits = try container.decodeIfPresent([FileEdit].self, forKey: .fileEdits) ?? [] + turnStatus = try container.decodeIfPresent(ChatMessage.TurnStatus.self, forKey: .turnStatus) + requestType = try container.decodeIfPresent(RequestType.self, forKey: .requestType) ?? .conversation + modelName = try container.decodeIfPresent(String.self, forKey: .modelName) + billingMultiplier = try container.decodeIfPresent(Float.self, forKey: .billingMultiplier) } // Default memberwise init for encoding init( content: String, + contentImageReferences: [ImageReference]? = nil, rating: ConversationRating, references: [ConversationReference], followUp: ConversationFollowUp?, @@ -41,9 +56,16 @@ extension ChatMessage { errorMessages: [String] = [], steps: [ConversationProgressStep]?, editAgentRounds: [AgentRound]? = nil, - panelMessages: [CopilotShowMessageParams]? = nil + parentTurnId: String? = nil, + panelMessages: [CopilotShowMessageParams]? = nil, + fileEdits: [FileEdit]? = nil, + turnStatus: ChatMessage.TurnStatus? = nil, + requestType: RequestType = .conversation, + modelName: String? = nil, + billingMultiplier: Float? = nil ) { self.content = content + self.contentImageReferences = contentImageReferences ?? [] self.rating = rating self.references = references self.followUp = followUp @@ -51,13 +73,20 @@ extension ChatMessage { self.errorMessages = errorMessages self.steps = steps ?? [] self.editAgentRounds = editAgentRounds ?? [] + self.parentTurnId = parentTurnId self.panelMessages = panelMessages ?? [] + self.fileEdits = fileEdits ?? [] + self.turnStatus = turnStatus + self.requestType = requestType + self.modelName = modelName + self.billingMultiplier = billingMultiplier } } func toTurnItem() -> TurnItem { let turnItemData = TurnItemData( content: self.content, + contentImageReferences: self.contentImageReferences, rating: self.rating, references: self.references, followUp: self.followUp, @@ -65,7 +94,13 @@ extension ChatMessage { errorMessages: self.errorMessages, steps: self.steps, editAgentRounds: self.editAgentRounds, - panelMessages: self.panelMessages + parentTurnId: self.parentTurnId, + panelMessages: self.panelMessages, + fileEdits: self.fileEdits, + turnStatus: self.turnStatus, + requestType: self.requestType, + modelName: self.modelName, + billingMultiplier: self.billingMultiplier ) // TODO: handle exception @@ -90,6 +125,7 @@ extension ChatMessage { clsTurnID: turnItem.CLSTurnID, role: ChatMessage.Role(rawValue: turnItem.role)!, content: turnItemData.content, + contentImageReferences: turnItemData.contentImageReferences, references: turnItemData.references, followUp: turnItemData.followUp, suggestedTitle: turnItemData.suggestedTitle, @@ -97,7 +133,13 @@ extension ChatMessage { rating: turnItemData.rating, steps: turnItemData.steps, editAgentRounds: turnItemData.editAgentRounds, + parentTurnId: turnItemData.parentTurnId, panelMessages: turnItemData.panelMessages, + fileEdits: turnItemData.fileEdits, + turnStatus: turnItemData.turnStatus, + requestType: turnItemData.requestType, + modelName: turnItemData.modelName, + billingMultiplier: turnItemData.billingMultiplier, createdAt: turnItem.createdAt, updatedAt: turnItem.updatedAt ) 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/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index 01611d11..2d0ecffc 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -9,6 +9,8 @@ import ChatAPIService import PromptToCodeService import SuggestionBasic import SuggestionWidget +import WorkspaceSuggestionService +import Workspace @MainActor final class WidgetDataSource {} @@ -47,7 +49,7 @@ extension WidgetDataSource: SuggestionWidgetDataSource { onAcceptSuggestionTapped: { Task { let handler = PseudoCommandHandler() - await handler.acceptSuggestion() + await handler.acceptSuggestion(.codeCompletion) NSWorkspace.activatePreviousActiveXcode() } }, @@ -63,5 +65,45 @@ extension WidgetDataSource: SuggestionWidgetDataSource { } return nil } + + func nesSuggestionForFile(at url: URL) async -> NESCodeSuggestionProvider? { + for workspace in await Service.shared.workspacePool.workspaces.values { + if let filespace = workspace.filespaces[url], + let nesSuggestion = filespace.presentingNESSuggestion + { + let sourceSnapshot = await getSourceSnapshot(from: filespace) + return .init( + fileURL: url, + code: nesSuggestion.text, + sourceSnapshot: sourceSnapshot, + range: nesSuggestion.range, + language: filespace.language.rawValue, + onRejectSuggestionTapped: { + Task { + let handler = PseudoCommandHandler() + await handler.rejectNESSuggestions() + } + }, + onAcceptNESSuggestionTapped: { + Task { + let handler = PseudoCommandHandler() + await handler.acceptSuggestion(.nes) + NSWorkspace.activatePreviousActiveXcode() + } + }, + onDismissNESSuggestionTapped: { + // Refer to Code Completion suggestion, the `dismiss` action is not support + } + ) + } + } + + return nil + } } + +@WorkspaceActor +private func getSourceSnapshot(from filespace: Filespace) -> FilespaceSuggestionSnapshot { + return filespace.nesSuggestionSourceSnapshot +} diff --git a/Core/Sources/Service/Helpers.swift b/Core/Sources/Service/Helpers.swift index 90ac6344..99dc2a65 100644 --- a/Core/Sources/Service/Helpers.swift +++ b/Core/Sources/Service/Helpers.swift @@ -6,6 +6,7 @@ extension NSError { static func from(_ error: Error) -> NSError { if let error = error as? ServerError { var message = "Unknown" + var errorData: Codable? = nil switch error { case let .handlerUnavailable(handler): message = "Handler unavailable: \(handler)." @@ -29,8 +30,9 @@ extension NSError { message = "Unable to send request: \(error.localizedDescription)." case let .unableToSendNotification(error): message = "Unable to send notification: \(error.localizedDescription)." - case let .serverError(code, m, _): + case let .serverError(code, m, data): message = "Server error: (\(code)) \(m)." + errorData = data case let .invalidRequest(error): message = "Invalid request: \(error?.localizedDescription ?? "Unknown")." case .timeout: @@ -38,9 +40,28 @@ extension NSError { case .unknownError: message = "Unknown error: \(error.localizedDescription)." } - return NSError(domain: "com.github.CopilotForXcode", code: -1, userInfo: [ - NSLocalizedDescriptionKey: message, - ]) + + var userInfo: [String: Any] = [NSLocalizedDescriptionKey: message] + + // Try to encode errorData to JSON for XPC transfer + if let errorData = errorData { + // Try to decode as MCPRegistryErrorData first + if let jsonData = try? JSONEncoder().encode(errorData), + let mcpErrorData = try? JSONDecoder().decode(MCPRegistryErrorData.self, from: jsonData) { + userInfo["errorType"] = mcpErrorData.errorType + if let status = mcpErrorData.status { + userInfo["status"] = status + } + if let shouldRetry = mcpErrorData.shouldRetry { + userInfo["shouldRetry"] = shouldRetry + } + } else if let jsonData = try? JSONEncoder().encode(errorData) { + // Fallback to encoding any Codable type + userInfo["serverErrorData"] = jsonData + } + } + + return NSError(domain: "com.github.CopilotForXcode", code: -1, userInfo: userInfo) } if let error = error as? CancellationError { return NSError(domain: "com.github.CopilotForXcode", code: -100, userInfo: [ diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 899865f1..d24416a1 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -72,17 +72,10 @@ public actor RealtimeSuggestionController { await self.triggerPrefetchDebounced() await self.notifyEditingFileChange(editor: sourceEditor.element) } - - if #available(macOS 13.0, *) { - for await _ in valueChange._throttle(for: .milliseconds(200)) { - if Task.isCancelled { return } - await handler() - } - } else { - for await _ in valueChange { - if Task.isCancelled { return } - await handler() - } + + for await _ in valueChange { + if Task.isCancelled { return } + await handler() } } group.addTask { @@ -139,6 +132,10 @@ public actor RealtimeSuggestionController { } } } + + // The `valueChange` event may be missed when the source editor changes focus and + // a file is opened with immediate edits (e.g., `insertEditIntoFile` tool in Agent mode). + await self.onFocusElementChanged(editor: sourceEditor) } } @@ -155,9 +152,6 @@ public actor RealtimeSuggestionController { let authStatus = await Status.shared.getAuthStatus() guard authStatus.status == .loggedIn else { return } - guard UserDefaults.shared.value(for: \.realtimeSuggestionToggle) - else { return } - if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally), let fileURL = await XcodeInspector.shared.safe.activeDocumentURL, let (workspace, _) = try? await Service.shared.workspacePool @@ -202,5 +196,24 @@ public actor RealtimeSuggestionController { else { return } await workspace.didUpdateFilespace(fileURL: fileURL, content: editor.value) } + + func onFocusElementChanged(editor: SourceEditor) async { + guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL else { + return + } + + if let (workspace, _) = await Service.shared.workspacePool + .fetchWorkspaceAndFilespace(fileURL: fileURL) { + await workspace.didUpdateFilespace(fileURL: fileURL, content: editor.element.value) + } else if let (workspace, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) { + await workspace.didOpenFilespace(filespace) + } + + await PseudoCommandHandler() + .invalidateRealtimeNESSuggestionsIfNeeded( + fileURL: fileURL, sourceEditor: editor + ) + } } diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 8072778a..ab6c35e2 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -62,7 +62,10 @@ public final class Service { keyBindingManager = .init( workspacePool: workspacePool, acceptSuggestion: { - Task { await PseudoCommandHandler().acceptSuggestion() } + Task { await PseudoCommandHandler().acceptSuggestion(.codeCompletion) } + }, + acceptNESSuggestion: { + Task { await PseudoCommandHandler().acceptSuggestion(.nes) } }, expandSuggestion: { if !ExpandableSuggestionService.shared.isSuggestionExpanded { @@ -76,6 +79,15 @@ public final class Service { }, dismissSuggestion: { Task { await PseudoCommandHandler().dismissSuggestion() } + }, + rejectNESSuggestion: { + Task { await PseudoCommandHandler().rejectNESSuggestions() } + }, + goToNextEditSuggestion: { + Task { await PseudoCommandHandler().goToNextEditSuggestion() } + }, + isNESPanelOutOfFrame: { [weak guiController] in + guiController?.store.state.suggestionWidgetState.panelState.nesSuggestionPanelState.isPanelOutOfFrame ?? false } ) let scheduledCleaner = ScheduledCleaner() diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 2ad3e765..3499a230 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -10,6 +10,7 @@ import WorkspaceSuggestionService import XcodeInspector import XPCShared import AXHelper +import GitHubCopilotService /// It's used to run some commands without really triggering the menu bar item. /// @@ -57,17 +58,92 @@ struct PseudoCommandHandler { .fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return } if Task.isCancelled { return } + + let codeCompletionEnabled = UserDefaults.shared.value(for: \.realtimeSuggestionToggle) + // Enabled both by Feature Flag and User. + let nesEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures && UserDefaults.shared.value(for: \.realtimeNESToggle) + guard codeCompletionEnabled || nesEnabled else { + cleanupAllSuggestions(filespace: filespace, presenter: nil) + return + } // Can't use handler if content is not available. guard let editor = await getEditorContent(sourceEditor: sourceEditor) else { return } - let fileURL = filespace.fileURL let presenter = PresentInWindowSuggestionPresenter() presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } + do { + if codeCompletionEnabled { + try await _generateRealtimeCodeCompletionSuggestions( + editor: editor, + sourceEditor: sourceEditor, + filespace: filespace, + workspace: workspace, + presenter: presenter + ) + } else { + cleanupCodeCompletionSuggestion(filespace: filespace, presenter: presenter) + } + + if nesEnabled, + (codeCompletionEnabled == false || filespace.presentingSuggestion == nil) { + try await _generateRealtimeNESSuggestions( + editor: editor, + sourceEditor: sourceEditor, + filespace: filespace, + workspace: workspace, + presenter: presenter + ) + } else { + cleanupNESSuggestion(filespace: filespace, presenter: presenter) + } + + } catch { + cleanupAllSuggestions(filespace: filespace, presenter: presenter) + } + } + + @WorkspaceActor + private func cleanupCodeCompletionSuggestion( + filespace: Filespace, + presenter: PresentInWindowSuggestionPresenter? + ) { + filespace.reset() + presenter?.discardSuggestion(fileURL: filespace.fileURL) + } + + @WorkspaceActor + private func cleanupNESSuggestion( + filespace: Filespace, + presenter: PresentInWindowSuggestionPresenter? + ) { + filespace.resetNESSuggestion() + presenter?.discardNESSuggestion(fileURL: filespace.fileURL) + } + + @WorkspaceActor + private func cleanupAllSuggestions( + filespace: Filespace, + presenter: PresentInWindowSuggestionPresenter? + ) { + cleanupCodeCompletionSuggestion(filespace: filespace, presenter: presenter) + cleanupNESSuggestion(filespace: filespace, presenter: presenter) + filespace.resetSnapshot() + filespace.resetNESSnapshot() + } + + @WorkspaceActor + func _generateRealtimeCodeCompletionSuggestions( + editor: EditorContent, + sourceEditor: SourceEditor?, + filespace: Filespace, + workspace: Workspace, + presenter: PresentInWindowSuggestionPresenter + ) async throws { if filespace.presentingSuggestion != nil { // Check if the current suggestion is still valid. if filespace.validateSuggestions( @@ -76,37 +152,78 @@ struct PseudoCommandHandler { ) { return } else { + filespace.reset() presenter.discardSuggestion(fileURL: filespace.fileURL) } } - - do { - try await workspace.generateSuggestions( - forFileAt: fileURL, - editor: editor + + let fileURL = filespace.fileURL + + try await workspace.generateSuggestions( + forFileAt: fileURL, + editor: editor + ) + let editorContent = sourceEditor?.getContent() + if let editorContent { + _ = filespace.validateSuggestions( + lines: editorContent.lines, + cursorPosition: editorContent.cursorPosition ) - if let sourceEditor { - let editorContent = sourceEditor.getContent() - _ = filespace.validateSuggestions( - lines: editorContent.lines, - cursorPosition: editorContent.cursorPosition + } + + if !filespace.errorMessage.isEmpty { + presenter + .presentWarningMessage( + filespace.errorMessage, + url: "https://github.com/github-copilot/signup/copilot_individual" ) - } - if !filespace.errorMessage.isEmpty { - presenter - .presentWarningMessage( - filespace.errorMessage, - url: "https://github.com/github-copilot/signup/copilot_individual" - ) - } - if filespace.presentingSuggestion != nil { - presenter.presentSuggestion(fileURL: fileURL) - workspace.notifySuggestionShown(fileFileAt: fileURL) + } + if filespace.presentingSuggestion != nil { + presenter.presentSuggestion(fileURL: fileURL) + workspace.notifySuggestionShown(fileFileAt: fileURL) + } else { + presenter.discardSuggestion(fileURL: fileURL) + } + } + + @WorkspaceActor + func _generateRealtimeNESSuggestions( + editor: EditorContent, + sourceEditor: SourceEditor?, + filespace: Filespace, + workspace: Workspace, + presenter: PresentInWindowSuggestionPresenter + ) async throws { + if filespace.presentingNESSuggestion != nil { + // Check if the current NES suggestion is still valid. + if filespace.validateNESSuggestions( + lines: editor.lines, + cursorPosition: editor.cursorPosition + ) { + return } else { - presenter.discardSuggestion(fileURL: fileURL) + filespace.resetNESSuggestion() + presenter.discardNESSuggestion(fileURL: filespace.fileURL) } - } catch { - return + } + + let fileURL = filespace.fileURL + + try await workspace.generateNESSuggestions(forFileAt: fileURL, editor: editor) + + let editorContent = sourceEditor?.getContent() + if let editorContent { + _ = filespace.validateNESSuggestions( + lines: editorContent.lines, + cursorPosition: editorContent.cursorPosition + ) + } + // TODO: handle errorMessage if any + if filespace.presentingNESSuggestion != nil { + presenter.presentNESSuggestion(fileURL: fileURL) + workspace.notifySuggestionShown(fileFileAt: fileURL) + } else { + presenter.discardNESSuggestion(fileURL: fileURL) } } @@ -127,6 +244,24 @@ struct PseudoCommandHandler { PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: fileURL) } } + + @WorkspaceActor + func invalidateRealtimeNESSuggestionsIfNeeded(fileURL: URL, sourceEditor: SourceEditor) async { + guard let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } + + if filespace.presentingNESSuggestion == nil { + return // skip if there's no NES suggestion presented. + } + + let content = sourceEditor.getContent() + if !filespace.validateNESSuggestions( + lines: content.lines, + cursorPosition: content.cursorPosition + ) { + PresentInWindowSuggestionPresenter().discardNESSuggestion(fileURL: fileURL) + } + } func rejectSuggestions() async { let handler = WindowBaseCommandHandler() @@ -142,6 +277,21 @@ struct PseudoCommandHandler { usesTabsForIndentation: false )) } + + func rejectNESSuggestions() async { + let handler = WindowBaseCommandHandler() + _ = try? await handler.rejectNESSuggestion(editor: .init( + content: "", + lines: [], + uti: "", + cursorPosition: .outOfScope, + cursorOffset: -1, + selections: [], + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false + )) + } func handleCustomCommand(_ command: CustomCommand) async { guard let editor = await { @@ -248,14 +398,20 @@ struct PseudoCommandHandler { } } - func acceptSuggestion() async { + func acceptSuggestion(_ suggestionType: CodeSuggestionType) async { do { if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { throw CancellationError() } do { - try await XcodeInspector.shared.safe.latestActiveXcode? - .triggerCopilotCommand(name: "Accept Suggestion") + switch suggestionType { + case .codeCompletion: + try await XcodeInspector.shared.safe.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Suggestion") + case .nes: + try await XcodeInspector.shared.safe.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Next Edit Suggestion") + } } catch { let lastBundleNotFoundTime = Self.lastBundleNotFoundTime let lastBundleDisabledTime = Self.lastBundleDisabledTime @@ -318,7 +474,7 @@ struct PseudoCommandHandler { } let handler = WindowBaseCommandHandler() do { - guard let result = try await handler.acceptSuggestion(editor: .init( + let editor: EditorContent = .init( content: content, lines: lines, uti: "", @@ -328,7 +484,18 @@ struct PseudoCommandHandler { tabSize: 0, indentSize: 0, usesTabsForIndentation: false - )) else { return } + ) + + let result = try await { + switch suggestionType { + case .codeCompletion: + return try await handler.acceptSuggestion(editor: editor) + case .nes: + return try await handler.acceptNESSuggestion(editor: editor) + } + }() + + guard let result else { return } try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) } catch { @@ -336,6 +503,27 @@ struct PseudoCommandHandler { } } } + + func goToNextEditSuggestion() async { + do { + guard let sourceEditor = await XcodeInspector.shared.safe.focusedEditor, + let fileURL = sourceEditor.realtimeDocumentURL + else { return } + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + + guard let suggestion = await workspace.getNESSuggestion(forFileAt: fileURL) + else { return } + + AXHelper.scrollSourceEditorToLine( + suggestion.range.start.line, + content: sourceEditor.getContent().content, + focusedElement: sourceEditor.element + ) + } catch { + // Handle if needed + } + } func dismissSuggestion() async { guard let documentURL = await XcodeInspector.shared.safe.activeDocumentURL else { return } diff --git a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift index 3d612e82..7aa5d20a 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift @@ -11,8 +11,12 @@ protocol SuggestionCommandHandler { @ServiceActor func rejectSuggestion(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor + func rejectNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor + func acceptNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor func presentRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 694bff25..4e0b2a74 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -57,8 +57,21 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { if filespace.presentingSuggestion != nil { presenter.presentSuggestion(fileURL: fileURL) workspace.notifySuggestionShown(fileFileAt: fileURL) + presenter.discardNESSuggestion(fileURL: fileURL) } else { presenter.discardSuggestion(fileURL: fileURL) + try Task.checkCancellation() + + // When no code completion generated, fallback to NES + try await workspace.generateNESSuggestions(forFileAt: fileURL, editor: editor) + + try Task.checkCancellation() + + if filespace.presentingNESSuggestion != nil { + presenter.presentNESSuggestion(fileURL: fileURL) + } else { + presenter.discardNESSuggestion(fileURL: fileURL) + } } } @@ -137,6 +150,28 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { workspace.rejectSuggestion(forFileAt: fileURL, editor: editor) presenter.discardSuggestion(fileURL: fileURL) } + + func rejectNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? { + Task { + do { + try await _rejectNESSuggestion(editor: editor) + } catch { + presenter.presentError(error) + } + } + return nil + } + + @WorkspaceActor + private func _rejectNESSuggestion(editor: EditorContent) async throws { + guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + else { return } + + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + workspace.rejectNESSuggestion(forFileAt: fileURL, editor: editor) + presenter.discardNESSuggestion(fileURL: fileURL) + } @WorkspaceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? { @@ -174,6 +209,41 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + + @WorkspaceActor + func acceptNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? { + guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + else { return nil } + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + + let injector = SuggestionInjector() + var lines = editor.lines + var cursorPosition = editor.cursorPosition + var extraInfo = SuggestionInjector.ExtraInfo() + + if let acceptedSuggestion = workspace.acceptNESSuggestion( + forFileAt: fileURL, editor: editor + ) { + injector.acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursorPosition, + completion: acceptedSuggestion, + extraInfo: &extraInfo, + isNES: true + ) + + presenter.discardNESSuggestion(fileURL: fileURL) + + return .init( + content: String(lines.joined(separator: "")), + newSelection: .cursor(cursorPosition), + modifications: extraInfo.modifications + ) + } + + return nil + } func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? { guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift index 4007a06c..80f60141 100644 --- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -11,6 +11,13 @@ struct PresentInWindowSuggestionPresenter { controller.suggestCode() } } + + func presentNESSuggestion(fileURL: URL) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.suggestNESCode() + } + } func expandSuggestion(fileURL: URL) { Task { @MainActor in @@ -25,6 +32,13 @@ struct PresentInWindowSuggestionPresenter { controller.discardSuggestion() } } + + func discardNESSuggestion(fileURL: URL) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.discardNESSuggestion() + } + } func markAsProcessing(_ isProcessing: Bool) { Task { @MainActor in diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 0297224a..b64e841c 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -9,6 +9,8 @@ import XPCShared import HostAppActivator import XcodeInspector import GitHubCopilotViewModel +import Workspace +import ConversationServiceProvider public class XPCService: NSObject, XPCServiceProtocol { // MARK: - Service @@ -119,6 +121,15 @@ public class XPCService: NSObject, XPCServiceProtocol { try await handler.rejectSuggestion(editor: editor) } } + + public func getNESSuggestionRejectedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.rejectNESSuggestion(editor: editor) + } + } public func getSuggestionAcceptedCode( editorContent: Data, @@ -128,6 +139,15 @@ public class XPCService: NSObject, XPCServiceProtocol { try await handler.acceptSuggestion(editor: editor) } } + + public func getNESSuggestionAcceptedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.acceptNESSuggestion(editor: editor) + } + } public func getPromptToCodeAcceptedCode( editorContent: Data, @@ -228,6 +248,29 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(nil) } } + + public func toggleRealtimeNES(withReply reply: @escaping (Error?) -> Void) { + guard AXIsProcessTrusted() else { + reply(NoAccessToAccessibilityAPIError()) + return + } + Task { @ServiceActor in + await Service.shared.realtimeSuggestionController.cancelInFlightTasks() + let on = !UserDefaults.shared.value(for: \.realtimeNESToggle) + UserDefaults.shared.set(on, for: \.realtimeNESToggle) + Task { @MainActor in + Service.shared.guiController.store + .send(.suggestionWidget(.toastPanel(.toast(.toast( + "Next Edit Suggestions (NES) is turned \(on ? "on" : "off")", + .info, + nil + ))))) + Service.shared.guiController.store + .send(.suggestionWidget(.panel(.onRealtimeNESToggleChanged(on)))) + } + reply(nil) + } + } public func postNotification(name: String, withReply reply: @escaping () -> Void) { reply() @@ -289,22 +332,221 @@ public class XPCService: NSObject, XPCServiceProtocol { } } - public func updateMCPServerToolsStatus(tools: Data) { + public func updateMCPServerToolsStatus( + tools: Data, + chatAgentMode: Data?, + customChatModeId: Data?, + workspaceFolders: Data? + ) { // Decode the data let decoder = JSONDecoder() var collections: [UpdateMCPToolsStatusServerCollection] = [] + var folders: [WorkspaceFolder]? = nil + var mode: ChatMode? = nil + var modeId: String? = nil do { collections = try decoder.decode([UpdateMCPToolsStatusServerCollection].self, from: tools) + if let workspaceFolders = workspaceFolders { + folders = try? decoder.decode([WorkspaceFolder].self, from: workspaceFolders) + } + if let chatAgentMode = chatAgentMode { + mode = try? decoder.decode(ChatMode.self, from: chatAgentMode) + } + if let customChatModeId = customChatModeId { + modeId = try? decoder.decode(String.self, from: customChatModeId) + } if collections.isEmpty { return } } catch { - Logger.service.error("Failed to decode MCP server collections: \(error)") + Logger.service.error("Failed to decode MCP server collections or workspace folders: \(error)") return } Task { @MainActor in - await GitHubCopilotService.updateAllClsMCP(collections: collections) + // Only use auth service when ALL three parameters are provided. + if mode != nil, modeId != nil, folders != nil { + do { + if let uri = folders!.first?.uri, let projectRootURL = URL(string: uri) { + if let service = GitHubCopilotService.getProjectGithubCopilotService( + for: projectRootURL + ) { + let params = UpdateMCPToolsStatusParams( + chatModeKind: mode, + customChatModeId: modeId, + workspaceFolders: folders, + servers: collections + ) + try await service.updateMCPToolsStatus(params: params) + } + } + } catch { + Logger.service.error("Failed to update MCP tool status via auth service: \(error)") + } + } else { + // Fallback to legacy/global update when context not fully provided. + await GitHubCopilotService.updateAllClsMCP(collections: collections) + } + } + } + + // 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, NSError.from(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, NSError.from(error)) + } + } + } + + public func getMCPRegistryAllowlist(withReply reply: @escaping (Data?, Error?) -> Void) { + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.getMCPRegistryAllowlist() + let data = try? JSONEncoder().encode(response) + reply(data, nil) + } catch { + Logger.service.error("Failed to get MCP Registry allowlist: \(error)") + reply(nil, NSError.from(error)) + } + } + } + + // 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 refreshClientTools(withReply reply: @escaping (Data?) -> Void) { + Task { @MainActor in + await GitHubCopilotService.refreshClientTools() + let availableLanguageModelTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() + if let availableLanguageModelTools = availableLanguageModelTools { + let data = try? JSONEncoder().encode(availableLanguageModelTools) + reply(data) + } else { + reply(nil) + } + } + } + + public func updateToolsStatus( + tools: Data, + chatAgentMode: Data?, + customChatModeId: Data?, + workspaceFolders: Data?, + withReply reply: @escaping (Data?) -> Void + ) { + // Decode the data + let decoder = JSONDecoder() + var toolStatusUpdates: [ToolStatusUpdate] = [] + var folders: [WorkspaceFolder]? = nil + var mode: ChatMode? = nil + var modeId: String? = nil + do { + toolStatusUpdates = try decoder.decode([ToolStatusUpdate].self, from: tools) + if let workspaceFolders = workspaceFolders { + folders = try? decoder.decode([WorkspaceFolder].self, from: workspaceFolders) + } + if let chatAgentMode = chatAgentMode { + mode = try? decoder.decode(ChatMode.self, from: chatAgentMode) + } + if let customChatModeId = customChatModeId { + modeId = try? decoder.decode(String.self, from: customChatModeId) + } + if toolStatusUpdates.isEmpty { + let emptyData = try JSONEncoder().encode([LanguageModelTool]()) + reply(emptyData) + return + } + } catch { + Logger.service.error("Failed to decode built-in tools or workspace folders: \(error)") + reply(nil) + return + } + + Task { @MainActor in + var updatedTools: [LanguageModelTool] = [] + if mode != nil, modeId != nil, folders != nil { + // Use auth service path when all three context parameters are present. + do { + if let uri = folders!.first?.uri, let projectRootURL = URL(string: uri) { + if let service = GitHubCopilotService.getProjectGithubCopilotService( + for: projectRootURL + ) { + updatedTools = try await service.updateToolsStatus( + params: .init( + chatmodeKind: mode, + customChatModeId: modeId, + workspaceFolders: folders, + tools: toolStatusUpdates + ) + ) + } + } + } catch { + Logger.service.error("Failed contextual tools update: \(error)") + updatedTools = await GitHubCopilotService.updateAllCLSTools(tools: toolStatusUpdates) + } + } else { + // Fallback without contextual parameters. + 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) + } } } @@ -317,6 +559,33 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(data) } + public func getCopilotPolicy( + withReply reply: @escaping (Data?) -> Void + ) { + let copilotPolicy = CopilotPolicyNotifierImpl.shared.copilotPolicy + let data = try? JSONEncoder().encode(copilotPolicy) + reply(data) + } + + public func getModes(workspaceFolders: Data?, withReply reply: @escaping (Data?, Error?) -> Void) { + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + var folders: [WorkspaceFolder]? = nil + if let workspaceFolders = workspaceFolders { + folders = try JSONDecoder().decode([WorkspaceFolder].self, from: workspaceFolders) + } + + let modes = try await service.modes(workspaceFolders: folders) + let data = try JSONEncoder().encode(modes) + reply(data, nil) + } catch { + Logger.service.error("Failed to get modes: \(error.localizedDescription)") + reply(nil, NSError.from(error)) + } + } + } + // MARK: - Auth public func signOutAllGitHubCopilotService() { Task { @MainActor in @@ -337,6 +606,163 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(data) } } + + public func updateCopilotModels(withReply reply: @escaping (Data?, Error?) -> Void) { + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let models = try await service.models() + CopilotModelManager.updateLLMs(models) + let data = try JSONEncoder().encode(models) + reply(data, nil) + } catch { + Logger.service.error("Failed to get models: \(error.localizedDescription)") + reply(nil, NSError.from(error)) + } + } + } + + // 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/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Core/Sources/SuggestionInjector/SuggestionInjector.swift index df78acf5..c2edff6c 100644 --- a/Core/Sources/SuggestionInjector/SuggestionInjector.swift +++ b/Core/Sources/SuggestionInjector/SuggestionInjector.swift @@ -20,7 +20,8 @@ public struct SuggestionInjector { cursorPosition: inout CursorPosition, completion: CodeSuggestion, extraInfo: inout ExtraInfo, - suggestionLineLimit: Int? = nil + suggestionLineLimit: Int? = nil, + isNES: Bool = false ) { extraInfo.didChangeContent = true extraInfo.didChangeCursorPosition = true @@ -77,6 +78,35 @@ public struct SuggestionInjector { at: toBeInserted[0].startIndex ) } + + // appending suffix text not in range if needed. + if isNES, + let lastRemovedLine, + !lastRemovedLine.isEmptyOrNewLine, + end.character >= 0, + end.character < lastRemovedLine.count, + !toBeInserted.isEmpty + { + let suffixStartIndex = lastRemovedLine.utf16.index( + lastRemovedLine.utf16.startIndex, + offsetBy: end.character, + limitedBy: lastRemovedLine.utf16.endIndex + ) ?? lastRemovedLine.utf16.endIndex + var suffix = String(lastRemovedLine[suffixStartIndex...]) + if suffix.last?.isNewline ?? false { + suffix.removeLast(1) + } + let lastIndex = toBeInserted.endIndex - 1 + var lastLine = toBeInserted[lastIndex] + if lastLine.last?.isNewline ?? false { + lastLine.removeLast(1) + lastLine.append(contentsOf: suffix) + lastLine.append(lineEnding) + } else { + lastLine.append(contentsOf: suffix) + } + toBeInserted[lastIndex] = lastLine + } let recoveredSuffixLength = recoverSuffixIfNeeded( endOfReplacedContent: end, diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index 2802d787..1766001c 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -64,6 +64,28 @@ public extension SuggestionService { return try await getSuggestion(request, workspaceInfo) } + + func getNESSuggestions( + _ request: SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo, + ) async throws -> [SuggestionBasic.CodeSuggestion] { + var getNESSuggestion = suggestionProvider.getNESSuggestions(_:workspaceInfo:) + let configuration = await configuration + + for middleware in middlewares.reversed() { + getNESSuggestion = { [getNESSuggestion] request, workspaceInfo in + try await middleware.getNESSuggestion( + request, + configuration: configuration, + next: { [getNESSuggestion] request in + try await getNESSuggestion(request, workspaceInfo) + } + ) + } + } + + return try await getNESSuggestion(request, workspaceInfo) + } func notifyAccepted( _ suggestion: SuggestionBasic.CodeSuggestion, diff --git a/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift new file mode 100644 index 00000000..fd423990 --- /dev/null +++ b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift @@ -0,0 +1,1186 @@ +import AppKit +import ChatService +import ComposableArchitecture +import ConversationServiceProvider +import ConversationTab +import GitHubCopilotService +import LanguageServerProtocol +import Logger +import SharedUIComponents +import SuggestionBasic +import SwiftUI +import XcodeInspector + +struct SelectedAgentModel: Equatable { + let displayName: String + let modelName: String + let source: ModelSource + + enum ModelSource: Equatable { + case copilot + case byok(provider: String) + } +} + +struct AgentConfigurationWidgetView: View { + let store: StoreOf + + @State private var showPopover = false + @State private var isHovered = false + @State private var selectedToolStates: [String: [String: Bool]] = [:] + @State private var selectedModel: SelectedAgentModel? = nil + @State private var searchText = "" + @State private var isSearchFieldExpanded = false + @State private var generateHandoffExample: Bool = true + @Environment(\.colorScheme) var colorScheme + + var body: some View { + WithPerceptionTracking { + if store.isPanelDisplayed { + VStack { + buildAgentConfigurationButton() + .popover(isPresented: $showPopover) { + buildConfigView(currentMode: store.currentMode).padding(.horizontal, 4) + } + } + .animation(.easeInOut(duration: 0.2), value: store.isPanelDisplayed) + .onChange(of: showPopover) { newValue in + if newValue { + // Load state from agent file when popover is opened + loadToolStatesFromAgentFile(currentMode: store.currentMode) + // Refresh client tools to get any late-arriving server tools + Task { + await GitHubCopilotService.refreshClientTools() + } + } + } + } + } + } + + @ViewBuilder + private func buildAgentConfigurationButton() -> some View { + let fontSize = store.lineHeight * 0.7 + let lineHeight = store.lineHeight + + ZStack { + Button(action: { showPopover.toggle() }) { + HStack(spacing: 4) { + Image(systemName: "square.and.pencil") + .resizable() + .scaledToFit() + .frame(width: fontSize, height: fontSize) + Text("Customize Agent") + .font(.system(size: fontSize)) + .fixedSize() + } + .frame(height: lineHeight) + .foregroundColor(isHovered ? Color("ItemSelectedColor") : .secondary) + } + .buttonStyle(.plain) + .contentShape(Capsule()) + .help("Configure tools and model for custom agent") + .onHover { isHovered = $0 } + } + } + + @ViewBuilder + private func buildConfigView(currentMode: ConversationMode?) -> some View { + if let currentMode = currentMode { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text("Configure Model") + .font(.system(size: 15, weight: .bold)) + + Text("The AI model to use when running the prompt. If not specified, the currently selected model in model picker is used.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .padding(.bottom, 8) + + AgentModelPickerSection( + selectedModel: $selectedModel + ) + + Divider() + + if currentMode.handOffs?.isEmpty ?? true { + Text("Configure Handoffs") + .font(.system(size: 15, weight: .bold)) + + Text("Suggested next actions or prompts to transition between custom agents. Handoff buttons appear as interactive suggestions after a chat response completes.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + + Toggle(isOn: $generateHandoffExample) { + Text("Generate Handoff Example") + .font(.system(size: 11, weight: .regular)) + } + .toggleStyle(.checkbox) + .help("Adds a starter handoff example to the agent file YAML frontmatter.") + + Divider() + } + + // Title with Search + HStack { + Text("Configure Tools") + .font(.system(size: 15, weight: .bold)) + + Spacer() + + CollapsibleSearchField( + searchText: $searchText, + isExpanded: $isSearchFieldExpanded, + placeholderString: "Search tools..." + ) + } + + Text("A list of built-in tools and MCP tools that are available for this agent. If a given tool is not available when running the agent, it is ignored.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .padding(.bottom, 8) + + // MCP Tools Section + AgentToolsSection( + title: "MCP Tools", + currentMode: currentMode, + selectedToolStates: $selectedToolStates, + searchText: searchText + ) + + // Built-In Tools Section + AgentBuiltInToolsSection( + title: "Built-In Tools", + currentMode: currentMode, + selectedToolStates: $selectedToolStates, + searchText: searchText + ) + } + .padding(12) + } + .frame(width: 500, height: 600) + + Divider() + + // Buttons + HStack(spacing: 12) { + Button(action: { showPopover = false }) { + Text("Cancel") + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Button(action: { + updateAgentTools(selectedToolStates: selectedToolStates, currentMode: currentMode) + applyAgentFileChanges( + selectedModel: selectedModel, + generateHandoffExample: generateHandoffExample, + currentMode: currentMode + ) + showPopover = false + }) { + Text("Apply") + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .keyboardShortcut(.defaultAction) + } + .padding(12) + } + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } else { + // Should never be shown since widget only displays when mode exists + VStack { + Text("No agent mode available") + .foregroundColor(.secondary) + } + .frame(width: 500, height: 600) + } + } + + // MARK: - Helper functions + + // MARK: - Agent File Utilities + + private struct AgentFileAccess { + let documentURL: URL + let content: String + } + + private func validateAndReadAgentFile() -> AgentFileAccess? { + guard let documentURL = store.withState({ $0.focusedEditor?.realtimeDocumentURL }) else { + Logger.extension.error("Could not access agent file - documentURL is nil") + return nil + } + guard documentURL.pathExtension == "md" else { + Logger.extension.error("Could not access agent file - invalid extension") + return nil + } + guard documentURL.lastPathComponent.hasSuffix(".agent.md") else { + Logger.extension.error("Could not access agent file - filename does not end with .agent.md") + return nil + } + guard let content = try? String(contentsOf: documentURL) else { + Logger.extension.error("Could not access agent file - unable to read file") + return nil + } + return AgentFileAccess(documentURL: documentURL, content: content) + } + + private struct YAMLFrontmatterInfo { + var lines: [String] + let frontmatterEndIndex: Int? + let modelLineIndex: Int? + let toolsLineIndex: Int? + let handoffsLineIndex: Int? + } + + private func parseYAMLFrontmatter(content: String) -> YAMLFrontmatterInfo { + var lines = content.components(separatedBy: .newlines) + var inFrontmatter = false + var frontmatterEndIndex: Int? + var modelLineIndex: Int? + var toolsLineIndex: Int? + var handoffsLineIndex: Int? + + for (idx, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed == "---" { + if !inFrontmatter { + inFrontmatter = true + } else { + inFrontmatter = false + frontmatterEndIndex = idx + break + } + } else if inFrontmatter { + if trimmed.hasPrefix("model:") { + modelLineIndex = idx + } else if trimmed.hasPrefix("tools:") { + toolsLineIndex = idx + } else if trimmed.hasPrefix("handoffs:") || trimmed.hasPrefix("handOffs:") { + handoffsLineIndex = idx + } + } + } + + return YAMLFrontmatterInfo( + lines: lines, + frontmatterEndIndex: frontmatterEndIndex, + modelLineIndex: modelLineIndex, + toolsLineIndex: toolsLineIndex, + handoffsLineIndex: handoffsLineIndex + ) + } + + private func writeToAgentFile(url: URL, content: String, successMessage: String) { + do { + try content.write(to: url, atomically: true, encoding: .utf8) + Logger.extension.info(successMessage) + } catch { + Logger.extension.error("Error writing agent file: \(error)") + } + } + + private func formatModelLine(_ selectedModel: SelectedAgentModel?) -> String? { + guard let model = selectedModel else { return nil } + let sourceLabel: String + switch model.source { + case .copilot: + sourceLabel = "copilot" + case let .byok(provider): + sourceLabel = provider + } + return "model: '\(model.displayName) (\(sourceLabel))'" + } + + private func loadMCPToolStates(enabledTools: Set) { + guard let mcpServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() else { return } + for server in mcpServerTools { + for tool in server.tools { + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: server.name, + toolName: tool.name + ) + selectedToolStates["mcp"]?[configurationKey] = enabledTools.contains(configurationKey) + } + } + } + + private func loadBuiltInToolStates(enabledTools: Set) { + guard let builtInTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() else { return } + for tool in builtInTools { + selectedToolStates["builtin"]?[tool.name] = enabledTools.contains(tool.name) + } + } + + private func collectMCPToolUpdates(selectedToolStates: [String: [String: Bool]]) -> [UpdateMCPToolsStatusServerCollection] { + guard let mcpStates = selectedToolStates["mcp"], + let mcpServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() else { + return [] + } + + return mcpServerTools.map { server in + let toolUpdates = server.tools.map { tool in + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: server.name, + toolName: tool.name + ) + let isEnabled = mcpStates[configurationKey] ?? false + return UpdatedMCPToolsStatus( + name: tool.name, + status: isEnabled ? .enabled : .disabled + ) + } + return UpdateMCPToolsStatusServerCollection( + name: server.name, + tools: toolUpdates + ) + } + } + + private func collectBuiltInToolUpdates(selectedToolStates: [String: [String: Bool]]) -> [ToolStatusUpdate] { + guard let builtInStates = selectedToolStates["builtin"], + let builtInTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() else { + return [] + } + + return builtInTools.map { tool in + let isEnabled = builtInStates[tool.name] ?? false + return ToolStatusUpdate( + name: tool.name, + status: isEnabled ? .enabled : .disabled + ) + } + } + + private func updateMCPToolsViaAPI( + service: GitHubCopilotService, + mcpCollections: [UpdateMCPToolsStatusServerCollection], + chatModeKind: ChatMode?, + customChatModeId: String?, + workspaceFolders: [WorkspaceFolder] + ) async { + guard !mcpCollections.isEmpty else { return } + do { + let _ = try await service.updateMCPToolsStatus( + params: UpdateMCPToolsStatusParams( + chatModeKind: chatModeKind, + customChatModeId: customChatModeId, + workspaceFolders: workspaceFolders, + servers: mcpCollections + ) + ) + Logger.extension.info("MCP tools updated via API") + + // Notify Settings app about custom agent tool changes + DistributedNotificationCenter.default().postNotificationName( + .gitHubCopilotCustomAgentToolsDidChange, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } catch { + Logger.extension.error("Error updating MCP tools via API: \(error)") + } + } + + private func updateBuiltInToolsViaAPI( + service: GitHubCopilotService, + builtInToolUpdates: [ToolStatusUpdate], + chatModeKind: ChatMode?, + customChatModeId: String?, + workspaceFolders: [WorkspaceFolder] + ) async { + guard !builtInToolUpdates.isEmpty else { return } + do { + let _ = try await service.updateToolsStatus( + params: UpdateToolsStatusParams( + chatmodeKind: chatModeKind, + customChatModeId: customChatModeId, + workspaceFolders: workspaceFolders, + tools: builtInToolUpdates + ) + ) + Logger.extension.info("Built-in tools updated via API") + + // Notify Settings app about custom agent tool changes + DistributedNotificationCenter.default().postNotificationName( + .gitHubCopilotCustomAgentToolsDidChange, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } catch { + Logger.extension.error("Error updating built-in tools via API: \(error)") + } + } + + private func parseModelFromMode(_ mode: ConversationMode?) -> SelectedAgentModel? { + guard let mode = mode, + let modelString = mode.model else { + return nil + } + + // Parse format: "displayName (copilot)" or "displayName (providerName)" + if let openParen = modelString.lastIndex(of: "("), + let closeParen = modelString.lastIndex(of: ")") { + let displayName = String(modelString[.. Int? { + let modelLine = formatModelLine(selectedModel) + + if let modelLine = modelLine { + if let modelIdx = yamlInfo.modelLineIndex { + yamlInfo.lines[modelIdx] = modelLine + return modelIdx + } else if let endIdx = yamlInfo.frontmatterEndIndex { + yamlInfo.lines.insert(modelLine, at: endIdx) + return endIdx + } + } else if let modelIdx = yamlInfo.modelLineIndex { + yamlInfo.lines.remove(at: modelIdx) + return nil + } + return yamlInfo.modelLineIndex + } + + private func applyHandoffsUpdate(to yamlInfo: inout YAMLFrontmatterInfo, afterModelIndex modelIndex: Int?) { + guard yamlInfo.handoffsLineIndex == nil else { return } + + let snippet = [ + "handoffs:", + " - label: Start Implementation", + " agent: implementation", + " prompt: Now implement the plan outlined above.", + " send: true", + ] + + if let mIdx = modelIndex { + yamlInfo.lines.insert(contentsOf: snippet, at: mIdx + 1) + } else if let endIdx = yamlInfo.frontmatterEndIndex { + yamlInfo.lines.insert(contentsOf: snippet, at: endIdx) + } + } + + // MARK: - MCP Tools Section + + private struct AgentToolsSection: View { + let title: String + let currentMode: ConversationMode + @Binding var selectedToolStates: [String: [String: Bool]] + let searchText: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + + let mcpServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() ?? [] + + if mcpServerTools.isEmpty { + Text("No MCP tools available.") + .foregroundColor(.secondary) + .font(.system(size: 13)) + .padding(.vertical, 8) + } else { + ForEach(mcpServerTools, id: \.name) { server in + AgentMCPServerSection( + serverTools: server, + currentMode: currentMode, + selectedToolStates: $selectedToolStates, + searchText: searchText + ) + } + } + } + } + } + + // MARK: - MCP Server Section + + private struct AgentMCPServerSection: View { + let serverTools: MCPServerToolsCollection + let currentMode: ConversationMode + @Binding var selectedToolStates: [String: [String: Bool]] + let searchText: String + + @State private var isExpanded: Bool = false + @State private var checkboxState: CheckboxMixedState = .off + + private func matchesSearch(_ text: String, _ description: String?) -> Bool { + guard !searchText.isEmpty else { return true } + let lowercasedSearch = searchText.lowercased() + return text.lowercased().contains(lowercasedSearch) || + (description?.lowercased().contains(lowercasedSearch) ?? false) + } + + private var serverNameMatches: Bool { + matchesSearch(serverTools.name, nil) + } + + private var hasMatchingTools: Bool { + guard !searchText.isEmpty else { return false } + if serverNameMatches { return true } + return serverTools.tools.contains { tool in + matchesSearch(tool.name, tool.description) + } + } + + private var filteredTools: [MCPTool] { + guard !searchText.isEmpty else { return serverTools.tools } + if serverNameMatches { return serverTools.tools } + return serverTools.tools.filter { tool in + matchesSearch(tool.name, tool.description) + } + } + + var body: some View { + // Don't show this server if search is active and there are no matches + if searchText.isEmpty || hasMatchingTools { + VStack(alignment: .leading, spacing: 0) { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 0) { + Divider() + .padding(.vertical, 4) + + ForEach(filteredTools, id: \.name) { tool in + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: serverTools.name, + toolName: tool.name + ) + let isSelected = selectedToolStates["mcp"]?[configurationKey] ?? AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: configurationKey, + currentStatus: .enabled, + selectedMode: currentMode + ) + AgentToolRow( + toolName: tool.name, + toolDescription: tool.description, + isSelected: isSelected, + isBlocked: serverTools.status == .blocked || serverTools.status == .error, + onToggle: { isSelected in + if selectedToolStates["mcp"] == nil { + selectedToolStates["mcp"] = [:] + } + selectedToolStates["mcp"]?[configurationKey] = isSelected + updateServerSelectionState() + } + ) + .padding(.leading, 20) + } + } + } label: { + HStack(spacing: 8) { + MixedStateCheckbox( + title: "", + font: .systemFont(ofSize: 13), + state: $checkboxState, + action: { + // Toggle based on current state + switch checkboxState { + case .off, .mixed: + toggleAllTools(selected: true) + case .on: + toggleAllTools(selected: false) + } + } + ) + .disabled(serverTools.status == .blocked || serverTools.status == .error) + + HStack(spacing: 8) { + if serverTools.status == .blocked || serverTools.status == .error { + Text("MCP Server: \(serverTools.name)") + .font(.system(size: 13, weight: .medium)) + } else { + let selectedCount = serverTools.tools.filter { tool in + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: serverTools.name, + toolName: tool.name + ) + if let state = selectedToolStates["mcp"]?[configurationKey] { + return state + } + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: configurationKey, + currentStatus: .enabled, + selectedMode: currentMode + ) + }.count + Text("MCP Server: \(serverTools.name) ") + .font(.system(size: 13, weight: .medium)) + + Text("(\(selectedCount) of \(serverTools.tools.count) Selected)") + .font(.system(size: 13, weight: .regular)) + } + + if serverTools.status == .error { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + .font(.system(size: 11)) + } else if serverTools.status == .blocked { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.system(size: 11)) + } + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + isExpanded.toggle() + } + } + + Spacer() + } + } + .padding(.vertical, 4) + } + .disabled(serverTools.status != .running) + .onAppear { + updateServerSelectionState() + } + .onChange(of: selectedToolStates) { _ in + updateServerSelectionState() + } + .onChange(of: searchText) { _ in + if hasMatchingTools && !isExpanded && serverTools.status == .running { + isExpanded = true + } + } + } + } + + private func toggleAllTools(selected: Bool) { + if selectedToolStates["mcp"] == nil { + selectedToolStates["mcp"] = [:] + } + for tool in serverTools.tools { + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: serverTools.name, + toolName: tool.name + ) + selectedToolStates["mcp"]?[configurationKey] = selected + } + updateServerSelectionState() + } + + private func isToolSelected(_ tool: MCPTool) -> Bool { + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: serverTools.name, + toolName: tool.name + ) + if let state = selectedToolStates["mcp"]?[configurationKey] { + return state + } + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: configurationKey, + currentStatus: .enabled, + selectedMode: currentMode + ) + } + + private func updateServerSelectionState() { + guard serverTools.status != .blocked && serverTools.status != .error && !serverTools.tools.isEmpty else { + checkboxState = .off + return + } + + let selectedCount = serverTools.tools.filter { isToolSelected($0) }.count + checkboxState = selectedCount == 0 ? .off : (selectedCount == serverTools.tools.count ? .on : .mixed) + } + } + + // MARK: - Built-In Tools Section + + private struct AgentBuiltInToolsSection: View { + let title: String + let currentMode: ConversationMode + @Binding var selectedToolStates: [String: [String: Bool]] + let searchText: String + + @State private var isExpanded: Bool = false + @State private var checkboxState: CheckboxMixedState = .off + + private func matchesBuiltInSearch(_ tool: LanguageModelTool) -> Bool { + guard !searchText.isEmpty else { return true } + let lowercasedSearch = searchText.lowercased() + return tool.name.lowercased().contains(lowercasedSearch) || + (tool.displayName?.lowercased().contains(lowercasedSearch) ?? false) || + (tool.description?.lowercased().contains(lowercasedSearch) ?? false) + } + + private var builtInNameMatches: Bool { + guard !searchText.isEmpty else { return false } + let lowercasedSearch = searchText.lowercased() + return "built-in".contains(lowercasedSearch) || "builtin".contains(lowercasedSearch) + } + + private func hasMatchingTools(builtInTools: [LanguageModelTool]) -> Bool { + guard !searchText.isEmpty else { return false } + if builtInNameMatches { return true } + return builtInTools.contains { matchesBuiltInSearch($0) } + } + + private func filteredTools(builtInTools: [LanguageModelTool]) -> [LanguageModelTool] { + guard !searchText.isEmpty else { return builtInTools } + if builtInNameMatches { return builtInTools } + return builtInTools.filter { matchesBuiltInSearch($0) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + + let builtInTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? [] + + if builtInTools.isEmpty { + Text("No built-in tools available.") + .foregroundColor(.secondary) + .font(.system(size: 13)) + .padding(.vertical, 8) + } else if searchText.isEmpty || hasMatchingTools(builtInTools: builtInTools) { + VStack(alignment: .leading, spacing: 0) { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 0) { + Divider() + .padding(.vertical, 4) + + ForEach(filteredTools(builtInTools: builtInTools), id: \.name) { tool in + let isSelected = selectedToolStates["builtin"]?[tool.name] ?? AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: tool.name, + currentStatus: tool.status, + selectedMode: currentMode + ) + AgentToolRow( + toolName: tool.displayName ?? tool.name, + toolDescription: tool.description, + isSelected: isSelected, + isBlocked: false, + onToggle: { isSelected in + if selectedToolStates["builtin"] == nil { + selectedToolStates["builtin"] = [:] + } + selectedToolStates["builtin"]?[tool.name] = isSelected + updateBuiltInSelectionState(builtInTools: builtInTools) + } + ) + .padding(.leading, 20) + } + } + } label: { + HStack(spacing: 8) { + MixedStateCheckbox( + title: "", + font: .systemFont(ofSize: 13), + state: $checkboxState, + action: { + // Toggle based on current state + switch checkboxState { + case .off, .mixed: + toggleAllBuiltInTools(selected: true, builtInTools: builtInTools) + case .on: + toggleAllBuiltInTools(selected: false, builtInTools: builtInTools) + } + } + ) + + let selectedCount = builtInTools.filter { tool in + if let state = selectedToolStates["builtin"]?[tool.name] { + return state + } + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: tool.name, + currentStatus: tool.status, + selectedMode: currentMode + ) + }.count + (Text("Built-In ") + .font(.system(size: 13, weight: .medium)) + + Text("(\(selectedCount) of \(builtInTools.count) Selected)") + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.secondary)) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + isExpanded.toggle() + } + } + + Spacer() + } + } + .padding(.vertical, 4) + } + .onAppear { + updateBuiltInSelectionState(builtInTools: builtInTools) + } + .onChange(of: selectedToolStates) { _ in + updateBuiltInSelectionState(builtInTools: builtInTools) + } + .onChange(of: searchText) { _ in + if hasMatchingTools(builtInTools: builtInTools) && !isExpanded { + isExpanded = true + } + } + } + } + } + + private func toggleAllBuiltInTools(selected: Bool, builtInTools: [LanguageModelTool]) { + if selectedToolStates["builtin"] == nil { + selectedToolStates["builtin"] = [:] + } + for tool in builtInTools { + selectedToolStates["builtin"]?[tool.name] = selected + } + updateBuiltInSelectionState(builtInTools: builtInTools) + } + + private func isBuiltInToolSelected(_ tool: LanguageModelTool) -> Bool { + if let state = selectedToolStates["builtin"]?[tool.name] { + return state + } + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: tool.name, + currentStatus: tool.status, + selectedMode: currentMode + ) + } + + private func updateBuiltInSelectionState(builtInTools: [LanguageModelTool]) { + guard !builtInTools.isEmpty else { + checkboxState = .off + return + } + + let selectedCount = builtInTools.filter { isBuiltInToolSelected($0) }.count + checkboxState = selectedCount == 0 ? .off : (selectedCount == builtInTools.count ? .on : .mixed) + } + } + + // MARK: - Agent Tool Row + + private struct AgentToolRow: View { + let toolName: String + let toolDescription: String? + let isSelected: Bool + let isBlocked: Bool + let onToggle: (Bool) -> Void + + var body: some View { + HStack(alignment: .center) { + Toggle(isOn: Binding( + get: { isSelected }, + set: { onToggle($0) } + )) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(toolName) + .font(.system(size: 12, weight: .medium)) + + if let description = toolDescription { + Text(description) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .help(description) + .lineLimit(1) + } + } + } + } + .toggleStyle(.checkbox) + .disabled(isBlocked) + } + .padding(.vertical, 4) + } + } + + // MARK: - Agent Model Picker Section + + private struct AgentModelPickerSection: View { + @Binding var selectedModel: SelectedAgentModel? + @State private var copilotModels: [LLMModel] = [] + @State private var byokModels: [LLMModel] = [] + @State private var modelCache: [String: String] = [:] + + // Target width for menu items (popover width minus padding and margins) + // Popover is 500pt wide, subtract horizontal padding (12pt * 2) and menu item padding (8pt * 2) + let targetMenuItemWidth: CGFloat = 460 + let attributes: [NSAttributedString.Key: NSFont] = ModelMenuItemFormatter.attributes + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Menu { + // None option + Button(action: { + selectedModel = nil + }) { + Text(createModelMenuItemAttributedString( + modelName: "Not Specified", + isSelected: selectedModel == nil, + multiplierText: "" + )) + } + + Divider() + + if let model = copilotModels.first(where: { $0.isAutoModel }) { + Button(action: { selectModel(model) }) { + Text(createModelMenuItemAttributedString( + modelName: model.displayName ?? model.modelName, + isSelected: isModelSelected(model), + multiplierText: modelCache[model.modelName] ?? "Variable" + )) + } + + Divider() + } + + // Copilot models section + if !copilotModels.isEmpty { + Section(header: Text("Copilot Models")) { + ForEach(copilotModels.filter { !$0.isAutoModel }, id: \.modelName) { model in + Button(action: { selectModel(model) }) { + Text(createModelMenuItemAttributedString( + modelName: model.displayName ?? model.modelName, + isSelected: isModelSelected(model), + multiplierText: modelCache[model.modelName] ?? "" + )) + } + } + } + } + + // BYOK models section + if !byokModels.isEmpty { + Divider() + Section(header: Text("BYOK Models")) { + ForEach(byokModels, id: \.modelName) { model in + Button(action: { selectModel(model) }) { + Text(createModelMenuItemAttributedString( + modelName: model.displayName ?? model.modelName, + isSelected: isModelSelected(model), + multiplierText: modelCache[model.modelName] ?? "" + )) + } + } + } + } + } label: { + HStack { + Text(selectedModelDisplayText()) + .font(.system(size: 12)) + .foregroundColor(selectedModel == nil ? .secondary : .primary) + Spacer() + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.05)) + .cornerRadius(6) + } + .buttonStyle(.plain) + .onAppear { + loadModels() + } + } + } + + private func selectModel(_ model: LLMModel) { + selectedModel = SelectedAgentModel( + displayName: model.displayName ?? model.modelName, + modelName: model.modelName, + source: model.providerName == nil ? .copilot : .byok(provider: model.providerName!) + ) + } + + private func isModelSelected(_ model: LLMModel) -> Bool { + guard let selected = selectedModel else { return false } + if selected.modelName != model.modelName { return false } + + switch selected.source { + case .copilot: + return model.providerName == nil + case let .byok(provider): + return model.providerName?.lowercased() == provider.lowercased() + } + } + + private func loadModels() { + copilotModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel) + byokModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel) + + var newCache: [String: String] = [:] + let allModels = copilotModels + byokModels + for model in allModels { + newCache[model.modelName] = ModelMenuItemFormatter.getMultiplierText(for: model) + } + modelCache = newCache + } + + private func selectedModelDisplayText() -> String { + guard let model = selectedModel else { + return "Select a model..." + } + + let sourceLabel: String + switch model.source { + case .copilot: + sourceLabel = "copilot" + case let .byok(provider): + sourceLabel = provider + } + + return "\(model.displayName) (\(sourceLabel))" + } + + private func createModelMenuItemAttributedString( + modelName: String, + isSelected: Bool, + multiplierText: String + ) -> AttributedString { + return ModelMenuItemFormatter.createModelMenuItemAttributedString( + modelName: modelName, + isSelected: isSelected, + multiplierText: multiplierText, + targetWidth: targetMenuItemWidth, + ) + } + } +} 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 3817c812..bb3747c6 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift @@ -19,18 +19,20 @@ struct ChatHistoryView: View { VStack(alignment: .center, spacing: 0) { Header(isChatHistoryVisible: $isChatHistoryVisible) - .frame(height: 32) - .padding(.leading, 16) - .padding(.trailing, 12) + .scaledFrame(height: 32) + .scaledPadding(.leading, 12) + .scaledPadding(.trailing, 8) Divider() ChatHistorySearchBarView(searchText: $searchText) - .padding(.horizontal, 16) - .padding(.vertical, 4) + .scaledPadding(.leading, 12) + .scaledPadding(.trailing, 8) + .scaledPadding(.vertical, 8) ItemView(store: store, searchText: $searchText, isChatHistoryVisible: $isChatHistoryVisible) - .padding(.horizontal, 16) + .scaledPadding(.leading, 12) + .scaledPadding(.trailing, 8) } } } @@ -42,8 +44,9 @@ struct ChatHistoryView: View { var body: some View { HStack { Text("Chat History") - .font(.system(size: 13, weight: .bold)) - .lineLimit(nil) + .scaledFont(size: 13, weight: .bold) + .scaledPadding(.leading, 4) + .scaledFrame(maxWidth: 192, alignment: .leading) Spacer() @@ -51,6 +54,7 @@ struct ChatHistoryView: View { isChatHistoryVisible = false }) { Image(systemName: "xmark") + .scaledFont(.body) } .buttonStyle(HoverButtonStyle()) .help("Close") @@ -79,7 +83,7 @@ struct ChatHistoryView: View { refreshStoredChatTabInfos() } .id(previewInfo.id) - .frame(height: 61) + .scaledFrame(height: 61) } } } @@ -123,8 +127,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)) @@ -145,6 +151,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 @@ -170,12 +177,13 @@ 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(size: 14, weight: .semibold) .foregroundColor(.primary) .lineLimit(1) if isTabSelected() { Text("Current") + .scaledFont(.footnote) .foregroundStyle(.secondary) } @@ -185,7 +193,7 @@ struct ChatHistoryItemView: View { HStack(spacing: 0) { Text(formatDate(previewInfo.updatedAt)) .frame(alignment: .leading) - .font(.system(size: 13, weight: .regular)) + .scaledFont(size: 13, weight: .regular) .foregroundColor(.secondary) .lineLimit(1) @@ -204,6 +212,7 @@ struct ChatHistoryItemView: View { }) { Image(systemName: "trash") .foregroundColor(.primary) + .scaledFont(.body) .opacity(isHovered ? 1 : 0) } .buttonStyle(HoverButtonStyle()) @@ -214,13 +223,17 @@ struct ChatHistoryItemView: View { .padding(.horizontal, 12) } .frame(maxHeight: .infinity) + .contentShape(Rectangle()) .onHover(perform: { isHovered = $0 }) .hoverRadiusBackground( isHovered: isHovered, - hoverColor: Color(nsColor: .textBackgroundColor.withAlphaComponent(0.55)), - cornerRadius: 4, + hoverColor: Color( + nsColor: .controlColor + .withAlphaComponent(colorScheme == .dark ? 0.1 : 0.55) + ), + cornerRadius: 8, showBorder: isHovered, borderColor: Color(nsColor: .separatorColor) ) diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift index 871dd24e..0a70e8ba 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")) @@ -51,11 +55,11 @@ struct ChatLoginView: View { } } } - .padding(.top, 16) + .scaledPadding(.top, 16) 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( @@ -63,7 +67,7 @@ struct ChatLoginView: View { maxHeight: .infinity ) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) .alert( viewModel.signInResponse?.userCode ?? "", @@ -71,7 +75,10 @@ struct ChatLoginView: View { presenting: viewModel.signInResponse ) { _ in Button("Cancel", role: .cancel, action: {}) - Button("Copy Code and Open", action: viewModel.copyAndOpen) + .scaledFont(.body) + + Button("Copy Code and Open", action: { viewModel.copyAndOpen(fromHostApp: false) }) + .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..f6674e4a 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift @@ -14,15 +14,15 @@ struct ChatNoAXPermissionView: View { .resizable() .renderingMode(.template) .scaledToFill() - .frame(width: 64.0, height: 64.0) + .scaledFrame(width: 64.0, height: 64.0) .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) } @@ -42,7 +43,7 @@ struct ChatNoAXPermissionView: View { maxHeight: .infinity ) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) } } diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift index 5c052411..1ecbfc90 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift @@ -16,15 +16,15 @@ struct ChatNoSubscriptionView: View { .resizable() .renderingMode(.template) .scaledToFill() - .frame(width: 60.0, height: 60.0) + .scaledFrame(width: 60.0, height: 60.0) .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( @@ -55,7 +57,7 @@ struct ChatNoSubscriptionView: View { maxHeight: .infinity ) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) } } diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift index 8d7cbf60..9e342bca 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) } @@ -35,7 +35,7 @@ struct ChatNoWorkspaceView: View { maxHeight: .infinity ) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) } } 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 cc4a82a8..665ccd70 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -59,24 +59,23 @@ struct ChatView: View { var body: some View { VStack(spacing: 0) { - Rectangle().fill(.regularMaterial).frame(height: 28) + Rectangle() + .fill(Color.chatWindowBackgroundColor) + .scaledFrame(height: 28) - Divider() - - ZStack { - VStack(spacing: 0) { - ChatBar(store: store, isChatHistoryVisible: $isChatHistoryVisible) - .frame(height: 32) - .background(Color(nsColor: .windowBackgroundColor)) - - Divider() - - ChatTabContainer(store: store) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + VStack(spacing: 0) { + ChatBar(store: store, isChatHistoryVisible: $isChatHistoryVisible) + .scaledFrame(height: 32) + .scaledPadding(.leading, 16) + .scaledPadding(.trailing, 8) + + Divider() + + ChatTabContainer(store: store) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) } } @@ -89,21 +88,21 @@ struct ChatHistoryViewWrapper: View { var body: some View { WithPerceptionTracking { VStack(spacing: 0) { - Rectangle().fill(.regularMaterial).frame(height: 28) - - Divider() + Rectangle() + .fill(Color.chatWindowBackgroundColor) + .scaledFrame(height: 28) ChatHistoryView( store: store, isChatHistoryVisible: $isChatHistoryVisible ) - .background(Color(nsColor: .windowBackgroundColor)) + .background(Color.chatWindowBackgroundColor) .frame( maxWidth: .infinity, maxHeight: .infinity ) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) .preferredColorScheme(store.colorScheme) .focusable() @@ -133,10 +132,10 @@ struct ChatLoadingView: View { Spacer() } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .windowBackgroundColor)) + .background(.ultraThinMaterial) } } @@ -163,7 +162,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 +180,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 +212,7 @@ struct ChatTitleBar: View { ? color : Color(nsColor: .separatorColor) ) - .frame( + .scaledFrame( width: Style.trafficLightButtonSize, height: Style.trafficLightButtonSize ) @@ -252,7 +251,7 @@ struct ChatBar: View { var body: some View { WithPerceptionTracking { - HStack(spacing: 0) { + HStack(spacing: 8) { if store.chatHistory.selectedWorkspaceName != nil { ChatWindowHeader(store: store) } @@ -265,7 +264,6 @@ struct ChatBar: View { SettingsButton(store: store) } - .padding(.horizontal, 12) } } @@ -322,13 +320,13 @@ 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)) - .padding(.leading, 4) + .scaledFont(size: 13, weight: .bold) + .scaledPadding(.leading, 4) .truncationMode(.tail) - .frame(maxWidth: 192, alignment: .leading) + .scaledFrame(maxWidth: 192, alignment: .leading) .help(store.chatHistory.selectedWorkspacePath!) } } @@ -344,9 +342,9 @@ struct ChatBar: View { store.send(.createNewTapButtonClicked(kind: nil)) }) { Image(systemName: "plus.bubble") + .scaledFont(.body) } .buttonStyle(HoverButtonStyle()) - .padding(.horizontal, 4) .help("New Chat") .accessibilityLabel("New Chat") } @@ -364,12 +362,13 @@ 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()) - .padding(.horizontal, 4) .help("Show Chats...") .accessibilityLabel("Show Chats...") } @@ -385,9 +384,9 @@ struct ChatBar: View { store.send(.openSettings) }) { Image(systemName: "gearshape") + .scaledFont(.body) } .buttonStyle(HoverButtonStyle()) - .padding(.horizontal, 4) .help("Open Settings") .accessibilityLabel("Open Settings") } @@ -454,28 +453,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() } } } @@ -518,7 +505,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/CodeReviewPanelView.swift b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift new file mode 100644 index 00000000..214996a8 --- /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() + .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/Extensions/Helper.swift b/Core/Sources/SuggestionWidget/Extensions/Helper.swift new file mode 100644 index 00000000..9e52c3c3 --- /dev/null +++ b/Core/Sources/SuggestionWidget/Extensions/Helper.swift @@ -0,0 +1,52 @@ +import AppKit + +struct LocationStrategyHelper { + + /// `lineNumber` is 0-based + /// + /// - Parameters: + /// - length: If specified, use this length instead of the actual line length. Useful when you want to get the exact line height and y that ignores the unwrappded lines. + static func getLineFrame( + _ lineNumber: Int, + in editor: AXUIElement, + with lines: [String], + length: Int? = nil + ) -> CGRect? { + guard editor.isSourceEditor, + lineNumber < lines.count && lineNumber >= 0 + else { + return nil + } + + var characterPosition = 0 + for i in 0.. 0 && fittingSize.height > 0 { + return fittingSize + } + + let intrinsicSize = contentView.intrinsicContentSize + if intrinsicSize.width > 0 && intrinsicSize.height > 0 { + return intrinsicSize + } + + return nil + }() + + guard let contentSize = effectiveSize, + contentSize.width.isFinite, + contentSize.height.isFinite, + let frame = location.calcDiffViewFrame(contentSize: contentSize) + else { + return + } + + windows.nesDiffWindow.setFrame( + frame, + display: false, + animate: animated + ) + } + + @MainActor + func updateNESNotificationWindowFrame( + _ location: WidgetLocation.NESPanelLocation, + animated: Bool + ) async { + var notificationWindowFrame = windows.nesNotificationWindow.frame + let scrollViewFrame = location.scrollViewFrame + let screenFrame = location.screenFrame + + notificationWindowFrame.origin.x = scrollViewFrame.minX + scrollViewFrame.width / 2 - notificationWindowFrame.width / 2 + notificationWindowFrame.origin.y = screenFrame.height - scrollViewFrame.maxY + Style.nesSuggestionMenuLeadingPadding * 2 + + windows.nesNotificationWindow.setFrame( + notificationWindowFrame, + display: false, + animate: animated + ) + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/AgentConfigurationWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/AgentConfigurationWidgetFeature.swift new file mode 100644 index 00000000..ef5ac74d --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/AgentConfigurationWidgetFeature.swift @@ -0,0 +1,65 @@ +import ComposableArchitecture +import Foundation +import SuggestionBasic +import XcodeInspector +import ChatTab +import ConversationTab +import ChatService +import ConversationServiceProvider + +@Reducer +public struct AgentConfigurationWidgetFeature { + @ObservableState + public struct State: Equatable { + public var focusedEditor: SourceEditor? = nil + public var isPanelDisplayed: Bool = false + public var currentMode: ConversationMode? = nil + + public var lineHeight: Double = 16.0 + } + + public enum Action: Equatable { + case setCurrentMode(ConversationMode?) + case onFocusedEditorChanged(SourceEditor?) + } + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onFocusedEditorChanged(let editor): + state.focusedEditor = editor + return .run { send in + let currentMode = await getCurrentMode(for: editor) + await send(.setCurrentMode(currentMode)) + } + case .setCurrentMode(let mode): + state.currentMode = mode + return .none + } + } + } +} + +private func getCurrentMode(for focusedEditor: SourceEditor?) async -> ConversationMode? { + guard let documentURL = focusedEditor?.realtimeDocumentURL, + documentURL.pathExtension == "md", + documentURL.lastPathComponent.hasSuffix(".agent.md") else { + return nil + } + + // Load all conversation modes + guard let modes = await SharedChatService.shared.loadConversationModes() else { + return nil + } + + // Find the mode that matches the current document URL + let documentURLString = documentURL.absoluteString + let mode = modes.first { mode in + guard let modeURI = mode.uri else { return false } + return modeURI == documentURLString || URL(string: modeURI)?.path == documentURL.path + } + + return mode +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index d22b6024..e6f274b7 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -186,6 +186,8 @@ public struct ChatPanelFeature { case deleteChatTabInfo(id: String, ChatWorkspace) case restoreWorkspace(ChatWorkspace) + case syncChatTabInfo([ChatTabInfo?]) + // ChatWorkspace cleanup case scheduleLRUCleanup(ChatWorkspace) case performLRUCleanup(ChatWorkspace) @@ -375,6 +377,7 @@ public struct ChatPanelFeature { return .run { send in await send(.focusActiveChatTab) await send(.saveChatTabInfo([originalTab, currentTab], workspace)) + await send(.syncChatTabInfo([originalTab, currentTab])) } case let .chatHistoryItemClicked(id): @@ -413,6 +416,8 @@ public struct ChatPanelFeature { } await send(.saveChatTabInfo([originalTab, currentTab], workspace)) + + await send(.syncChatTabInfo([originalTab, currentTab])) } } @@ -435,6 +440,7 @@ public struct ChatPanelFeature { await send(.focusActiveChatTab) await send(.saveChatTabInfo([originalTab, currentTab], currentChatWorkspace)) await send(.scheduleLRUCleanup(currentChatWorkspace)) + await send(.syncChatTabInfo([originalTab, currentTab])) } case .appendTabToWorkspace(var tab, let chatWorkspace): guard !chatWorkspace.tabInfo.contains(where: { $0.id == tab.id }) @@ -448,6 +454,7 @@ public struct ChatPanelFeature { return .run { send in await send(.saveChatTabInfo([originalTab, currentTab], currentChatWorkspace)) await send(.scheduleLRUCleanup(currentChatWorkspace)) + await send(.syncChatTabInfo([originalTab, currentTab])) } // case .switchToNextTab: @@ -615,6 +622,15 @@ public struct ChatPanelFeature { state.chatHistory.addWorkspace(chatWorkspace) return .none + case .syncChatTabInfo(let tabInfos): + for tabInfo in tabInfos { + guard let tabInfo = tabInfo else { continue } + if let conversationTab = chatTabPool.getTab(of: tabInfo.id) as? ConversationTab { + conversationTab.updateChatTabInfo(tabInfo) + } + } + return .none + // MARK: - Clean up ChatWorkspace case .scheduleLRUCleanup(let chatWorkspace): return .run { send in 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/FixErrorPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/FixErrorPanelFeature.swift new file mode 100644 index 00000000..71850fa5 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/FixErrorPanelFeature.swift @@ -0,0 +1,266 @@ +import ComposableArchitecture +import Foundation +import SuggestionBasic +import XcodeInspector +import ChatTab +import ConversationTab + +@Reducer +public struct FixErrorPanelFeature { + @ObservableState + public struct State: Equatable { + public var focusedEditor: SourceEditor? = nil + public var editorContent: EditorInformation.SourceEditorContent? = nil + public var fixId: String? = nil + public var fixFailure: FixEditorErrorIssueFailure? = nil + public var cursorPosition: CursorPosition? { + editorContent?.cursorPosition + } + public var isPanelDisplayed: Bool = false + 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] { + guard let editorContent = editorContent else { + return [] + } + + 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() { + fixFailure = nil + fixId = nil + } + } + + public enum Action: Equatable { + case onFocusedEditorChanged(SourceEditor?) + case onEditorContentChanged + case onScrollPositionChanged + case onCursorPositionChanged + + case fixErrorIssue([EditorInformation.LineAnnotation]) + case scheduleFixFailureReset + case observeErrorNotification + + case appear + case onFailure(FixEditorErrorIssueFailure) + case checkDisplay + case resetFixFailure + + // Annotation checking + case startAnnotationCheck + case onAnnotationCheckTimerFired + case stopCheckingAnnotation + } + + let id = UUID() + + enum CancelID: Hashable { + case observeErrorNotification(UUID) + case annotationCheck(UUID) + case scheduleFixFailureReset(UUID) + } + + public init() {} + + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + + public var body: some ReducerOf { + 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( + 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 + state.editorContent = nil + state.shouldCheckingAnnotations = true + return .none + + case .onEditorContentChanged: + state.shouldCheckingAnnotations = true + return .none + + case .onScrollPositionChanged: + if state.shouldCheckingAnnotations { + state.shouldCheckingAnnotations = false + } + if state.editorContent != nil { + state.editorContent = nil + } + return .none + + case .onCursorPositionChanged: + state.shouldCheckingAnnotations = true + return .none + + 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 interval: TimeInterval = 2 + + while !Task.isCancelled { + try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) + + await send(.onAnnotationCheckTimerFired) + } + }.cancellable(id: CancelID.annotationCheck(id), cancelInFlight: true) + + case .onAnnotationCheckTimerFired: + // 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 newErrorAnnotationsAtCursorPosition = state.getErrorAnnotationsAtCursor(from: newEditorContent) + let errorAnnotationsAtCursorPosition = state.errorAnnotationsAtCursorPosition + + if state.editorContent != newEditorContent { + state.editorContent = newEditorContent + } + + if Set(errorAnnotationsAtCursorPosition) != Set(newErrorAnnotationsAtCursorPosition) { + // Keep checking annotations as Xcode may update them asynchronously after content changes + return .merge( + .run { send in + await send(.checkDisplay) + } + ) + } else { + return .none + } + + case .stopCheckingAnnotation: + state.shouldCheckingAnnotations = false + return .none + } + } + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/NESSuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/NESSuggestionPanelFeature.swift new file mode 100644 index 00000000..1bb7dc47 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/NESSuggestionPanelFeature.swift @@ -0,0 +1,62 @@ +import ComposableArchitecture +import Foundation +import SwiftUI + +@Reducer +public struct NESSuggestionPanelFeature { + @ObservableState + public struct State: Equatable { + static let baseFontSize: CGFloat = 13 + static let defaultLineHeight: Double = 18 + + var nesContent: NESCodeSuggestionProvider? { + didSet { closeNotificationByUser = false } + } + var colorScheme: ColorScheme = .light + var firstLineIndent: Double = 0 + var lineHeight: Double = Self.defaultLineHeight + var lineFontSize: Double { + Self.baseFontSize * fontSizeScale + } + var isPanelDisplayed: Bool = false + public var isPanelOutOfFrame: Bool = false + var closeNotificationByUser: Bool = false + // TODO: handle warnings + // var warningMessage: String? + // var warningURL: String? + var opacity: Double { + guard isPanelDisplayed else { return 0 } + if isPanelOutOfFrame { return 0 } + guard nesContent != nil else { return 0 } + return 1 + } + var menuViewOpacity: Double { + guard nesContent != nil else { return 0 } + guard isPanelDisplayed else { return 0 } + return isPanelOutOfFrame ? 0 : 1 + } + var diffViewOpacity: Double { menuViewOpacity } + var notificationViewOpacity: Double { + guard nesContent != nil else { return 0 } + guard isPanelDisplayed else { return 0 } + return isPanelOutOfFrame ? 1 : 0 + } + var fontSizeScale: Double { + (lineHeight / Self.defaultLineHeight * 100).rounded() / 100 + } + } + + public enum Action: Equatable { + case onUserCloseNotification + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onUserCloseNotification: + state.closeNotificationByUser = true + return .none + } + } + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift index e76afbc0..525affb4 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -4,6 +4,10 @@ import Foundation @Reducer public struct PanelFeature { + public enum PanelType { + case suggestion, nes, agentConfiguration + } + @ObservableState public struct State: Equatable { public var content: SharedPanelFeature.Content { @@ -11,6 +15,13 @@ public struct PanelFeature { set { sharedPanelState.content = newValue suggestionPanelState.content = newValue.suggestion + } + } + + public var nesContent: NESCodeSuggestionProvider? { + get { nesSuggestionPanelState.nesContent } + set { + nesSuggestionPanelState.nesContent = newValue } } @@ -21,6 +32,14 @@ public struct PanelFeature { // MARK: SuggestionPanel var suggestionPanelState = SuggestionPanelFeature.State() + + // MARK: NESSuggestionPanel + + public var nesSuggestionPanelState = NESSuggestionPanelFeature.State() + + // MARK: SubAgent + + public var agentConfigurationWidgetState = AgentConfigurationWidgetFeature.State() var warningMessage: String? var warningURL: String? @@ -28,19 +47,26 @@ public struct PanelFeature { public enum Action: Equatable { case presentSuggestion + case presentNESSuggestion case presentSuggestionProvider(CodeSuggestionProvider, displayContent: Bool) + case presentNESSuggestionProvider(NESCodeSuggestionProvider, displayContent: Bool) case presentError(String) case presentPromptToCode(PromptToCodeGroup.PromptToCodeInitialState) case displayPanelContent + case displayNESPanelContent case expandSuggestion case discardSuggestion + case discardNESSuggestion case removeDisplayedContent case switchToAnotherEditorAndUpdateContent - case hidePanel - case showPanel + case hidePanel(PanelType) + case showPanel(PanelType) + case onRealtimeNESToggleChanged(Bool) case sharedPanel(SharedPanelFeature.Action) case suggestionPanel(SuggestionPanelFeature.Action) + case nesSuggestionPanel(NESSuggestionPanelFeature.Action) + case agentConfigurationWidget(AgentConfigurationWidgetFeature.Action) case presentWarning(message: String, url: String?) case dismissWarning @@ -59,6 +85,14 @@ public struct PanelFeature { Scope(state: \.sharedPanelState, action: \.sharedPanel) { SharedPanelFeature() } + + Scope(state: \.nesSuggestionPanelState, action: \.nesSuggestionPanel) { + NESSuggestionPanelFeature() + } + + Scope(state: \.agentConfigurationWidgetState, action: \.agentConfigurationWidget) { + AgentConfigurationWidgetFeature() + } Reduce { state, action in switch action { @@ -69,6 +103,14 @@ public struct PanelFeature { else { return } await send(.presentSuggestionProvider(provider, displayContent: true)) } + + case .presentNESSuggestion: + return .run { send in + guard let fileURL = await xcodeInspector.safe.activeDocumentURL, + let provider = await fetchNESSuggestionProvider(fileURL: fileURL) + else { return } + await send(.presentNESSuggestionProvider(provider, displayContent: true)) + } case let .presentSuggestionProvider(provider, displayContent): state.content.suggestion = provider @@ -78,6 +120,15 @@ public struct PanelFeature { }.animation(.easeInOut(duration: 0.2)) } return .none + + case let .presentNESSuggestionProvider(provider, displayContent): + state.nesContent = provider + if displayContent { + return .run { send in + await send(.displayNESPanelContent) + }.animation(.easeInOut(duration: 0.2)) + } + return .none case let .presentError(errorDescription): state.content.error = errorDescription @@ -98,12 +149,22 @@ public struct PanelFeature { if state.suggestionPanelState.content != nil { state.suggestionPanelState.isPanelDisplayed = true } - + return .none + + case .displayNESPanelContent: + if state.nesSuggestionPanelState.nesContent != nil { + state.nesSuggestionPanelState.isPanelDisplayed = true + } return .none case .discardSuggestion: state.content.suggestion = nil return .none + + case .discardNESSuggestion: + state.nesContent = nil + return .none + case .expandSuggestion: state.content.isExpanded = true return .none @@ -118,15 +179,39 @@ public struct PanelFeature { ) )) } - case .hidePanel: - state.suggestionPanelState.isPanelDisplayed = false + case .hidePanel(let panelType): + switch panelType { + case .suggestion: + state.suggestionPanelState.isPanelDisplayed = false + case .nes: + state.nesSuggestionPanelState.isPanelDisplayed = false + case .agentConfiguration: + state.agentConfigurationWidgetState.isPanelDisplayed = false + } return .none - case .showPanel: - state.suggestionPanelState.isPanelDisplayed = true + case .showPanel(let panelType): + switch panelType { + case .suggestion: + state.suggestionPanelState.isPanelDisplayed = true + case .nes: + state.nesSuggestionPanelState.isPanelDisplayed = true + case .agentConfiguration: + state.agentConfigurationWidgetState.isPanelDisplayed = true + } return .none + case let .onRealtimeNESToggleChanged(isOn): + if !isOn { + return .run { send in + await send(.hidePanel(.nes)) + await send(.discardNESSuggestion) + } + } + return .none + case .removeDisplayedContent: state.content.error = nil state.content.suggestion = nil + state.nesContent = nil return .none case .sharedPanel(.promptToCodeGroup(.activateOrCreatePromptToCode)), @@ -148,6 +233,12 @@ public struct PanelFeature { case .suggestionPanel: return .none + + case .nesSuggestionPanel: + return .none + + case .agentConfigurationWidget: + return .none case .presentWarning(let message, let url): state.warningMessage = message @@ -172,5 +263,12 @@ public struct PanelFeature { .suggestionForFile(at: fileURL) else { return nil } return provider } + + func fetchNESSuggestionProvider(fileURL: URL) async -> NESCodeSuggestionProvider? { + guard let provider = await suggestionWidgetControllerDependency + .suggestionWidgetDataSource? + .nesSuggestionForFile(at: fileURL) else { return nil } + return provider + } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index e0af56cb..0bbda7e5 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -36,6 +36,14 @@ public struct WidgetFeature { // MARK: ChatPanel public var chatPanelState = ChatPanelFeature.State() + + // MARK: CodeReview + + public var codeReviewPanelState = CodeReviewPanelFeature.State() + + // MARK: FixError + + public var fixErrorPanelState = FixErrorPanelFeature.State() // MARK: CircularWidget @@ -103,6 +111,8 @@ public struct WidgetFeature { case updateColorScheme case updatePanelStateToMatch(WidgetLocation) + case updateNESSuggestionPanelStateToMatch(WidgetLocation) + case updateAgentConfigurationWidgetStateToMatch(WidgetLocation) case updateFocusingDocumentURL case setFocusingDocumentURL(to: URL?) case updateKeyWindow(WindowCanBecomeKey) @@ -111,6 +121,8 @@ public struct WidgetFeature { case panel(PanelFeature.Action) case chatPanel(ChatPanelFeature.Action) case circularWidget(CircularWidgetFeature.Action) + case codeReviewPanel(CodeReviewPanelFeature.Action) + case fixErrorPanel(FixErrorPanelFeature.Action) } var windowsController: WidgetWindowsController? { @@ -138,6 +150,14 @@ public struct WidgetFeature { Scope(state: \._internalCircularWidgetState, action: \.circularWidget) { CircularWidgetFeature() } + + Scope(state: \.codeReviewPanelState, action: \.codeReviewPanel) { + CodeReviewPanelFeature() + } + + Scope(state: \.fixErrorPanelState, action: \.fixErrorPanel) { + FixErrorPanelFeature() + } Reduce { state, action in switch action { @@ -373,6 +393,36 @@ public struct WidgetFeature { .alignPanelTop return .none + + case let .updateNESSuggestionPanelStateToMatch(widgetLocation): + + guard let nesSuggestionPanelLocation = widgetLocation.nesSuggestionPanelLocation else { + state.panelState.nesSuggestionPanelState.isPanelDisplayed = false + state.panelState.nesSuggestionPanelState.isPanelOutOfFrame = false + return .none + } + + let lineFirstCharacterFrame = nesSuggestionPanelLocation.lineFirstCharacterFrame + let scrollViewFrame = nesSuggestionPanelLocation.scrollViewFrame + if scrollViewFrame.contains(lineFirstCharacterFrame) { + state.panelState.nesSuggestionPanelState.isPanelOutOfFrame = false + } else { + state.panelState.nesSuggestionPanelState.isPanelOutOfFrame = true + } + state.panelState.nesSuggestionPanelState.lineHeight = nesSuggestionPanelLocation.lineHeight + + return .none + + case let .updateAgentConfigurationWidgetStateToMatch(widgetLocation): + guard let agentConfigurationWidgetLocation = widgetLocation.agentConfigurationWidgetLocation else { + state.panelState.agentConfigurationWidgetState.isPanelDisplayed = false + return .none + } + + state.panelState.agentConfigurationWidgetState.isPanelDisplayed = true + state.panelState.agentConfigurationWidgetState.lineHeight = agentConfigurationWidgetLocation.lineHeight + + return .none case let .updateKeyWindow(window): return .run { _ in @@ -399,6 +449,12 @@ public struct WidgetFeature { case .chatPanel: return .none + + 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..0799b7a0 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FixErrorPanelView.swift @@ -0,0 +1,96 @@ +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 + @Environment(\.colorScheme) var colorScheme + + 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.7 + + Group { + if !annotations.isEmpty { + ZStack { + Button(action: { + store.send(.fixErrorIssue(annotations)) + }) { + Image("FixError") + .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 + .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/NES/NESDiffView.swift b/Core/Sources/SuggestionWidget/NES/NESDiffView.swift new file mode 100644 index 00000000..5d650596 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESDiffView.swift @@ -0,0 +1,150 @@ +import SwiftUI +import ComposableArchitecture +import SuggestionBasic + +struct NESDiffView: View { + var store: StoreOf + + var body: some View { + WithPerceptionTracking { + if store.isPanelDisplayed, + !store.isPanelOutOfFrame, + let nesContent = store.nesContent, + let originalCodeSnippet = nesContent.getOriginalCodeSnippet() + { + let nesCode = nesContent.code + + ScrollView(showsIndicators: true) { + Group { + if nesContent.range.isOneLine && nesCode.components(separatedBy: .newlines).count <= 1 { + InlineDiffView( + store: store, + segments: DiffBuilder.inlineSegments( + oldLine: originalCodeSnippet, + newLine: nesCode + ) + ) + } else { + LineDiffView( + store: store, + segments: DiffBuilder.lineSegments( + oldContent: originalCodeSnippet, + newContent: nesCode + ) + ) + } + } + } + .padding(.leading, 12 * store.fontSizeScale) + .padding(.trailing, 10 * store.fontSizeScale) + .padding(.vertical, 4 * store.fontSizeScale) + .xcodeStyleFrame() + .opacity(store.diffViewOpacity) + } + } + } +} + + +private struct AccentStrip: View { + let store: StoreOf + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill(.blue) + .frame(width: 4 * store.fontSizeScale) + } +} + +struct InlineDiffView: View { + let store: StoreOf + let segments: [DiffSegment] + + var body: some View { + HStack(spacing: 0) { + AccentStrip(store: store) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in + buildSegmentView(segment) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + @ViewBuilder + func buildSegmentView(_ segment: DiffSegment) -> some View { + Text(verbatim: segment.text.diffDisplayEscaped()) + .lineLimit(1) + .font(.system(size: store.lineFontSize, weight: .medium)) + .padding(.vertical, 4 * store.fontSizeScale) + .background( + Rectangle() + .fill(segment.backgroundColor) + ) + .alignmentGuide(.firstTextBaseline) { d in + d[.firstTextBaseline] + } + } +} + + +struct LineDiffView: View { + let store: StoreOf + let segments: [DiffSegment] + + var body: some View { + HStack(spacing: 0) { + AccentStrip(store: store) + + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in + buildSegmentView(segment) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + @ViewBuilder + func buildSegmentView(_ segment: DiffSegment) -> some View { + Text(segment.text.diffDisplayEscaped()) + .font(.system(size: store.lineFontSize, weight: .medium)) + .multilineTextAlignment(.leading) + .padding(.vertical, 4 * store.fontSizeScale) + .background( + Rectangle() + .fill(segment.backgroundColor) + ) + } +} + + +extension DiffSegment { + var backgroundColor: Color { + switch change { + case .added: return Color("editor.focusedStackFrameHighlightBackground") + case .removed: return Color("editorOverviewRuler.inlineChatRemoved") + case .unchanged: return .clear + } + } +} + +private extension String { + func diffDisplayEscaped() -> String { + var escaped = "" + for scalar in unicodeScalars { + switch scalar { + case "\n": escaped.append("\\n") + case "\r": escaped.append("\\r") + case "\t": escaped.append("\\t") + default: escaped.append(Character(scalar)) + } + } + return escaped + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESDiffView/NESDiffBuilder.swift b/Core/Sources/SuggestionWidget/NES/NESDiffView/NESDiffBuilder.swift new file mode 100644 index 00000000..54b9c6d6 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESDiffView/NESDiffBuilder.swift @@ -0,0 +1,136 @@ +import Foundation + +struct DiffSegment { + enum Change { + case unchanged + case added + case removed + } + let text: String + let change: Change +} + +enum DiffBuilder { + static func inlineSegments(oldLine: String, newLine: String) -> [DiffSegment] { + let oldTokens = tokenizePreservingWhitespace(oldLine) + let newTokens = tokenizePreservingWhitespace(newLine) + let condensed = condensedSegments(oldTokens: oldTokens, newTokens: newTokens) + return mergeInlineWhitespaceSegments(condensed) + } + + static func lineSegments(oldContent: String, newContent: String) -> [DiffSegment] { + let oldLines = oldContent.components(separatedBy: .newlines) + let newLines = newContent.components(separatedBy: .newlines) + return diff(tokensInOld: oldLines, tokensInNew: newLines) + } + + private static func tokenizePreservingWhitespace(_ text: String) -> [String] { + guard !text.isEmpty else { return [] } + // This pattern matches either: + // - a sequence of non-whitespace characters (\\S+) followed by optional whitespace (\\s*), or + // - a sequence of whitespace characters (\\s+) + // This ensures that tokens preserve trailing whitespace, or capture standalone whitespace sequences. + let pattern = "\\S+\\s*|\\s+" + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return [text] + } + let nsText = text as NSString + let fullRange = NSRange(location: 0, length: nsText.length) + let matches = regex.matches(in: text, range: fullRange) + if matches.isEmpty { + return [text] + } + return matches.map { nsText.substring(with: $0.range) } + } + + private static func condensedSegments(oldTokens: [String], newTokens: [String]) -> [DiffSegment] { + let raw = diff(tokensInOld: oldTokens, tokensInNew: newTokens) + guard var last = raw.first else { return [] } + var condensed: [DiffSegment] = [] + for segment in raw.dropFirst() { + if segment.change == last.change { + last = DiffSegment(text: last.text + segment.text, change: last.change) + } else { + condensed.append(last) + last = segment + } + } + condensed.append(last) + return condensed + } + + private static func diff(tokensInOld oldTokens: [String], tokensInNew newTokens: [String]) -> [DiffSegment] { + let m = oldTokens.count + let n = newTokens.count + if m == 0 { return newTokens.map { DiffSegment(text: $0, change: .added) } } + if n == 0 { return oldTokens.map { DiffSegment(text: $0, change: .removed) } } + var lcs = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1) + for i in 1...m { + for j in 1...n { + if oldTokens[i - 1] == newTokens[j - 1] { + lcs[i][j] = lcs[i - 1][j - 1] + 1 + } else { + lcs[i][j] = max(lcs[i - 1][j], lcs[i][j - 1]) + } + } + } + var i = m + var j = n + var result: [DiffSegment] = [] + while i > 0 && j > 0 { + if oldTokens[i - 1] == newTokens[j - 1] { + result.append(DiffSegment(text: oldTokens[i - 1], change: .unchanged)) + i -= 1 + j -= 1 + } else if lcs[i - 1][j] > lcs[i][j - 1] { + result.append(DiffSegment(text: oldTokens[i - 1], change: .removed)) + i -= 1 + } else { + result.append(DiffSegment(text: newTokens[j - 1], change: .added)) + j -= 1 + } + } + while i > 0 { + result.append(DiffSegment(text: oldTokens[i - 1], change: .removed)) + i -= 1 + } + while j > 0 { + result.append(DiffSegment(text: newTokens[j - 1], change: .added)) + j -= 1 + } + return result.reversed() + } + + private static func mergeInlineWhitespaceSegments(_ segments: [DiffSegment]) -> [DiffSegment] { + guard !segments.isEmpty else { return segments } + var merged: [DiffSegment] = [] + var index = 0 + while index < segments.count { + let current = segments[index] + switch current.change { + case .added, .removed: + var combinedText = current.text + var lookahead = index + 1 + while lookahead + 1 < segments.count, + segments[lookahead].change == .unchanged, + segments[lookahead].text.isWhitespaceOnly, + segments[lookahead + 1].change == current.change { + combinedText += segments[lookahead].text + segments[lookahead + 1].text + lookahead += 2 + } + merged.append(DiffSegment(text: combinedText, change: current.change)) + index = lookahead + case .unchanged: + merged.append(current) + index += 1 + } + } + return merged + } +} + +private extension String { + var isWhitespaceOnly: Bool { + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESCustomMenu.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESCustomMenu.swift new file mode 100644 index 00000000..e20ddbf8 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESCustomMenu.swift @@ -0,0 +1,24 @@ +import Cocoa +import CGEventOverride +import Logger + +class NESCustomMenu: NSMenu { + weak var menuController: NESMenuController? + + override func awakeFromNib() { + super.awakeFromNib() + } + + override init(title: String) { + super.init(title: title) + } + + required init(coder: NSCoder) { + super.init(coder: coder) + } + + private func setupMenuAppearance() { + self.showsStateColumn = false + self.allowsContextMenuPlugIns = false + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuButtonView.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuButtonView.swift new file mode 100644 index 00000000..c9dd8c59 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuButtonView.swift @@ -0,0 +1,94 @@ +import SwiftUI +import Cocoa +import Logger + +struct NESMenuButtonView: NSViewRepresentable { + let menuController: NESMenuController + var fontSize: CGFloat + + var buttonImage: NSImage? { + NSImage( + systemSymbolName: "arrow.right.to.line", + accessibilityDescription: "Next Edit Suggestion Menu" + ) + } + + var buttonFont: NSFont { + NSFont.systemFont(ofSize: fontSize, weight: .medium) + } + + func makeNSView(context: Context) -> NSButton { + let button = NSButton(frame: .zero) + button.title = "" + button.bezelStyle = .shadowlessSquare + button.isBordered = false + button.imageScaling = .scaleProportionallyDown + button.contentTintColor = .white + button.imagePosition = .imageOnly + button.focusRingType = .none + button.target = context.coordinator + button.action = #selector(Coordinator.buttonClicked) + button.font = buttonFont + + let baseConfig = NSImage.SymbolConfiguration(pointSize: fontSize, weight: .regular) + let colorConfig = NSImage.SymbolConfiguration(hierarchicalColor: NSColor.white) + button.image = buttonImage? + .withSymbolConfiguration(baseConfig)? + .withSymbolConfiguration(colorConfig) + + context.coordinator.setupMenu(for: button) + + return button + } + + func updateNSView(_ nsView: NSButton, context: Context) { + nsView.font = buttonFont + if let image = buttonImage { + let base = NSImage.SymbolConfiguration(pointSize: fontSize, weight: .regular) + let tinted = NSImage.SymbolConfiguration(hierarchicalColor: .white) + nsView.image = image.withSymbolConfiguration(base)?.withSymbolConfiguration(tinted) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(menuController: menuController) + } + + class Coordinator: NSObject { + let menuController: NESMenuController + private weak var button: NSButton? + + init(menuController: NESMenuController) { + self.menuController = menuController + super.init() + } + + func setupMenu(for button: NSButton) { + self.button = button + } + + @objc func buttonClicked(_ sender: NSButton) { + let menu = menuController.createMenu() + showMenu(menu, for: sender) + } + + private func showMenu(_ menu: NSMenu, for button: NSButton) { + // Ensure the button is still in a window before showing the menu + guard let window = button.window else { + return + } + + // Ensure menu is properly positioned and shown + let location = NSPoint(x: 0, y: button.bounds.height + 5) + let originalLevel = window.level + window.level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue + 1) + defer { window.level = originalLevel } + + menu.popUp(positioning: nil, at: location, in: button) + } + + @objc func menuDidClose(_ menu: NSMenu) { } + + @objc func menuWillOpen(_ menu: NSMenu) { } + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift new file mode 100644 index 00000000..071b1dd2 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift @@ -0,0 +1,230 @@ +import Cocoa +import ComposableArchitecture +import SwiftUI +import HostAppActivator + +class NESMenuController: ObservableObject { + private static let defaultParagraphTabStopLocation: CGFloat = 180.0 + private static let titleColor: NSColor = NSColor(Color.secondary) + private static let shortcutIconColor: NSColor = NSColor.tertiaryLabelColor + static let baseFontSize: CGFloat = 13 + + private var menu: NSMenu? + var fontSize: CGFloat { + didSet { menu = nil } + } + var fontSizeScale: Double { + didSet { menu = nil } + } + var store: StoreOf + + private var imageSize: NSSize { + NSSize(width: self.fontSize, height: self.fontSize) + } + private var paragraphStyle: NSMutableParagraphStyle { + let style = NSMutableParagraphStyle() + style.tabStops = [ + NSTextTab( + textAlignment: .right, + location: Self.defaultParagraphTabStopLocation * fontSizeScale + ) + ] + return style + } + + init(fontSize: CGFloat, fontSizeScale: Double, store: StoreOf) { + self.fontSize = fontSize + self.fontSizeScale = fontSizeScale + self.store = store + } + + func createMenu() -> NSMenu { + let menu = NESCustomMenu(title: "") + menu.menuController = self + + menu.font = NSFont.systemFont(ofSize: fontSize, weight: .regular) + + let titleItem = createTitleItem() + let settingsItem = createSettingItem() + let goToAcceptItem = createGoToAcceptItem() + let rejectItem = createRejectItem() + let moreInfoItem = createGetMoreInfoItem() + + menu.addItem(titleItem) + menu.addItem(NSMenuItem.separator()) + menu.addItem(settingsItem) + menu.addItem(NSMenuItem.separator()) + menu.addItem(goToAcceptItem) + menu.addItem(rejectItem) +// menu.addItem(NSMenuItem.separator()) +// menu.addItem(moreInfoItem) + + self.menu = menu + return menu + } + + private func createImage(_ name: String, description accessibilityDescription: String) -> NSImage? { + guard let image = NSImage( + systemSymbolName: name, accessibilityDescription: accessibilityDescription + ) else { return nil } + + image.size = self.imageSize + return image + } + + private func createParagraphAttributedTitle(_ text: String, helpText: String) -> NSAttributedString { + let attributedTitle = NSMutableAttributedString(string: text) + attributedTitle.append(NSAttributedString( + string: "\t\(helpText)", + attributes: [ + .foregroundColor: Self.shortcutIconColor, + .font: NSFont.systemFont(ofSize: fontSize - 1, weight: .regular), + .paragraphStyle: paragraphStyle + ] + )) + + attributedTitle.addAttribute( + .paragraphStyle, + value: paragraphStyle, + range: NSRange(location: 0, length: attributedTitle.length) + ) + + return attributedTitle + + } + + private func createParagraphAttributedTitle(_ text: String, systemSymbolName: String) -> NSAttributedString { + let attributedTitle = NSMutableAttributedString(string: text) + attributedTitle.append(NSAttributedString(string: "\t")) + + if let image = createImage(systemSymbolName, description: "\(systemSymbolName) key") { + let attachment = NSTextAttachment() + attachment.image = image + + let attachmentString = NSMutableAttributedString(attachment: attachment) + attachmentString.addAttributes([ + .foregroundColor: Self.shortcutIconColor, + .font: NSFont.systemFont(ofSize: fontSize - 1, weight: .regular), + .paragraphStyle: paragraphStyle + ], range: NSRange(location: 0, length: attachmentString.length)) + + attributedTitle.append(attachmentString) + } + + attributedTitle.addAttribute( + .paragraphStyle, + value: paragraphStyle, + range: NSRange(location: 0, length: attributedTitle.length) + ) + + return attributedTitle + + } + + @objc func handleSettingsAction() { + try? launchHostAppAdvancedSettings() + } + + @objc func handleGoToAcceptAction() { + let state = store.withState { $0 } + state.nesContent?.acceptNESSuggestion() + } + + @objc func handleRejectAction() { + let state = store.withState { $0 } + state.nesContent?.rejectNESSuggestion() + } + + @objc func handleGetMoreInfoAction() { } + + private func createTitleItem() -> NSMenuItem { + let titleItem = NSMenuItem() + + titleItem.isEnabled = false + + let attributedTitle = NSMutableAttributedString(string: "Copilot Next Edit Suggestion") + attributedTitle.addAttributes([ + .foregroundColor: Self.titleColor, + .font: NSFont.systemFont(ofSize: fontSize - 1, weight: .medium) + ], range: NSRange(location: 0, length: attributedTitle.length)) + + titleItem.attributedTitle = attributedTitle + return titleItem + } + + private func createSettingItem() -> NSMenuItem { + let settingsItem = NSMenuItem( + title: "Settings", + action: #selector(handleSettingsAction), + keyEquivalent: "" + ) + settingsItem.target = self + + if let gearImage = NSImage( + systemSymbolName: "gearshape", + accessibilityDescription: "Settings" + ) { + gearImage.size = self.imageSize + settingsItem.image = gearImage + } + + return settingsItem + } + + private func createGoToAcceptItem() -> NSMenuItem { + let goToAcceptItem = NSMenuItem( + title: "Go To / Accept", + action: #selector(handleGoToAcceptAction), + keyEquivalent: "" + ) + goToAcceptItem.target = self + + let imageSymbolName = "arrow.right.to.line" + + if let arrowImage = createImage(imageSymbolName, description: "Go To or Accept") { + goToAcceptItem.image = arrowImage + } + + let attributedTitle = createParagraphAttributedTitle("Go To / Accept", systemSymbolName: imageSymbolName) + goToAcceptItem.attributedTitle = attributedTitle + + return goToAcceptItem + } + + private func createRejectItem() -> NSMenuItem { + let rejectItem = NSMenuItem( + title: "Reject", + action: #selector(handleRejectAction), + keyEquivalent: "" + ) + rejectItem.target = self + + if let xImage = createImage("xmark", description: "Reject") { + rejectItem.image = xImage + } + + let attributedTitle = createParagraphAttributedTitle("Reject", helpText: "Esc") + rejectItem.attributedTitle = attributedTitle + + return rejectItem + } + + private func createGetMoreInfoItem() -> NSMenuItem { + let moreInfoItem = NSMenuItem( + title: "Get More Info", + action: #selector(handleGetMoreInfoAction), + keyEquivalent: "" + ) + moreInfoItem.target = self + + let attributedTitle = NSMutableAttributedString(string: "Get More Info") + attributedTitle.addAttributes([ + .foregroundColor: NSColor.linkColor, + .font: NSFont.systemFont(ofSize: fontSize, weight: .medium) + ], range: NSRange(location: 0, length: attributedTitle.length)) + + moreInfoItem.attributedTitle = attributedTitle + + return moreInfoItem + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESMenuView.swift b/Core/Sources/SuggestionWidget/NES/NESMenuView.swift new file mode 100644 index 00000000..b6ca96f7 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESMenuView.swift @@ -0,0 +1,57 @@ +import ComposableArchitecture +import SwiftUI +import Foundation +import SharedUIComponents +import XcodeInspector +import Logger + +struct NESMenuView: View { + let store: StoreOf + + @State private var menuController: NESMenuController + + init(store: StoreOf) { + self.store = store + self._menuController = State( + initialValue: NESMenuController( + fontSize: store.lineFontSize, + fontSizeScale: store.fontSizeScale, + store: store + ) + ) + } + + var body: some View { + WithPerceptionTracking { + let lineHeight = store.lineHeight + let fontSizeScale = store.fontSizeScale + let fontSize = store.lineFontSize + if store.isPanelDisplayed && !store.isPanelOutOfFrame && store.nesContent != nil { + NESMenuButtonView( + menuController: menuController, + fontSize: fontSize + ) + .id("nes-menu-button") + .frame(width: lineHeight, height: calcMenuHeight(by: lineHeight)) + .padding(.horizontal, 3 * fontSizeScale) + .padding(.leading, 1 * fontSizeScale) + .padding(.vertical, 3 * fontSizeScale) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color("LightBluePrimary")) + ) + .opacity(store.menuViewOpacity) + .onChange(of: store.lineFontSize) { + menuController.fontSize = $0 + } + .onChange(of: store.fontSizeScale) { + menuController.fontSizeScale = $0 + } + } + } + } + + private func calcMenuHeight(by lineHeight: Double) -> Double { + return (lineHeight * 2 / 3 * 100).rounded() / 100 + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESNotificationView.swift b/Core/Sources/SuggestionWidget/NES/NESNotificationView.swift new file mode 100644 index 00000000..a73ebaae --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESNotificationView.swift @@ -0,0 +1,66 @@ +import SwiftUI +import ComposableArchitecture +import Logger + +struct NESNotificationView: View { + let store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + WithPerceptionTracking { + if store.isPanelOutOfFrame, + !store.closeNotificationByUser, + store.nesContent != nil { + + let fontSize = store.lineFontSize + let scale = store.fontSizeScale + + HStack(spacing: 8) { + Image("EditSparkle") + .resizable() + .scaledToFit() + .font(.system(size: calcImageFontSize(fontSize, scale), weight: .medium)) + + HStack(spacing: 4 * scale) { + Text("Press") + + Text("Tab") + .foregroundStyle(.secondary) + + Text("to jump to Next Edit Suggestion") + } + .font(.system(size: fontSize, weight: .medium)) + + Button(action: { + store.send(.onUserCloseNotification) + }) { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .font(.system(size: calcImageFontSize(fontSize, scale), weight: .medium)) + } + .foregroundStyle(Color(NSColor.controlBackgroundColor)) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(.primary) + ) + .shadow( + color: Color("NESShadowColor"), + radius: 12, + x: 0, + y: 3 + ) + .opacity(store.notificationViewOpacity) + } + } + .frame(maxWidth: .infinity, alignment: .center) + } + + func calcImageFontSize(_ baseFontSize: CGFloat, _ scale: Double) -> CGFloat { + return baseFontSize + 2 * scale + } +} diff --git a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift index dd50233f..0ec9bb1f 100644 --- a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift +++ b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift @@ -4,6 +4,8 @@ import Perception import SharedUIComponents import SwiftUI import XcodeInspector +import SuggestionBasic +import WorkspaceSuggestionService @Perceptible public final class CodeSuggestionProvider: Equatable { @@ -58,3 +60,95 @@ public final class CodeSuggestionProvider: Equatable { } +@Perceptible +public final class NESCodeSuggestionProvider: Equatable { + public static func == (lhs: NESCodeSuggestionProvider, rhs: NESCodeSuggestionProvider) -> Bool { + lhs.code == rhs.code && lhs.language == rhs.language + } + + public let fileURL: URL + public let code: String + public let sourceSnapshot: FilespaceSuggestionSnapshot + public let range: CursorRange + public let language: String + + @PerceptionIgnored public var onRejectSuggestionTapped: () -> Void + @PerceptionIgnored public var onAcceptNESSuggestionTapped: () -> Void + @PerceptionIgnored public var onDismissNESSuggestionTapped: () -> Void + + public init( + fileURL: URL, + code: String, + sourceSnapshot: FilespaceSuggestionSnapshot, + range: CursorRange, + language: String = "", + onRejectSuggestionTapped: @escaping () -> Void = {}, + onAcceptNESSuggestionTapped: @escaping () -> Void = {}, + onDismissNESSuggestionTapped: @escaping () -> Void = {} + ) { + self.fileURL = fileURL + self.code = code + self.sourceSnapshot = sourceSnapshot + self.range = range + self.language = language + self.onRejectSuggestionTapped = onRejectSuggestionTapped + self.onAcceptNESSuggestionTapped = onAcceptNESSuggestionTapped + self.onDismissNESSuggestionTapped = onDismissNESSuggestionTapped + } + + func rejectNESSuggestion() { onRejectSuggestionTapped() } + func acceptNESSuggestion() { onAcceptNESSuggestionTapped() } + func dismissNESSuggestion() { onDismissNESSuggestionTapped() } + + func getOriginalCodeSnippet() -> String? { + /// The lines is from `EditorContent`, the "\n" is kept there. + let lines = sourceSnapshot.lines.joined(separator: "").components(separatedBy: .newlines) + guard range.start.line >= 0, + range.end.line >= range.start.line, + range.end.line < lines.count + else { return nil } + + // Single line case + if range.start.line == range.end.line { + let line = lines[range.start.line] + let startIndex = calcStartIndex(of: line, by: range) + let endIndex = calcEndIndex(of: line, by: range) + return String(line[startIndex.. 0) + let endIndex = calcEndIndex(of: line, by: range) + result.append(String(line[.. String.Index { + return line.index(line.startIndex, offsetBy: range.start.character, limitedBy: line.endIndex) ?? line.endIndex + } + + private func calcEndIndex(of line: String, by range: CursorRange) -> String.Index { + return line.index(line.startIndex, offsetBy: range.end.character, limitedBy: line.endIndex) ?? line.endIndex + } +} + diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index 382771cf..6f063016 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -14,6 +14,11 @@ 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 + static let fixPanelToAnnotationSpacing: Double = 1 + static let nesSuggestionMenuLeadingPadding: Double = 4 + static let agentConfigurationWidgetLeadingSpacing: Double = 4 } extension Color { @@ -55,7 +60,7 @@ struct XcodeLikeFrame: View { content.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .background( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Material.bar) + .fill(Color.chatWindowBackgroundColor) ) .overlay( RoundedRectangle(cornerRadius: max(0, cornerRadius), style: .continuous) @@ -70,8 +75,9 @@ struct XcodeLikeFrame: View { } extension View { - func xcodeStyleFrame(cornerRadius: Double? = nil) -> some View { - XcodeLikeFrame(content: self, cornerRadius: cornerRadius ?? 10) + var xcodeStyleCornerRadius: Double { 16 } + func xcodeStyleFrame() -> some View { + XcodeLikeFrame(content: self, cornerRadius: xcodeStyleCornerRadius) } } 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/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 06adce2f..366d98d3 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -8,6 +8,7 @@ import Preferences import SwiftUI import UserDefaultsObserver import XcodeInspector +import SuggestionBasic @MainActor public final class SuggestionWidgetController: NSObject { @@ -48,6 +49,11 @@ public extension SuggestionWidgetController { store.send(.panel(.presentSuggestion)) } + + func suggestNESCode() { + store.send(.panel(.presentNESSuggestion)) + } + func expandSuggestion() { store.withState { state in if state.panelState.content.suggestion != nil { @@ -63,6 +69,14 @@ public extension SuggestionWidgetController { } } } + + func discardNESSuggestion() { + store.withState { state in + if state.panelState.nesContent != nil { + store.send(.panel(.discardNESSuggestion)) + } + } + } #warning("TODO: Make a progress controller that doesn't use TCA.") func markAsProcessing(_ isProcessing: Bool) { diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift index f7ad662a..9c691e14 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift @@ -2,6 +2,7 @@ import Foundation public protocol SuggestionWidgetDataSource { func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? + func nesSuggestionForFile(at url: URL) async -> NESCodeSuggestionProvider? } struct MockWidgetDataSource: SuggestionWidgetDataSource { @@ -20,5 +21,24 @@ struct MockWidgetDataSource: SuggestionWidgetDataSource { currentSuggestionIndex: 0 ) } + + func nesSuggestionForFile(at url: URL) async -> NESCodeSuggestionProvider? { + return NESCodeSuggestionProvider( + fileURL: URL(fileURLWithPath: "the/file/path.swift"), + code: """ + func test() { + let x = 1 + let y = 2 + let z = x + y + } + """, + sourceSnapshot: .init( + lines: [""], + cursorPosition: .init(line: 0, character: 0) + ), + range: .init(startPair: (1, 0), endPair: (2, 0)), + language: "swift" + ) + } } diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index d6e6e60c..6f681218 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -1,19 +1,126 @@ import AppKit import Foundation import XcodeInspector +import ConversationServiceProvider public struct WidgetLocation: Equatable { + // Indicates from where the widget location generation was triggered + enum LocationTrigger { + case sourceEditor, xcodeWorkspaceWindow, unknown, otherApp + + var isSourceEditor: Bool { self == .sourceEditor } + var isOtherApp: Bool { self == .otherApp } + var isFromXcode: Bool { self == .sourceEditor || self == .xcodeWorkspaceWindow} + } + + struct NESPanelLocation: Equatable { + struct DiffViewConstraints: Equatable { + var maxX: CGFloat + var y: CGFloat + var maxWidth: CGFloat + var maxHeight: CGFloat + } + + var scrollViewFrame: CGRect + var screenFrame: CGRect + var lineFirstCharacterFrame: CGRect + + var lineHeight: Double { + lineFirstCharacterFrame.height + } + var menuFrame: CGRect { + .init( + x: scrollViewFrame.minX + Style.nesSuggestionMenuLeadingPadding, + y: screenFrame.height - lineFirstCharacterFrame.maxY, + width: lineFirstCharacterFrame.width, + height: lineHeight + ) + } + + var availableHeight: CGFloat? { + guard scrollViewFrame.contains(lineFirstCharacterFrame) else { + return nil + } + return scrollViewFrame.maxY - lineFirstCharacterFrame.minY + } + + var availableWidth: CGFloat { + return scrollViewFrame.width / 2 + } + + func calcDiffViewFrame(contentSize: CGSize) -> CGRect? { + guard scrollViewFrame.contains(lineFirstCharacterFrame) else { + return nil + } + + let availableWidth = max(0, scrollViewFrame.width / 2) + let availableHeight = max(0, scrollViewFrame.maxY - lineFirstCharacterFrame.minY) + let preferredWidth = max(contentSize.width, 1) + let preferredHeight = max(contentSize.height, lineHeight) + + let width = availableWidth > 0 ? min(preferredWidth, availableWidth) : preferredWidth + let height = availableHeight > 0 ? min(preferredHeight, availableHeight) : preferredHeight + + return .init( + x: scrollViewFrame.maxX - width - Style.nesSuggestionMenuLeadingPadding, + y: screenFrame.height - lineFirstCharacterFrame.minY - height, + width: width, + height: height + ) + } + } + + struct AgentConfigurationWidgetLocation: Equatable { + var firstLineFrame: CGRect + var scrollViewRect: CGRect + var screenFrame: CGRect + var textEndX: CGFloat + + var lineHeight: CGFloat { + firstLineFrame.height + } + + func getWidgetFrame(_ originalFrame: NSRect) -> NSRect { + let width = originalFrame.width + let height = originalFrame.height + let lineCenter = firstLineFrame.minY + firstLineFrame.height / 2 + let panelHalfHeight = originalFrame.height / 2 + + return .init( + x: textEndX + Style.agentConfigurationWidgetLeadingSpacing, + y: screenFrame.maxY - lineCenter - panelHalfHeight + screenFrame.minY, + width: width, + height: height + ) + } + } + struct PanelLocation: Equatable { var frame: CGRect var alignPanelTop: Bool var firstLineIndent: Double? var lineHeight: Double? } - + var widgetFrame: CGRect var tabFrame: CGRect var defaultPanelLocation: PanelLocation var suggestionPanelLocation: PanelLocation? + var nesSuggestionPanelLocation: NESPanelLocation? + var locationTrigger: LocationTrigger = .unknown + var agentConfigurationWidgetLocation: AgentConfigurationWidgetLocation? + + mutating func setNESSuggestionPanelLocation(_ location: NESPanelLocation?) { + self.nesSuggestionPanelLocation = location + } + + mutating func setLocationTrigger(_ trigger: LocationTrigger) { + self.locationTrigger = trigger + } + + mutating func setAgentConfigurationWidgetLocation(_ location: AgentConfigurationWidgetLocation?) { + self.agentConfigurationWidgetLocation = location + } } enum UpdateLocationStrategy { @@ -29,10 +136,10 @@ enum UpdateLocationStrategy { ) -> WidgetLocation { guard let selectedRange: AXValue = try? editor .copyValue(key: kAXSelectedTextRangeAttribute), - let rect: AXValue = try? editor.copyParameterizedValue( + let rect: AXValue = try? editor.copyParameterizedValue( key: kAXBoundsForRangeParameterizedAttribute, parameters: selectedRange - ) + ) else { return FixedToBottom().framesForWindows( editorFrame: editorFrame, @@ -62,7 +169,7 @@ enum UpdateLocationStrategy { ) } } - + struct FixedToBottom { func framesForWindows( editorFrame: CGRect, @@ -85,7 +192,7 @@ enum UpdateLocationStrategy { ) } } - + struct HorizontalMovable { func framesForWindows( y: CGFloat, @@ -108,34 +215,34 @@ enum UpdateLocationStrategy { mainScreen.frame.height - editorFrame.minY - Style.widgetHeight - Style .widgetPadding ) - + var proposedAnchorFrameOnTheRightSide = CGRect( x: editorFrame.maxX - Style.widgetPadding, y: y, width: 0, height: 0 ) - + let widgetFrameOnTheRightSide = CGRect( x: editorFrame.maxX - Style.widgetPadding - Style.widgetWidth, y: y, width: Style.widgetWidth, height: Style.widgetHeight ) - + if !hideCircularWidget { proposedAnchorFrameOnTheRightSide = widgetFrameOnTheRightSide } - + let proposedPanelX = proposedAnchorFrameOnTheRightSide.maxX - + Style.widgetPadding * 2 - - editorFrameExpendedSize.width + + Style.widgetPadding * 2 + - editorFrameExpendedSize.width let putPanelToTheRight = { if editorFrame.size.width >= preferredInsideEditorMinWidth { return false } return activeScreen.frame.maxX > proposedPanelX + Style.panelWidth }() let alignPanelTopToAnchor = fixedAlignment ?? (y > activeScreen.frame.midY) - + let chatPanelFrame = getChatPanelFrame(mainScreen) if putPanelToTheRight { @@ -143,12 +250,12 @@ enum UpdateLocationStrategy { let tabFrame = CGRect( x: anchorFrame.origin.x, y: alignPanelTopToAnchor - ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding - : anchorFrame.maxY + Style.widgetPadding, + ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding + : anchorFrame.maxY + Style.widgetPadding, width: Style.widgetWidth, height: Style.widgetHeight ) - + return .init( widgetFrame: widgetFrameOnTheRightSide, tabFrame: tabFrame, @@ -165,22 +272,22 @@ enum UpdateLocationStrategy { width: 0, height: 0 ) - + let widgetFrameOnTheLeftSide = CGRect( x: editorFrame.minX + Style.widgetPadding, y: proposedAnchorFrameOnTheRightSide.origin.y, width: Style.widgetWidth, height: Style.widgetHeight ) - + if !hideCircularWidget { proposedAnchorFrameOnTheLeftSide = widgetFrameOnTheLeftSide } - + let proposedPanelX = proposedAnchorFrameOnTheLeftSide.minX - - Style.widgetPadding * 2 - - Style.panelWidth - + editorFrameExpendedSize.width + - Style.widgetPadding * 2 + - Style.panelWidth + + editorFrameExpendedSize.width let putAnchorToTheLeft = { if editorFrame.size.width >= preferredInsideEditorMinWidth { if editorFrame.maxX <= activeScreen.frame.maxX { @@ -189,14 +296,14 @@ enum UpdateLocationStrategy { } return proposedPanelX > activeScreen.frame.minX }() - + if putAnchorToTheLeft { let anchorFrame = proposedAnchorFrameOnTheLeftSide let tabFrame = CGRect( x: anchorFrame.origin.x, y: alignPanelTopToAnchor - ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding - : anchorFrame.maxY + Style.widgetPadding, + ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding + : anchorFrame.maxY + Style.widgetPadding, width: Style.widgetWidth, height: Style.widgetHeight ) @@ -230,7 +337,7 @@ enum UpdateLocationStrategy { } } } - + struct NearbyTextCursor { func framesForSuggestionWindow( editorFrame: CGRect, @@ -241,35 +348,37 @@ enum UpdateLocationStrategy { ) -> WidgetLocation.PanelLocation? { guard let selectionFrame = UpdateLocationStrategy .getSelectionFirstLineFrame(editor: editor) else { return nil } - + // hide it when the line of code is outside of the editor visible rect if selectionFrame.maxY < editorFrame.minY || selectionFrame.minY > editorFrame.maxY { return nil } - + + let lineHeight: Double = selectionFrame.height + let selectionMinY = selectionFrame.minY // Always place suggestion window at cursor position. return .init( frame: .init( x: editorFrame.minX, - y: mainScreen.frame.height - selectionFrame.minY - Style.inlineSuggestionMaxHeight + Style.inlineSuggestionPadding, + y: mainScreen.frame.height - selectionMinY - Style.inlineSuggestionMaxHeight + Style.inlineSuggestionPadding, width: editorFrame.width, height: Style.inlineSuggestionMaxHeight ), alignPanelTop: true, firstLineIndent: selectionFrame.maxX - editorFrame.minX - Style.inlineSuggestionPadding, - lineHeight: selectionFrame.height + lineHeight: lineHeight ) } } - + /// Get the frame of the selection. static func getSelectionFrame(editor: AXUIElement) -> CGRect? { guard let selectedRange: AXValue = try? editor .copyValue(key: kAXSelectedTextRangeAttribute), - let rect: AXValue = try? editor.copyParameterizedValue( + let rect: AXValue = try? editor.copyParameterizedValue( key: kAXBoundsForRangeParameterizedAttribute, parameters: selectedRange - ) + ) else { return nil } @@ -278,36 +387,36 @@ enum UpdateLocationStrategy { guard found else { return nil } return selectionFrame } - + /// Get the frame of the first line of the selection. static func getSelectionFirstLineFrame(editor: AXUIElement) -> CGRect? { // Find selection range rect guard let selectedRange: AXValue = try? editor .copyValue(key: kAXSelectedTextRangeAttribute), - let rect: AXValue = try? editor.copyParameterizedValue( + let rect: AXValue = try? editor.copyParameterizedValue( key: kAXBoundsForRangeParameterizedAttribute, parameters: selectedRange - ) + ) else { return nil } var selectionFrame: CGRect = .zero let found = AXValueGetValue(rect, .cgRect, &selectionFrame) guard found else { return nil } - + var firstLineRange: CFRange = .init() let foundFirstLine = AXValueGetValue(selectedRange, .cfRange, &firstLineRange) firstLineRange.length = 0 - - #warning( - "FIXME: When selection is too low and out of the screen, the selection range becomes something else." + +#warning( + "FIXME: When selection is too low and out of the screen, the selection range becomes something else." ) - + if foundFirstLine, let firstLineSelectionRange = AXValueCreate(.cfRange, &firstLineRange), let firstLineRect: AXValue = try? editor.copyParameterizedValue( - key: kAXBoundsForRangeParameterizedAttribute, - parameters: firstLineSelectionRange + key: kAXBoundsForRangeParameterizedAttribute, + parameters: firstLineSelectionRange ) { var firstLineFrame: CGRect = .zero @@ -316,7 +425,7 @@ enum UpdateLocationStrategy { selectionFrame = firstLineFrame } } - + return selectionFrame } @@ -357,3 +466,160 @@ 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 + ) // 0-based + + guard let rect = LocationStrategyHelper.getLineFrame(currentLineNumber, in: editor, with: currentLines) else { + return (nil, nil) + } + + return (currentLineNumber, rect) + } +} + +public struct NESPanelLocationStrategy { + static func getNESPanelLocation( + maybeEditor: AXUIElement, + state: WidgetFeature.State + ) -> WidgetLocation.NESPanelLocation? { + guard let sourceEditor = maybeEditor.findSourceEditorElement(shouldRetry: false), + let editorContent: String = try? sourceEditor.copyValue(key: kAXValueAttribute), + let nesContent = state.panelState.nesContent, + let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }) + else { + return nil + } + + let startLine = nesContent.range.start.line + guard let lineFirstCharacterFrame = LocationStrategyHelper.getLineFrame( + startLine, + in: sourceEditor, + with: editorContent.components(separatedBy: .newlines), + length: 1 + ) else { + return nil + } + + guard let scrollViewFrame = sourceEditor.parent?.rect else { + return nil + } + + return .init( + scrollViewFrame: scrollViewFrame, + screenFrame: screen.frame, + lineFirstCharacterFrame: lineFirstCharacterFrame + ) + } +} + +public struct AgentConfigurationWidgetLocationStrategy { + static func getAgentConfigurationWidgetLocation( + maybeEditor: AXUIElement, + screen: NSScreen + ) -> WidgetLocation.AgentConfigurationWidgetLocation? { + guard let sourceEditorElement = maybeEditor.findSourceEditorElement(shouldRetry: false), + let editorContent: String = try? sourceEditorElement.copyValue(key: kAXValueAttribute), + let scrollViewRect = sourceEditorElement.parent?.rect + else { + return nil + } + + // Get the editor content to access lines + let lines = editorContent.components(separatedBy: .newlines) + guard !lines.isEmpty else { + return nil + } + + // Get the frame of the first line (line 0) + guard let firstLineFrame = LocationStrategyHelper.getLineFrame( + 0, + in: sourceEditorElement, + with: [lines[0]] + ) else { + return nil + } + + // Check if the first line is visible within the scroll view + guard firstLineFrame.width > 0, firstLineFrame.height > 0, + scrollViewRect.contains(firstLineFrame) + else { + return nil + } + + // Get the actual text content width (excluding trailing whitespace) + let firstLineText = lines[0].trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + let textEndX: CGFloat + + if !firstLineText.isEmpty { + // Calculate character position for the end of the trimmed text + let textLength = firstLineText.count + var range = CFRange(location: 0, length: textLength) + + if let rangeValue = AXValueCreate(AXValueType.cfRange, &range), + let boundsValue: AXValue = try? sourceEditorElement.copyParameterizedValue( + key: kAXBoundsForRangeParameterizedAttribute, + parameters: rangeValue + ) { + var textRect = CGRect.zero + if AXValueGetValue(boundsValue, .cgRect, &textRect) { + textEndX = textRect.maxX + } else { + textEndX = firstLineFrame.minX + } + } else { + textEndX = firstLineFrame.minX + } + } else { + textEndX = firstLineFrame.minX + } + + return .init( + firstLineFrame: firstLineFrame, + scrollViewRect: scrollViewRect, + screenFrame: screen.frame, + textEndX: textEndX + ) + } +} diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 9c4feb0f..fa02da2a 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() @@ -60,6 +61,11 @@ actor WidgetWindowsController: NSObject { }.store(in: &cancellable) xcodeInspector.$focusedEditor.sink { [weak self] editor in + Task { @MainActor [weak self] in + self?.store.send(.fixErrorPanel(.onFocusedEditorChanged(editor))) + self?.store.send(.panel(.agentConfigurationWidget(.onFocusedEditorChanged(editor)))) + } + guard let editor else { return } Task { [weak self] in await self?.observe(toEditor: editor) } }.store(in: &cancellable) @@ -70,12 +76,63 @@ 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() + + // Observe state change of fix error + setupFixErrorPanelObservers() + + // Observer state change for NES + setupNESSuggestionPanelObservers() + + // Observe feature flags + setupFeatureFlagObservers() + } + + private func setupCodeReviewPanelObservers() { + Task { @MainActor in + let currentIndexPublisher = store.publisher + .map(\.codeReviewPanelState.currentIndex) + .removeDuplicates() + .sink { [weak self] _ in + Task { [weak self] in + await self?.updateCodeReviewWindowLocation(.onCurrentReviewIndexChanged) + } + } + + let isPanelDisplayedPublisher = store.publisher + .map(\.codeReviewPanelState.isPanelDisplayed) + .removeDuplicates() + .sink { [weak self] isPanelDisplayed in + Task { [weak self] in + await self?.updateCodeReviewWindowLocation(.onIsPanelDisplayedChanged(isPanelDisplayed)) + } + } + + await self.storeCancellables([currentIndexPublisher, isPanelDisplayedPublisher]) + } + } + + func storeCancellables(_ newCancellables: [AnyCancellable]) { + for cancellable in newCancellables { + self.cancellable.insert(cancellable) + } } } @@ -99,6 +156,8 @@ private extension WidgetWindowsController { await hideSuggestionPanelWindow() } await adjustChatPanelWindowLevel() + + await updateFixErrorPanelWindowLocation() } guard currentApplicationProcessIdentifier != app.processIdentifier else { return } currentApplicationProcessIdentifier = app.processIdentifier @@ -153,12 +212,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 +237,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 +256,12 @@ 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) { + for await notification in merge(selectionRangeChange, scroll, valueChange) { guard await xcodeInspector.safe.latestActiveXcode != nil else { return } try Task.checkCancellation() @@ -205,6 +272,9 @@ private extension WidgetWindowsController { updateWindowLocation(animated: false, immediately: false) updateWindowOpacity(immediately: false) + await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) + + await handleFixErrorEditorNotification(notification: notification) } } } @@ -239,12 +309,25 @@ extension WidgetWindowsController { @MainActor func hideSuggestionPanelWindow() { windows.suggestionPanelWindow.alphaValue = 0 - send(.panel(.hidePanel)) + send(.panel(.hidePanel(.suggestion))) + } + + @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? { + func generateWidgetLocation(_ state: WidgetFeature.State) -> WidgetLocation { // Default location when no active application/window - let defaultLocation = generateDefaultLocation() + var defaultLocation = generateDefaultLocation() if let application = xcodeInspector.latestActiveXcode?.appElement { if let focusElement = xcodeInspector.focusedEditor?.element, @@ -257,6 +340,12 @@ extension WidgetWindowsController { .value(for: \.suggestionWidgetPositionMode) let suggestionMode = UserDefaults.shared .value(for: \.suggestionPresentationMode) + + let nesPanelLocation: WidgetLocation.NESPanelLocation? = NESPanelLocationStrategy.getNESPanelLocation(maybeEditor: parent, state: state) + let locationTrigger: WidgetLocation.LocationTrigger = .sourceEditor + let agentConfigurationWidgetLocation = AgentConfigurationWidgetLocationStrategy.getAgentConfigurationWidgetLocation( + maybeEditor: parent, screen: screen + ) switch positionMode { case .fixedToBottom: @@ -265,6 +354,9 @@ extension WidgetWindowsController { mainScreen: screen, activeScreen: firstScreen ) + result.setNESSuggestionPanelLocation(nesPanelLocation) + result.setLocationTrigger(locationTrigger) + result.setAgentConfigurationWidgetLocation(agentConfigurationWidgetLocation) switch suggestionMode { case .nearbyTextCursor: result.suggestionPanelLocation = UpdateLocationStrategy @@ -286,6 +378,9 @@ extension WidgetWindowsController { activeScreen: firstScreen, editor: focusElement ) + result.setNESSuggestionPanelLocation(nesPanelLocation) + result.setLocationTrigger(locationTrigger) + result.setAgentConfigurationWidgetLocation(agentConfigurationWidgetLocation) switch suggestionMode { case .nearbyTextCursor: result.suggestionPanelLocation = UpdateLocationStrategy @@ -303,19 +398,20 @@ extension WidgetWindowsController { } } else if var window = application.focusedWindow, var frame = application.focusedWindow?.rect, - !["menu bar", "menu bar item"].contains(window.description), + !window.isXcodeMenuBar, frame.size.height > 300, let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), let firstScreen = NSScreen.main { - if ["open_quickly"].contains(window.identifier) - || ["alert"].contains(window.label) + if window.isXcodeOpenQuickly + || window.isXcodeAlert { // fallback to use workspace window guard let workspaceWindow = application.windows - .first(where: { $0.identifier == "Xcode.WorkspaceWindow" }), + .first(where: { $0.isXcodeWorkspaceWindow }), let rect = workspaceWindow.rect else { + defaultLocation.setLocationTrigger(.otherApp) return defaultLocation } @@ -324,7 +420,7 @@ extension WidgetWindowsController { } var expendedSize = CGSize.zero - if ["Xcode.WorkspaceWindow"].contains(window.identifier) { + if window.isXcodeWorkspaceWindow { // extra padding to bottom so buttons won't be covered frame.size.height -= 40 } else { @@ -335,13 +431,16 @@ extension WidgetWindowsController { expendedSize.height += Style.widgetPadding } - return UpdateLocationStrategy.FixedToBottom().framesForWindows( + var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( editorFrame: frame, mainScreen: screen, activeScreen: firstScreen, preferredInsideEditorMinWidth: 9_999_999_999, // never editorFrameExpendedSize: expendedSize ) + result.setLocationTrigger(.xcodeWorkspaceWindow) + + return result } } return defaultLocation @@ -358,12 +457,15 @@ extension WidgetWindowsController { frame: chatPanelFrame, alignPanelTop: false ), - suggestionPanelLocation: nil + suggestionPanelLocation: nil, + nesSuggestionPanelLocation: nil ) } func updatePanelState(_ location: WidgetLocation) async { await send(.updatePanelStateToMatch(location)) + await send(.updateNESSuggestionPanelStateToMatch(location)) + await send(.updateAgentConfigurationWidgetStateToMatch(location)) } func updateWindowOpacity(immediately: Bool) { @@ -399,8 +501,13 @@ extension WidgetWindowsController { /// We need this to hide the windows when Xcode is minimized. let noFocus = application.focusedWindow == nil windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 - send(.panel(noFocus ? .hidePanel : .showPanel)) + send(.panel(noFocus ? .hidePanel(.suggestion) : .showPanel(.suggestion))) windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 + send(.panel(noFocus ? .hidePanel(.nes) : .showPanel(.nes))) + applyOpacityForNESWindows(by: noFocus) + send(.panel(noFocus ? .hidePanel(.agentConfiguration) : .showPanel(.agentConfiguration))) + applyOpacityForAgentConfigurationWidget(by: noFocus) + windows.nesNotificationWindow.alphaValue = noFocus ? 0 : 1 windows.widgetWindow.alphaValue = noFocus ? 0 : 1 windows.toastWindow.alphaValue = noFocus ? 0 : 1 @@ -422,8 +529,13 @@ extension WidgetWindowsController { let previousAppIsXcode = previousActiveApplication?.isXcode ?? false - send(.panel(noFocus ? .hidePanel : .showPanel)) + send(.panel(noFocus ? .hidePanel(.suggestion) : .showPanel(.suggestion))) windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 + send(.panel(noFocus ? .hidePanel(.nes) : .showPanel(.nes))) + applyOpacityForNESWindows(by: noFocus) + send(.panel(noFocus ? .hidePanel(.agentConfiguration) : .showPanel(.agentConfiguration))) + applyOpacityForAgentConfigurationWidget(by: noFocus) + windows.nesNotificationWindow.alphaValue = noFocus ? 0 : 1 windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 windows.widgetWindow.alphaValue = if noFocus { 0 @@ -442,6 +554,10 @@ extension WidgetWindowsController { } else { windows.sharedPanelWindow.alphaValue = 0 windows.suggestionPanelWindow.alphaValue = 0 + windows.nesMenuWindow.alphaValue = 0 + windows.nesDiffWindow.alphaValue = 0 + applyOpacityForAgentConfigurationWidget() + windows.nesNotificationWindow.alphaValue = 0 windows.widgetWindow.alphaValue = 0 windows.toastWindow.alphaValue = 0 if !isChatPanelDetached { @@ -462,7 +578,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 { @@ -515,7 +635,7 @@ extension WidgetWindowsController { func update() async { let state = store.withState { $0 } let isChatPanelDetached = state.chatPanelState.isDetached - guard let widgetLocation = await generateWidgetLocation() else { return } + let widgetLocation = await generateWidgetLocation(state) await updatePanelState(widgetLocation) windows.widgetWindow.setFrame( @@ -542,6 +662,29 @@ extension WidgetWindowsController { ) } + if let nesPanelLocation = widgetLocation.nesSuggestionPanelLocation { + windows.nesMenuWindow.setFrame( + nesPanelLocation.menuFrame, + display: false, + animate: animated + ) + await updateNESDiffWindowFrame( + nesPanelLocation, + animated: animated, + trigger: widgetLocation.locationTrigger + ) + + await updateNESNotificationWindowFrame(nesPanelLocation, animated: animated) + } + + if let agentConfigurationWidgetLocation = widgetLocation.agentConfigurationWidgetLocation { + windows.agentConfigurationWidgetWindow.setFrame( + agentConfigurationWidgetLocation.getWidgetFrame(windows.agentConfigurationWidgetWindow.frame), + display: false, + animate: animated + ) + } + let isAttachedToXcodeEnabled = UserDefaults.shared.value(for: \.autoAttachChatToXcode) if isAttachedToXcodeEnabled { // update in `updateAttachedChatWindowLocation` @@ -556,6 +699,8 @@ extension WidgetWindowsController { } await adjustChatPanelWindowLevel() + + await updateFixErrorPanelWindowLocation() } let now = Date() @@ -636,6 +781,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 { @@ -798,7 +1072,195 @@ public final class WidgetWindows { it.setIsVisible(true) return it }() + + @MainActor + lazy var nesMenuWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isOpaque = false + it.backgroundColor = .clear + it.level = widgetLevel(2) + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.hasShadow = false + it.contentView = NSHostingView( + rootView: NESMenuView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.nesSuggestionPanelState, + action: \.nesSuggestionPanel + ) + ) + ) + it.canBecomeKeyChecker = { false } + it.setIsVisible(true) + return it + }() + + @MainActor + lazy var nesDiffWindow = { + let it = CanBecomeKeyWindow( + contentRect: .zero, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isOpaque = false + it.backgroundColor = .clear + it.level = widgetLevel(2) + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.contentView = NSHostingView( + rootView: NESDiffView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.nesSuggestionPanelState, + action: \.nesSuggestionPanel + ) + ) + ) + it.canBecomeKeyChecker = { false } + it.setIsVisible(true) + it.hasShadow = true + return it + }() + + @MainActor + lazy var nesNotificationWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isOpaque = false + it.backgroundColor = .clear + it.level = widgetLevel(2) + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.contentView = NSHostingView( + rootView: NESNotificationView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.nesSuggestionPanelState, + action: \.nesSuggestionPanel + ) + ) + ) + it.canBecomeKeyChecker = { false } + it.setIsVisible(true) + 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 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 = false + 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 agentConfigurationWidgetWindow = { + 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 = false + it.level = widgetLevel(2) + it.contentView = NSHostingView( + rootView: AgentConfigurationWidgetView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.agentConfigurationWidgetState, + action: \.agentConfigurationWidget + ) + ).environment(cursorPositionTracker) + ) + it.canBecomeKeyChecker = { true } + it.alphaValue = 0 + it.setIsVisible(false) + return it + }() + @MainActor lazy var chatPanelWindow = { let it = ChatPanelWindow( @@ -857,6 +1319,11 @@ public final class WidgetWindows { toastWindow.orderFrontRegardless() sharedPanelWindow.orderFrontRegardless() suggestionPanelWindow.orderFrontRegardless() + nesMenuWindow.orderFrontRegardless() + fixErrorPanelWindow.orderFrontRegardless() + nesDiffWindow.orderFrontRegardless() + nesNotificationWindow.orderFrontRegardless() + agentConfigurationWidgetWindow.orderFrontRegardless() if chatPanelWindow.level.rawValue > NSWindow.Level.normal.rawValue { chatPanelWindow.orderFrontRegardless() } @@ -876,4 +1343,3 @@ func widgetLevel(_ addition: Int) -> NSWindow.Level { minimumWidgetLevel = NSWindow.Level.floating.rawValue return .init(minimumWidgetLevel + addition) } - diff --git a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift index 70469700..966f047f 100644 --- a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift +++ b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift @@ -1,10 +1,12 @@ import Foundation import XCTest +import SuggestionBasic @testable import Workspace @testable import KeyBindingManager class TabToAcceptSuggestionTests: XCTestCase { + @WorkspaceActor func test_should_accept() { let fileURL = URL(string: "file:///test")! @@ -20,7 +22,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (true, nil) + ), (true, nil, .codeCompletion) ) } @@ -39,7 +41,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No suggestion") + ), (false, "No suggestion", nil) ) } @@ -57,7 +59,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No filespace") + ), (false, "No filespace", nil) ) } @@ -76,7 +78,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No focused editor") + ), (false, "No focused editor", nil) ) } @@ -95,7 +97,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No active Xcode") + ), (false, "No active Xcode", nil) ) } @@ -114,7 +116,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No active document") + ), (false, "No active document", nil) ) } @@ -133,7 +135,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48, flags: .maskShift), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, nil) + ), (false, nil, nil) ) } @@ -152,7 +154,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48, flags: .maskCommand), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, nil) + ), (false, nil, nil) ) } @@ -171,7 +173,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48, flags: .maskControl), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, nil) + ), (false, nil, nil) ) } @@ -190,33 +192,14 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48, flags: .maskHelp), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, nil) - ) - } - - @WorkspaceActor - func test_should_not_accept_without_tab() { - let fileURL = URL(string: "file:///test")! - let workspacePool = FakeWorkspacePool() - workspacePool.setTestFile(fileURL: fileURL) - let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( - activeDocumentURL: fileURL, - hasActiveXcode: true, - hasFocusedEditor: true - ) - assertEqual( - TabToAcceptSuggestion.shouldAcceptSuggestion( - event: createEvent(50), - workspacePool: workspacePool, - xcodeInspector: xcodeInspector - ), (false, nil) + ), (false, nil, nil) ) } } private func assertEqual( - _ result: (Bool, String?), - _ expected: (Bool, String?) + _ result: (Bool, String?, CodeSuggestionType?), + _ expected: (Bool, String?, CodeSuggestionType?), ) { if result != expected { XCTFail("Expected \(expected), got \(result)") @@ -242,7 +225,7 @@ private class FakeWorkspacePool: WorkspacePool { @WorkspaceActor func setTestFile(fileURL: URL, skipSuggestion: Bool = false) { self.fileURL = fileURL - self.filespace = Filespace(fileURL: fileURL, onSave: {_ in }, onClose: {_ in }) + self.filespace = Filespace(fileURL: fileURL, content: "", onSave: {_ in }, onClose: {_ in }) if skipSuggestion { return } guard let filespace = self.filespace else { return } filespace.setSuggestions([.init(id: "id", text: "test", position: .zero, range: .zero)]) diff --git a/Core/Tests/ServiceTests/Environment.swift b/Core/Tests/ServiceTests/Environment.swift index 191bf6aa..26859f48 100644 --- a/Core/Tests/ServiceTests/Environment.swift +++ b/Core/Tests/ServiceTests/Environment.swift @@ -6,6 +6,7 @@ import SuggestionBasic import Workspace import XCTest import XPCShared +import LanguageServerProtocol @testable import Service @@ -26,7 +27,7 @@ class MockSuggestionService: GitHubCopilotSuggestionServiceType { fatalError() } - func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws { + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, contentChanges: [LanguageServerProtocol.TextDocumentContentChangeEvent]?) async throws { fatalError() } @@ -59,6 +60,14 @@ class MockSuggestionService: GitHubCopilotSuggestionServiceType { completions } + func getCopilotInlineEdit( + fileURL: URL, + content: String, + cursorPosition: SuggestionBasic.CursorPosition + ) async throws -> [SuggestionBasic.CodeSuggestion] { + completions + } + func notifyShown(_ completion: SuggestionBasic.CodeSuggestion) async { shown = completion.id } diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift index 941a6c84..83990303 100644 --- a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift +++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift @@ -21,6 +21,7 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { let pool = WorkspacePool() let filespace = Filespace( fileURL: URL(fileURLWithPath: "file/path/to.swift"), + content: "", onSave: { _ in }, onClose: { _ in } ) diff --git a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift index dfdb4b3e..653492fc 100644 --- a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift +++ b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift @@ -856,6 +856,57 @@ final class AcceptSuggestionTests: XCTestCase { """) } + + func test_accept_multi_lines_suggestion_with_overlay() async throws { + let content = """ + struct Cat { + var name: String + var age: String + } + """ + let text = """ + newName: String + var newAge + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 1, character: 12), + range: .init( + start: .init(line: 1, character: 8), + end: .init(line: 2, character: 11) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 1, character: 12) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo, + isNES: true + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + lines, + content.breakIntoEditorStyleLines().applying(extraInfo.modifications) + ) + XCTAssertEqual(cursor, .init(line: 2, character: 22)) + XCTAssertEqual( + lines.joined(separator: ""), + """ + struct Cat { + var newName: String + var newAge: String + } + + """ + ) + } } extension String { diff --git a/Core/Tests/SuggestionWidgetTests/NES/NESDiffBuilderTests.swift b/Core/Tests/SuggestionWidgetTests/NES/NESDiffBuilderTests.swift new file mode 100644 index 00000000..fb52105b --- /dev/null +++ b/Core/Tests/SuggestionWidgetTests/NES/NESDiffBuilderTests.swift @@ -0,0 +1,55 @@ +import XCTest + +@testable import SuggestionWidget + +final class NESDiffBuilderTests: XCTestCase { + func testInlineSegmentsIdentifiesChangesWithinLine() { + let oldLine = " let foo = 1" + let newLine = " let bar = 2" + + let segments = DiffBuilder.inlineSegments(oldLine: oldLine, newLine: newLine) + + XCTAssertEqual(segments.count, 6) + XCTAssertEqual( + segments.map(\.change), + [.unchanged, .removed, .added, .unchanged, .removed, .added] + ) + XCTAssertEqual( + segments.map(\.text), + [" let ", "foo ", "bar ", "= ", "1", "2"] + ) + } + + func testInlineSegmentsWhenOldLineIsEmptyTreatsNewContentAsAdded() { + let segments = DiffBuilder.inlineSegments(oldLine: "", newLine: "value") + + XCTAssertEqual(segments.count, 1) + XCTAssertEqual(segments.first?.change, .added) + XCTAssertEqual(segments.first?.text, "value") + } + + func testLineSegmentsReturnsDiffAcrossLineBoundaries() { + let oldContent = [ + "line1", + "line2", + "line3" + ].joined(separator: "\n") + let newContent = [ + "line1", + "line3" + ].joined(separator: "\n") + + let segments = DiffBuilder.lineSegments(oldContent: oldContent, newContent: newContent) + + XCTAssertEqual(segments.count, 3) + XCTAssertEqual( + segments.map(\.change), + [.unchanged, .removed, .unchanged] + ) + XCTAssertEqual( + segments.map(\.text), + ["line1", "line2", "line3"] + ) + } +} + diff --git a/Docs/BYOK.md b/Docs/BYOK.md new file mode 100644 index 00000000..662a92a1 --- /dev/null +++ b/Docs/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/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/Images/chat_agent.gif b/Docs/Images/chat_agent.gif new file mode 100644 index 00000000..a6a684d1 Binary files /dev/null and b/Docs/Images/chat_agent.gif differ 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/Images/document-folder-permission-request.png b/Docs/Images/document-folder-permission-request.png new file mode 100644 index 00000000..1d512ae4 Binary files /dev/null and b/Docs/Images/document-folder-permission-request.png differ 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/Images/screen-record-permission-request.png b/Docs/Images/screen-record-permission-request.png new file mode 100644 index 00000000..0b3eeb25 Binary files /dev/null and b/Docs/Images/screen-record-permission-request.png differ 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/Docs/chat_dark.gif b/Docs/chat_dark.gif deleted file mode 100644 index abd5cc20..00000000 Binary files a/Docs/chat_dark.gif and /dev/null differ diff --git a/EditorExtension/AcceptNESSuggestionCommand.swift b/EditorExtension/AcceptNESSuggestionCommand.swift new file mode 100644 index 00000000..6c015030 --- /dev/null +++ b/EditorExtension/AcceptNESSuggestionCommand.swift @@ -0,0 +1,32 @@ +import Client +import SuggestionBasic +import Foundation +import XcodeKit +import XPCShared + +class AcceptNESSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Accept Next Edit Suggestion" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + Task { + do { + try await (Task(timeout: 7) { + let service = try getService() + if let content = try await service.getNESSuggestionAcceptedCode( + editorContent: .init(invocation) + ) { + invocation.accept(content) + } + completionHandler(nil) + }.value) + } catch is CancellationError { + completionHandler(nil) + } catch { + completionHandler(error) + } + } + } +} 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/EditorExtension/RejectNESSuggestionCommand.swift b/EditorExtension/RejectNESSuggestionCommand.swift new file mode 100644 index 00000000..43183779 --- /dev/null +++ b/EditorExtension/RejectNESSuggestionCommand.swift @@ -0,0 +1,20 @@ +import Client +import Foundation +import SuggestionBasic +import XcodeKit + +class RejectNESSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Decline Next Edit Suggestion" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.getNESSuggestionRejectedCode(editorContent: .init(invocation)) + } + } +} + diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift index a9d252f9..a0ca6579 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -12,7 +12,9 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { var builtin: [[XCSourceEditorCommandDefinitionKey: Any]] { [ AcceptSuggestionCommand(), + AcceptNESSuggestionCommand(), RejectSuggestionCommand(), + RejectNESSuggestionCommand(), GetSuggestionsCommand(), NextSuggestionCommand(), PreviousSuggestionCommand(), diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 4dfc0da1..a40b2137 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -88,6 +88,12 @@ extension AppDelegate { action: nil, keyEquivalent: "" ) + + toggleNES = NSMenuItem( + title: "Enable/Disable Next Edit Suggestions (NES)", + action: #selector(toggleNESEnabled), + keyEquivalent: "" + ) // Auth menu item with custom view accountItem = NSMenuItem() @@ -163,6 +169,7 @@ extension AppDelegate { statusBarMenu.addItem(openChat) statusBarMenu.addItem(toggleCompletions) statusBarMenu.addItem(toggleIgnoreLanguage) + statusBarMenu.addItem(toggleNES) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(openCopilotForXcodeItem) statusBarMenu.addItem(openDocs) @@ -204,6 +211,10 @@ extension AppDelegate: NSMenuDelegate { toggleIgnoreLanguage.action = nil } } + + if toggleNES != nil { + toggleNES.title = "\(UserDefaults.shared.value(for: \.realtimeNESToggle) ? "Disable" : "Enable") Next Edit Suggestions (NES)" + } Task { await forceAuthStatusCheck() @@ -322,6 +333,19 @@ private extension AppDelegate { } } + @objc func toggleNESEnabled() { + Task { + let initialSetting = UserDefaults.shared.value(for: \.realtimeNESToggle) + do { + let service = getXPCExtensionService() + try await service.toggleRealtimeNES() + } catch { + Logger.service.error("Failed to toggle NES enabled via XPC: \(error)") + UserDefaults.shared.set(!initialSetting, for: \.realtimeNESToggle) + } + } + } + @objc func toggleIgnoreLanguageEnabled() { guard let lang = DisabledLanguageList.shared.activeDocumentLanguage else { return } diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 7f89e6cf..4995aa31 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -42,6 +42,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { var quotaItem: NSMenuItem! var toggleCompletions: NSMenuItem! var toggleIgnoreLanguage: NSMenuItem! + var toggleNES: NSMenuItem! var openChat: NSMenuItem! var signOutItem: NSMenuItem! var xpcController: XPCController? @@ -67,6 +68,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { Logger.service.info("XPC Service started.") NSApp.setActivationPolicy(.accessory) buildStatusBarMenu() + _ = FeatureFlagNotifierImpl.shared + observeFeatureFlags() watchServiceStatus() watchAXStatus() watchAuthStatus() @@ -234,6 +237,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } } + + + func observeFeatureFlags() { + Task { @MainActor in + FeatureFlagNotifierImpl.shared.featureFlagsDidChange + .sink(receiveValue: { [weak self] featureFlags in + self?.toggleNES.isHidden = !featureFlags.editorPreviewFeatures + }) + } + } func watchAuthStatus() { let notifications = DistributedNotificationCenter.default().notifications(named: .authStatusDidChange) @@ -286,6 +299,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.quotaItem.isHidden = true self.toggleCompletions.isHidden = true self.toggleIgnoreLanguage.isHidden = true + self.toggleNES.isHidden = true self.signOutItem.isHidden = true } @@ -300,26 +314,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { // 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 + configureCLSAuthStatusItem( + summary: CLSMessageSummary, + actionTitle: "View Details on GitHub", + action: #selector(openGitHubDetailsLink) ) - submenu.addItem(attributedCLSErrorItem) - submenu.addItem(.separator()) - submenu.addItem( - NSMenuItem( - title: "View Details on GitHub", - action: #selector(openGitHubDetailsLink), - keyEquivalent: "" - ) + } else if CLSMessageSummary.summary == CLSMessageType.byokLimitedReached.summary { + configureCLSAuthStatusItem( + summary: CLSMessageSummary, + actionTitle: "Dismiss", + action: #selector(dismissBYOKMessage) ) - - self.authStatusItem.submenu = submenu - self.authStatusItem.isEnabled = true + } else { + // Explicitly hide to avoid leaving stale content if another CLS state was previously shown. + self.authStatusItem.isHidden = true } } else { self.authStatusItem.isHidden = true @@ -352,9 +360,36 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.toggleCompletions.isHidden = false self.toggleIgnoreLanguage.isHidden = false + self.toggleNES.isHidden = false self.signOutItem.isHidden = false } + func configureCLSAuthStatusItem( + summary: CLSMessage, + actionTitle: String, + action: Selector + ) { + self.authStatusItem.isHidden = false + self.authStatusItem.title = summary.summary + let submenu = NSMenu() + + let attributedCLSErrorItem = NSMenuItem() + attributedCLSErrorItem.view = ErrorMessageView( + errorMessage: summary.detail + ) + submenu.addItem(attributedCLSErrorItem) + submenu.addItem(.separator()) + submenu.addItem( + NSMenuItem( + title: actionTitle, + action: action, + keyEquivalent: "" + ) + ) + self.authStatusItem.submenu = submenu + self.authStatusItem.isEnabled = true + } + private func configureNotAuthorized(status: StatusResponse) { self.accountItem.view = AccountItemView( target: self, @@ -378,6 +413,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.quotaItem.isHidden = true self.toggleCompletions.isHidden = true self.toggleIgnoreLanguage.isHidden = true + self.toggleNES.isHidden = true self.signOutItem.isHidden = false } @@ -391,6 +427,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.quotaItem.isHidden = true self.toggleCompletions.isHidden = false self.toggleIgnoreLanguage.isHidden = false + self.toggleNES.isHidden = false self.signOutItem.isHidden = false } @@ -477,6 +514,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } } + + @objc func dismissBYOKMessage() { + Task { + await Status.shared.updateCLSStatus(.normal, busy: false, message: "") + } + } } extension NSRunningApplication { @@ -491,6 +534,7 @@ extension NSRunningApplication { enum CLSMessageType { case chatLimitReached case completionLimitReached + case byokLimitedReached case other var summary: String { @@ -499,6 +543,8 @@ enum CLSMessageType { return "Monthly Chat Limit Reached" case .completionLimitReached: return "Monthly Completion Limit Reached" + case .byokLimitedReached: + return "BYOK Limit Reached" case .other: return "CLS Error" } @@ -526,6 +572,8 @@ func getCLSMessageSummary(_ message: String) -> CLSMessage { messageType = .chatLimitReached } else if message.contains("Completions limit reached") { messageType = .completionLimitReached + } else if message.contains("BYOK") { + messageType = .byokLimitedReached } else { messageType = .other } diff --git a/ExtensionService/Assets.xcassets/Agent.imageset/Agent.svg b/ExtensionService/Assets.xcassets/Agent.imageset/Agent.svg new file mode 100644 index 00000000..0d699645 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Agent.imageset/Agent.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/Agent.imageset/Contents.json b/ExtensionService/Assets.xcassets/Agent.imageset/Contents.json new file mode 100644 index 00000000..7154a326 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Agent.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Agent.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/ChatWindowBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ChatWindowBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..1ff6f10e --- /dev/null +++ b/ExtensionService/Assets.xcassets/ChatWindowBackgroundColor.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" : "0x24", + "green" : "0x24", + "red" : "0x24" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/ChatWindowEditorBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ChatWindowEditorBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..a68c9465 --- /dev/null +++ b/ExtensionService/Assets.xcassets/ChatWindowEditorBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x30", + "green" : "0x2A", + "red" : "0x29" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/DarkBlue.colorset/Contents.json b/ExtensionService/Assets.xcassets/DarkBlue.colorset/Contents.json new file mode 100644 index 00000000..9658cfff --- /dev/null +++ b/ExtensionService/Assets.xcassets/DarkBlue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "248", + "green" : "189", + "red" : "160" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "85", + "green" : "45", + "red" : "25" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json b/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json new file mode 100644 index 00000000..c66c7fae --- /dev/null +++ b/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "edit-sparkle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg b/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg new file mode 100644 index 00000000..9cff22ff --- /dev/null +++ b/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + 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/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/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/LightBlue.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json new file mode 100644 index 00000000..552c7769 --- /dev/null +++ b/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xE2", + "red" : "0xD4" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "110", + "green" : "67", + "red" : "46" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json new file mode 100644 index 00000000..1c8b1e91 --- /dev/null +++ b/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF0", + "green" : "0x74", + "red" : "0x35" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/LightGreen.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightGreen.colorset/Contents.json new file mode 100644 index 00000000..ef4b486e --- /dev/null +++ b/ExtensionService/Assets.xcassets/LightGreen.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "60", + "green" : "138", + "red" : "32" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3C", + "green" : "0x8A", + "red" : "0x20" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json new file mode 100644 index 00000000..f606b54c --- /dev/null +++ b/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.180", + "blue" : "0x26", + "green" : "0x1F", + "red" : "0x1B" + } + }, + "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 new file mode 100644 index 00000000..2f1e961f --- /dev/null +++ b/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "sparkle.svg", + "idiom" : "mac" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "sparkle_dark.svg", + "idiom" : "mac" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} 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/SubagentTurnBackground.colorset/Contents.json b/ExtensionService/Assets.xcassets/SubagentTurnBackground.colorset/Contents.json new file mode 100644 index 00000000..3bd5adce --- /dev/null +++ b/ExtensionService/Assets.xcassets/SubagentTurnBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.940", + "green" : "0.930", + "red" : "0.920" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.260", + "green" : "0.230", + "red" : "0.220" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json b/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json new file mode 100644 index 00000000..a1548aa0 --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "code-review-light.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "code-review-dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-dark.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-dark.svg new file mode 100644 index 00000000..39eea110 --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-light.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-light.svg new file mode 100644 index 00000000..b60a0982 --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + 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/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/README.md b/README.md index d9c550d1..ed31bd71 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# GitHub Copilot for Xcode +# GitHub Copilot for Xcode + +[GitHub Copilot](https://github.com/features/copilot) for Xcode is the leading AI coding assistant for Xcode developers, helping you code faster and smarter. Stay in flow with **inline completions** and get instant help through **chat support**—explaining code, answering questions, and suggesting improvements. When you need more, Copilot scales with advanced features like **Agent Mode, MCP Registry, Copilot Vision, Code Review, Custom Instructions, and more**, making your Xcode workflow more efficient and intelligent. -[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. ## 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 @@ -153,4 +153,4 @@ forum](https://github.com/orgs/community/discussions/categories/copilot). Thank you to @intitni for creating the original project that this is based on. Attributions can be found under About when running the app or in -[Credits.rtf](./Copilot%20for%20Xcode/Credits.rtf). \ No newline at end of file +[Credits.rtf](./Copilot%20for%20Xcode/Credits.rtf). diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 75211dae..c846d8be 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,12 +1,19 @@ -### GitHub Copilot for Xcode 0.40.0 +### GitHub Copilot for Xcode 0.45.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. +- Added new models: GPT-5.1, GPT-5.1-Codex, GPT-5.1-Codex-Mini, Claude Haiku 4.5, and Auto (preview). +- Added support for custom agents (preview). +- Introduced the built-in Plan agent (preview). +- Added support for subagent execution (preview). +- Added support for Next Edit Suggestions (preview). + +**💪 Improvements** + +- MCP servers now support dynamic OAuth setup for third-party authentication providers. +- Added a setting to configure the maximum number of tool requests allowed. **🛠️ 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. +- Fixed an issue that the terminal view in Agent conversation was clipped +- Fixed an issue that the Chat panel failed to recognize newly created workspaces. diff --git a/Server/package-lock.json b/Server/package-lock.json index e2d8b63e..f6edf895 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,18 +8,20 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.351.0", + "@github/copilot-language-server": "1.395.0", + "@github/copilot-language-server-darwin-arm64": "1.395.0", + "@github/copilot-language-server-darwin-x64": "1.395.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" }, "devDependencies": { "@types/node": "^22.15.17", - "copy-webpack-plugin": "^13.0.0", + "copy-webpack-plugin": "^13.0.1", "css-loader": "^7.1.2", "style-loader": "^4.0.0", "terser-webpack-plugin": "^5.3.14", - "ts-loader": "^9.5.2", + "ts-loader": "^9.5.4", "typescript": "^5.8.3", "webpack": "^5.99.9", "webpack-cli": "^6.0.1" @@ -36,17 +38,87 @@ } }, "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==", - "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", + "version": "1.395.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.395.0.tgz", + "integrity": "sha512-hlZSr2BMO4BXK2b7GiJIa6TZZEuzu5CTJUl/nVrucZYU5Zz1M5RVBErEqGtk5f2Nwvkrl7SwNQMFdP+2feXqgw==", + "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.395.0", + "@github/copilot-language-server-darwin-x64": "1.395.0", + "@github/copilot-language-server-linux-arm64": "1.395.0", + "@github/copilot-language-server-linux-x64": "1.395.0", + "@github/copilot-language-server-win32-x64": "1.395.0" } }, + "node_modules/@github/copilot-language-server-darwin-arm64": { + "version": "1.395.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.395.0.tgz", + "integrity": "sha512-s+2hNm04lcYmONZzRubbdE7t40iffc4pq9pHhrBaKFJkfzsuPw3V5sDahHVFi88WmePCua1DQHu7tYWwOhcZnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "os": [ + "darwin" + ] + }, + "node_modules/@github/copilot-language-server-darwin-x64": { + "version": "1.395.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.395.0.tgz", + "integrity": "sha512-YdWLRVCp1cmmjRJUzdCQudvF4xon37a3r9LlhF56FTpd1Z3j5jv+wMvnpCP8mObzh6NZb9LI8hG8cQxkSNDVhg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "os": [ + "darwin" + ] + }, + "node_modules/@github/copilot-language-server-linux-arm64": { + "version": "1.395.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.395.0.tgz", + "integrity": "sha512-7rRBcd4AkUhc3Mp1ysTSRV+hE8BYDyIbwi4x6g4TKPmn/u2tuy5rWulolASKIl70ZC0UJq7lrOWS1CG/8pYqjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@github/copilot-language-server-linux-x64": { + "version": "1.395.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.395.0.tgz", + "integrity": "sha512-3Te4bHu1om9QFMB+Ga4GD7vfhrwB4Dp7kvBINSYjxy3Rp5Z6l6nFefGBNiVXw0UkqGGuTkmN1ypEmebyT1o/NA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@github/copilot-language-server-win32-x64": { + "version": "1.395.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.395.0.tgz", + "integrity": "sha512-7RcLxCkqrV9S10jwxtWOS28JHXwXadG+JlugB/nU1tqCcafeeNyMa8lqZt3oIe382ShwZIJ/1Qce4bzPYt5vsA==", + "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", @@ -635,9 +707,9 @@ "license": "MIT" }, "node_modules/copy-webpack-plugin": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.0.tgz", - "integrity": "sha512-FgR/h5a6hzJqATDGd9YG41SeDViH+0bkHn6WNXCi5zKAZkeESeSxLySSsFLHqLEVCh0E+rITmCf0dusXWYukeQ==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz", + "integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1827,9 +1899,9 @@ } }, "node_modules/ts-loader": { - "version": "9.5.2", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.2.tgz", - "integrity": "sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==", + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", + "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/Server/package.json b/Server/package.json index 9bd5a961..956f6da6 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,18 +7,20 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.351.0", + "@github/copilot-language-server": "1.395.0", + "@github/copilot-language-server-darwin-arm64": "1.395.0", + "@github/copilot-language-server-darwin-x64": "1.395.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" }, "devDependencies": { "@types/node": "^22.15.17", - "copy-webpack-plugin": "^13.0.0", + "copy-webpack-plugin": "^13.0.1", "css-loader": "^7.1.2", "style-loader": "^4.0.0", "terser-webpack-plugin": "^5.3.14", - "ts-loader": "^9.5.2", + "ts-loader": "^9.5.4", "typescript": "^5.8.3", "webpack": "^5.99.9", "webpack-cli": "^6.0.1" diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 4c179941..eee16478 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -12,6 +12,8 @@ common issues: - [Extension Permission](#extension-permission) - Allows GitHub Copilot to integrate with Xcode - [Accessibility Permission](#accessibility-permission) - Enables real-time code suggestions - [Background Permission](#background-permission) - Allows extension to connect with host app + - [Files & Folders Permission](#files--folders-permission) - Allows GitHub Copilot for Xcode to access files and folders + - [Screen & System Audio Recording Permission](#screen--system-audio-recording-permission-optional) (Optional) - Allow GitHub Copilot for Xcode to capture screen when using Copilot Vision Please note that GitHub Copilot for Xcode may not work properly if any necessary permissions are missing. @@ -30,7 +32,8 @@ Or you can navigate to the permission manually depending on your OS version: | macOS | Location | | :--- | :--- | -| 15 | System Settings > General > Login Items > Extensions > Xcode Source Editor | +| 26 | System Settings > General > Login Items & Extensions > Extensions > By Category > Xcode Source Editor | +| 15 | System Settings > General > Login Items & Extensions > Extensions > Xcode Source Editor | | 13 & 14 | System Settings > Privacy & Security > Extensions > Xcode Source Editor | | 12 | System Preferences > Extensions | @@ -60,29 +63,71 @@ 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: | macOS | Location | | :--- | :--- | +| 26 | System Settings > General > Login Items & Extensions > App Background Activity | | 15 | System Settings > General > Login Items & Extensions > Allow in the Background | | 13 & 14 | System Settings > General > Login Items > Allow in the Background | Ensure that "GitHub Copilot for Xcode" is enabled in the list of allowed background items. Without this permission, the extension may not be able to properly communicate with the host app, which can result in inconsistent behavior or reduced functionality. +## Files & Folders Permission + +GitHub Copilot for Xcode needs permission to read your project’s files so it can: + +- Use actual file contents as contextual grounding when you ask questions in Ask and Agent mode (instead of generic language-only answers) +- Safely apply or preview multi-file edits in Agent modes (e.g. refactors, adding tests, updating related types) +- Improve precision by leveraging nearby code, patterns, and naming conventions + +

+ Files & Folders Permission +

+ +When first prompted macOS shows a dialog asking to allow access to folders. Click "Allow". +If you clicked "Don't Allow" or nothing appears: + +| macOS | Location | +| :--- | :--- | +| 13 & 14 & 15 & 26 | System Settings > Privacy & Security > Files and Folders | +| 12 | System Preferences > Security & Privacy > Privacy > Files and Folders | + +In the list, expand `GitHub Copilot for Xcode` and enable the toggles for any relevant locations (e.g. “Documents” if your repositories live there). If your code is elsewhere (e.g. `~/Developer`), macOS may instead prompt dynamically the next time Copilot tries to read those paths—accept when prompted. + +## Screen & System Audio Recording Permission (Optional) + +This permission is only needed if you choose to use Copilot Vision (screen-based context capture). + +Copilot does NOT require screen recording for standard inline suggestions, chat, or agent operations. + +

+ Screen & System Audio Recording Permission +

+ +This permission is typically granted automatically when you first use Copilot Vision and try to capture screen in GitHub Copilot for Xcode. You can also manually navigate to the background permission setting based on your macOS version: + +| macOS | Location | +| :--- | :--- | +| 14 & 15 & 26 | System Settings > Privacy & Security > Screen & System Audio Recording | +| 13 | System Settings > Privacy & Security > Screen Recording | +| 12 | System Preferences > Security & Privacy > Privacy > Screen Recording | + +Check `GitHub Copilot for Xcode` and restart the app. ## Logs -Logs can be found in `~/Library/Logs/GitHubCopilot/` the most recent log file +Logs can be found in `~/Library/Logs/GitHubCopilot/`. The most recent log file is: ``` diff --git a/Tool/Package.swift b/Tool/Package.swift index e7c4e9f3..b54bd789 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: "WorkspaceSuggestionService", targets: ["WorkspaceSuggestionService"]), .library(name: "WebContentExtractor", targets: ["WebContentExtractor"]), .library( name: "SuggestionProvider", @@ -64,7 +65,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. @@ -139,6 +141,7 @@ let package = Package( name: "SuggestionBasic", dependencies: [ "LanguageClient", + "AXExtension", .product(name: "Parsing", package: "swift-parsing"), .product(name: "CodableWrappers", package: "CodableWrappers"), ] @@ -194,6 +197,7 @@ let package = Package( .target( name: "SharedUIComponents", dependencies: [ + "AppKitExtension", "Highlightr", "Preferences", "SuggestionBasic", @@ -254,7 +258,7 @@ let package = Package( .target( name: "Status", - dependencies: ["Cache"] + dependencies: ["Cache", "Preferences"] ), .target( @@ -276,6 +280,8 @@ let package = Package( .testTarget(name: "SuggestionProviderTests", dependencies: ["SuggestionProvider"]), .target(name: "ConversationServiceProvider", dependencies: [ + "GitHelper", + "SuggestionBasic", .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), ]), @@ -313,6 +319,7 @@ let package = Package( "SystemUtils", "Workspace", "Persist", + "SuggestionProvider", .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] @@ -360,7 +367,17 @@ let package = Package( // MARK: - AppKitExtension - .target(name: "AppKitExtension") + .target(name: "AppKitExtension", dependencies: ["Logger"]), + + // MARK: - GitHelper + .target( + name: "GitHelper", + dependencies: [ + "Terminal", + .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol") + ] + ), + .testTarget(name: "GitHelperTests", dependencies: ["GitHelper"]) ] ) diff --git a/Tool/Sources/AXExtension/AXUIElement+Xcode.swift b/Tool/Sources/AXExtension/AXUIElement+Xcode.swift new file mode 100644 index 00000000..d9b1da9c --- /dev/null +++ b/Tool/Sources/AXExtension/AXUIElement+Xcode.swift @@ -0,0 +1,31 @@ +import AppKit +import Foundation + +// Extension for xcode specifically +public extension AXUIElement { + private static let XcodeWorkspaceWindowIdentifier = "Xcode.WorkspaceWindow" + + var isSourceEditor: Bool { + description == "Source Editor" + } + + var isEditorArea: Bool { + description == "editor area" + } + + var isXcodeWorkspaceWindow: Bool { + self.description == Self.XcodeWorkspaceWindowIdentifier || self.identifier == Self.XcodeWorkspaceWindowIdentifier + } + + var isXcodeOpenQuickly: Bool { + ["open_quickly"].contains(self.identifier) + } + + var isXcodeAlert: Bool { + ["alert"].contains(self.label) + } + + var isXcodeMenuBar: Bool { + ["menu bar", "menu bar item"].contains(self.description) + } +} diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index 1a790e20..e9b9ed3b 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -56,18 +56,6 @@ public extension AXUIElement { (try? copyValue(key: kAXLabelValueAttribute)) ?? "" } - var isSourceEditor: Bool { - description == "Source Editor" - } - - var isEditorArea: Bool { - description == "editor area" - } - - var isXcodeWorkspaceWindow: Bool { - description == "Xcode.WorkspaceWindow" || identifier == "Xcode.WorkspaceWindow" - } - var selectedTextRange: ClosedRange? { guard let value: AXValue = try? copyValue(key: kAXSelectedTextRangeAttribute) else { return nil } @@ -245,6 +233,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 +322,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/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift index 525b5c54..186d829d 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift @@ -3,6 +3,122 @@ import Foundation import Preferences import ConversationServiceProvider import TelemetryServiceProvider +import LanguageServerProtocol + +// Exported from `CopilotForXcodeKit`, as we need to modify the protocol for document change +public protocol CopilotForXcodeExtensionCapability { + associatedtype TheSuggestionService: SuggestionServiceType + associatedtype TheChatService: ChatServiceType + associatedtype ThePromptToCodeService: PromptToCodeServiceType + + /// The suggestion service. + /// + /// Provide a non nil value if the extension provides a suggestion service, even if + /// the extension is not yet ready to provide suggestions. + /// + /// If you don't have a suggestion service in this extension, simply ignore this property. + var suggestionService: TheSuggestionService? { get } + /// Not implemented yet. + var chatService: TheChatService? { get } + /// Not implemented yet. + var promptToCodeService: ThePromptToCodeService? { get } + + // MARK: Optional Methods + + /// Called when a workspace is opened. + /// + /// A workspace may have already been opened when the extension is activated. + /// Use ``HostServer/getExistedWorkspaces()`` to get all ``WorkspaceInfo`` instead. + func workspaceDidOpen(_ workspace: WorkspaceInfo) + + /// Called when a workspace is closed. + func workspaceDidClose(_ workspace: WorkspaceInfo) + + /// Called when a document is saved. + func workspace(_ workspace: WorkspaceInfo, didSaveDocumentAt documentURL: URL) + + /// Called when a document is closed. + /// + /// - note: Copilot for Xcode doesn't know that a document is closed. It use + /// some mechanism to detect if the document is closed which is inaccurate and could be delayed. + func workspace(_ workspace: WorkspaceInfo, didCloseDocumentAt documentURL: URL) + + /// Called when a document is opened. + /// + /// - note: Copilot for Xcode doesn't know that a document is opened. It use + /// some mechanism to detect if the document is opened which is inaccurate and could be delayed. + func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) + + /// Called when a document is changed. + /// + /// - attention: `content` could be nil if \ + /// • the document is too large \ + /// • the document is binary \ + /// • the document is git ignored \ + /// • the extension is not considered in-use by the host app \ + /// • the extension has no permission to access the file \ + /// \ + /// If you still want to access the file content in these cases, + /// you will have to access the file by yourself, or call ``HostServer/getDocument(at:)``. + func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String?, + contentChanges: [TextDocumentContentChangeEvent]? + ) + + /// Called occasionally to inform the extension how it is used in the app. + /// + /// The `usage` contains information like the current user-picked suggestion service, etc. + /// You can use this to determine if you would like to startup or dispose some resources. + /// + /// For example, if you are running a language server to provide suggestions, you may want to + /// kill the process when the suggestion service is no longer in use. + func extensionUsageDidChange(_ usage: ExtensionUsage) +} + +public extension CopilotForXcodeExtensionCapability { + func xcodeDidBecomeActive() {} + + func xcodeDidBecomeInactive() {} + + func xcodeDidSwitchEditor() {} + + func workspaceDidOpen(_: WorkspaceInfo) {} + + func workspaceDidClose(_: WorkspaceInfo) {} + + func workspace(_: WorkspaceInfo, didSaveDocumentAt _: URL) {} + + func workspace(_: WorkspaceInfo, didCloseDocumentAt _: URL) {} + + func workspace(_: WorkspaceInfo, didOpenDocumentAt _: URL) {} + + func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String?, + contentChanges: [TextDocumentContentChangeEvent]? = nil + ) {} + + func extensionUsageDidChange(_: ExtensionUsage) {} +} + +public extension CopilotForXcodeExtensionCapability +where TheSuggestionService == NoSuggestionService +{ + var suggestionService: TheSuggestionService? { nil } +} + +public extension CopilotForXcodeExtensionCapability +where ThePromptToCodeService == NoPromptToCodeService +{ + var promptToCodeService: ThePromptToCodeService? { nil } +} + +public extension CopilotForXcodeExtensionCapability where TheChatService == NoChatService { + var chatService: TheChatService? { nil } +} public typealias CopilotForXcodeCapability = CopilotForXcodeExtensionCapability & CopilotForXcodeChatCapability & CopilotForXcodeTelemetryCapability diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift index 3d4be7c1..14625052 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -56,36 +56,53 @@ public final class BuiltinExtensionConversationServiceProvider< } } - public func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws { + public func createConversation( + _ request: ConversationRequest, workspaceURL: URL? + ) async throws -> ConversationCreateResponse? { guard let conversationService else { Logger.service.error("Builtin chat service not found.") - return + return nil } guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") - return + return nil } - try await conversationService.createConversation(request, workspace: workspaceInfo) + return try await conversationService.createConversation(request, workspace: workspaceInfo) } - public func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws { + public func createTurn( + with conversationId: String, request: ConversationRequest, workspaceURL: URL? + ) async throws -> ConversationCreateResponse? { guard let conversationService else { Logger.service.error("Builtin chat service not found.") - return + return nil } guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") - return + return nil } - try await conversationService + return try await conversationService .createTurn( with: conversationId, request: request, workspace: workspaceInfo ) } + + public func deleteTurn(with conversationId: String, turnId: String, 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.deleteTurn(with: conversationId, turnId: turnId, workspace: workspaceInfo) + } public func stopReceivingMessage(_ workDoneToken: String, workspaceURL: URL?) async throws { guard let conversationService else { @@ -136,6 +153,19 @@ public final class BuiltinExtensionConversationServiceProvider< return (try? await conversationService.templates(workspace: workspaceInfo)) } + + public func modes() async throws -> [ConversationMode]? { + 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.modes(workspace: workspaceInfo)) + } public func models() async throws -> [CopilotModel]? { guard let conversationService else { @@ -171,4 +201,17 @@ public final class BuiltinExtensionConversationServiceProvider< return (try? await conversationService.agents(workspace: workspaceInfo)) } + + public func reviewChanges(_ changes: [ReviewChangesParams.Change]) 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, changes: changes)) + } } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift index f6234ddf..4b09aeef 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift @@ -29,8 +29,8 @@ public final class BuiltinExtensionSuggestionServiceProvider< self.extensionManager = extensionManager } - var service: CopilotForXcodeKit.SuggestionServiceType? { - extensionManager.extensions.first { $0 is T }?.suggestionService + var service: (SuggestionServiceType & NESSuggestionServiceType)? { + extensionManager.extensions.first { $0 is T }?.suggestionService as? (SuggestionServiceType & NESSuggestionServiceType) } struct BuiltinExtensionSuggestionServiceNotFoundError: Error, LocalizedError { @@ -47,25 +47,22 @@ public final class BuiltinExtensionSuggestionServiceProvider< Logger.service.error("Builtin suggestion service not found.") throw BuiltinExtensionSuggestionServiceNotFoundError() } + return try await service.getSuggestions( - .init( - fileURL: request.fileURL, - relativePath: request.relativePath, - language: .init( - rawValue: languageIdentifierFromFileURL(request.fileURL).rawValue - ) ?? .plaintext, - content: request.content, - originalContent: request.originalContent, - cursorPosition: .init( - line: request.cursorPosition.line, - character: request.cursorPosition.character - ), - tabSize: request.tabSize, - indentSize: request.indentSize, - usesTabsForIndentation: request.usesTabsForIndentation, - relevantCodeSnippets: request.relevantCodeSnippets.map { $0.converted } - ), - workspace: workspaceInfo + request.toCopilotForXcodeKitSuggestionRequest(), workspace: workspaceInfo + ).map { $0.converted } + } + + public func getNESSuggestions( + _ request: SuggestionProvider.SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async throws -> [SuggestionBasic.CodeSuggestion] { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + throw BuiltinExtensionSuggestionServiceNotFoundError() + } + return try await service.getNESSuggestions( + request.toCopilotForXcodeKitSuggestionRequest(), workspace: workspaceInfo ).map { $0.converted } } @@ -121,6 +118,26 @@ extension SuggestionProvider.SuggestionRequest { relevantCodeSnippets: relevantCodeSnippets.map(\.converted) ) } + + func toCopilotForXcodeKitSuggestionRequest() -> CopilotForXcodeKit.SuggestionRequest { + .init( + fileURL: self.fileURL, + relativePath: self.relativePath, + language: .init( + rawValue: languageIdentifierFromFileURL(self.fileURL).rawValue + ) ?? .plaintext, + content: self.content, + originalContent: self.originalContent, + cursorPosition: .init( + line: self.cursorPosition.line, + character: self.cursorPosition.character + ), + tabSize: self.tabSize, + indentSize: self.indentSize, + usesTabsForIndentation: self.usesTabsForIndentation, + relevantCodeSnippets: self.relevantCodeSnippets.map { $0.converted } + ) + } } extension SuggestionBasic.CodeSuggestion { diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift index a03c34d1..a79f0e00 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift @@ -1,5 +1,6 @@ import Foundation import Workspace +import LanguageServerProtocol public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { let extensionManager: BuiltinExtensionManager @@ -17,8 +18,12 @@ public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { notifySaveFile(filespace: filespace) } - override public func didUpdateFilespace(_ filespace: Filespace, content: String) { - notifyUpdateFile(filespace: filespace, content: content) + override public func didUpdateFilespace( + _ filespace: Filespace, + content: String, + contentChanges: [TextDocumentContentChangeEvent]? = nil + ) { + notifyUpdateFile(filespace: filespace, content: content, contentChanges: contentChanges) } override public func didCloseFilespace(_ fileURL: URL) { @@ -44,15 +49,20 @@ public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { } } - public func notifyUpdateFile(filespace: Filespace, content: String) { + public func notifyUpdateFile( + filespace: Filespace, + content: String, + contentChanges: [TextDocumentContentChangeEvent]? = nil + ) { Task { guard filespace.isTextReadable else { return } for ext in extensionManager.extensions { ext.workspace( .init(workspaceURL: workspaceURL, projectURL: projectRootURL), didUpdateDocumentAt: filespace.fileURL, - content: content - ) + content: content, + contentChanges: contentChanges + ) } } } diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift index bde4a954..bfd0efd0 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. @@ -8,10 +9,59 @@ public protocol ChatMemory { } public extension ChatMemory { - /// Append a message to the history. func appendMessage(_ message: ChatMessage) async { await mutateHistory { history in - if let index = history.firstIndex(where: { $0.id == message.id }) { + if let parentTurnId = message.parentTurnId { + history.removeAll { $0.id == message.id } + + guard let parentIndex = history.firstIndex(where: { $0.id == parentTurnId }) else { + return + } + + var parentMessage = history[parentIndex] + + if !message.editAgentRounds.isEmpty { + var parentRounds = parentMessage.editAgentRounds + + if let lastParentRoundIndex = parentRounds.indices.last { + var existingSubRounds = parentRounds[lastParentRoundIndex].subAgentRounds ?? [] + + for messageRound in message.editAgentRounds { + if let subIndex = existingSubRounds.firstIndex(where: { $0.roundId == messageRound.roundId }) { + existingSubRounds[subIndex].reply = existingSubRounds[subIndex].reply + messageRound.reply + + if let messageToolCalls = messageRound.toolCalls, !messageToolCalls.isEmpty { + var mergedToolCalls = existingSubRounds[subIndex].toolCalls ?? [] + for newToolCall in messageToolCalls { + 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 = progressMessage + } + if let error = newToolCall.error, !error.isEmpty { + mergedToolCalls[toolCallIndex].error = error + } + if let invokeParams = newToolCall.invokeParams { + mergedToolCalls[toolCallIndex].invokeParams = invokeParams + } + } else { + mergedToolCalls.append(newToolCall) + } + } + existingSubRounds[subIndex].toolCalls = mergedToolCalls + } + } else { + existingSubRounds.append(messageRound) + } + } + + parentRounds[lastParentRoundIndex].subAgentRounds = existingSubRounds + parentMessage.editAgentRounds = parentRounds + } + } + + history[parentIndex] = parentMessage + } else if let index = history.firstIndex(where: { $0.id == message.id }) { history[index].mergeMessage(with: message) } else { history.append(message) @@ -25,6 +75,15 @@ public extension ChatMemory { $0.removeAll { $0.id == id } } } + + /// Remove multiple messages from the history by their IDs. + func removeMessages(_ ids: [String]) async { + await mutateHistory { history in + history.removeAll { message in + ids.contains(message.id) + } + } + } /// Clear the history. func clearHistory() async { @@ -34,26 +93,19 @@ public extension ChatMemory { extension ChatMessage { mutating func mergeMessage(with message: ChatMessage) { - // merge content self.content = self.content + message.content - // merge references var seen = Set() - // without duplicated and keep order self.references = (self.references + message.references).filter { seen.insert($0).inserted } - // merge followUp self.followUp = message.followUp ?? self.followUp - // merge suggested title self.suggestedTitle = message.suggestedTitle ?? self.suggestedTitle - // merge error message self.errorMessages = self.errorMessages + message.errorMessages self.panelMessages = self.panelMessages + message.panelMessages - // merge steps if !message.steps.isEmpty { var mergedSteps = self.steps @@ -68,40 +120,110 @@ extension ChatMessage { self.steps = mergedSteps } - // 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 + self.editAgentRounds = mergedAgentRounds + } + + self.parentTurnId = message.parentTurnId ?? self.parentTurnId + + self.codeReviewRound = message.codeReviewRound + + self.fileEdits = mergeFileEdits(oldEdits: self.fileEdits, newEdits: message.fileEdits) + + self.turnStatus = message.turnStatus ?? self.turnStatus + + // merge modelName and billingMultiplier + self.modelName = message.modelName ?? self.modelName + self.billingMultiplier = message.billingMultiplier ?? self.billingMultiplier + } + + 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 + } + + if let newSubAgentRounds = newRound.subAgentRounds, !newSubAgentRounds.isEmpty { + var mergedSubRounds = mergedAgentRounds[index].subAgentRounds ?? [] + for newSubRound in newSubAgentRounds { + if let subIndex = mergedSubRounds.firstIndex(where: { $0.roundId == newSubRound.roundId }) { + mergedSubRounds[subIndex].reply = mergedSubRounds[subIndex].reply + newSubRound.reply + + if let subToolCalls = newSubRound.toolCalls, !subToolCalls.isEmpty { + var mergedSubToolCalls = mergedSubRounds[subIndex].toolCalls ?? [] + for newSubToolCall in subToolCalls { + if let toolCallIndex = mergedSubToolCalls.firstIndex(where: { $0.id == newSubToolCall.id }) { + mergedSubToolCalls[toolCallIndex].status = newSubToolCall.status + if let progressMessage = newSubToolCall.progressMessage, !progressMessage.isEmpty { + mergedSubToolCalls[toolCallIndex].progressMessage = newSubToolCall.progressMessage + } + if let error = newSubToolCall.error, !error.isEmpty { + mergedSubToolCalls[toolCallIndex].error = newSubToolCall.error + } + if let invokeParams = newSubToolCall.invokeParams { + mergedSubToolCalls[toolCallIndex].invokeParams = invokeParams + } + } else { + mergedSubToolCalls.append(newSubToolCall) + } } - } else { - mergedToolCalls.append(newToolCall) + mergedSubRounds[subIndex].toolCalls = mergedSubToolCalls } + } else { + mergedSubRounds.append(newSubRound) } - mergedAgentRounds[index].toolCalls = mergedToolCalls } - } else { - mergedAgentRounds.append(newRound) + mergedAgentRounds[index].subAgentRounds = mergedSubRounds } + } else { + mergedAgentRounds.append(newRound) + } + } + + return mergedAgentRounds + } + + private func mergeFileEdits(oldEdits: [FileEdit], newEdits: [FileEdit]) -> [FileEdit] { + var edits = oldEdits + + for newEdit in newEdits { + if let index = edits.firstIndex( + where: { $0.fileURL == newEdit.fileURL && $0.toolName == newEdit.toolName } + ) { + edits[index].modifiedContent = newEdit.modifiedContent + edits[index].status = newEdit.status + } else { + edits.append(newEdit) } - - self.editAgentRounds = mergedAgentRounds } + + return edits } } diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index 9706a4bd..6d205f5e 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -3,6 +3,37 @@ import Foundation import ConversationServiceProvider import GitHubCopilotService +public struct FileEdit: Equatable, Codable { + + public enum Status: String, Codable { + case none = "none" + case kept = "kept" + case undone = "undone" + } + + public let fileURL: URL + public let originalContent: String + public var modifiedContent: String + public var status: Status + + /// Different toolName, the different undo logic. Like `insert_edit_into_file` and `create_file` + public var toolName: ToolName + + public init( + fileURL: URL, + originalContent: String, + modifiedContent: String, + status: Status = .none, + toolName: ToolName + ) { + self.fileURL = fileURL + self.originalContent = originalContent + self.modifiedContent = modifiedContent + self.status = status + self.toolName = toolName + } +} + // move here avoid circular reference public struct ConversationReference: Codable, Equatable, Hashable { public enum Kind: Codable, Equatable, Hashable { @@ -21,18 +52,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,20 +85,29 @@ 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 = .file ) { self.uri = uri self.status = status self.kind = kind - + self.referenceType = referenceType } } +public enum RequestType: String, Equatable, Codable { + case conversation, codeReview +} + +public let HardCodedToolRoundExceedErrorMessage: String = "Oops, maximum tool attempts reached. You can update the max tool requests in settings." + public struct ChatMessage: Equatable, Codable { public typealias ID = String @@ -70,6 +115,12 @@ public struct ChatMessage: Equatable, Codable { case user case assistant case system + + public var isAssistant: Bool { self == .assistant } + } + + public enum TurnStatus: String, Codable, Equatable { + case inProgress, success, cancelled, error, waitForConfirmation } /// The role of a message. @@ -109,8 +160,25 @@ public struct ChatMessage: Equatable, Codable { public var editAgentRounds: [AgentRound] + public var parentTurnId: String? + public var panelMessages: [CopilotShowMessageParams] + public var codeReviewRound: CodeReviewRound? + + /// File edits performed during the current conversation turn. + /// Used as a checkpoint to track file modifications made by tools. + /// Note: Status changes (kept/undone) are tracked separately and not updated here. + public var fileEdits: [FileEdit] + + public var turnStatus: TurnStatus? + + public let requestType: RequestType + + // The model name used for the turn. + public var modelName: String? + public var billingMultiplier: Float? + /// The timestamp of the message. public var createdAt: Date public var updatedAt: Date @@ -129,7 +197,14 @@ public struct ChatMessage: Equatable, Codable { rating: ConversationRating = .unrated, steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], + parentTurnId: String? = nil, panelMessages: [CopilotShowMessageParams] = [], + codeReviewRound: CodeReviewRound? = nil, + fileEdits: [FileEdit] = [], + turnStatus: TurnStatus? = nil, + requestType: RequestType = .conversation, + modelName: String? = nil, + billingMultiplier: Float? = nil, createdAt: Date? = nil, updatedAt: Date? = nil ) { @@ -146,12 +221,93 @@ public struct ChatMessage: Equatable, Codable { self.rating = rating self.steps = steps self.editAgentRounds = editAgentRounds + self.parentTurnId = parentTurnId self.panelMessages = panelMessages + self.codeReviewRound = codeReviewRound + self.fileEdits = fileEdits + self.turnStatus = turnStatus + self.requestType = requestType + self.modelName = modelName + self.billingMultiplier = billingMultiplier 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] = [], + requestType: RequestType = .conversation + ) { + self.init( + id: id, + chatTabID: chatTabId, + role: .user, + content: content, + contentImageReferences: contentImageReferences, + references: references, + requestType: requestType + ) + } + + public init( + assistantMessageWithId id: String, // TurnId + chatTabID: String, + content: String = "", + references: [ConversationReference] = [], + followUp: ConversationFollowUp? = nil, + suggestedTitle: String? = nil, + steps: [ConversationProgressStep] = [], + editAgentRounds: [AgentRound] = [], + parentTurnId: String? = nil, + codeReviewRound: CodeReviewRound? = nil, + fileEdits: [FileEdit] = [], + turnStatus: TurnStatus? = nil, + requestType: RequestType = .conversation, + modelName: String? = nil, + billingMultiplier: Float? = nil + ) { + self.init( + id: id, + chatTabID: chatTabID, + clsTurnID: id, + role: .assistant, + content: content, + references: references, + followUp: followUp, + suggestedTitle: suggestedTitle, + steps: steps, + editAgentRounds: editAgentRounds, + parentTurnId: parentTurnId, + codeReviewRound: codeReviewRound, + fileEdits: fileEdits, + turnStatus: turnStatus, + requestType: requestType, + modelName: modelName, + billingMultiplier: billingMultiplier + ) + } + + 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/AgentModeToolHelpers.swift b/Tool/Sources/ConversationServiceProvider/AgentModeToolHelpers.swift new file mode 100644 index 00000000..4819cc8f --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/AgentModeToolHelpers.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Helper class for determining tool enabled state and interaction permissions based on agent mode +public final class AgentModeToolHelpers { + public static func makeConfigurationKey(serverName: String, toolName: String) -> String { + return "\(serverName)/\(toolName)" + } + + /// Determines if a tool should be enabled based on the selected agent mode + public static func isToolEnabledInMode( + configurationKey: String, + currentStatus: ToolStatus, + selectedMode: ConversationMode + ) -> Bool { + // For modes other than default agent mode, check if tool is in customTools list + if !selectedMode.isDefaultAgent { + guard let customTools = selectedMode.customTools else { + // If customTools is nil, no tools are enabled + return false + } + + // If customTools is empty, no tools are enabled + if customTools.isEmpty { + return false + } + + return customTools.contains(configurationKey) + } + + // For built-in modes (Agent, Plan, etc.), use tool's current status + return currentStatus == .enabled + } + + /// Determines if users should be allowed to interact with tool checkboxes + public static func isInteractionAllowed(selectedMode: ConversationMode) -> Bool { + // Allow interaction for built-in "Agent" mode and custom modes + if selectedMode.isDefaultAgent || !selectedMode.isBuiltIn { + return true + } + + // Disable interaction for other built-in modes (like Plan) + return false + } + + private init() {} +} diff --git a/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift b/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift new file mode 100644 index 00000000..945733b0 --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift @@ -0,0 +1,163 @@ +import Foundation +import LanguageServerProtocol +import GitHelper +import CopilotForXcodeKit + +extension WorkspaceInfo: @retroactive Equatable { + public static func ==(lhs: WorkspaceInfo, rhs: WorkspaceInfo) -> Bool { + return lhs.projectURL == rhs.projectURL + && lhs.workspaceURL == rhs.workspaceURL + } +} + +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 workspaceInfo: WorkspaceInfo? + + 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..0692fdbf 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -4,72 +4,147 @@ import CodableWrappers import LanguageServerProtocol public protocol ConversationServiceType { - func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws - func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws + func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws -> ConversationCreateResponse? + func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws -> ConversationCreateResponse? + func deleteTurn(with conversationId: String, turnId: String, workspace: WorkspaceInfo) async throws func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws func rateConversation(turnId: String, rating: ConversationRating, workspace: WorkspaceInfo) async throws func copyCode(request: CopyCodeRequest, workspace: WorkspaceInfo) async throws func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]? + func modes(workspace: WorkspaceInfo) async throws -> [ConversationMode]? 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 + func reviewChanges( + workspace: WorkspaceInfo, + changes: [ReviewChangesParams.Change] + ) async throws -> CodeReviewResult? } public protocol ConversationServiceProvider { - func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws - func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws + func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws -> ConversationCreateResponse? + func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws -> ConversationCreateResponse? + func deleteTurn(with conversationId: String, turnId: String, 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 modes() async throws -> [ConversationMode]? 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 + func reviewChanges(_ changes: [ReviewChangesParams.Change]) 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 + } } } @@ -266,10 +341,12 @@ 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 customChatModeId: String? = nil public var userLanguage: String? = nil public var turnId: String? = nil @@ -281,10 +358,12 @@ 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, + customChatModeId: String? = nil, userLanguage: String?, turnId: String? = nil ) { @@ -297,8 +376,10 @@ public struct ConversationRequest { self.ignoredSkills = ignoredSkills self.references = references self.model = model + self.modelProviderName = modelProviderName self.turns = turns self.agentMode = agentMode + self.customChatModeId = customChatModeId self.userLanguage = userLanguage self.turnId = turnId } @@ -384,11 +465,13 @@ public struct AgentRound: Codable, Equatable { public let roundId: Int public var reply: String public var toolCalls: [AgentToolCall]? + public var subAgentRounds: [AgentRound]? - public init(roundId: Int, reply: String, toolCalls: [AgentToolCall]? = []) { + public init(roundId: Int, reply: String, toolCalls: [AgentToolCall]? = [], subAgentRounds: [AgentRound]? = []) { self.roundId = roundId self.reply = reply self.toolCalls = toolCalls + self.subAgentRounds = subAgentRounds } } diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift index 63d44b32..289fcdbd 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift @@ -1,6 +1,7 @@ import Foundation import JSONRPC import LanguageServerProtocol +import SuggestionBasic // MARK: Conversation template public struct ChatTemplate: Codable, Equatable { @@ -63,6 +64,76 @@ 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: ChatModes +public enum ChatMode: String, Codable { + case Ask = "Ask" + case Edit = "Edit" + case Agent = "Agent" +} + +public struct ConversationMode: Codable, Equatable { + public let id: String + public let name: String + public let kind: ChatMode + public let isBuiltIn: Bool + public let uri: String? + public let description: String? + public let customTools: [String]? + public let model: String? + public let handOffs: [HandOff]? + + public var isDefaultAgent: Bool { id == "Agent" } + + public static let `defaultAgent` = ConversationMode( + id: "Agent", + name: "Agent", + kind: .Agent, + isBuiltIn: true, + description: "Advanced agent mode with access to tools and capabilities" + ) + + public init( + id: String, + name: String, + kind: ChatMode, + isBuiltIn: Bool, + uri: String? = nil, + description: String? = nil, + customTools: [String]? = nil, + model: String? = nil, + handOffs: [HandOff]? = nil + ) { + self.id = id + self.name = name + self.kind = kind + self.isBuiltIn = isBuiltIn + self.uri = uri + self.description = description + self.customTools = customTools + self.model = model + self.handOffs = handOffs + } +} + +public struct HandOff: Codable, Equatable { + public let agent: String + public let label: String + public let prompt: String + public let send: Bool? + + public init(agent: String, label: String, prompt: String, send: Bool?) { + self.agent = agent + self.label = label + self.prompt = prompt + self.send = send + } } // MARK: Conversation Agents @@ -90,6 +161,40 @@ public struct RegisterToolsParams: Codable, Equatable { } } +public struct UpdateToolsStatusParams: Codable, Equatable { + public let chatModeKind: ChatMode? + public let customChatModeId: String? + public let workspaceFolders: [WorkspaceFolder]? + public let tools: [ToolStatusUpdate] + + public init( + chatmodeKind: ChatMode? = nil, + customChatModeId: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil, + tools: [ToolStatusUpdate] + ) { + self.chatModeKind = chatmodeKind + self.customChatModeId = customChatModeId + self.workspaceFolders = workspaceFolders + 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 @@ -153,6 +258,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 @@ -342,3 +509,230 @@ 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 let workspaceFolders: [WorkspaceFolder]? + + public init(changes: [Change], workspaceFolders: [WorkspaceFolder]? = nil) { + self.changes = changes + self.workspaceFolders = workspaceFolders + } +} + +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 + } +} + + +// MARK: - Conversation / Turn + +public enum ConversationSource: String, Codable { + case panel, inline +} + +public struct FileReference: Codable, Equatable, Hashable { + public var type: String = "file" + public let uri: String + public let position: Position? + public let visibleRange: SuggestionBasic.CursorRange? + public let selection: SuggestionBasic.CursorRange? + public let openedAt: String? + public let activeAt: String? +} + +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) + } + } + + 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): + 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)) + } + } +} + +public struct ConversationCreateResponse: Codable { + public let conversationId: String + public let turnId: String + public let agentSlug: String? + public let modelName: String? + public let modelProviderName: String? + public let billingMultiplier: Float? +} + +public struct ConversationCreateParams: Codable { + public var workDoneToken: String + public var turns: [TurnSchema] + public var capabilities: Capabilities + public var textDocument: Doc? + public var references: [Reference]? + public var computeSuggestions: Bool? + public var source: ConversationSource? + public var workspaceFolder: String? + public var workspaceFolders: [WorkspaceFolder]? + public var ignoredSkills: [String]? + public var model: String? + public var modelProviderName: String? + public var chatMode: String? + public var customChatModeId: String? + public var needToolCallConfirmation: Bool? + public var userLanguage: String? + + public struct Capabilities: Codable { + public var skills: [String] + public var allSkills: Bool? + + public init(skills: [String], allSkills: Bool? = nil) { + self.skills = skills + self.allSkills = allSkills + } + } + + public init( + workDoneToken: String, + turns: [TurnSchema], + capabilities: Capabilities, + textDocument: Doc? = nil, + references: [Reference]? = nil, + computeSuggestions: Bool? = nil, + source: ConversationSource? = nil, + workspaceFolder: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil, + ignoredSkills: [String]? = nil, + model: String? = nil, + modelProviderName: String? = nil, + chatMode: String? = nil, + customChatModeId: String? = nil, + needToolCallConfirmation: Bool? = nil, + userLanguage: String? = nil + ) { + self.workDoneToken = workDoneToken + self.turns = turns + self.capabilities = capabilities + self.textDocument = textDocument + self.references = references + self.computeSuggestions = computeSuggestions + self.source = source + self.workspaceFolder = workspaceFolder + self.workspaceFolders = workspaceFolders + self.ignoredSkills = ignoredSkills + self.model = model + self.modelProviderName = modelProviderName + self.chatMode = chatMode + self.customChatModeId = customChatModeId + self.needToolCallConfirmation = needToolCallConfirmation + self.userLanguage = userLanguage + } +} + +// MARK: - ConversationErrorCode +public enum ConversationErrorCode: Int { + // -1: Unknown error, used when the error may not be user friendly. + case unknown = -1 + // 0: Default error code, for backward compatibility with Copilot Chat. + case `default` = 0 + case toolRoundExceedError = 10000 +} diff --git a/Tool/Sources/ConversationServiceProvider/PromptType.swift b/Tool/Sources/ConversationServiceProvider/PromptType.swift new file mode 100644 index 00000000..6a896746 --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/PromptType.swift @@ -0,0 +1,123 @@ +import Foundation + +public enum PromptType: String, CaseIterable, Equatable { + case instructions = "instructions" + case prompt = "prompt" + case agent = "agent" + + /// The directory name under .github where files of this type are stored + public var directoryName: String { + switch self { + case .instructions: + return "instructions" + case .prompt: + return "prompts" + case .agent: + return "agents" + } + } + + /// The file extension for this prompt type + public var fileExtension: String { + switch self { + case .instructions: + return ".instructions.md" + case .prompt: + return ".prompt.md" + case .agent: + return ".agent.md" + } + } + + /// Human-readable name for display purposes + public var displayName: String { + switch self { + case .instructions: + return "Instruction File" + case .prompt: + return "Prompt File" + case .agent: + return "Agent File" + } + } + + /// Human-readable name for settings + public var settingTitle: String { + switch self { + case .instructions: + return "Custom Instructions" + case .prompt: + return "Prompt Files" + case .agent: + return "Agent Files" + } + } + + /// Description for the prompt type + public 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 prompts. Trigger with '/' commands in the Chat view." + case .agent: + return "Configure `.github/agents/*.agent.md` files for autonomous agent tasks. Agents can perform multi-step operations." + } + } + + /// Default template content for new files + public 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: Prompt Description + --- + Define the task to achieve, including specific requirements, constraints, and success criteria. + + """ + case .agent: + return """ + --- + description: 'Describe what this custom agent does and when to use it.' + tools: [] + --- + Define what this custom agent accomplishes for the user, when to use it, and the edges it won't cross. Specify its ideal inputs/outputs, the tools it may call, and how it reports progress or asks for help. + + """ + } + } + + /// Get the help link for this prompt type. Requires the editor plugin version string. + public func helpLink(editorPluginVersion: String) -> String { + let version = editorPluginVersion == "0.0.0" ? "main" : editorPluginVersion + + switch self { + case .instructions: + return "https://github.com/github/CopilotForXcode/blob/\(version)/Docs/CustomInstructions.md" + case .prompt: + return "https://github.com/github/CopilotForXcode/blob/\(version)/Docs/PromptFiles.md" + case .agent: + return "https://github.com/github/CopilotForXcode/blob/\(version)/Docs/AgentFiles.md" + } + } + + /// Get the full file path for a given name and project URL + public 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 + public func getDirectoryPath(projectURL: URL) -> URL { + return projectURL.appendingPathComponent(".github/\(directoryName)") + } +} diff --git a/Tool/Sources/ConversationServiceProvider/ToolNames.swift b/Tool/Sources/ConversationServiceProvider/ToolNames.swift index 7b9d12c9..6040f4be 100644 --- a/Tool/Sources/ConversationServiceProvider/ToolNames.swift +++ b/Tool/Sources/ConversationServiceProvider/ToolNames.swift @@ -1,5 +1,5 @@ -public enum ToolName: String { +public enum ToolName: String, Codable { case runInTerminal = "run_in_terminal" case getTerminalOutput = "get_terminal_output" case getErrors = "get_errors" 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/DynamicOAuthRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/DynamicOAuthRequestHandler.swift new file mode 100644 index 00000000..977396c4 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Conversation/DynamicOAuthRequestHandler.swift @@ -0,0 +1,293 @@ +import AppKit +import Combine +import Foundation +import JSONRPC +import LanguageServerProtocol +import Logger + +public protocol DynamicOAuthRequestHandler { + func handleDynamicOAuthRequest( + _ request: DynamicOAuthRequest, + completion: @escaping (AnyJSONRPCResponse) -> Void + ) +} + +public final class DynamicOAuthRequestHandlerImpl: NSObject, DynamicOAuthRequestHandler { + public static let shared = DynamicOAuthRequestHandlerImpl() + + // MARK: - Constants + + private enum LayoutConstants { + static let containerWidth: CGFloat = 450 + static let fieldWidth: CGFloat = 330 + static let labelWidth: CGFloat = 100 + static let labelX: CGFloat = 4 + static let fieldX: CGFloat = 100 + + static let spacing: CGFloat = 8 + static let hintSpacing: CGFloat = 4 + static let labelHeight: CGFloat = 17 + static let fieldHeight: CGFloat = 28 + static let labelVerticalOffset: CGFloat = 6 + + static let hintFontSize: CGFloat = 11 + static let regularFontSize: CGFloat = 13 + } + + private enum Strings { + static let clientIdLabel = "Client ID *" + static let clientSecretLabel = "Client Secret" + static let clientIdPlaceholder = "OAuth client ID (azye39d...)" + static let clientSecretPlaceholder = "OAuth client secret (wer32o50f...) or leave it blank" + static let okButton = "OK" + static let cancelButton = "Cancel" + } + + public func handleDynamicOAuthRequest( + _ request: DynamicOAuthRequest, + completion: @escaping (AnyJSONRPCResponse) -> Void + ) { + guard let params = request.params else { return } + Logger.gitHubCopilot.debug("Received Dynamic OAuth Request: \(params)") + Task { @MainActor in + let response = self.dynamicOAuthRequestAlert(params) + let jsonResult = try? JSONEncoder().encode(response) + let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null + completion(AnyJSONRPCResponse(id: request.id, result: jsonValue)) + } + } + + @MainActor + func dynamicOAuthRequestAlert(_ params: DynamicOAuthParams) -> DynamicOAuthResponse? { + let alert = configureAlert(with: params) + let (clientIdField, clientSecretField) = createAccessoryView(for: alert, params: params) + + let modalResponse = alert.runModal() + + return handleAlertResponse( + modalResponse, + clientIdField: clientIdField, + clientSecretField: clientSecretField + ) + } + + // MARK: - Alert Configuration + + @MainActor + private func configureAlert(with params: DynamicOAuthParams) -> NSAlert { + let alert = NSAlert() + alert.messageText = params.header ?? params.title + alert.informativeText = params.detail + alert.alertStyle = .warning + alert.addButton(withTitle: Strings.okButton) + alert.addButton(withTitle: Strings.cancelButton) + return alert + } + + // MARK: - Accessory View Creation + + @MainActor + private func createAccessoryView( + for alert: NSAlert, + params: DynamicOAuthParams + ) -> (clientIdField: NSTextField, clientSecretField: NSSecureTextField) { + let (clientIdHint, clientIdHintHeight) = createHintLabel( + text: params.inputs.first(where: { $0.value == "clientId" })?.description ?? "" + ) + + let (clientSecretHint, clientSecretHintHeight) = createHintLabel( + text: params.inputs.first(where: { $0.value == "clientSecret" })?.description ?? "" + ) + + let totalHeight = calculateTotalHeight( + clientIdHintHeight: clientIdHintHeight, + clientSecretHintHeight: clientSecretHintHeight + ) + + let containerView = NSView(frame: NSRect( + x: 0, + y: 0, + width: LayoutConstants.containerWidth, + height: totalHeight + )) + + let clientIdField = NSTextField() + let clientSecretField = NSSecureTextField() + + layoutComponents( + in: containerView, + clientIdField: clientIdField, + clientSecretField: clientSecretField, + clientIdHint: clientIdHint, + clientSecretHint: clientSecretHint, + clientIdHintHeight: clientIdHintHeight, + clientSecretHintHeight: clientSecretHintHeight, + params: params + ) + + alert.accessoryView = containerView + + return (clientIdField, clientSecretField) + } + + // MARK: - Component Creation + + @MainActor + private func createHintLabel(text: String) -> (label: NSTextField, height: CGFloat) { + let hint = NSTextField(wrappingLabelWithString: text) + hint.font = NSFont.systemFont(ofSize: LayoutConstants.hintFontSize) + hint.textColor = NSColor.secondaryLabelColor + let height = hint.sizeThatFits(NSSize( + width: LayoutConstants.fieldWidth, + height: CGFloat.greatestFiniteMagnitude + )).height + return (hint, height) + } + + @MainActor + private func createInputField(placeholder: String) -> NSTextField { + let field = NSTextField() + field.placeholderString = placeholder + field.font = NSFont.systemFont(ofSize: LayoutConstants.regularFontSize) + field.isEditable = true + return field + } + + @MainActor + private func createSecureField(placeholder: String) -> NSSecureTextField { + let field = NSSecureTextField() + field.placeholderString = placeholder + field.font = NSFont.systemFont(ofSize: LayoutConstants.regularFontSize) + field.isEditable = true + return field + } + + @MainActor + private func createLabel(text: String) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = NSFont.systemFont(ofSize: LayoutConstants.regularFontSize) + label.alignment = .left + return label + } + + // MARK: - Layout + + private func calculateTotalHeight( + clientIdHintHeight: CGFloat, + clientSecretHintHeight: CGFloat + ) -> CGFloat { + return clientSecretHintHeight + LayoutConstants.hintSpacing + LayoutConstants.fieldHeight + + LayoutConstants.spacing + clientIdHintHeight + LayoutConstants.hintSpacing + + LayoutConstants.fieldHeight + } + + @MainActor + private func layoutComponents( + in containerView: NSView, + clientIdField: NSTextField, + clientSecretField: NSSecureTextField, + clientIdHint: NSTextField, + clientSecretHint: NSTextField, + clientIdHintHeight: CGFloat, + clientSecretHintHeight: CGFloat, + params: DynamicOAuthParams + ) { + var currentY: CGFloat = 0 + + // Client Secret section (bottom) + layoutFieldSection( + in: containerView, + field: clientSecretField, + label: createLabel(text: Strings.clientSecretLabel), + hint: clientSecretHint, + hintHeight: clientSecretHintHeight, + placeholder: params.inputs.first(where: { $0.value == "clientSecret" })?.placeholder ?? Strings.clientSecretPlaceholder, + currentY: ¤tY, + isLastSection: false + ) + + // Client ID section (top) + layoutFieldSection( + in: containerView, + field: clientIdField, + label: createLabel(text: Strings.clientIdLabel), + hint: clientIdHint, + hintHeight: clientIdHintHeight, + placeholder: params.inputs.first(where: { $0.value == "clientId" })?.placeholder ?? Strings.clientIdPlaceholder, + currentY: ¤tY, + isLastSection: true + ) + } + + @MainActor + private func layoutFieldSection( + in containerView: NSView, + field: NSTextField, + label: NSTextField, + hint: NSTextField, + hintHeight: CGFloat, + placeholder: String, + currentY: inout CGFloat, + isLastSection: Bool + ) { + // Position hint + hint.frame = NSRect( + x: LayoutConstants.fieldX, + y: currentY, + width: LayoutConstants.fieldWidth, + height: hintHeight + ) + currentY += hintHeight + LayoutConstants.hintSpacing + + // Position field + field.frame = NSRect( + x: LayoutConstants.fieldX, + y: currentY, + width: LayoutConstants.fieldWidth, + height: LayoutConstants.fieldHeight + ) + field.placeholderString = placeholder + + // Position label + label.frame = NSRect( + x: LayoutConstants.labelX, + y: currentY + LayoutConstants.labelVerticalOffset, + width: LayoutConstants.labelWidth, + height: LayoutConstants.labelHeight + ) + + // Add to container + containerView.addSubview(label) + containerView.addSubview(field) + containerView.addSubview(hint) + + if !isLastSection { + currentY += LayoutConstants.fieldHeight + LayoutConstants.spacing + } + } + + // MARK: - Response Handling + + private func handleAlertResponse( + _ response: NSApplication.ModalResponse, + clientIdField: NSTextField, + clientSecretField: NSSecureTextField + ) -> DynamicOAuthResponse? { + guard response == .alertFirstButtonReturn else { + return nil + } + + let clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !clientId.isEmpty else { + Logger.gitHubCopilot.info("Client ID is required but was not provided") + return nil + } + + let clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + + return DynamicOAuthResponse( + clientId: clientId, + clientSecret: clientSecret + ) + } +} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift index cf137aa3..ad2de6a7 100644 --- a/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift +++ b/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift @@ -1,22 +1,118 @@ import JSONRPC +import Foundation import Combine +import Logger +import AppKit +import LanguageServerProtocol +import UserNotifications public protocol ShowMessageRequestHandler { - var onShowMessage: PassthroughSubject<(ShowMessageRequest, (AnyJSONRPCResponse) -> Void), Never> { get } - func handleShowMessage( + func handleShowMessageRequest( _ request: ShowMessageRequest, - completion: @escaping ( - AnyJSONRPCResponse - ) -> Void + callback: @escaping @Sendable (Result>) async -> Void ) } -public final class ShowMessageRequestHandlerImpl: ShowMessageRequestHandler { +public final class ShowMessageRequestHandlerImpl: NSObject, ShowMessageRequestHandler, UNUserNotificationCenterDelegate { public static let shared = ShowMessageRequestHandlerImpl() - public let onShowMessage: PassthroughSubject<(ShowMessageRequest, (AnyJSONRPCResponse) -> Void), Never> = .init() + private var isNotificationSetup = false + + private override init() { + super.init() + } + + @MainActor + private func setupNotificationCenterIfNeeded() async { + guard !isNotificationSetup else { return } + guard Bundle.main.bundleIdentifier != nil else { + // Skip notification setup in test environment + return + } + + isNotificationSetup = true + UNUserNotificationCenter.current().delegate = self + _ = try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound]) + } - public func handleShowMessage(_ request: ShowMessageRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) { - onShowMessage.send((request, completion)) + public func handleShowMessageRequest( + _ request: ShowMessageRequest, + callback: @escaping @Sendable (Result>) async -> Void + ) { + guard let params = request.params else { return } + Logger.gitHubCopilot.debug("Received Show Message Request: \(params)") + Task { @MainActor in + await setupNotificationCenterIfNeeded() + + let actionCount = params.actions?.count ?? 0 + + // Use notification for messages with no action, alert for messages with actions + if actionCount == 0 { + await showMessageRequestNotification(params) + await callback(.success(nil)) + } else { + let selectedAction = showMessageRequestAlert(params) + await callback(.success(selectedAction)) + } + } + } + + @MainActor + func showMessageRequestNotification(_ params: ShowMessageRequestParams) async { + let content = UNMutableNotificationContent() + content.title = "GitHub Copilot for Xcode" + content.body = params.message + content.sound = .default + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + Logger.gitHubCopilot.error("Failed to show notification: \(error)") + } + } + + @MainActor + func showMessageRequestAlert(_ params: ShowMessageRequestParams) -> MessageActionItem? { + let alert = NSAlert() + + alert.messageText = "GitHub Copilot" + alert.informativeText = params.message + alert.alertStyle = params.type == .info ? .informational : .warning + + let actions = params.actions ?? [] + for item in actions { + alert.addButton(withTitle: item.title) + } + + let response = alert.runModal() + + // Map the button response to the corresponding action + // .alertFirstButtonReturn = 1000, .alertSecondButtonReturn = 1001, etc. + let buttonIndex = response.rawValue - NSApplication.ModalResponse.alertFirstButtonReturn.rawValue + + guard buttonIndex >= 0 && buttonIndex < actions.count else { + return nil + } + + return actions[buttonIndex] + } + + // MARK: - UNUserNotificationCenterDelegate + + // This method is called when a notification is delivered while the app is in the foreground + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Show the notification banner even when app is in foreground + completionHandler([.banner, .list, .badge, .sound]) } } diff --git a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift index 281b534d..73069562 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?) @@ -28,53 +29,118 @@ public final class WatchedFilesHandlerImpl: WatchedFilesHandler { 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(fileUris.prefix(batchSize).map { .hash(["uri": .string($0)]) }) - let jsonValue: JSONValue = .hash(["files": jsonResult]) - - completion(AnyJSONRPCResponse(id: request.id, result: jsonValue)) Task { - if fileUris.count > batchSize { - for startIndex in stride(from: batchSize, to: fileUris.count, by: batchSize) { + var sentCount = 0 + if params.partialResultToken != nil && fileUris.count > batchSize { + for startIndex in stride(from: 0, to: fileUris.count, by: batchSize) { let endIndex = min(startIndex + batchSize, fileUris.count) - let batch = Array(fileUris[startIndex.. ProgressParams? { + let copilotProgress = CopilotProgressParams(token: token, value: value) + + if let jsonData = try? JSONEncoder().encode(copilotProgress), + let progressParams = try? JSONDecoder().decode(ProgressParams.self, from: jsonData) { + return progressParams + } + return nil + } +} diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index 119278ee..24a79475 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -96,7 +96,8 @@ public final class GitHubCopilotExtension: BuiltinExtension { public func workspace( _ workspace: WorkspaceInfo, didUpdateDocumentAt documentURL: URL, - content: String? + content: String?, + contentChanges: [TextDocumentContentChangeEvent]? = nil ) { guard isLanguageServerInUse else { return } // check if file size is larger than 15MB, if so, return immediately @@ -113,7 +114,8 @@ public final class GitHubCopilotExtension: BuiltinExtension { try await service.notifyChangeTextDocument( fileURL: documentURL, content: content, - version: 0 + version: 0, + contentChanges: contentChanges ) } catch let error as ServerError { switch error { 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/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..2c530455 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift @@ -0,0 +1,108 @@ +import ConversationServiceProvider +import Foundation +import Logger + +public extension Notification.Name { + static let gitHubCopilotToolsDidChange = Notification + .Name("com.github.CopilotForXcode.CopilotToolsDidChange") + static let gitHubCopilotCustomAgentToolsDidChange = Notification + .Name("com.github.CopilotForXcode.CustomAgentToolsDidChange") +} + +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(previous.map { ($0.name, $0) }) { first, _ in first } + let incomingByName = Dictionary(tools.map { ($0.name, $0) }) { first, _ in first } + + 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/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index 29e33d35..d5e5c1c9 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -1,4 +1,5 @@ import Combine +import ConversationServiceProvider import Foundation import JSONRPC import LanguageClient @@ -29,12 +30,37 @@ public enum ServerError: LocalizedError { message: error.message, data: error.data) } + + static func decodingError(_ error: DecodingError) -> ServerError { + let message: String + + switch error { + case .typeMismatch(let type, let context): + message = "Type mismatch: Expected \(type). \(context.debugDescription)" + + case .valueNotFound(let type, let context): + message = "Value not found: Expected \(type). \(context.debugDescription)" + + case .keyNotFound(let key, let context): + message = "Key '\(key.stringValue)' not found. \(context.debugDescription)" + + case .dataCorrupted(let context): + message = "Data corrupted: \(context.debugDescription)" + + @unknown default: + message = error.localizedDescription + } + + return ServerError.serverError(code: -32700, message: message, data: nil) + } 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) + } else if let decodeError = error as? DecodingError { + return decodingError(decodeError) } return .unknownError(error) @@ -114,7 +140,7 @@ class CopilotLocalProcessServer { return } - if request.method == "getCompletionsCycling" { + if request.method == "getCompletionsCycling" || request.method == "textDocument/copilotInlineEdit" { Task { @MainActor [weak self] in self?.ongoingCompletionRequestIDs.append(request.id) } @@ -196,6 +222,9 @@ class CopilotLocalProcessServer { case "copilot/mcpRuntimeLogs": notificationPublisher.send(anyNotification) return true + case "policy/didChange": + notificationPublisher.send(anyNotification) + return true case "conversation/preconditionsNotification", "statusNotification": // Ignore return true @@ -244,6 +273,12 @@ extension CopilotLocalProcessServer: ServerConnection { } catch { throw ServerError.unableToSendNotification(error) } + case .clientProtocolProgress(let params): + do { + try await server.sendNotification(params, method: method) + } catch { + throw ServerError.unableToSendNotification(error) + } } } @@ -321,14 +356,18 @@ public struct CopilotDidChangeWatchedFilesParams: Codable, Hashable { public enum CopilotClientNotification { public enum Method: String { case workspaceDidChangeWatchedFiles = "workspace/didChangeWatchedFiles" + case protocolProgress = "$/progress" } case copilotDidChangeWatchedFiles(CopilotDidChangeWatchedFilesParams) + case clientProtocolProgress(ProgressParams) public var method: Method { switch self { case .copilotDidChangeWatchedFiles: return .workspaceDidChangeWatchedFiles + case .clientProtocolProgress: + return .protocolProgress } } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift index a2baecbc..bc0b017e 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift @@ -15,7 +15,12 @@ public class CopilotMCPToolManager { availableMCPServerTools = sortedMCPServerTools DispatchQueue.main.async { Logger.client.info("Notify about MCP tools change: \(getToolsSummary())") - DistributedNotificationCenter.default().post(name: .gitHubCopilotMCPToolsDidChange, object: nil) + DistributedNotificationCenter.default().postNotificationName( + .gitHubCopilotMCPToolsDidChange, + object: nil, + userInfo: nil, + deliverImmediately: true + ) } } @@ -45,7 +50,12 @@ public class CopilotMCPToolManager { public static func clearMCPTools() { availableMCPServerTools = [] DispatchQueue.main.async { - DistributedNotificationCenter.default().post(name: .gitHubCopilotMCPToolsDidChange, object: nil) + DistributedNotificationCenter.default().postNotificationName( + .gitHubCopilotMCPToolsDidChange, + object: nil, + userInfo: nil, + deliverImmediately: true + ) } } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 9453e54f..0134a636 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -1,9 +1,9 @@ +import ConversationServiceProvider import Foundation import JSONRPC import LanguageServerProtocol import Status import SuggestionBasic -import ConversationServiceProvider struct GitHubCopilotDoc: Codable { var source: String @@ -80,7 +80,7 @@ public func editorConfiguration(includeMCP: Bool) -> JSONValue { var authProvider: JSONValue? { let enterpriseURI = UserDefaults.shared.value(for: \.gitHubCopilotEnterpriseURI) - return .hash([ "uri": .string(enterpriseURI) ]) + return .hash(["uri": .string(enterpriseURI)]) } var mcp: JSONValue? { @@ -92,6 +92,15 @@ public func editorConfiguration(includeMCP: Bool) -> JSONValue { let instructions = UserDefaults.shared.value(for: \.globalCopilotInstructions) return .string(instructions) } + + var agent: JSONValue? { + var d: [String: JSONValue] = [:] + + let agentMaxToolCallingLoop = Double(UserDefaults.shared.value(for: \.agentMaxToolCallingLoop)) + d["maxToolCallingLoop"] = .number(agentMaxToolCallingLoop) + + return .hash(d) + } var d: [String: JSONValue] = [:] if let http { d["http"] = http } @@ -103,6 +112,7 @@ public func editorConfiguration(includeMCP: Bool) -> JSONValue { copilot["mcp"] = mcp } copilot["globalCopilotInstructions"] = customInstructions + copilot["agent"] = agent github["copilot"] = .hash(copilot) d["github"] = .hash(github) } @@ -135,7 +145,7 @@ enum GitHubCopilotRequest { .custom("checkStatus", .hash([:]), ClientRequest.NullHandler) } } - + struct CheckQuota: GitHubCopilotRequestType { typealias Response = GitHubCopilotQuotaInfo @@ -281,6 +291,20 @@ enum GitHubCopilotRequest { ]), ClientRequest.NullHandler) } } + + // MARK: - NES + + struct CopilotInlineEdit: GitHubCopilotRequestType { + typealias Response = CopilotInlineEditsResponse + + var params: CopilotInlineEditsParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("textDocument/copilotInlineEdit", dict, ClientRequest.NullHandler) + } + } struct NotifyShown: GitHubCopilotRequestType { struct Response: Codable {} @@ -328,7 +352,7 @@ enum GitHubCopilotRequest { // MARK: Conversation struct CreateConversation: GitHubCopilotRequestType { - struct Response: Codable {} + typealias Response = ConversationCreateResponse var params: ConversationCreateParams @@ -342,7 +366,7 @@ enum GitHubCopilotRequest { // MARK: Conversation turn struct CreateTurn: GitHubCopilotRequestType { - struct Response: Codable {} + typealias Response = ConversationCreateResponse var params: TurnCreateParams @@ -353,6 +377,18 @@ enum GitHubCopilotRequest { } } + struct DeleteTurn: GitHubCopilotRequestType { + struct Response: Codable {} + + var params: TurnDeleteParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/turnDelete", dict, ClientRequest.NullHandler) + } + } + // MARK: Conversation rating struct ConversationRating: GitHubCopilotRequestType { @@ -366,17 +402,37 @@ enum GitHubCopilotRequest { return .custom("conversation/rating", dict, ClientRequest.NullHandler) } } - + // MARK: Conversation templates struct GetTemplates: GitHubCopilotRequestType { typealias Response = Array + var params: ConversationTemplatesParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/templates", dict, ClientRequest.NullHandler) + } + } + + // MARK: Conversation Modes + + struct GetModes: GitHubCopilotRequestType { + typealias Response = Array + + var params: ConversationModesParams + 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/modes", dict, ClientRequest.NullHandler) } } + // MARK: Copilot Models + struct CopilotModels: GitHubCopilotRequestType { typealias Response = Array @@ -384,12 +440,12 @@ enum GitHubCopilotRequest { .custom("copilot/models", .hash([:]), ClientRequest.NullHandler) } } - + // MARK: MCP Tools - + struct UpdatedMCPToolsStatus: GitHubCopilotRequestType { typealias Response = Array - + var params: UpdateMCPToolsStatusParams var request: ClientRequest { @@ -398,9 +454,43 @@ enum GitHubCopilotRequest { return .custom("mcp/updateToolsStatus", dict, ClientRequest.NullHandler) } } - + + // 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) + } + } + + struct MCPRegistryGetAllowlist: GitHubCopilotRequestType { + typealias Response = GetMCPRegistryAllowlistResult + + var request: ClientRequest { + .custom("mcp/registry/getAllowlist", .hash([:]), ClientRequest.NullHandler) + } + } + // MARK: - Conversation Agents - + struct GetAgents: GitHubCopilotRequestType { typealias Response = Array @@ -409,8 +499,22 @@ enum GitHubCopilotRequest { } } + // 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 {} + typealias Response = Array var params: RegisterToolsParams @@ -421,6 +525,18 @@ enum GitHubCopilotRequest { } } + 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 struct CopyCode: GitHubCopilotRequestType { @@ -434,7 +550,7 @@ enum GitHubCopilotRequest { return .custom("conversation/copyCode", dict, ClientRequest.NullHandler) } } - + // MARK: Telemetry struct TelemetryException: GitHubCopilotRequestType { @@ -448,14 +564,87 @@ 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 public enum GitHubCopilotNotification { - public struct StatusNotification: Codable { - public enum StatusKind : String, Codable { + public enum StatusKind: String, Codable { case normal = "Normal" case error = "Error" case warning = "Warning" @@ -464,13 +653,13 @@ public enum GitHubCopilotNotification { public var clsStatus: CLSStatus.Status { switch self { case .normal: - .normal + .normal case .error: - .error + .error case .warning: - .warning + .warning case .inactive: - .inactive + .inactive } } } @@ -484,7 +673,6 @@ public enum GitHubCopilotNotification { } } - public struct MCPRuntimeNotification: Codable { public enum MCPRuntimeLogLevel: String, Codable { case Info = "info" @@ -497,10 +685,9 @@ public enum GitHubCopilotNotification { 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/GitHubCopilotRequestTypes/BYOK.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/BYOK.swift new file mode 100644 index 00000000..060eab88 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/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/GitHubCopilotRequestTypes/Conversation.swift similarity index 80% rename from Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift index 4c1ca9e7..95bff025 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift @@ -6,42 +6,6 @@ import ConversationServiceProvider import JSONRPC import Logger -enum ConversationSource: String, Codable { - case panel, inline -} - -public struct Reference: Codable, Equatable, Hashable { - public var type: String = "file" - public let uri: String - public let position: Position? - public let visibleRange: SuggestionBasic.CursorRange? - public let selection: SuggestionBasic.CursorRange? - public let openedAt: String? - public let activeAt: String? -} - -struct ConversationCreateParams: Codable { - var workDoneToken: String - var turns: [TurnSchema] - var capabilities: Capabilities - var textDocument: Doc? - var references: [Reference]? - var computeSuggestions: Bool? - var source: ConversationSource? - var workspaceFolder: String? - var workspaceFolders: [WorkspaceFolder]? - var ignoredSkills: [String]? - var model: String? - var chatMode: String? - var needToolCallConfirmation: Bool? - var userLanguage: String? - - struct Capabilities: Codable { - var skills: [String] - var allSkills: Bool? - } -} - // MARK: Conversation Progress public enum ConversationProgressKind: String, Codable { @@ -58,6 +22,7 @@ public struct ConversationProgressBegin: BaseConversationProgress { public let kind: ConversationProgressKind public let conversationId: String public let turnId: String + public let parentTurnId: String? } public struct ConversationProgressReport: BaseConversationProgress { @@ -66,9 +31,10 @@ 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]? + public let parentTurnId: String? } public struct ConversationProgressEnd: BaseConversationProgress { @@ -121,6 +87,13 @@ struct ConversationRatingParams: Codable { var source: ConversationSource? } +// MARK: Conversation templates +struct ConversationTemplatesParams: Codable { + var workspaceFolders: [WorkspaceFolder]? +} + +typealias ConversationModesParams = ConversationTemplatesParams + // MARK: Conversation turn struct TurnCreateParams: Codable { var workDoneToken: String @@ -131,12 +104,20 @@ struct TurnCreateParams: Codable { var ignoredSkills: [String]? var references: [Reference]? var model: String? + var modelProviderName: String? var workspaceFolder: String? var workspaceFolders: [WorkspaceFolder]? var chatMode: String? + var customChatModeId: String? var needToolCallConfirmation: Bool? } +struct TurnDeleteParams: Codable { + var conversationId: String + var turnId: String + var source: ConversationSource? +} + // MARK: Copy struct CopyCodeParams: Codable { @@ -167,6 +148,7 @@ public struct WatchedFilesParams: Codable { public var workspaceFolder: WorkspaceFolder public var excludeGitignoredFiles: Bool public var excludeIDEIgnoredFiles: Bool + public var partialResultToken: ProgressToken? } public typealias WatchedFilesRequest = JSONRPCRequest diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift similarity index 65% rename from Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift index 431ca5ed..17792a88 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift @@ -1,16 +1,13 @@ import Foundation import JSONRPC import LanguageServerProtocol +import ConversationServiceProvider public enum MCPServerStatus: String, Codable, Equatable, Hashable { case running = "running" case stopped = "stopped" case error = "error" -} - -public enum MCPToolStatus: String, Codable, Equatable, Hashable { - case enabled = "enabled" - case disabled = "disabled" + case blocked = "blocked" } public struct InputSchema: Codable, Equatable, Hashable { @@ -81,14 +78,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 ) { @@ -113,12 +110,20 @@ public struct MCPServerToolsCollection: Codable, Equatable, Hashable { public let status: MCPServerStatus public let tools: [MCPTool] public let error: String? + public let registryInfo: String? - public init(name: String, status: MCPServerStatus, tools: [MCPTool], error: String? = nil) { + public init( + name: String, + status: MCPServerStatus, + tools: [MCPTool], + error: String? = nil, + registryInfo: String? = nil + ) { self.name = name self.status = status self.tools = tools self.error = error + self.registryInfo = registryInfo } } @@ -132,9 +137,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 } @@ -151,11 +156,78 @@ public struct UpdateMCPToolsStatusServerCollection: Codable, Hashable { } public struct UpdateMCPToolsStatusParams: Codable, Hashable { + public var chatModeKind: ChatMode? + public var customChatModeId: String? + public var workspaceFolders: [WorkspaceFolder]? public var servers: [UpdateMCPToolsStatusServerCollection] - - public init(servers: [UpdateMCPToolsStatusServerCollection]) { + + public init( + chatModeKind: ChatMode? = nil, + customChatModeId: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil, + servers: [UpdateMCPToolsStatusServerCollection] + ) { + self.chatModeKind = chatModeKind + self.customChatModeId = customChatModeId + self.workspaceFolders = workspaceFolders self.servers = servers } } public typealias CopilotMCPToolsRequest = JSONRPCRequest + +public struct DynamicOAuthParams: Codable, Hashable { + public let title: String + public let header: String? + public let detail: String + public let inputs: [DynamicOAuthInput] + + public init( + title: String, + header: String?, + detail: String, + inputs: [DynamicOAuthInput] + ) { + self.title = title + self.header = header + self.detail = detail + self.inputs = inputs + } +} + +public struct DynamicOAuthInput: Codable, Hashable { + public let title: String + public let value: String + public let description: String + public let placeholder: String + public let required: Bool + + public init( + title: String, + value: String, + description: String, + placeholder: String, + required: Bool + ) { + self.title = title + self.value = value + self.description = description + self.placeholder = placeholder + self.required = required + } +} + +public typealias DynamicOAuthRequest = JSONRPCRequest + +public struct DynamicOAuthResponse: Codable, Hashable { + public let clientId: String + public let clientSecret: String + + public init( + clientId: String, + clientSecret: String + ) { + self.clientId = clientId + self.clientSecret = clientSecret + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift new file mode 100644 index 00000000..90abc560 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift @@ -0,0 +1,696 @@ +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? + + public init(url: String, source: String, id: String?, subfolder: String?) { + self.url = url + self.source = source + self.id = id + self.subfolder = subfolder + } + + 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, Hashable { + 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" + } + + // Implement Hashable + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(description) + hasher.combine(isRequired) + hasher.combine(format) + hasher.combine(value) + hasher.combine(isSecret) + hasher.combine(defaultValue) + hasher.combine(choices) + hasher.combine(valueHint) + hasher.combine(isRepeated) + } + + public static func == (lhs: PositionalArgument, rhs: PositionalArgument) -> Bool { + lhs.type == rhs.type && + lhs.description == rhs.description && + lhs.isRequired == rhs.isRequired && + lhs.format == rhs.format && + lhs.value == rhs.value && + lhs.isSecret == rhs.isSecret && + lhs.defaultValue == rhs.defaultValue && + lhs.choices == rhs.choices && + lhs.valueHint == rhs.valueHint && + lhs.isRepeated == rhs.isRepeated + } +} + +// MARK: - Named Argument + +public struct NamedArgument: ArgumentProtocol, Hashable { + 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" + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(name) + hasher.combine(description) + hasher.combine(isRequired) + hasher.combine(format) + hasher.combine(value) + hasher.combine(isSecret) + hasher.combine(defaultValue) + hasher.combine(choices) + hasher.combine(isRepeated) + } + + public static func == (lhs: NamedArgument, rhs: NamedArgument) -> Bool { + lhs.type == rhs.type && + lhs.name == rhs.name && + lhs.description == rhs.description && + lhs.isRequired == rhs.isRequired && + lhs.format == rhs.format && + lhs.value == rhs.value && + lhs.isSecret == rhs.isSecret && + lhs.defaultValue == rhs.defaultValue && + lhs.choices == rhs.choices && + lhs.isRepeated == rhs.isRepeated + } +} + +// MARK: - Argument Enum + +public enum Argument: Codable, Hashable { + 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, Hashable { + 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 init( + name: String, + description: String?, + isRequired: Bool?, + format: ArgumentFormat?, + value: String?, + isSecret: Bool?, + defaultValue: String?, + choices: [String]?, + variables: [String : Input]? + ) { + self.name = name + self.description = description + self.isRequired = isRequired + self.format = format + self.value = value + self.isSecret = isSecret + self.defaultValue = defaultValue + self.choices = choices + self.variables = variables + } + + enum CodingKeys: String, CodingKey { + case name, description, format, value, choices, variables + case isRequired = "is_required" + case isSecret = "is_secret" + case defaultValue = "default" + } + + // Implement Hashable + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(description) + hasher.combine(isRequired) + hasher.combine(format) + hasher.combine(value) + hasher.combine(isSecret) + hasher.combine(defaultValue) + hasher.combine(choices) + // Note: variables is excluded as Input would also need to be Hashable + } + + public static func == (lhs: KeyValueInput, rhs: KeyValueInput) -> Bool { + lhs.name == rhs.name && + lhs.description == rhs.description && + lhs.isRequired == rhs.isRequired && + lhs.format == rhs.format && + lhs.value == rhs.value && + lhs.isSecret == rhs.isSecret && + lhs.defaultValue == rhs.defaultValue && + lhs.choices == rhs.choices + // Note: variables is excluded as Input would also need to be Hashable + } +} + +// MARK: - Package + +public struct Package: Codable, Hashable { + 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]? + + public init( + registryType: String?, + registryBaseURL: String?, + identifier: String?, + version: String?, + fileSHA256: String?, + runtimeHint: String?, + runtimeArguments: [Argument]?, + packageArguments: [Argument]?, + environmentVariables: [KeyValueInput]? + ) { + self.registryType = registryType + self.registryBaseURL = registryBaseURL + self.identifier = identifier + self.version = version + self.fileSHA256 = fileSHA256 + self.runtimeHint = runtimeHint + self.runtimeArguments = runtimeArguments + self.packageArguments = packageArguments + self.environmentVariables = environmentVariables + } + + 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 streamableHttp = "streamable-http" + case http = "http" + case sse = "sse" + + public var displayText: String { + switch self { + case .streamableHttp: + return "Streamable HTTP" + case .http: + return "HTTP" + case .sse: + return "SSE" + } + } +} + +// MARK: - Remote + +public struct Remote: Codable, Hashable { + public let transportType: TransportType + public let url: String + public let headers: [KeyValueInput]? + + public init( + transportType: TransportType, + url: String, + headers: [KeyValueInput]? + ) { + self.transportType = transportType + self.url = url + self.headers = headers + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Try "transport_type" first, then fall back to "type" + transportType = try container.decodeIfPresent(TransportType.self, forKey: .transportTypePreferred) + ?? container.decode(TransportType.self, forKey: .transportType) + + url = try container.decode(String.self, forKey: .url) + headers = try container.decodeIfPresent([KeyValueInput].self, forKey: .headers) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(transportType, forKey: .transportTypePreferred) + try container.encode(url, forKey: .url) + try container.encodeIfPresent(headers, forKey: .headers) + } + + enum CodingKeys: String, CodingKey { + case url, headers + case transportType = "type" + case transportTypePreferred = "transport_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]? + + enum CodingKeys: String, CodingKey { + case tool, version + case buildInfo = "build_info" + } + + public init( + tool: String?, + version: String?, + buildInfo: BuildInfo?, + additionalProperties: [String: AnyCodable]? = nil + ) { + self.tool = tool + self.version = version + self.buildInfo = buildInfo + self.additionalProperties = additionalProperties + } + + 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)!) + } + } + } +} + +public struct BuildInfo: Codable { + public let commit: String? + public let timestamp: String? + public let pipelineID: String? + + public init(commit: String?, timestamp: String?, pipelineID: String?) { + self.commit = commit + self.timestamp = timestamp + self.pipelineID = pipelineID + } + + enum CodingKeys: String, CodingKey { + case commit, timestamp + case pipelineID = "pipeline_id" + } +} + +// MARK: - Official Meta + +public struct OfficialMeta: Codable { + public let id: String + public let publishedAt: String + public let updatedAt: String + public let isLatest: Bool + + public init( + id: String, + publishedAt: String, + updatedAt: String, + isLatest: Bool + ) { + self.id = id + self.publishedAt = publishedAt + self.updatedAt = updatedAt + self.isLatest = isLatest + } + + 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( + publisherProvided: PublisherProvidedMeta?, + official: OfficialMeta?, + additionalProperties: [String: AnyCodable]? = nil + ) { + self.publisherProvided = publisherProvided + self.official = official + self.additionalProperties = additionalProperties + } + + 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? + + public init( + name: String, + description: String, + status: ServerStatus?, + repository: Repository?, + version: String, + websiteURL: String?, + createdAt: String?, + updatedAt: String?, + schemaURL: String?, + packages: [Package]?, + remotes: [Remote]?, + meta: ServerMeta? + ) { + self.name = name + self.description = description + self.status = status + self.repository = repository + self.version = version + self.websiteURL = websiteURL + self.createdAt = createdAt + self.updatedAt = updatedAt + self.schemaURL = schemaURL + self.packages = packages + self.remotes = remotes + self.meta = meta + } + + 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/GitHubCopilotRequestTypes/MCPRegistryAllowlist.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistryAllowlist.swift new file mode 100644 index 00000000..39cf074f --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistryAllowlist.swift @@ -0,0 +1,83 @@ +import Foundation + +// MARK: - MCPRegistryOwner + +public struct MCPRegistryOwner: Codable, Hashable { + public let login: String + public let id: Int + public let type: String // "Business" (Enterprise) or "Organization" + public let parentLogin: String? + public let parentId: Int? + + enum CodingKeys: String, CodingKey { + case login + case id + case type + case parentLogin = "parent_login" + case parentId = "parent_id" + } + + public init(login: String, id: Int, type: String, parentLogin: String? = nil, parentId: Int? = nil) { + self.login = login + self.id = id + self.type = type + self.parentLogin = parentLogin + self.parentId = parentId + } +} + +// MARK: - RegistryAccess + +public enum RegistryAccess: String, Codable, Hashable { + case registryOnly = "registry_only" + case allowAll = "allow_all" +} + +// MARK: - McpRegistryEntry + +public struct MCPRegistryEntry: Codable, Hashable { + public let url: String + public let registryAccess: RegistryAccess + public let owner: MCPRegistryOwner + + enum CodingKeys: String, CodingKey { + case url + case registryAccess = "registry_access" + case owner + } + + public init(url: String, registryAccess: RegistryAccess, owner: MCPRegistryOwner) { + self.url = url + self.registryAccess = registryAccess + self.owner = owner + } +} + +// MARK: - GetMCPRegistryAllowlistResult + +/// Result schema for getMCPRegistryAllowlist method +public struct GetMCPRegistryAllowlistResult: Codable, Hashable { + public let mcpRegistries: [MCPRegistryEntry] + + enum CodingKeys: String, CodingKey { + case mcpRegistries = "mcp_registries" + } +} + +public struct MCPRegistryErrorData: Codable { + public let errorType: String + public let status: Int? + public let shouldRetry: Bool? + + enum CodingKeys: String, CodingKey { + case errorType + case status + case shouldRetry + } + + public init(errorType: String, status: Int? = nil, shouldRetry: Bool? = nil) { + self.errorType = errorType + self.status = status + self.shouldRetry = shouldRetry + } +} 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/GitHubCopilotRequestTypes/NES.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/NES.swift new file mode 100644 index 00000000..dd5a2d19 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/NES.swift @@ -0,0 +1,40 @@ +import SuggestionBasic +import LanguageServerProtocol + + +public struct CopilotInlineEditsParams: Codable { + public let textDocument: VersionedTextDocumentIdentifier + public let position: CursorPosition +} + +public struct CopilotInlineEdit: Codable { + public struct Command: Codable { + public let title: String + public let command: String + public let arguments: [String] + } + /** + * The new text for this edit. + */ + public let text: String + /** + * The text document this edit applies to including the version + * Uses the same schema as for completions: src + * + * "textDocument": { + * "uri": "file:///path/to/file", + * "version": 0 + * }, + * + */ + public let textDocument: VersionedTextDocumentIdentifier + public let range: CursorRange + /** + * Called by the client with workspace/executeCommand after accepting the next edit suggestion. + */ + public let command: Command? +} + +public struct CopilotInlineEditsResponse: Codable { + public let edits: [CopilotInlineEdit] +} 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 4ea5de5c..3002a66a 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -33,11 +33,21 @@ public protocol GitHubCopilotSuggestionServiceType { indentSize: Int, usesTabsForIndentation: Bool ) async throws -> [CodeSuggestion] + func getCopilotInlineEdit( + fileURL: URL, + content: String, + cursorPosition: CursorPosition + ) async throws -> [CodeSuggestion] func notifyShown(_ completion: CodeSuggestion) async func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int?) async func notifyRejected(_ completions: [CodeSuggestion]) async func notifyOpenTextDocument(fileURL: URL, content: String) async throws - func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws + func notifyChangeTextDocument( + fileURL: URL, + content: String, + version: Int, + contentChanges: [TextDocumentContentChangeEvent]? + ) async throws func notifyCloseTextDocument(fileURL: URL) async throws func notifySaveTextDocument(fileURL: URL) async throws func cancelRequest() async @@ -60,28 +70,35 @@ 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 + customChatModeId: String?, + userLanguage: String?) async throws -> ConversationCreateResponse func createTurn(_ message: MessageContent, workDoneToken: String, conversationId: String, turnId: String?, activeDoc: Doc?, ignoredSkills: [String]?, - references: [FileReference], + references: [ConversationAttachedReference], model: String?, + modelProviderName: String?, workspaceFolder: String, workspaceFolders: [WorkspaceFolder]?, - agentMode: Bool) async throws + agentMode: Bool, + customChatModeId: String?) async throws -> ConversationCreateResponse + func deleteTurn(conversationId: String, turnId: String) async throws 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 modes(workspaceFolders: [WorkspaceFolder]?) async throws -> [ConversationMode] 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 { @@ -145,6 +162,8 @@ public enum GitHubCopilotError: Error, LocalizedError { public extension Notification.Name { static let gitHubCopilotShouldRefreshEditorInformation = Notification .Name("com.github.CopilotForXcode.GitHubCopilotShouldRefreshEditorInformation") + static let githubCopilotAgentMaxToolCallingLoopDidChange = Notification + .Name("com.github.CopilotForXcode.GithubCopilotAgentMaxToolCallingLoopDidChange") } public class GitHubCopilotBaseService { @@ -190,6 +209,7 @@ public class GitHubCopilotBaseService { let watchedFiles = JSONValue( booleanLiteral: projectRootURL.path == "/" ? false : true ) + let enableSubagent = UserDefaults.shared.value(for: \.enableSubagent) #if DEBUG // Use local language server if set and available @@ -250,6 +270,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, @@ -267,8 +288,11 @@ public class GitHubCopilotBaseService { "copilotCapabilities": [ /// The editor has support for watching files over LSP "watchedFiles": watchedFiles, - "didChangeFeatureFlags": true - ] + "didChangeFeatureFlags": true, + "stateDatabase": true, + "subAgent": JSONValue(booleanLiteral: enableSubagent), + ], + "githubAppId": authAppId.map(JSONValue.string) ?? .null, ], capabilities: capabilities, trace: .off, @@ -286,41 +310,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() - } - } } @@ -414,6 +403,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, @@ -430,6 +443,8 @@ 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) { super.init(designatedServer: designatedServer) @@ -438,6 +453,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 != "/" { @@ -465,12 +482,12 @@ public final class GitHubCopilotService: 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 - } + self.serverRequestHandler.handleRequest( + id: id, + request, + workspaceURL: workspaceURL, + service: self + ) default: break } @@ -482,7 +499,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) @@ -581,8 +600,9 @@ public final class GitHubCopilotService: ) do { + let maxTry: Int = UserDefaults.shared.value(for: \.realtimeNESToggle) ? 1 : 5 try Task.checkCancellation() - return try await sendRequest() + return try await sendRequest(maxTry: maxTry) } catch let error as CancellationError { if ongoingTasks.isEmpty { await recoverContent() @@ -598,20 +618,65 @@ public final class GitHubCopilotService: return try await task.value } + + // MARK: - NES + @GitHubCopilotSuggestionActor + public func getCopilotInlineEdit( + fileURL: URL, + content: String, + cursorPosition: CursorPosition + ) async throws -> [CodeSuggestion] { + ongoingTasks.forEach { $0.cancel() } + ongoingTasks.removeAll() + await localProcessServer?.cancelOngoingTasks() + + do { + try? await notifyChangeTextDocument( + fileURL: fileURL, + content: content, + version: 1 + ) + + let completions = try await sendRequest( + GitHubCopilotRequest.CopilotInlineEdit( + params: CopilotInlineEditsParams( + textDocument: .init(uri: fileURL.absoluteString, version: 1), + position: cursorPosition + ) + )) + .edits + .compactMap { edit in + CodeSuggestion.init( + id: edit.command?.arguments.first ?? UUID().uuidString, + text: edit.text, + position: cursorPosition, + range: edit.range + ) + } + return completions + } catch { + Logger.gitHubCopilot.error("Failed to get copilot inline edit: \(error.localizedDescription)") + throw error + } + } @GitHubCopilotSuggestionActor - public func createConversation(_ message: MessageContent, - workDoneToken: String, - workspaceFolder: String, - workspaceFolders: [WorkspaceFolder]? = nil, - activeDoc: Doc?, - skills: [String], - ignoredSkills: [String]?, - references: [FileReference], - model: String?, - turns: [TurnSchema], - agentMode: Bool, - userLanguage: String?) async throws { + public func createConversation( + _ message: MessageContent, + workDoneToken: String, + workspaceFolder: String, + workspaceFolders: [WorkspaceFolder]? = nil, + activeDoc: Doc?, + skills: [String], + ignoredSkills: [String]?, + references: [ConversationAttachedReference], + model: String?, + modelProviderName: String?, + turns: [TurnSchema], + agentMode: Bool, + customChatModeId: String?, + userLanguage: String? + ) async throws -> ConversationCreateResponse { var conversationCreateTurns: [TurnSchema] = [] // invoke conversation history if turns.count > 0 { @@ -633,24 +698,19 @@ 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, + customChatModeId: customChatModeId, needToolCallConfirmation: true, userLanguage: userLanguage) do { - _ = try await sendRequest( + return try await sendRequest( GitHubCopilotRequest.CreateConversation(params: params)) } catch { print("Failed to create conversation. Error: \(error)") @@ -659,17 +719,21 @@ public final class GitHubCopilotService: } @GitHubCopilotSuggestionActor - public func createTurn(_ message: MessageContent, - workDoneToken: String, - conversationId: String, - turnId: String?, - activeDoc: Doc?, - ignoredSkills: [String]?, - references: [FileReference], - model: String?, - workspaceFolder: String, - workspaceFolders: [WorkspaceFolder]? = nil, - agentMode: Bool) async throws { + public func createTurn( + _ message: MessageContent, + workDoneToken: String, + conversationId: String, + turnId: String?, + activeDoc: Doc?, + ignoredSkills: [String]?, + references: [ConversationAttachedReference], + model: String?, + modelProviderName: String?, + workspaceFolder: String, + workspaceFolders: [WorkspaceFolder]? = nil, + agentMode: Bool, + customChatModeId: String? + ) async throws -> ConversationCreateResponse { do { let params = TurnCreateParams(workDoneToken: workDoneToken, conversationId: conversationId, @@ -677,32 +741,51 @@ 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, + customChatModeId: customChatModeId, needToolCallConfirmation: true) - _ = try await sendRequest( + return try await sendRequest( GitHubCopilotRequest.CreateTurn(params: params)) } catch { print("Failed to create turn. Error: \(error)") throw error } } + + @GitHubCopilotSuggestionActor + public func deleteTurn(conversationId: String, turnId: String) async throws { + do { + let params = TurnDeleteParams(conversationId: conversationId, turnId: turnId, source: .panel) + _ = try await sendRequest(GitHubCopilotRequest.DeleteTurn(params: params)) + } catch { + throw error + } + } @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(params: params) + ) + return response + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func modes(workspaceFolders: [WorkspaceFolder]? = nil) async throws -> [ConversationMode] { do { + let params = ConversationModesParams(workspaceFolders: workspaceFolders) let response = try await sendRequest( - GitHubCopilotRequest.GetTemplates() + GitHubCopilotRequest.GetModes(params: params) ) return response } catch { @@ -733,13 +816,38 @@ 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 { + 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 } @@ -757,6 +865,41 @@ 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 getMCPRegistryAllowlist() async throws -> GetMCPRegistryAllowlistResult { + do { + let response = try await sendRequest( + GitHubCopilotRequest.MCPRegistryGetAllowlist() + ) + return response + } catch { + throw error + } + } @GitHubCopilotSuggestionActor public func rateConversation(turnId: String, rating: ConversationRating) async throws { @@ -842,20 +985,18 @@ public final class GitHubCopilotService: public func notifyChangeTextDocument( fileURL: URL, content: String, - version: Int + version: Int, + contentChanges: [TextDocumentContentChangeEvent]? = nil ) async throws { - let uri = "file://\(fileURL.path)" + let uri = fileURL.absoluteString + let changes: [TextDocumentContentChangeEvent] = contentChanges ?? [.init(range: nil, rangeLength: nil, text: content)] // Logger.service.debug("Change \(uri), \(content.count)") try await server.sendNotification( .textDocumentDidChange( DidChangeTextDocumentParams( uri: uri, version: version, - contentChange: .init( - range: nil, - rangeLength: nil, - text: content - ) + contentChanges: changes ) ) ) @@ -929,6 +1070,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 @@ -1138,6 +1301,110 @@ 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 + } + + /// Refresh client tools by registering an empty list to get the latest tools from the server. + /// This is a workaround for the issue where server-side tools may not be ready when client tools are initially registered. + public static func refreshClientTools() async { + // Use the first available service since CopilotLanguageModelToolManager is shared + guard let service = services.first(where: { $0.projectRootURL.path != "/" }) else { + Logger.gitHubCopilot.error("No available service to refresh client tools") + return + } + + do { + // Capture previous snapshot to detect newly added tools only + let previousNames = Set((CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? []).map { $0.name }) + + // Register empty list to get the complete updated tool list from server + let refreshedTools = try await service.registerTools(tools: []) + CopilotLanguageModelToolManager.updateToolsStatus(refreshedTools) + Logger.gitHubCopilot.info("Refreshed client tools: \(refreshedTools.count) tools available (previous: \(previousNames.count))") + + // Restore status ONLY for newly added tools whose saved status differs. + if let savedJSON = AppState.shared.get(key: "languageModelToolsStatus"), + let data = try? JSONEncoder().encode(savedJSON), + let savedStatusList = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data), + !savedStatusList.isEmpty { + let refreshedByName = Dictionary(uniqueKeysWithValues: (CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? []).map { ($0.name, $0) }) + let newlyAddedNames = refreshedTools.map { $0.name }.filter { !previousNames.contains($0) } + if !newlyAddedNames.isEmpty { + let neededUpdates: [ToolStatusUpdate] = newlyAddedNames.compactMap { newName in + guard let saved = savedStatusList.first(where: { $0.name == newName }), + let current = refreshedByName[newName], current.status != saved.status else { return nil } + return saved + } + if !neededUpdates.isEmpty { + do { + let finalTools = try await service.updateToolsStatus(params: .init(tools: neededUpdates)) + CopilotLanguageModelToolManager.updateToolsStatus(finalTools) + Logger.gitHubCopilot.info("Restored statuses for newly added tools: \(neededUpdates.map{ $0.name }.joined(separator: ", "))") + } catch { + Logger.gitHubCopilot.error("Failed to restore newly added tool statuses: \(error)") + } + } + } + } + } catch { + Logger.gitHubCopilot.error("Failed to refresh client tools: \(error)") + } + } + + 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"), @@ -1192,10 +1459,11 @@ public final class GitHubCopilotService: 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)") + // Only remove from unrestored list after successful restoration + self.unrestoredMcpServers.removeAll { toRestore.contains($0) } CopilotMCPToolManager.updateMCPTools(tools) return } @@ -1239,6 +1507,128 @@ public final class GitHubCopilotService: let pathHash = String(workspacePath.hash.magnitude, radix: 36).prefix(6) return "\(workspaceName)-\(pathHash)" } + + public static func getProjectGithubCopilotService(for projectRootURL: URL) -> GitHubCopilotService? { + if let existingService = services.first(where: { $0.projectRootURL == projectRootURL }) { + return existingService + } else { + return nil + } + } + + 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.Merge3( + NotificationCenter.default.publisher(for: .gitHubCopilotShouldRefreshEditorInformation).map { _ in "editorInfo" }, + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.map { _ in "featureFlags" }, + DistributedNotificationCenter.default() + .publisher(for: .githubCopilotAgentMaxToolCallingLoopDidChange) + .map { _ in "agentMaxToolCallingLoop" } + ) + + 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 + } + + 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/LanguageServer/ServerNotificationHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift index 39c2c4a5..e7f9eba9 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift @@ -13,6 +13,7 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { var protocolProgressSubject: PassthroughSubject var conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared var featureFlagNotifier: FeatureFlagNotifier = FeatureFlagNotifierImpl.shared + var copilotPolicyNotifier: CopilotPolicyNotifier = CopilotPolicyNotifierImpl.shared init() { self.protocolProgressSubject = PassthroughSubject() @@ -44,6 +45,15 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { featureFlagNotifier.handleFeatureFlagNotification(didChangeFeatureFlagsParams) } break + case "policy/didChange": + if let data = try? JSONEncoder().encode(notification.params), + let policy = try? JSONDecoder().decode( + CopilotPolicy.self, + from: data + ) { + copilotPolicyNotifier.handleCopilotPolicyNotification(policy) + } + break default: break } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift index 897245f2..eb61fa50 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift @@ -1,6 +1,6 @@ -import Foundation -import ConversationServiceProvider import Combine +import ConversationServiceProvider +import Foundation import JSONRPC import LanguageClient import LanguageServerProtocol @@ -10,78 +10,112 @@ public typealias ResponseHandler = ServerRequest.Handler public typealias LegacyResponseHandler = (AnyJSONRPCResponse) -> Void protocol ServerRequestHandler { - func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping ResponseHandler, service: GitHubCopilotService?) + func handleRequest(id: JSONId, _ request: ServerRequest, workspaceURL: URL, service: GitHubCopilotService?) } -class ServerRequestHandlerImpl : ServerRequestHandler { +class ServerRequestHandlerImpl: ServerRequestHandler { public static let shared = ServerRequestHandlerImpl() private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared private let showMessageRequestHandler: ShowMessageRequestHandler = ShowMessageRequestHandlerImpl.shared + private let dynamicOAuthRequestHandler: DynamicOAuthRequestHandler = DynamicOAuthRequestHandlerImpl.shared + + func handleRequest(id: JSONId, _ request: ServerRequest, workspaceURL: URL, service: GitHubCopilotService?) { + switch request { + case let .windowShowMessageRequest(params, callback): + if workspaceURL.path != "/" { + do { + let paramsData = try JSONEncoder().encode(params) + let showMessageRequestParams = try JSONDecoder().decode(ShowMessageRequestParams.self, from: paramsData) - 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": - let params = try JSONEncoder().encode(request.params) - let contextParams = try JSONDecoder().decode(ConversationContextParams.self, from: params) - conversationContextHandler.handleConversationContext( - ConversationContextRequest(id: request.id, method: request.method, params: contextParams), - 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: legacyResponseHandler, service: service) - - case "window/showMessageRequest": - let params = try JSONEncoder().encode(request.params) - let showMessageRequestParams = try JSONDecoder().decode(ShowMessageRequestParams.self, from: params) - showMessageRequestHandler - .handleShowMessage( + showMessageRequestHandler.handleShowMessageRequest( ShowMessageRequest( - id: request.id, - method: request.method, + id: id, + method: "window/showMessageRequest", params: showMessageRequestParams ), + callback: callback + ) + } catch { + Task { + await callback(.success(nil)) + } + } + } + + case let .custom(method, params, callback): + let legacyResponseHandler = toLegacyResponseHandler(callback) + do { + switch method { + case "conversation/context": + let paramsData = try JSONEncoder().encode(params) + let contextParams = try JSONDecoder().decode(ConversationContextParams.self, from: paramsData) + conversationContextHandler.handleConversationContext( + ConversationContextRequest(id: id, method: method, params: contextParams), + completion: legacyResponseHandler + ) + + case "copilot/watchedFiles": + let paramsData = try JSONEncoder().encode(params) + let watchedFilesParams = try JSONDecoder().decode(WatchedFilesParams.self, from: paramsData) + watchedFilesHandler.handleWatchedFiles( + WatchedFilesRequest(id: id, method: method, params: watchedFilesParams), + workspaceURL: workspaceURL, + completion: legacyResponseHandler, + service: service + ) + + case "conversation/invokeClientTool": + let paramsData = try JSONEncoder().encode(params) + let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: paramsData) + ClientToolHandlerImpl.shared.invokeClientTool( + InvokeClientToolRequest(id: id, method: method, params: invokeParams), 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: legacyResponseHandler) + case "conversation/invokeClientToolConfirmation": + let paramsData = try JSONEncoder().encode(params) + let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: paramsData) + ClientToolHandlerImpl.shared.invokeClientToolConfirmation( + InvokeClientToolConfirmationRequest(id: id, method: 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: legacyResponseHandler) + case "copilot/dynamicOAuth": + let paramsData = try JSONEncoder().encode(params) + let dynamicOAuthParams = try JSONDecoder().decode(DynamicOAuthParams.self, from: paramsData) + DynamicOAuthRequestHandlerImpl.shared.handleDynamicOAuthRequest( + DynamicOAuthRequest(id: id, method: method, params: dynamicOAuthParams), + completion: legacyResponseHandler + ) - default: - break + default: + break + } + } catch { + handleError(id: id, method: method, error: error, callback: legacyResponseHandler) } - } catch { - handleError(request, error: error, callback: legacyResponseHandler) + + default: + break } } - - private func handleError(_ request: AnyJSONRPCRequest, error: Error, callback: @escaping (AnyJSONRPCResponse) -> Void) { + + private func handleError(id: JSONId, method: String, error: Error, callback: @escaping (AnyJSONRPCResponse) -> Void) { callback( AnyJSONRPCResponse( - id: request.id, + id: id, result: JSONValue.array([ JSONValue.null, JSONValue.hash([ - "code": .number(-32602/* Invalid params */), - "message": .string("Error: \(error.localizedDescription)")]) + "code": .number(-32602 /* Invalid params */ ), + "message": .string("Error handling \(method): \(error.localizedDescription)")]), ]) ) ) Logger.gitHubCopilot.error(error) } - + /// Converts a new Handler to work with old code that expects LegacyResponseHandler private func toLegacyResponseHandler( _ newHandler: @escaping ResponseHandler diff --git a/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift new file mode 100644 index 00000000..e1d07f4c --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift @@ -0,0 +1,51 @@ +import Combine +import SwiftUI +import JSONRPC + +public extension Notification.Name { + static let gitHubCopilotPolicyDidChange = Notification + .Name("com.github.CopilotForXcode.CopilotPolicyDidChange") +} + +public struct CopilotPolicy: Hashable, Codable { + public var mcpContributionPointEnabled: Bool = true + public var customAgentEnabled: Bool = true + public var subagentEnabled: Bool = true + public var cveRemediatorAgentEnabled: Bool = true + + enum CodingKeys: String, CodingKey { + case mcpContributionPointEnabled = "mcp.contributionPoint.enabled" + case customAgentEnabled = "customAgent.enabled" + case subagentEnabled = "subagent.enabled" + case cveRemediatorAgentEnabled = "cveRemediatorAgent.enabled" + } +} + +public protocol CopilotPolicyNotifier { + var copilotPolicy: CopilotPolicy { get } + var policyDidChange: PassthroughSubject { get } + func handleCopilotPolicyNotification(_ policy: CopilotPolicy) +} + +public class CopilotPolicyNotifierImpl: CopilotPolicyNotifier { + public private(set) var copilotPolicy: CopilotPolicy + public static let shared = CopilotPolicyNotifierImpl() + public var policyDidChange: PassthroughSubject + + init( + copilotPolicy: CopilotPolicy = CopilotPolicy(), + policyDidChange: PassthroughSubject = PassthroughSubject() + ) { + self.copilotPolicy = copilotPolicy + self.policyDidChange = policyDidChange + } + + public func handleCopilotPolicyNotification(_ policy: CopilotPolicy) { + self.copilotPolicy = policy + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.policyDidChange.send(self.copilotPolicy) + DistributedNotificationCenter.default().post(name: .gitHubCopilotPolicyDidChange, object: nil) + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift index 2008b33c..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 { @@ -30,6 +31,9 @@ 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 byok: Bool + public var editorPreviewFeatures: Bool public var activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags public init( @@ -40,6 +44,9 @@ public struct FeatureFlags: Hashable, Codable { projectContext: Bool = true, agentMode: Bool = true, mcp: Bool = true, + ccr: Bool = true, + byok: Bool = true, + editorPreviewFeatures: Bool = true, activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags = [:] ) { self.restrictedTelemetry = restrictedTelemetry @@ -49,6 +56,9 @@ public struct FeatureFlags: Hashable, Codable { self.projectContext = projectContext self.agentMode = agentMode self.mcp = mcp + self.ccr = ccr + self.byok = byok + self.editorPreviewFeatures = editorPreviewFeatures self.activeExperimentForFeatureFlags = activeExperimentForFeatureFlags } } @@ -66,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() ) { @@ -84,6 +99,9 @@ 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.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 fc86e530..b99c854f 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -40,8 +40,10 @@ public final class GitHubCopilotConversationService: ConversationServiceType { return message } - public func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws { - guard let service = await serviceLocator.getService(from: workspace) else { return } + public func createConversation( + _ request: ConversationRequest, workspace: WorkspaceInfo + ) async throws -> ConversationCreateResponse? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } let message = getMessageContent(request) @@ -54,13 +56,17 @@ public final class GitHubCopilotConversationService: ConversationServiceType { ignoredSkills: request.ignoredSkills, references: request.references ?? [], model: request.model, + modelProviderName: request.modelProviderName, turns: request.turns, agentMode: request.agentMode, + customChatModeId: request.customChatModeId, userLanguage: request.userLanguage) } - public func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws { - guard let service = await serviceLocator.getService(from: workspace) else { return } + public func createTurn( + with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo + ) async throws -> ConversationCreateResponse? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } let message = getMessageContent(request) @@ -72,9 +78,17 @@ 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) + agentMode: request.agentMode, + customChatModeId: request.customChatModeId) + } + + public func deleteTurn(with conversationId: String, turnId: String, workspace: WorkspaceInfo) async throws { + guard let service = await serviceLocator.getService(from: workspace) else { return } + + return try await service.deleteTurn(conversationId: conversationId, turnId: turnId) } public func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws { @@ -95,7 +109,19 @@ 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() + let isPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures + let workspaceFolders = isPreviewEnabled ? getWorkspaceFolders(workspace: workspace) : nil + return try await service.templates(workspaceFolders: workspaceFolders) + } + + public func modes(workspace: WorkspaceInfo) async throws -> [ConversationMode]? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } + let isPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures + let isCustomAgentEnabled = CopilotPolicyNotifierImpl.shared.copilotPolicy.customAgentEnabled + let workspaceFolders = isPreviewEnabled && isCustomAgentEnabled ? getWorkspaceFolders( + workspace: workspace + ) : nil + return try await service.modes(workspaceFolders: workspaceFolders) } public func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]? { @@ -115,5 +141,18 @@ 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, + changes: [ReviewChangesParams.Change] + ) async throws -> CodeReviewResult? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } + + return try await service + .reviewChanges(params: .init( + changes: changes, + workspaceFolders: getWorkspaceFolders(workspace: workspace)) + ) + } } diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift index f9f8a9b5..b135fb65 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift @@ -2,8 +2,9 @@ import CopilotForXcodeKit import Foundation import SuggestionBasic import Workspace +import SuggestionProvider -public final class GitHubCopilotSuggestionService: SuggestionServiceType { +public final class GitHubCopilotSuggestionService: SuggestionServiceType, NESSuggestionServiceType { public var configuration: SuggestionServiceConfiguration { .init( acceptsRelevantCodeSnippets: true, @@ -19,7 +20,7 @@ public final class GitHubCopilotSuggestionService: SuggestionServiceType { } public func getSuggestions( - _ request: SuggestionRequest, + _ request: CopilotForXcodeKit.SuggestionRequest, workspace: WorkspaceInfo ) async throws -> [CopilotForXcodeKit.CodeSuggestion] { guard let service = await serviceLocator.getService(from: workspace) else { return [] } @@ -36,6 +37,21 @@ public final class GitHubCopilotSuggestionService: SuggestionServiceType { usesTabsForIndentation: request.usesTabsForIndentation ).map(Self.convert) } + + public func getNESSuggestions( + _ request: CopilotForXcodeKit.SuggestionRequest, + workspace: WorkspaceInfo + ) async throws -> [CopilotForXcodeKit.CodeSuggestion] { + guard let service = await serviceLocator.getService(from: workspace) else { return [] } + + return try await service + .getCopilotInlineEdit( + fileURL: request.fileURL, + content: request.content, + cursorPosition: .init(line: request.cursorPosition.line, character: request.cursorPosition.character) + ) + .map(Self.convert) + } public func notifyAccepted( _ suggestion: CopilotForXcodeKit.CodeSuggestion, diff --git a/Tool/Sources/HostAppActivator/HostAppActivator.swift b/Tool/Sources/HostAppActivator/HostAppActivator.swift index 81658337..64afc07a 100644 --- a/Tool/Sources/HostAppActivator/HostAppActivator.swift +++ b/Tool/Sources/HostAppActivator/HostAppActivator.swift @@ -7,8 +7,14 @@ 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") + static let openAdvancedSettingsWindowRequest = Notification + .Name("com.github.CopilotForXcode.OpenAdvancedSettingsWindowRequest") + static let selectedAgentSubModeDidChange = Notification + .Name("com.github.CopilotForXcode.SelectedAgentSubModeDidChange") } public enum GitHubCopilotForXcodeSettingsLaunchError: Error, LocalizedError { @@ -54,7 +60,7 @@ public func launchHostAppSettings() throws { } } -public func launchHostAppMCPSettings() throws { +public func launchHostAppToolsSettings(currentAgentSubMode: String) throws { // Try the AppleScript approach first, but only if app is already running if let hostApp = getRunningHostApp() { let activated = hostApp.activate(options: [.activateIgnoringOtherApps]) @@ -63,14 +69,63 @@ public func launchHostAppMCPSettings() throws { _ = tryLaunchWithAppleScript() DistributedNotificationCenter.default().postNotificationName( - .openMCPSettingsWindowRequest, + .openToolsSettingsWindowRequest, object: nil ) + + // Notify settings app of current agent submode + DistributedNotificationCenter.default().postNotificationName( + .selectedAgentSubModeDidChange, + object: nil, + userInfo: ["agentSubMode": currentAgentSubMode], + options: .deliverImmediately + ) + 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"]) + } +} + +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"]) + } +} + +public func launchHostAppAdvancedSettings() 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( + .openAdvancedSettingsWindowRequest, + object: nil + ) + Logger.ui.info("\(hostAppName()) Advanced settings notification sent after activation") + return + } else { + // If app is not running, launch it with the settings flag + try launchHostAppWithArgs(args: ["--advanced"]) } } @@ -141,3 +196,5 @@ func hostAppName() -> String { return Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String ?? "GitHub Copilot for Xcode" } + +public let SELECTED_AGENT_SUBMODE_KEY = "selectedAgentSubMode" diff --git a/Tool/Sources/Logger/MCPRuntimeLogger.swift b/Tool/Sources/Logger/MCPRuntimeLogger.swift index 36527e43..638c7734 100644 --- a/Tool/Sources/Logger/MCPRuntimeLogger.swift +++ b/Tool/Sources/Logger/MCPRuntimeLogger.swift @@ -7,7 +7,7 @@ public final class MCPRuntimeFileLogger { .month() .day() .timeZone(separator: .omitted).time(includingFractionalSeconds: true) - private static let implementation = MCPRuntimeFileLoggerImplementation() + private let implementation = MCPRuntimeFileLoggerImplementation() /// Converts a timestamp in milliseconds since the Unix epoch to a formatted date string. private func timestamp(timeStamp: Double) -> String { @@ -22,10 +22,11 @@ public final class MCPRuntimeFileLogger { tool: String? = nil, time: Double ) { - let log = "[\(timestamp(timeStamp: time))] [\(level)] [\(server)\(tool == nil ? "" : "-\(tool!))")] \(message)\(message.hasSuffix("\n") ? "" : "\n")" + let toolSuffix = tool.map { "-\($0)" } ?? "" + let log = "[\(timestamp(timeStamp: time))] [\(level)] [\(server)\(toolSuffix)] \(message)\(message.hasSuffix("\n") ? "" : "\n")" Task { - await MCPRuntimeFileLogger.implementation.logToFile(logFileName: logFileName, log: log) + await self.implementation.logToFile(logFileName: logFileName, log: log) } } } diff --git a/Tool/Sources/Persist/AppState.swift b/Tool/Sources/Persist/AppState.swift index 3b7f8cc2..e9e8424a 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 { @@ -61,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) @@ -70,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] } @@ -81,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] ?? [:]) @@ -93,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 c4296fc7..a00a603c 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -166,6 +166,10 @@ public extension UserDefaultPreferenceKeys { var realtimeSuggestionToggle: PreferenceKey { .init(defaultValue: true, key: "RealtimeSuggestionToggle") } + + var realtimeNESToggle: PreferenceKey { + .init(defaultValue: true, key: "RealtimeNESToggle") + } var suggestionDisplayCompactMode: PreferenceKey { .init(defaultValue: true, key: "SuggestionDisplayCompactMode") @@ -243,6 +247,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") @@ -304,6 +312,10 @@ public extension UserDefaultPreferenceKeys { var chatResponseLocale: PreferenceKey { .init(defaultValue: "en", key: "ChatResponseLocale") } + + var agentMaxToolCallingLoop: PreferenceKey { + .init(defaultValue: 25, key: "AgentMaxToolCallingLoop") + } var globalCopilotInstructions: PreferenceKey { .init(defaultValue: "", key: "GlobalCopilotInstructions") @@ -312,6 +324,18 @@ public extension UserDefaultPreferenceKeys { var autoAttachChatToXcode: PreferenceKey { .init(defaultValue: true, key: "AutoAttachChatToXcode") } + + var enableFixError: PreferenceKey { + .init(defaultValue: true, key: "EnableFixError") + } + + var suppressRestoreCheckpointConfirmation: PreferenceKey { + .init(defaultValue: false, key: "SuppressRestoreCheckpointConfirmation") + } + + var enableSubagent: PreferenceKey { + .init(defaultValue: true, key: "EnableSubagent") + } } // MARK: - Theme @@ -587,4 +611,16 @@ public extension UserDefaultPreferenceKeys { var verboseLoggingEnabled: PreferenceKey { .init(defaultValue: false, key: "VerboseLoggingEnabled") } + + var currentUserName: PreferenceKey { + .init(defaultValue: "", key: "CurrentUserName") + } + + var mcpRegistryURL: PreferenceKey { + .init(defaultValue: "https://api.mcp.github.com/2025-09-15/v0/servers", key: "MCPRegistryURL") + } + + var mcpRegistryURLHistory: PreferenceKey<[String]> { + .init(defaultValue: [], key: "MCPRegistryURLHistory") + } } diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift index dfaa5b67..d322f321 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -13,9 +13,12 @@ public extension UserDefaults { static func setupDefaultSettings() { shared.setupDefaultValue(for: \.quitXPCServiceOnXcodeAndAppQuit) shared.setupDefaultValue(for: \.realtimeSuggestionToggle) + shared.setupDefaultValue(for: \.realtimeNESToggle) shared.setupDefaultValue(for: \.realtimeSuggestionDebounce) shared.setupDefaultValue(for: \.suggestionPresentationMode) shared.setupDefaultValue(for: \.autoAttachChatToXcode) + shared.setupDefaultValue(for: \.enableFixError) + shared.setupDefaultValue(for: \.enableSubagent) shared.setupDefaultValue(for: \.widgetColorScheme) shared.setupDefaultValue(for: \.customCommands) shared.setupDefaultValue( @@ -61,6 +64,10 @@ public extension UserDefaults { weight: .regular ))) ) + shared.setupDefaultValue( + for: \.fontScale, + defaultValue: shared.value(for: \.fontScale) + ) } } diff --git a/Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift b/Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift new file mode 100644 index 00000000..5e06037b --- /dev/null +++ b/Tool/Sources/SharedUIComponents/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. +public struct AdaptiveHelpLink: View { + let action: () -> Void + var controlSize: ControlSize = .small + + public init(controlSize: ControlSize = .small, action: @escaping () -> Void) { + self.controlSize = controlSize + self.action = action + } + + public 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/Tool/Sources/SharedUIComponents/Base/Colors.swift b/Tool/Sources/SharedUIComponents/Base/Colors.swift new file mode 100644 index 00000000..0b342146 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/Base/Colors.swift @@ -0,0 +1,41 @@ +import SwiftUI + +public extension Color { + static var hoverColor: Color { .gray.opacity(0.1) } + + static var chatWindowBackgroundColor: Color { Color("ChatWindowBackgroundColor") } + + static var successLightGreen: Color { Color("LightGreen") } +} + +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/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/Base/HoverButtunStyle.swift b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift index f8f1116d..313346ad 100644 --- a/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift +++ b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift @@ -5,24 +5,34 @@ public struct HoverButtonStyle: ButtonStyle { @State private var isHovered: Bool private var padding: CGFloat private var hoverColor: Color + private var backgroundColor: Color + private var cornerRadius: CGFloat - 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, + backgroundColor: Color = .clear, + cornerRadius: CGFloat = 4 + ) { self.isHovered = isHovered self.padding = padding self.hoverColor = hoverColor + self.backgroundColor = backgroundColor + self.cornerRadius = cornerRadius } public func makeBody(configuration: Configuration) -> some View { configuration.label - .padding(padding) + .scaledPadding(padding) .background( configuration.isPressed ? Color.gray.opacity(0.2) : isHovered ? hoverColor - : Color.clear + : backgroundColor ) - .cornerRadius(4) + .cornerRadius(cornerRadius) .onHover { hover in isHovered = hover } diff --git a/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift b/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift new file mode 100644 index 00000000..54edfe08 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift @@ -0,0 +1,118 @@ +import SwiftUI +import AppKit + +public struct CollapsibleSearchField: View { + @Binding public var searchText: String + @Binding public var isExpanded: Bool + public let placeholderString: String + + public init( + searchText: Binding, + isExpanded: Binding, + placeholderString: String = "Search..." + ) { + self._searchText = searchText + self._isExpanded = isExpanded + self.placeholderString = placeholderString + } + + public var body: some View { + Group { + if isExpanded { + SearchFieldRepresentable( + searchText: $searchText, + isExpanded: $isExpanded, + placeholderString: placeholderString + ) + .frame(width: 200, height: 24) + .transition(.opacity) + } else { + Button(action: { + isExpanded = true + }) { + Image(systemName: "magnifyingglass") + .font(.system(size: 13)) + } + .buttonStyle(.plain) + .frame(height: 24) + .transition(.opacity) + } + } + } +} + +private struct SearchFieldRepresentable: NSViewRepresentable { + @Binding var searchText: String + @Binding var isExpanded: Bool + let placeholderString: String + + func makeNSView(context: Context) -> NSSearchField { + let searchField = NSSearchField() + searchField.placeholderString = placeholderString + searchField.delegate = context.coordinator + searchField.target = context.coordinator + searchField.action = #selector(Coordinator.searchFieldDidChange(_:)) + + // Make the magnifying glass clickable to collapse + if let cell = searchField.cell as? NSSearchFieldCell { + cell.searchButtonCell?.target = context.coordinator + cell.searchButtonCell?.action = #selector(Coordinator.magnifyingGlassClicked(_:)) + } + + return searchField + } + + func updateNSView(_ nsView: NSSearchField, context: Context) { + if nsView.stringValue != searchText { + nsView.stringValue = searchText + } + + context.coordinator.isExpanded = $isExpanded + + // Auto-focus when expanded, only if not already first responder + if isExpanded && nsView.window?.firstResponder != nsView.currentEditor() { + DispatchQueue.main.async { + nsView.window?.makeFirstResponder(nsView) + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(searchText: $searchText, isExpanded: $isExpanded) + } + + class Coordinator: NSObject, NSSearchFieldDelegate, NSTextFieldDelegate { + @Binding var searchText: String + var isExpanded: Binding + + init(searchText: Binding, isExpanded: Binding) { + _searchText = searchText + self.isExpanded = isExpanded + } + + @objc func searchFieldDidChange(_ sender: NSSearchField) { + searchText = sender.stringValue + } + + @objc func magnifyingGlassClicked(_ sender: Any) { + // Collapse when magnifying glass is clicked + DispatchQueue.main.async { [weak self] in + withAnimation(.easeInOut(duration: 0.2)) { + self?.isExpanded.wrappedValue = false + } + } + } + + func controlTextDidEndEditing(_ obj: Notification) { + // Collapse search field when it loses focus and text is empty + if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + DispatchQueue.main.async { [weak self] in + withAnimation(.easeInOut(duration: 0.2)) { + self?.isExpanded.wrappedValue = false + self?.searchText = "" + } + } + } + } + } +} 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 afaa6073..34494137 100644 --- a/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift +++ b/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift @@ -1,24 +1,29 @@ 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) + .scaledFrame(width: 24, height: 24) + + Image("CopilotLogo") + .resizable() + .renderingMode(.template) + .scaledToFit() + .scaledFrame(width: 14, height: 14) + } + Text("GitHub Copilot") - .font(.system(size: 13)) - .fontWeight(.semibold) - .padding(4) + .scaledFont(size: 13, weight: .semibold) + .padding(.leading, 4) Spacer() } diff --git a/Tool/Sources/SharedUIComponents/CopyButton.swift b/Tool/Sources/SharedUIComponents/CopyButton.swift index 0e79a0b4..e705d183 100644 --- a/Tool/Sources/SharedUIComponents/CopyButton.swift +++ b/Tool/Sources/SharedUIComponents/CopyButton.swift @@ -28,11 +28,11 @@ public struct CopyButton: View { }) { Image(systemName: isCopied ? "checkmark.circle" : "doc.on.doc") .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) + .scaledToFit() + .scaledPadding(2) + .scaledFrame(width: 16, height: 16) .foregroundColor(foregroundColor ?? .secondary) .conditionalFontWeight(fontWeight) - .padding(4) } .buttonStyle(HoverButtonStyle(padding: 0)) .help("Copy") diff --git a/Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift b/Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift new file mode 100644 index 00000000..2758a5cf --- /dev/null +++ b/Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift @@ -0,0 +1,195 @@ +import SwiftUI +import ConversationServiceProvider +import AppKitExtension + +public struct CreateCustomCopilotFileView: View { + public let promptType: PromptType + public let editorPluginVersion: String + public let getCurrentProjectURL: () async -> URL? + public let onSuccess: (String) -> Void + public let onError: (String) -> Void + + @State private var fileName = "" + @State private var projectURL: URL? + @State private var fileAlreadyExists = false + + @Environment(\.dismiss) private var dismiss + + public init( + promptType: PromptType, + editorPluginVersion: String, + getCurrentProjectURL: @escaping () async -> URL?, + onSuccess: @escaping (String) -> Void, + onError: @escaping (String) -> Void + ) { + self.promptType = promptType + self.editorPluginVersion = editorPluginVersion + self.getCurrentProjectURL = getCurrentProjectURL + self.onSuccess = onSuccess + self.onError = onError + } + + public var body: some View { + Form { + VStack(alignment: .center, spacing: 20) { + HStack(alignment: .center) { + Spacer() + Text("Create \(promptType.displayName)").font(.headline) + Spacer() + AdaptiveHelpLink(action: openHelpLink) + } + + // Content + VStack(alignment: .leading, spacing: 4) { + TextFieldsContainer { + TextField("File name", text: Binding( + get: { fileName }, + set: { newValue in + fileName = newValue + updateFileExistence() + } + )) + .disableAutocorrection(true) + .textContentType(.none) + .onSubmit { + Task { await createPromptFile() } + } + } + + validationMessageView + } + + HStack(spacing: 8) { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button("Create") { Task { await createPromptFile() } } + .buttonStyle(.borderedProminent) + .disabled(disableCreateButton) + .keyboardShortcut(.defaultAction) + } + } + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + .padding(20) + } + .frame(width: 350, height: 190) + .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) + .truncationMode(.middle) + .fixedSize(horizontal: false, vertical: true) + } else if trimmedFileName.isEmpty { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + Text("Enter the name of \(promptType.rawValue) file") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("Location:") + .foregroundColor(.primary) + .padding(.leading, 10) + .layoutPriority(1) + Text(".github/\(promptType.directoryName)/\(trimmedFileName)\(promptType.fileExtension)") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .truncationMode(.middle) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 2) + } + + // MARK: - Actions / Helpers + + private func openHelpLink() { + if let url = URL(string: promptType.helpLink(editorPluginVersion: editorPluginVersion)) { + 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 { + onError("No active workspace found") + } + 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 + onError("\(promptType.displayName) '\(trimmedFileName)\(promptType.fileExtension)' already exists") + } + return + } + + do { + try FileManager.default.createDirectory( + at: directoryPath, + withIntermediateDirectories: true + ) + + try promptType.defaultTemplate.write(to: filePath, atomically: true, encoding: .utf8) + + await MainActor.run { + onSuccess("Created \(promptType.rawValue) file '\(trimmedFileName)\(promptType.fileExtension)'") + NSWorkspace.openFileInXcode(fileURL: filePath) + dismiss() + } + } catch { + await MainActor.run { + onError("Failed to create \(promptType.rawValue) file: \(error)") + } + } + } +} diff --git a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift index e1ba7578..3110838f 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,11 +51,10 @@ public struct AutoresizingCustomTextEditor: View { onSubmit: onSubmit, heightDidChange: { height in self.textEditorHeight = min(height, maxHeight) - } + }, + onTextEditorStateChanged: onTextEditorStateChanged ) .frame(height: textEditorHeight) - .padding(.top, 1) - .padding(.bottom, -1) } } @@ -58,6 +70,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 +79,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 +89,7 @@ public struct CustomTextEditor: NSViewRepresentable { self.minHeight = minHeight self.onSubmit = onSubmit self.heightDidChange = heightDidChange + self.onTextEditorStateChanged = onTextEditorStateChanged } public func makeNSView(context: Context) -> NSScrollView { @@ -110,12 +125,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) + } + } } @@ -129,12 +152,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 +233,22 @@ 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 } + + // 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) + } + public func textView( _ textView: NSTextView, doCommandBy commandSelector: Selector @@ -193,4 +275,3 @@ public extension CustomTextEditor { } } } - diff --git a/Tool/Sources/SharedUIComponents/DestructiveButtonStyle.swift b/Tool/Sources/SharedUIComponents/DestructiveButtonStyle.swift new file mode 100644 index 00000000..3896b01f --- /dev/null +++ b/Tool/Sources/SharedUIComponents/DestructiveButtonStyle.swift @@ -0,0 +1,20 @@ +import SwiftUI + +public struct DestructiveButtonStyle: ButtonStyle { + public init() {} + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.red) + .padding(.horizontal, 13) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.red.opacity(0.25)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .fill(Color.black.opacity(configuration.isPressed ? 0.15 : 0)) + ) + ) + } +} diff --git a/Tool/Sources/SharedUIComponents/DownvoteButton.swift b/Tool/Sources/SharedUIComponents/DownvoteButton.swift index 952aadbc..b61e423c 100644 --- a/Tool/Sources/SharedUIComponents/DownvoteButton.swift +++ b/Tool/Sources/SharedUIComponents/DownvoteButton.swift @@ -17,15 +17,10 @@ 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) + .scaledToFit() + .scaledPadding(2) + .scaledFrame(width: 16, height: 16) .foregroundColor(.secondary) -// .background( -// .regularMaterial, -// in: RoundedRectangle(cornerRadius: 4, style: .circular) -// ) - .padding(4) .help("Unhelpful") } .buttonStyle(HoverButtonStyle(padding: 0)) diff --git a/Tool/Sources/SharedUIComponents/InsertButton.swift b/Tool/Sources/SharedUIComponents/InsertButton.swift index 355d8982..a6aca8c5 100644 --- a/Tool/Sources/SharedUIComponents/InsertButton.swift +++ b/Tool/Sources/SharedUIComponents/InsertButton.swift @@ -19,15 +19,10 @@ public struct InsertButton: View { }) { self.icon .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) -// .frame(width: 20, height: 20, alignment: .center) + .scaledToFit() + .scaledPadding(2) + .scaledFrame(width: 16, height: 16) .foregroundColor(.secondary) -// .background( -// .regularMaterial, -// in: RoundedRectangle(cornerRadius: 4, style: .circular) -// ) - .padding(4) } .buttonStyle(HoverButtonStyle(padding: 0)) .help("Insert at Cursor") diff --git a/Tool/Sources/SharedUIComponents/InstructionView.swift b/Tool/Sources/SharedUIComponents/InstructionView.swift index 774ea7c8..8a17b57b 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(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(size: 14, weight: .light) .multilineTextAlignment(.center) .lineSpacing(4) } @@ -42,22 +42,22 @@ 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)) } } } - }.frame(maxWidth: 350) + }.scaledFrame(maxWidth: 350) } } } diff --git a/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift b/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift new file mode 100644 index 00000000..bee224dd --- /dev/null +++ b/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift @@ -0,0 +1,78 @@ +import SwiftUI +import AppKit + +public enum CheckboxMixedState { + case off, mixed, on +} + +public struct MixedStateCheckbox: View { + let title: String + let font: NSFont + let action: () -> Void + + @Binding var state: CheckboxMixedState + + 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, font: font, state: state, action: action) + } +} + +private struct MixedStateCheckboxView: NSViewRepresentable { + let title: String + let font: NSFont + let state: CheckboxMixedState + let action: () -> Void + + func makeNSView(context: Context) -> NSButton { + let button = NSButton() + 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) + 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) { + if nsView.font != font { + nsView.font = font + } + + 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/SharedUIComponents/OverlayScrollView.swift b/Tool/Sources/SharedUIComponents/OverlayScrollView.swift new file mode 100644 index 00000000..751d3d4f --- /dev/null +++ b/Tool/Sources/SharedUIComponents/OverlayScrollView.swift @@ -0,0 +1,45 @@ +import SwiftUI +import AppKit + +public struct OverlayScrollView: NSViewRepresentable { + let showsVerticalScroller: Bool + let showsHorizontalScroller: Bool + let content: Content + + public init(showsVerticalScroller: Bool = true, + showsHorizontalScroller: Bool = false, + @ViewBuilder content: () -> Content) { + self.showsVerticalScroller = showsVerticalScroller + self.showsHorizontalScroller = showsHorizontalScroller + self.content = content() + } + + public func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = showsVerticalScroller + scrollView.hasHorizontalScroller = showsHorizontalScroller + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.verticalScrollElasticity = .automatic + scrollView.horizontalScrollElasticity = .automatic + + let hosting = NSHostingView(rootView: content) + hosting.translatesAutoresizingMaskIntoConstraints = false + + scrollView.documentView = hosting + + if let docView = scrollView.contentView.documentView { + docView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor).isActive = true + docView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor).isActive = true + docView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor).isActive = true + } + return scrollView + } + + public func updateNSView(_ nsView: NSScrollView, context: Context) { + if let hosting = nsView.documentView as? NSHostingView { + hosting.rootView = content + } + } +} 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..08c33882 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift @@ -0,0 +1,127 @@ +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) + } + + /// Applies a scaled frame to the target view based on the current font scaling factor. + /// Use this function only when the target view requires dynamic scaling to adapt to font size changes. + public func scaledFrame( + minWidth: CGFloat? = nil, + idealWidth: CGFloat? = nil, + maxWidth: CGFloat? = nil, + minHeight: CGFloat? = nil, + idealHeight: CGFloat? = nil, + maxHeight: CGFloat? = nil, + alignment: Alignment = .center + ) -> some View { + ScaledConstraintFrameView( + self, + minWidth: minWidth, + idealWidth: idealWidth, + maxWidth: maxWidth, + minHeight: minHeight, + idealHeight: idealHeight, + maxHeight: maxHeight, + 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) + } +} + +struct ScaledConstraintFrameView: View { + let content: Content + let minWidth: CGFloat? + let idealWidth: CGFloat? + let maxWidth: CGFloat? + let minHeight: CGFloat? + let idealHeight: CGFloat? + let maxHeight: CGFloat? + let alignment: Alignment + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + private func getScaledValue(_ v: CGFloat?) -> CGFloat? { + guard let v = v else { + return nil + } + + return v * fontScale + } + + init( + _ content: Content, + minWidth: CGFloat? = nil, + idealWidth: CGFloat? = nil, + maxWidth: CGFloat? = nil, + minHeight: CGFloat? = nil, + idealHeight: CGFloat? = nil, + maxHeight: CGFloat? = nil, + alignment: Alignment = .center + ) { + self.content = content + self.minWidth = minWidth + self.idealWidth = idealWidth + self.maxWidth = maxWidth + self.minHeight = minHeight + self.idealHeight = idealHeight + self.maxHeight = maxHeight + self.alignment = alignment + } + + var body: some View { + content + .frame( + minWidth: getScaledValue(minWidth), + idealWidth: getScaledValue(idealWidth), + maxWidth: getScaledValue(maxWidth), + minHeight: getScaledValue(minHeight), + idealHeight: getScaledValue(idealHeight), + maxHeight: getScaledValue(maxHeight), + 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/TextFieldsContainer.swift b/Tool/Sources/SharedUIComponents/TextFieldsContainer.swift new file mode 100644 index 00000000..b4c9bcc9 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/TextFieldsContainer.swift @@ -0,0 +1,25 @@ +import SwiftUI + +public struct TextFieldsContainer: View { + let content: Content + + public init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + public 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/Tool/Sources/SharedUIComponents/UpvoteButton.swift b/Tool/Sources/SharedUIComponents/UpvoteButton.swift index b4e13e2a..1af8ebf7 100644 --- a/Tool/Sources/SharedUIComponents/UpvoteButton.swift +++ b/Tool/Sources/SharedUIComponents/UpvoteButton.swift @@ -17,15 +17,10 @@ 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) + .scaledToFit() + .scaledPadding(2) + .scaledFrame(width: 16, height: 16) .foregroundColor(.secondary) -// .background( -// .regularMaterial, -// in: RoundedRectangle(cornerRadius: 4, style: .circular) -// ) - .padding(4) .help("Helpful") } .buttonStyle(HoverButtonStyle(padding: 0)) diff --git a/Tool/Sources/Status/Status.swift b/Tool/Sources/Status/Status.swift index be005f5f..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 @@ -120,6 +122,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..e19e3f70 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,10 +34,19 @@ 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() + if authStatus.status == .notLoggedIn { + await Status.shared.updateQuotaInfo(nil) + } + self.authStatus = AuthStatus( status: authStatus.status, username: statusInfo.userName, @@ -54,6 +65,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 +118,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/StatusBarItemView/QuotaView.swift b/Tool/Sources/StatusBarItemView/QuotaView.swift index f1b2d1d3..4f073716 100644 --- a/Tool/Sources/StatusBarItemView/QuotaView.swift +++ b/Tool/Sources/StatusBarItemView/QuotaView.swift @@ -68,7 +68,6 @@ public class QuotaView: NSView { autoresizingMask = [.width] setupView() - layoutSubtreeIfNeeded() let calculatedHeight = fittingSize.height frame = NSRect(x: 0, y: 0, width: Layout.viewWidth, height: calculatedHeight) } @@ -374,11 +373,19 @@ extension QuotaView { button.translatesAutoresizingMaskIntoConstraints = false button.bezelStyle = .push if isFreeQuotaUsedUp { - button.attributedTitle = NSAttributedString( - string: upgradeTitle, - attributes: [.foregroundColor: NSColor.white] - ) - button.bezelColor = .controlAccentColor + if #available(macOS 26.0, *) { + button.attributedTitle = NSAttributedString( + string: upgradeTitle, + attributes: [.foregroundColor: NSColor.controlTextColor] + ) + button.bezelColor = .controlBackgroundColor + } else { + button.attributedTitle = NSAttributedString( + string: upgradeTitle, + attributes: [.foregroundColor: NSColor.white] + ) + button.bezelColor = .controlAccentColor + } } else { button.title = upgradeTitle } diff --git a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift index bd124fc1..e910af17 100644 --- a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift +++ b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift @@ -1,6 +1,10 @@ import Foundation import CodableWrappers +public enum CodeSuggestionType: String { + case codeCompletion, nes +} + public struct CodeSuggestion: Codable, Equatable { public init( id: String, diff --git a/Tool/Sources/SuggestionBasic/EditorInformation.swift b/Tool/Sources/SuggestionBasic/EditorInformation.swift index 8518b8b0..5c717f5a 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, Hashable { 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/SuggestionProvider/NESSuggestionServiceType.swift b/Tool/Sources/SuggestionProvider/NESSuggestionServiceType.swift new file mode 100644 index 00000000..c088a688 --- /dev/null +++ b/Tool/Sources/SuggestionProvider/NESSuggestionServiceType.swift @@ -0,0 +1,9 @@ +import CopilotForXcodeKit + +public protocol NESSuggestionServiceType { + func getNESSuggestions( + _ request: CopilotForXcodeKit.SuggestionRequest, + workspace: CopilotForXcodeKit.WorkspaceInfo + ) async throws -> [CopilotForXcodeKit.CodeSuggestion] +} + diff --git a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift index e69e29d2..3a60489a 100644 --- a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift @@ -19,6 +19,23 @@ public struct PostProcessingSuggestionServiceMiddleware: SuggestionServiceMiddle return suggestion } } + + public func getNESSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] { + let suggestions = try await next(request) + + return suggestions.compactMap { + var suggestion = $0 + if suggestion.text.allSatisfy({ $0.isWhitespace || $0.isNewline }) { return nil } + Self.removeTrailingWhitespacesAndNewlines(&suggestion) + // TODO: If need to check? + // if !Self.checkIfSuggestionHasNoEffect(suggestion, request: request) { return nil } + return suggestion + } + } static func removeTrailingWhitespacesAndNewlines(_ suggestion: inout CodeSuggestion) { var text = suggestion.text[...] diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift index dcbfba5e..1bec7d30 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift @@ -10,6 +10,12 @@ public protocol SuggestionServiceMiddleware { configuration: SuggestionServiceConfiguration, next: Next ) async throws -> [CodeSuggestion] + + func getNESSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] } public enum SuggestionServiceMiddlewareContainer { @@ -49,6 +55,24 @@ public struct DisabledLanguageSuggestionServiceMiddleware: SuggestionServiceMidd return try await next(request) } + + public func getNESSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] { + let language = languageIdentifierFromFileURL(request.fileURL) + if UserDefaults.shared.value(for: \.suggestionFeatureDisabledLanguageList) + .contains(where: { $0 == language.rawValue }) + { + #if DEBUG + Logger.service.info("Suggestion service is disabled for \(language).") + #endif + return [] + } + + return try await next(request) + } } public struct DebugSuggestionServiceMiddleware: SuggestionServiceMiddleware { @@ -76,5 +100,28 @@ public struct DebugSuggestionServiceMiddleware: SuggestionServiceMiddleware { throw error } } + + public func getNESSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] { + Logger.service.info(""" + Get suggestion for \(request.fileURL) at \(request.cursorPosition) + """) + do { + let suggestions = try await next(request) + Logger.service.info(""" + Receive \(suggestions.count) suggestions for \(request.fileURL) \ + at \(request.cursorPosition) + """) + return suggestions + } catch { + Logger.service.info(""" + Error: \(error.localizedDescription) + """) + throw error + } + } } diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift index 24265613..bec85e8f 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift @@ -63,6 +63,10 @@ public protocol SuggestionServiceProvider { _ request: SuggestionRequest, workspaceInfo: CopilotForXcodeKit.WorkspaceInfo ) async throws -> [CodeSuggestion] + func getNESSuggestions( + _ request: SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo, + ) async throws -> [CodeSuggestion] func notifyAccepted( _ suggestion: CodeSuggestion, workspaceInfo: CopilotForXcodeKit.WorkspaceInfo diff --git a/Tool/Sources/SystemUtils/SystemUtils.swift b/Tool/Sources/SystemUtils/SystemUtils.swift index e5b0c79a..fb785f45 100644 --- a/Tool/Sources/SystemUtils/SystemUtils.swift +++ b/Tool/Sources/SystemUtils/SystemUtils.swift @@ -38,6 +38,21 @@ public class SystemUtils { public static let buildType: String = { return shared.isDeveloperMode() ? "true" : "false" }() + + public static let isDeveloperMode: Bool = { + return shared.isDeveloperMode() + }() + + public static let isPrereleaseBuild: Bool = { + let components = editorPluginVersionString.split(separator: ".") + if components.count >= 3 { + let patchComponent = String(components[2]) + // If patch version is not "0" + return patchComponent != "0" + } + + return false + }() private init() {} @@ -176,16 +191,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 +211,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/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/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift index 8da014a5..da77d574 100644 --- a/Tool/Sources/Workspace/Filespace.swift +++ b/Tool/Sources/Workspace/Filespace.swift @@ -27,6 +27,7 @@ public final class FilespacePropertyValues { } public struct FilespaceCodeMetadata: Equatable { + /// Stands for `Uniform Type Identifier` public var uti: String? public var tabSize: Int? public var indentSize: Int? @@ -66,6 +67,7 @@ public final class Filespace { // MARK: Metadata public let fileURL: URL + public private(set) var fileContent: String? = nil public private(set) lazy var language: CodeLanguage = languageIdentifierFromFileURL(fileURL) public var codeMetadata: FilespaceCodeMetadata = .init() public var isTextReadable: Bool { @@ -76,13 +78,22 @@ public final class Filespace { public private(set) var suggestionIndex: Int = 0 public internal(set) var suggestions: [CodeSuggestion] = [] { - didSet { refreshUpdateTime() } + didSet{ refreshUpdateTime() } + } + // Use Array for potential extensibility + public internal(set) var nesSuggestions: [CodeSuggestion] = [] { + didSet { refreshNESUpdateTime() } } public var presentingSuggestion: CodeSuggestion? { guard suggestions.endIndex > suggestionIndex, suggestionIndex >= 0 else { return nil } return suggestions[suggestionIndex] } + + public var presentingNESSuggestion: CodeSuggestion? { + // Currently, only one nes suggestion will exist there + return nesSuggestions.first + } public private(set) var errorMessage: String = "" { didSet { refreshUpdateTime() } @@ -93,8 +104,13 @@ public final class Filespace { public var isExpired: Bool { Environment.now().timeIntervalSince(lastUpdateTime) > 60 * 3 } + + public var isNESExpired: Bool { + Environment.now().timeIntervalSince(lastNESUpdateTime) > 60 * 3 + } public private(set) var lastUpdateTime: Date = Environment.now() + public private(set) var lastNESUpdateTime: Date = Environment.now() private var additionalProperties = FilespacePropertyValues() let fileSaveWatcher: FileSaveWatcher let onClose: (URL) -> Void @@ -110,15 +126,19 @@ public final class Filespace { init( fileURL: URL, + content: String, onSave: @escaping (Filespace) -> Void, onClose: @escaping (URL) -> Void ) { self.fileURL = fileURL + self.fileContent = content self.onClose = onClose fileSaveWatcher = .init(fileURL: fileURL) fileSaveWatcher.changeHandler = { [weak self] in guard let self else { return } + // TODO: should distinguish code completion and NES? onSave(self) + self.fileContent = try? String(contentsOf: self.fileURL) } } @@ -135,6 +155,11 @@ public final class Filespace { suggestions = [] suggestionIndex = 0 } + + @WorkspaceActor + public func resetNESSuggestion() { + nesSuggestions = [] + } @WorkspaceActor public func updateSuggestionsWithSameSelection(_ suggestions: [CodeSuggestion]) { @@ -145,11 +170,25 @@ public final class Filespace { public func refreshUpdateTime() { lastUpdateTime = Environment.now() } + + public func refreshNESUpdateTime() { + lastNESUpdateTime = Date.now + } @WorkspaceActor public func setSuggestions(_ suggestions: [CodeSuggestion]) { self.suggestions = suggestions suggestionIndex = 0 + if !self.suggestions.isEmpty { + self.resetNESSuggestion() + } + } + + @WorkspaceActor + public func setNESSuggestions(_ nesSuggestions: [CodeSuggestion]) { + // Only when there is no code completion suggestion, NES suggestion can be set + guard self.suggestions.isEmpty else { return } + self.nesSuggestions = nesSuggestions } @WorkspaceActor @@ -182,5 +221,23 @@ public final class Filespace { public func dismissError() { errorMessage = "" } + + @WorkspaceActor + public func updateCodeMetadata( + uti: String, + tabSize: Int, + indentSize: Int, + usesTabsForIndentation: Bool + ) { + self.codeMetadata.uti = uti + self.codeMetadata.tabSize = tabSize + self.codeMetadata.indentSize = indentSize + self.codeMetadata.usesTabsForIndentation = usesTabsForIndentation + } + + @WorkspaceActor + public func setFileContent(_ content: String) { + fileContent = content + } } diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index 82248822..811489fa 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -4,6 +4,7 @@ import UserDefaultsObserver import XcodeInspector import Logger import UniformTypeIdentifiers +import LanguageServerProtocol enum Environment { static var now = { Date() } @@ -45,7 +46,7 @@ open class WorkspacePlugin { open func didOpenFilespace(_: Filespace) {} open func didSaveFilespace(_: Filespace) {} - open func didUpdateFilespace(_: Filespace, content: String) {} + open func didUpdateFilespace(_: Filespace, content: String, contentChanges: [TextDocumentContentChangeEvent]?) {} open func didCloseFilespace(_: URL) {} } @@ -149,9 +150,12 @@ public final class Workspace { throw WorkspaceFileError.invalidFileFormat(fileURL: fileURL) } + let content = try String(contentsOf: fileURL) + let existedFilespace = filespaces[fileURL] let filespace = existedFilespace ?? .init( fileURL: fileURL, + content: content, onSave: { [weak self] filespace in guard let self else { return } self.didSaveFilespace(filespace) @@ -183,13 +187,29 @@ public final class Workspace { guard let filespace = filespaces[fileURL] else { return } filespace.bumpVersion() filespace.refreshUpdateTime() + + let oldContent = filespace.fileContent + + // Calculate incremental changes if NES is enabled and we have old content + let changes: [TextDocumentContentChangeEvent]? = { + guard let oldContent = oldContent else { return nil } + return calculateIncrementalChanges(oldContent: oldContent, newContent: content) + }() + for plugin in plugins.values { - plugin.didUpdateFilespace(filespace, content: content) + if let changes, let oldContent { + plugin.didUpdateFilespace(filespace, content: oldContent, contentChanges: changes) + } else { + // fallback to full content sync + plugin.didUpdateFilespace(filespace, content: content, contentChanges: nil) + } } + + filespace.setFileContent(content) } @WorkspaceActor - func didOpenFilespace(_ filespace: Filespace) { + public func didOpenFilespace(_ filespace: Filespace) { refreshUpdateTime() openedFileRecoverableStorage.openFile(fileURL: filespace.fileURL) for plugin in plugins.values { @@ -214,3 +234,137 @@ public final class Workspace { } } +extension Workspace { + /// Calculates incremental changes between two document states. + /// Each change is computed on the state resulting from the previous change, + /// as required by the LSP specification. + /// + /// This implementation finds the common prefix and suffix, then creates + /// a single change event for the differing middle section. This ensures + /// correctness while being efficient for typical editing scenarios. + /// + /// - Parameters: + /// - oldContent: The original document content + /// - newContent: The new document content + /// - Returns: Array of TextDocumentContentChangeEvent in order + func calculateIncrementalChanges( + oldContent: String, + newContent: String + ) -> [TextDocumentContentChangeEvent]? { + // Handle identical content + if oldContent == newContent { + return nil + } + + // Handle empty old content (new file) + if oldContent.isEmpty { + let endPosition = calculateEndPosition(content: oldContent) + return [TextDocumentContentChangeEvent( + range: LSPRange( + start: Position(line: 0, character: 0), + end: Position(line: 0, character: 0) + ), + rangeLength: 0, + text: newContent + )] + } + + // Handle empty new content (cleared file) + if newContent.isEmpty { + let endPosition = calculateEndPosition(content: oldContent) + return [TextDocumentContentChangeEvent( + range: LSPRange( + start: Position(line: 0, character: 0), + end: endPosition + ), + rangeLength: oldContent.utf16.count, + text: "" + )] + } + + // Find common prefix + let oldUTF16 = Array(oldContent.utf16) + let newUTF16 = Array(newContent.utf16) + let maxCalculationLength = 10000 + guard oldUTF16.count <= maxCalculationLength, + newUTF16.count <= maxCalculationLength else { + // Fallback to full replacement for very large contents + return nil + } + + var prefixLength = 0 + let minLength = min(oldUTF16.count, newUTF16.count) + while prefixLength < minLength && oldUTF16[prefixLength] == newUTF16[prefixLength] { + prefixLength += 1 + } + + // Find common suffix (after prefix) + var suffixLength = 0 + while suffixLength < minLength - prefixLength && + oldUTF16[oldUTF16.count - 1 - suffixLength] == newUTF16[newUTF16.count - 1 - suffixLength] { + suffixLength += 1 + } + + // Calculate positions + let startPosition = utf16OffsetToPosition( + content: oldContent, + offset: prefixLength + ) + + let endOffset = oldUTF16.count - suffixLength + let endPosition = utf16OffsetToPosition( + content: oldContent, + offset: endOffset + ) + + // Extract replacement text from new content + let newStartOffset = prefixLength + let newEndOffset = newUTF16.count - suffixLength + + let replacementText: String + if newStartOffset <= newEndOffset { + let startIndex = newContent.utf16.index(newContent.utf16.startIndex, offsetBy: newStartOffset) + let endIndex = newContent.utf16.index(newContent.utf16.startIndex, offsetBy: newEndOffset) + replacementText = String(newContent[startIndex.. Position { + var line = 0 + var character = 0 + + let utf16View = content.utf16 + let safeOffset = min(offset, utf16View.count) + let endIndex = utf16View.index(utf16View.startIndex, offsetBy: safeOffset) + + for char in utf16View[.. Position { + return utf16OffsetToPosition(content: content, offset: content.utf16.count) + } +} 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/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift index 9807702d..3b0ac9c6 100644 --- a/Tool/Sources/Workspace/WorkspacePool.swift +++ b/Tool/Sources/Workspace/WorkspacePool.swift @@ -67,7 +67,24 @@ public class WorkspacePool { if filespaces.count == 1 { return filespaces.first } Logger.workspacePool.info("Multiple workspaces found with file: \(fileURL)") // If multiple workspaces are found, return the first with a suggestion - return filespaces.first { $0.presentingSuggestion != nil } + return filespaces.first { $0.presentingSuggestion != nil } ?? filespaces.first { $0.presentingNESSuggestion != nil } + } + + public func fetchWorkspaceAndFilespace(fileURL: URL) -> (Workspace, Filespace)? { + var workspace: Workspace? + var filespace: Filespace? + + for wp in workspaces.values { + if let fp = wp.filespaces[fileURL] { + if fp.presentingSuggestion != nil || fp.presentingNESSuggestion != nil { + return (wp, fp) + } + workspace = wp + filespace = fp + } + } + + return workspace.flatMap { ws in filespace.map { fs in (ws, fs) } } } @WorkspaceActor diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift index 47e1d9dc..03656855 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift @@ -4,6 +4,7 @@ import Workspace import XPCShared public struct FilespaceSuggestionSnapshot: Equatable { + public let lines: [String] public let linesHash: Int public let prefixLinesHash: Int public let suffixLinesHash: Int @@ -15,6 +16,7 @@ public struct FilespaceSuggestionSnapshot: Equatable { return max(min(index, lines.endIndex), lines.startIndex) } + self.lines = lines self.linesHash = lines.hashValue self.cursorPosition = cursorPosition self.prefixLinesHash = lines[0.. FilespaceSuggestionSnapshot { .init(lines: [], cursorPosition: .outOfScope) } } +public struct FilespaceNESSuggestionSnapshotKey: FilespacePropertyKey { + public static func createDefaultValue() + -> FilespaceSuggestionSnapshot { .init(lines: [], cursorPosition: .outOfScope) } +} + public extension FilespacePropertyValues { @WorkspaceActor var suggestionSourceSnapshot: FilespaceSuggestionSnapshot { get { self[FilespaceSuggestionSnapshotKey.self] } set { self[FilespaceSuggestionSnapshotKey.self] = newValue } } + + @WorkspaceActor + var nesSuggestionSourceSnapshot: FilespaceSuggestionSnapshot { + get { self[FilespaceNESSuggestionSnapshotKey.self] } + set { self[FilespaceNESSuggestionSnapshotKey.self] = newValue } + } } public extension Filespace { @@ -53,6 +66,13 @@ public extension Filespace { self.suggestionSourceSnapshot = FilespaceSuggestionSnapshotKey.createDefaultValue() // swiftformat:enable all } + + @WorkspaceActor + func resetNESSnapshot() { + // swiftformat:disable redundantSelf + self.nesSuggestionSourceSnapshot = FilespaceNESSuggestionSnapshotKey.createDefaultValue() + // swiftformat:enable all + } /// Validate the suggestion is still valid. /// - Parameters: @@ -125,6 +145,26 @@ public extension Filespace { resetSnapshot() return false } - + + /// Validate the nes suggestion is still valid. + /// - Parameters: + /// - lines: lines of the file + /// - cursorPosition: cursor position + /// - Returns: `true` if the nes suggestion is still valid + @WorkspaceActor + func validateNESSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { + guard let presentingNESSuggestion else { return false } + + let updatedSnapshot = FilespaceSuggestionSnapshot(lines: lines, cursorPosition: cursorPosition) + + // document state is unchanged + if updatedSnapshot == self.nesSuggestionSourceSnapshot { + return true + } + + resetNESSuggestion() + resetNESSnapshot() + return false + } } diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift index e0c3f0f1..77fc3700 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift @@ -46,6 +46,52 @@ public extension Workspace { func generateSuggestions( forFileAt fileURL: URL, editor: EditorContent + ) async throws -> [CodeSuggestion] { + refreshUpdateTime() + + guard editor.cursorPosition != .outOfScope else { + throw EditorCursorOutOfScopeError() + } + + let filespace = try createFilespaceIfNeeded(fileURL: fileURL) + + if !editor.uti.isEmpty { + filespace.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) + } + + filespace.codeMetadata.guessLineEnding(from: editor.lines.first) + + let snapshot = FilespaceSuggestionSnapshot(content: editor) + filespace.suggestionSourceSnapshot = snapshot + + guard let suggestionService else { throw SuggestionFeatureDisabledError() } + let content = editor.lines.joined(separator: "") + let completions = try await suggestionService.getSuggestions( + .from(fileURL: fileURL, content: content, editor: editor, projectRootURL: projectRootURL), + workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) + ) + + let clsStatus = await Status.shared.getCLSStatus() + if clsStatus.isErrorStatus && clsStatus.message.contains("Completions limit reached") { + filespace.setError(clsStatus.message) + } else { + filespace.setError("") + filespace.setSuggestions(completions) + } + + return completions +} + + @WorkspaceActor + @discardableResult + func generateNESSuggestions( + forFileAt fileURL: URL, + editor: EditorContent ) async throws -> [CodeSuggestion] { refreshUpdateTime() @@ -56,45 +102,30 @@ public extension Workspace { let filespace = try createFilespaceIfNeeded(fileURL: fileURL) if !editor.uti.isEmpty { - filespace.codeMetadata.uti = editor.uti - filespace.codeMetadata.tabSize = editor.tabSize - filespace.codeMetadata.indentSize = editor.indentSize - filespace.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + filespace.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) } filespace.codeMetadata.guessLineEnding(from: editor.lines.first) let snapshot = FilespaceSuggestionSnapshot(content: editor) - filespace.suggestionSourceSnapshot = snapshot + filespace.nesSuggestionSourceSnapshot = snapshot guard let suggestionService else { throw SuggestionFeatureDisabledError() } let content = editor.lines.joined(separator: "") - let completions = try await suggestionService.getSuggestions( - .init( - fileURL: fileURL, - relativePath: fileURL.path.replacingOccurrences(of: projectRootURL.path, with: ""), - content: content, - originalContent: content, - lines: editor.lines, - cursorPosition: editor.cursorPosition, - cursorOffset: editor.cursorOffset, - tabSize: editor.tabSize, - indentSize: editor.indentSize, - usesTabsForIndentation: editor.usesTabsForIndentation, - relevantCodeSnippets: [] - ), + let completions = try await suggestionService.getNESSuggestions( + .from(fileURL: fileURL, content: content, editor: editor, projectRootURL: projectRootURL), workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) ) - - let clsStatus = await Status.shared.getCLSStatus() - if clsStatus.isErrorStatus && clsStatus.message.contains("Completions limit reached") { - filespace.setError(clsStatus.message) - } else { - filespace.setError("") - filespace.setSuggestions(completions) - } - + + // TODO: How to get the `limit reached` error? Same as Code Completion? + filespace.setNESSuggestions(completions) + return completions } @@ -130,10 +161,12 @@ public extension Workspace { refreshUpdateTime() if let editor, !editor.uti.isEmpty { - filespaces[fileURL]?.codeMetadata.uti = editor.uti - filespaces[fileURL]?.codeMetadata.tabSize = editor.tabSize - filespaces[fileURL]?.codeMetadata.indentSize = editor.indentSize - filespaces[fileURL]?.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + filespaces[fileURL]?.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) } Task { @@ -147,6 +180,31 @@ public extension Workspace { } filespaces[fileURL]?.reset() } + + @WorkspaceActor + func rejectNESSuggestion(forFileAt fileURL: URL, editor: EditorContent?) { + refreshUpdateTime() + + if let editor, !editor.uti.isEmpty { + filespaces[fileURL]?.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) + } + + Task { + await suggestionService?.notifyRejected( + filespaces[fileURL]?.nesSuggestions ?? [], + workspaceInfo: .init( + workspaceURL: workspaceURL, + projectURL: projectRootURL + ) + ) + } + filespaces[fileURL]?.resetNESSuggestion() + } @WorkspaceActor func acceptSuggestion(forFileAt fileURL: URL, editor: EditorContent?, suggestionLineLimit: Int? = nil) -> CodeSuggestion? { @@ -158,10 +216,12 @@ public extension Workspace { else { return nil } if let editor, !editor.uti.isEmpty { - filespaces[fileURL]?.codeMetadata.uti = editor.uti - filespaces[fileURL]?.codeMetadata.tabSize = editor.tabSize - filespaces[fileURL]?.codeMetadata.indentSize = editor.indentSize - filespaces[fileURL]?.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + filespaces[fileURL]?.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) } var allSuggestions = filespace.suggestions @@ -184,5 +244,57 @@ public extension Workspace { return suggestion } + + @WorkspaceActor + func acceptNESSuggestion(forFileAt fileURL: URL, editor: EditorContent?, suggestionLineLimit: Int? = nil) -> CodeSuggestion? { + refreshUpdateTime() + guard let filespace = filespaces[fileURL], + let suggestion = filespace.presentingNESSuggestion + else { return nil } + + if let editor, !editor.uti.isEmpty { + filespaces[fileURL]?.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) + } + + Task { + await gitHubCopilotService?.notifyAccepted(suggestion, acceptedLength: nil) + } + + filespace.resetNESSuggestion() + filespace.resetNESSnapshot() + + return suggestion + } + + @WorkspaceActor + func getNESSuggestion(forFileAt fileURL: URL) -> CodeSuggestion? { + guard let filespace = filespaces[fileURL], + let suggestion = filespace.presentingNESSuggestion + else { return nil } + + return suggestion + } } +extension SuggestionRequest { + static func from(fileURL: URL, content: String, editor: EditorContent, projectRootURL: URL) -> Self { + return .init( + fileURL: fileURL, + relativePath: fileURL.path.replacingOccurrences(of: projectRootURL.path, with: ""), + content: content, + originalContent: content, + lines: editor.lines, + cursorPosition: editor.cursorPosition, + cursorOffset: editor.cursorOffset, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation, + relevantCodeSnippets: [] + ) + } +} diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index 5b1d7953..4e4d59f5 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -1,7 +1,9 @@ import Foundation import GitHubCopilotService +import ConversationServiceProvider import Logger import Status +import LanguageServerProtocol public enum XPCExtensionServiceError: Swift.Error, LocalizedError { case failedToGetServiceEndpoint @@ -18,6 +20,15 @@ public enum XPCExtensionServiceError: Swift.Error, LocalizedError { return "Connection to extension service error: \(error.localizedDescription)" } } + + public var underlyingError: Error? { + switch self { + case let .xpcServiceError(error): + return error + default: + return nil + } + } } public class XPCExtensionService { @@ -108,6 +119,13 @@ public class XPCExtensionService { { $0.getSuggestionAcceptedCode } ) } + + public func getNESSuggestionAcceptedCode(editorContent: EditorContent) async throws -> UpdatedContent? { + try await suggestionRequest( + editorContent, + { $0.getNESSuggestionAcceptedCode } + ) + } public func getSuggestionRejectedCode(editorContent: EditorContent) async throws -> UpdatedContent? @@ -117,6 +135,15 @@ public class XPCExtensionService { { $0.getSuggestionRejectedCode } ) } + + public func getNESSuggestionRejectedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + editorContent, + { $0.getNESSuggestionRejectedCode } + ) + } public func getRealtimeSuggestedCode(editorContent: EditorContent) async throws -> UpdatedContent? @@ -148,6 +175,19 @@ public class XPCExtensionService { } } as Void } + + public func toggleRealtimeNES() async throws { + try await withXPCServiceConnected { + service, continuation in + service.toggleRealtimeNES { error in + if let error { + continuation.reject(error) + return + } + continuation.resume(()) + } + } as Void + } public func prefetchRealtimeSuggestions(editorContent: EditorContent) async { guard let data = try? JSONEncoder().encode(editorContent) else { return } @@ -357,6 +397,8 @@ extension XPCExtensionService { } } } + + // MARK: MCP Server Tools @XPCServiceActor public func getAvailableMCPServerToolsCollections() async throws -> [MCPServerToolsCollection]? { @@ -379,12 +421,25 @@ extension XPCExtensionService { } @XPCServiceActor - public func updateMCPServerToolsStatus(_ update: [UpdateMCPToolsStatusServerCollection]) async throws { + public func updateMCPServerToolsStatus( + _ update: [UpdateMCPToolsStatusServerCollection], + chatAgentMode: ChatMode? = nil, + customChatModeId: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil + ) async throws { return try await withXPCServiceConnected { service, continuation in do { let data = try JSONEncoder().encode(update) - service.updateMCPServerToolsStatus(tools: data) + let foldersData = workspaceFolders.flatMap { try? JSONEncoder().encode($0) } + let modeData = chatAgentMode.flatMap { try? JSONEncoder().encode($0) } + let modeIdData = customChatModeId.flatMap { try? JSONEncoder().encode($0) } + service.updateMCPServerToolsStatus( + tools: data, + chatAgentMode: modeData, + customChatModeId: modeIdData, + workspaceFolders: foldersData + ) continuation.resume(()) } catch { continuation.reject(error) @@ -392,6 +447,171 @@ 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 getMCPRegistryAllowlist() async throws -> GetMCPRegistryAllowlistResult? { + return try await withXPCServiceConnected { + service, continuation in + service.getMCPRegistryAllowlist { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(GetMCPRegistryAllowlistResult.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } + } + + @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 refreshClientTools() async throws -> [LanguageModelTool]? { + return try await withXPCServiceConnected { + service, continuation in + service.refreshClientTools { 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], + chatAgentMode: ChatMode? = nil, + customChatModeId: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil + ) async throws -> [LanguageModelTool]? { + return try await withXPCServiceConnected { + service, continuation in + do { + let data = try JSONEncoder().encode(update) + let foldersData = workspaceFolders.flatMap { try? JSONEncoder().encode($0) } + let modeData = chatAgentMode.flatMap { try? JSONEncoder().encode($0) } + let modeIdData = customChatModeId.flatMap { try? JSONEncoder().encode($0) } + service.updateToolsStatus( + tools: data, + chatAgentMode: modeData, + customChatModeId: modeIdData, + workspaceFolders: foldersData + ) { 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 { @@ -403,8 +623,54 @@ 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) + } + } + } + } + + @XPCServiceActor + public func getCopilotPolicy() async throws -> CopilotPolicy? { + return try await withXPCServiceConnected { + service, continuation in + service.getCopilotPolicy { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let copilotPolicy = try JSONDecoder().decode(CopilotPolicy.self, from: data) + continuation.resume(copilotPolicy) + } catch { + continuation.reject(error) + } + } + } + } + + @XPCServiceActor + public func getModes(workspaceFolders: [WorkspaceFolder]? = nil) async throws -> [ConversationMode]? { + return try await withXPCServiceConnected { + service, continuation in + let workspaceFoldersData = workspaceFolders.flatMap { try? JSONEncoder().encode($0) } + service.getModes(workspaceFolders: workspaceFoldersData) { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let modes = try JSONDecoder().decode([ConversationMode].self, from: data) + continuation.resume(modes) } catch { continuation.reject(error) } @@ -438,4 +704,185 @@ extension XPCExtensionService { } } } + + @XPCServiceActor + public func updateCopilotModels() async throws -> [CopilotModel]? { + return try await withXPCServiceConnected { + service, continuation in + service.updateCopilotModels { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let models = try JSONDecoder().decode([CopilotModel].self, from: data) + continuation.resume(models) + } catch { + continuation.reject(error) + } + } + } + } + + // 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..4489233f 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -8,13 +8,17 @@ public protocol XPCServiceProtocol { 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 getNESSuggestionAcceptedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) func getSuggestionRejectedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getNESSuggestionRejectedCode(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 toggleRealtimeNES(withReply reply: @escaping (Error?) -> Void) func prefetchRealtimeSuggestions(editorContent: Data, withReply reply: @escaping () -> Void) func getXPCServiceVersion(withReply reply: @escaping (String, String) -> Void) @@ -22,14 +26,42 @@ 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 updateMCPServerToolsStatus( + tools: Data, + chatAgentMode: Data?, + customChatModeId: Data?, + workspaceFolders: Data? + ) + func listMCPRegistryServers(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func getMCPRegistryServer(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func getMCPRegistryAllowlist(withReply reply: @escaping (Data?, Error?) -> Void) + func getAvailableLanguageModelTools(withReply reply: @escaping (Data?) -> Void) + func refreshClientTools(withReply reply: @escaping (Data?) -> Void) + func updateToolsStatus( + tools: Data, + chatAgentMode: Data?, + customChatModeId: Data?, + workspaceFolders: Data?, + withReply reply: @escaping (Data?) -> Void + ) + func getCopilotFeatureFlags(withReply reply: @escaping (Data?) -> Void) + func getCopilotPolicy(withReply reply: @escaping (Data?) -> Void) + func updateCopilotModels(withReply reply: @escaping (Data?, Error?) -> Void) + func getModes(workspaceFolders: Data?, withReply reply: @escaping (Data?, Error?) -> Void) 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..3bb2f76e 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 @@ -26,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 } diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 54865f1d..c1d1b415 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,20 +78,10 @@ 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, - window.identifier == "Xcode.WorkspaceWindow" - else { return nil } - - return WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) + appElement.realtimeWorkspaceURL } public var realtimeProjectURL: URL? { @@ -408,6 +399,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..3899412b 100644 --- a/Tool/Sources/XcodeInspector/Helpers.swift +++ b/Tool/Sources/XcodeInspector/Helpers.swift @@ -16,3 +16,26 @@ 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) + } + + 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) + return app + } +} diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 6082a324..601e095d 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -34,11 +34,20 @@ 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 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, @@ -46,7 +55,7 @@ public class SourceEditor { selections: selections, cursorPosition: selections.first?.start ?? .outOfScope, cursorOffset: selectionRange?.lowerBound ?? 0, - lineAnnotations: lineAnnotations + lineAnnotationElements: lineAnnotationElements ) } @@ -60,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)) @@ -70,7 +78,7 @@ public class SourceEditor { selections: selections, cursorPosition: selections.first?.start ?? .outOfScope, cursorOffset: selectionRange?.lowerBound ?? 0, - lineAnnotations: lineAnnotations + lineAnnotationElements: lineAnnotationElements ) } @@ -294,3 +302,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/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index d2506822..1f767be6 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -111,6 +111,72 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return url } } + + // Fallback: If no child has the workspace path in description, + // try to derive it from the window's document URL + if let documentURL = extractDocumentURL(windowElement: windowElement) { + if let workspaceURL = deriveWorkspaceFromDocumentURL(documentURL) { + return workspaceURL + } + } + + return nil + } + + static func deriveWorkspaceFromDocumentURL(_ documentURL: URL) -> URL? { + // Check if documentURL itself is already a workspace/project/playground + if documentURL.pathExtension == "xcworkspace" || + documentURL.pathExtension == "xcodeproj" || + documentURL.pathExtension == "playground" { + return documentURL + } + + // Try to find .xcodeproj or .xcworkspace in parent directories + var currentURL = documentURL + while currentURL.pathComponents.count > 1 { + currentURL.deleteLastPathComponent() + + // Check if current directory is a playground + if currentURL.pathExtension == "playground" { + return currentURL + } + + // Check if this directory contains .xcodeproj or .xcworkspace + guard let contents = try? FileManager.default.contentsOfDirectory(atPath: currentURL.path) else { + continue + } + + // Check for .playground, .xcworkspace, and .xcodeproj in a single pass + var foundPlaygroundURL: URL? + var foundWorkspaceURL: URL? + var foundProjectURL: URL? + for item in contents { + if foundPlaygroundURL == nil, item.hasSuffix(".playground") { + foundPlaygroundURL = currentURL.appendingPathComponent(item) + } + if foundWorkspaceURL == nil, item.hasSuffix(".xcworkspace") { + foundWorkspaceURL = currentURL.appendingPathComponent(item) + } + if foundProjectURL == nil, item.hasSuffix(".xcodeproj") { + foundProjectURL = currentURL.appendingPathComponent(item) + } + } + if let playgroundURL = foundPlaygroundURL { + return playgroundURL + } + if let workspaceURL = foundWorkspaceURL { + return workspaceURL + } + if let projectURL = foundProjectURL { + return projectURL + } + + // Stop at the user's home directory or root + if currentURL.path == "/" || currentURL.path == NSHomeDirectory() { + break + } + } + return nil } @@ -152,4 +218,3 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return url } } - 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" + ) + } } 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 + } +} diff --git a/Tool/Tests/WorkspaceTests/WorkspaceFileTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceFileTests.swift new file mode 100644 index 00000000..87276a06 --- /dev/null +++ b/Tool/Tests/WorkspaceTests/WorkspaceFileTests.swift @@ -0,0 +1,460 @@ +import XCTest +import Foundation +@testable import Workspace + +class WorkspaceFileTests: XCTestCase { + func testMatchesPatterns() { + let url1 = URL(fileURLWithPath: "/path/to/file.swift") + let url2 = URL(fileURLWithPath: "/path/to/.git") + let patterns = [".git", ".svn"] + + XCTAssertTrue(WorkspaceFile.matchesPatterns(url2, patterns: patterns)) + XCTAssertFalse(WorkspaceFile.matchesPatterns(url1, patterns: patterns)) + } + + 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 { + throw error + } + } + + 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 { + throw error + } + } + + func testGetFilesInActiveProject() throws { + let tmpDir = try createTemporaryDirectory() + do { + 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") + let files = WorkspaceFile.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() + 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:../notExistedDir/notExistedProject.xcodeproj", + "group:../myDependency",]) + 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: "") + // 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 = WorkspaceFile.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 { + throw error + } + } + + func testGetSubprojectURLsFromXCWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + 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 = """ + + + + + + + + + + + + + + """ + 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 = """ + + + + + + + + + + + + + + + + + + + + """ + + // 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) { + 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 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 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 + } + + 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 + } + } +} diff --git a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift index 87276a06..9dd8854b 100644 --- a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift +++ b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift @@ -1,460 +1,230 @@ import XCTest import Foundation +import LanguageServerProtocol @testable import Workspace -class WorkspaceFileTests: XCTestCase { - func testMatchesPatterns() { - let url1 = URL(fileURLWithPath: "/path/to/file.swift") - let url2 = URL(fileURLWithPath: "/path/to/.git") - let patterns = [".git", ".svn"] - - XCTAssertTrue(WorkspaceFile.matchesPatterns(url2, patterns: patterns)) - XCTAssertFalse(WorkspaceFile.matchesPatterns(url1, patterns: patterns)) - } - - 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 { - throw error - } - } - - 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 { - throw error - } - } - - func testGetFilesInActiveProject() throws { - let tmpDir = try createTemporaryDirectory() - do { - 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") - let files = WorkspaceFile.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() - 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:../notExistedDir/notExistedProject.xcodeproj", - "group:../myDependency",]) - 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: "") - // 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 = WorkspaceFile.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 { - throw error - } +class WorkspaceTests: XCTestCase { + func testCalculateIncrementalChanges_IdenticalContent() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "Hello World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNil(changes, "Identical content should return nil") } - - func testGetSubprojectURLsFromXCWorkspace() throws { - let tmpDir = try createTemporaryDirectory() - defer { - deleteDirectoryIfExists(at: tmpDir) - } - - 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 = """ - - - - - - - - - - - - - - """ - 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 testCalculateIncrementalChanges_EmptyOldContent() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "" + let newContent = "New content" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range, LSPRange(start: Position(line: 0, character: 0), end: Position(line: 0, character: 0))) + XCTAssertEqual(changes?[0].text, "New content") } - - 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 testCalculateIncrementalChanges_EmptyNewContent() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Old content" + let newContent = "" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].text, "") + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 0) + XCTAssertEqual(changes?[0].rangeLength, oldContent.utf16.count) } - - 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 = """ - - - - - - - - - - - - - - - - - - - - """ - - // 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 testCalculateIncrementalChanges_InsertAtBeginning() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "World" + let newContent = "Hello World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 0) + XCTAssertEqual(changes?[0].text, "Hello ") } - - 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 testCalculateIncrementalChanges_InsertAtEnd() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello" + let newContent = "Hello World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 5) + XCTAssertEqual(changes?[0].text, " World") } - - 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 testCalculateIncrementalChanges_InsertInMiddle() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "Hello Beautiful World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 6) + XCTAssertEqual(changes?[0].text, "Beautiful ") } - - 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 testCalculateIncrementalChanges_DeleteFromBeginning() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 0) + XCTAssertEqual(changes?[0].range?.end.character, 6) + XCTAssertEqual(changes?[0].text, "") } - - 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 testCalculateIncrementalChanges_DeleteFromEnd() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "Hello" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 5) + XCTAssertEqual(changes?[0].text, "") } + + func testCalculateIncrementalChanges_ReplaceMiddle() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "Hello Swift" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) - 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 + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 6) + XCTAssertEqual(changes?[0].text, "Swift") } - - 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 testCalculateIncrementalChanges_MultilineInsert() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Line 1\nLine 3" + let newContent = "Line 1\nLine 2\nLine 3" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 1) + XCTAssertEqual(changes?[0].range?.start.character, 5) + XCTAssertEqual(changes?[0].text, "2\nLine ") } - - 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 testCalculateIncrementalChanges_MultilineDelete() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Line 1\nLine 2\nLine 3" + let newContent = "Line 1\nLine 3" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 1) + XCTAssertEqual(changes?[0].range?.start.character, 5) + XCTAssertEqual(changes?[0].range?.end.line, 2) + XCTAssertEqual(changes?[0].text, "") } - - func createXCworkspacedataFile(directory: URL, fileRefs: [String]) throws -> URL { - let contents = generateXCWorkspacedataContents(fileRefs: fileRefs) - return try createFile(in: directory, withName: "contents.xcworkspacedata", contents: contents) + + func testCalculateIncrementalChanges_MultilineReplace() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Line 1\nOld Line\nLine 3" + let newContent = "Line 1\nNew Line\nLine 3" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 1) + XCTAssertEqual(changes?[0].text, "New") } - - func generateXCWorkspacedataContents(fileRefs: [String]) -> String { - var contents = """ - - - """ - for fileRef in fileRefs { - contents += """ - - - """ - } - contents += "" - return contents + + func testCalculateIncrementalChanges_UTF16Characters() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello 世界" + let newContent = "Hello 🌍 世界" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 6) + XCTAssertEqual(changes?[0].text, "🌍 ") } - 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 testCalculateIncrementalChanges_VeryLargeContent() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = String(repeating: "a", count: 20000) + let newContent = String(repeating: "b", count: 20000) + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + // Should fallback to nil for very large contents (> 10000 characters) + XCTAssertNil(changes, "Very large content should return nil for fallback") } - 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 testCalculateIncrementalChanges_ComplexEdit() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = """ + func hello() { + print("Hello") + } + """ + let newContent = """ + func hello(name: String) { + print("Hello, \\(name)!") + } + """ + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + // Verify that a change was detected + XCTAssertFalse(changes?[0].text.isEmpty ?? true) } - 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 - } + func testCalculateIncrementalChanges_NewlineVariations() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Line 1\nLine 2" + let newContent = "Line 1\nLine 2\n" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 1) + XCTAssertEqual(changes?[0].text, "\n") } }