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/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..b79131c4 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,46 @@ +# Copilot Agent Instructions for GitHub Copilot for Xcode + +## Project Overview +- **Purpose:** This project is an Xcode extension and companion app that brings GitHub Copilot's AI code suggestions and chat to Xcode, with deep integration for inline completions, chat, and agent-driven codebase modifications. +- **Architecture:** + - **Core/**: Swift Package containing main business logic, services, and UI components. Organized by feature (e.g., `ChatService`, `SuggestionService`, `PromptToCodeService`). + - **EditorExtension/**: Implements Xcode Source Editor Extension commands (e.g., Accept/Reject Suggestion, Open Chat, etc.). + - **CommunicationBridge/**: Handles XPC communication between the main app and extension services. + - **ExtensionService/**: Manages the lifecycle and UI of the extension's background service. + - **Server/**: Node.js backend for advanced features (optional, rarely modified). + - **Docs/**: Images and documentation assets. + +## Key Workflows +- **Build & Run:** + - Open `Copilot for Xcode.xcworkspace` in Xcode. + - Build the `GitHub Copilot for Xcode` app target for macOS 12+. + - The extension is enabled via System Preferences > Extensions > Xcode Source Editor. +- **Testing:** + - Run Swift Package tests from the `Core/` directory using Xcode or `swift test`. +- **Debugging:** + - Use the `CommunicationBridge` and `ExtensionService` logs for troubleshooting XPC and extension issues. + - See `TROUBLESHOOTING.md` for permission and integration issues. + +## Project Conventions +- **Feature Folders:** Each major feature in `Core/Sources/` is a separate folder with its own logic and tests. +- **Dependency Injection:** Uses [swift-dependencies](https://github.com/pointfreeco/swift-dependencies) and [Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) for state and effect management. +- **XPC Communication:** All cross-process calls use protocols in `Tool/` and are implemented in `CommunicationBridge/` and `ExtensionService/`. +- **Permissions:** Requires `Accessibility`, `Background`, and `Xcode Source Editor Extension` permissions. See `TROUBLESHOOTING.md` for details. +- **External Packages:** Managed in `Core/Package.swift`. Do not add dependencies directly to Xcode project files. + +## Integration Points +- **Xcode Extension:** Commands in `EditorExtension/` are registered in `Info.plist` and invoked via the Xcode Editor menu. +- **App ↔ Extension:** Communication via XPC, with protocols defined in `Tool/` and implemented in `CommunicationBridge/ServiceDelegate.swift`. +- **Updates:** Uses [Sparkle](https://sparkle-project.org/) for in-app updates. + +## Examples +- To add a new chat feature: create a folder in `Core/Sources/`, add logic, register in `Package.swift`, and connect via the appropriate service. +- To add a new editor command: implement in `EditorExtension/`, update `Info.plist`, and test in Xcode. + +## References +- See `README.md` for user setup and onboarding. +- See `TROUBLESHOOTING.md` for common integration and permission issues. +- See `Core/Package.swift` for dependency and target structure. + +--- +For questions about unclear patterns or missing documentation, ask for clarification or check the latest onboarding docs in `Docs/`. diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 00000000..21ae770f --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,22 @@ +# This workflow will build a Swift project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift + +name: Swift + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + - name: Build + run: swift build -v + - name: Run tests + run: swift test -v diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..d2895982 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,43 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Edge", + "request": "launch", + "type": "msedge", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + }, + { + "name": "Launch Chrome", + "request": "launch", + "type": "chrome", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + }, + { + "name": "Launch Edge", + "request": "launch", + "type": "msedge", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + }, + { + "name": "Launch Chrome", + "request": "launch", + "type": "chrome", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + }, + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ffedc4..9554f9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,109 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.43.0 - September 4, 2025 +### Fixed +- Cannot type non-Latin characters in the chat input field. + +## 0.42.0 - September 3, 2025 +### Added +- Support for Bring Your Own Keys (BYOK) with model providers including Azure, OpenAI, Anthropic, Gemini, Groq, and OpenRouter. See [BYOK.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/BYOK.md). +- 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. + +## 0.39.0 - July 23, 2025 +### Fixed +- Performance: Fixed a freezing issue in 'Add Context' view when opening large projects. +- Login failed due to insufficient permissions on the .config folder. +- Fixed an issue that setting changes like proxy config did not take effect. +- Increased the timeout for ask mode to prevent response failures due to timeout. + +## 0.38.0 - June 30, 2025 +### Added +- Support for Claude 4 in Chat. +- Support for Copilot Vision (image attachments). +- Support for remote MCP servers. + +### Changed +- Automatically suggests a title for conversations created in agent mode. +- Improved restoration of MCP tool status after Copilot restarts. +- Reduced duplication of MCP server instances. + +### Fixed +- Switching accounts now correctly refreshes the auth token and models. +- Fixed file create/edit issues in agent mode. + +## 0.37.0 - June 18, 2025 +### Added +- **Advanced** settings: Added option to configure **Custom Instructions** for GitHub Copilot during chat sessions. +- **Advanced** settings: Added option to keep the chat window automatically attached to Xcode. + +### Changed +- Enabled support for dragging-and-dropping files into the chat panel to provide context. + +### Fixed +- "Add Context" menu didn’t show files in workspaces organized with Xcode’s group feature. +- Chat didn’t respond when the workspace was in a system folder (like Desktop, Downloads, or Documents) and access permission hadn’t been granted. + +## 0.36.0 - June 4, 2025 +### Added +- Introduced a new chat setting "**Response Language**" under **Advanced** settings to customize the natural language used in chat replies. +- Enabled support for custom instructions defined in _.github/copilot-instructions.md_ within your workspace. +- Added support for premium request handling. + +### Fixed +- Performance: Improved UI responsiveness by lazily restoring chat history. +- Performance: Fixed lagging issue when pasting large text into the chat input. +- Performance: Improved project indexing performance. +- Don't trigger / (slash) commands when pasting a file path into the chat input. +- Adjusted terminal text styling to align with Xcode’s theme. + +## 0.35.0 - May 19, 2025 +### Added +- Launched Agent Mode. Copilot will automatically use multiple requests to edit files, run terminal commands, and fix errors. +- Introduced Model Context Protocol (MCP) support in Agent Mode, allowing you to configure MCP tools to extend capabilities. + +### Changed +- Added a button to enable/disable referencing current file in conversations +- Added an animated progress icon in the response section +- Refined onboarding experience with updated instruction screens and welcome views +- Improved conversation reliability with extended timeout limits for agent requests + +### Fixed +- Addressed critical error handling issues in core functionality +- Resolved UI inconsistencies with chat interface padding adjustments +- Implemented custom certificate handling using system environment variables `NODE_EXTRA_CA_CERTS` and `NODE_TLS_REJECT_UNAUTHORIZED`, fixing network access issues + ## 0.34.0 - April 29, 2025 ### Added - Added support for new models in Chat: OpenAI GPT-4.1, o3 and o4-mini, Gemini 2.5 Pro diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 844f7d7c..2cd753c1 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -188,8 +188,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server"; path = "Server/node_modules/@github/copilot-language-server/native/darwin-x64/copilot-language-server"; sourceTree = SOURCE_ROOT; }; - 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server-arm64"; path = "Server/node_modules/@github/copilot-language-server/native/darwin-arm64/copilot-language-server-arm64"; sourceTree = SOURCE_ROOT; }; + 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server"; path = "Server/node_modules/@github/copilot-language-server-darwin-x64/copilot-language-server"; sourceTree = SOURCE_ROOT; }; + 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server-arm64"; path = "Server/node_modules/@github/copilot-language-server-darwin-arm64/copilot-language-server-arm64"; sourceTree = SOURCE_ROOT; }; 3E5DB74F2D6B88EE00418952 /* ReleaseNotes.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ReleaseNotes.md; sourceTree = ""; }; 424ACA202CA4697200FA20F2 /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = ""; }; 427C63272C6E868B000E557C /* OpenSettingsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSettingsCommand.swift; sourceTree = ""; }; @@ -719,7 +719,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=/usr/local/bin:/opt/homebrew/bin:$PATH\n\nnpm -C Server install\ncp Server/node_modules/@github/copilot-language-server/native/darwin-arm64/copilot-language-server Server/node_modules/@github/copilot-language-server/native/darwin-arm64/copilot-language-server-arm64\n\necho \"Build and copy webview js/html files as the bundle resources\"\nnpm -C Server run build\nmkdir -p \"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/webViewDist\"\ncp -R Server/dist/* \"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/webViewDist/\"\n"; + shellScript = "export PATH=/usr/local/bin:/opt/homebrew/bin:$PATH\n\nnpm -C Server install --force\ncp Server/node_modules/@github/copilot-language-server-darwin-arm64/copilot-language-server Server/node_modules/@github/copilot-language-server-darwin-arm64/copilot-language-server-arm64\n\necho \"Build and copy webview js/html files as the bundle resources\"\nnpm -C Server run build\nmkdir -p \"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/webViewDist\"\ncp -R Server/dist/* \"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/webViewDist/\"\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/Copilot for Xcode.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"> + + + + + + NSView { return NSVisualEffectView() } @@ -19,7 +20,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { enum LaunchMode { case chat case settings - case mcp + case tools + case byok } func applicationDidFinishLaunching(_ notification: Notification) { @@ -47,8 +49,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { let launchArgs = CommandLine.arguments if launchArgs.contains("--settings") { return .settings - } else if launchArgs.contains("--mcp") { - return .mcp + } else if launchArgs.contains("--tools") { + return .tools + } else if launchArgs.contains("--byok") { + return .byok } else { return .chat } @@ -58,8 +62,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { switch mode { case .settings: openSettings() - case .mcp: - openMCPSettings() + case .tools: + openToolsSettings() + case .byok: + openBYOKSettings() case .chat: openChat() } @@ -80,10 +86,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - private func openMCPSettings() { + private func openToolsSettings() { DispatchQueue.main.async { activateAndOpenSettings() - hostAppStore.send(.setActiveTab(2)) + hostAppStore.send(.setActiveTab(.tools)) + } + } + + private func openBYOKSettings() { + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.byok)) } } @@ -180,26 +193,39 @@ struct CopilotForXcodeApp: App { } DistributedNotificationCenter.default().addObserver( - forName: .openMCPSettingsWindowRequest, + forName: .openToolsSettingsWindowRequest, object: nil, queue: .main ) { _ in DispatchQueue.main.async { activateAndOpenSettings() - hostAppStore.send(.setActiveTab(2)) + hostAppStore.send(.setActiveTab(.tools)) + } + } + + DistributedNotificationCenter.default().addObserver( + forName: .openBYOKSettingsWindowRequest, + object: nil, + queue: .main + ) { _ in + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.byok)) } } } var body: some Scene { - Settings { - TabContainer() - .frame(minWidth: 800, minHeight: 600) - .background(VisualEffect().ignoresSafeArea()) - .environment(\.updateChecker, UpdateChecker( - hostBundle: Bundle.main, - checkerDelegate: AppUpdateCheckerDelegate() - )) + WithPerceptionTracking { + Settings { + TabContainer() + .frame(minWidth: 800, minHeight: 600) + .background(VisualEffect().ignoresSafeArea()) + .environment(\.updateChecker, UpdateChecker( + hostBundle: Bundle.main, + checkerDelegate: AppUpdateCheckerDelegate() + )) + } } } } diff --git a/Copilot for Xcode/Assets.xcassets/Model.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/Model.imageset/Contents.json new file mode 100644 index 00000000..0923b9bd --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/Model.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ai-model-16.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Copilot for Xcode/Assets.xcassets/Model.imageset/ai-model-16.svg b/Copilot for Xcode/Assets.xcassets/Model.imageset/ai-model-16.svg new file mode 100644 index 00000000..8b7c28e2 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/Model.imageset/ai-model-16.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/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/Credits.rtf b/Copilot for Xcode/Credits.rtf index d282374b..13a16781 100644 --- a/Copilot for Xcode/Credits.rtf +++ b/Copilot for Xcode/Credits.rtf @@ -163,7 +163,7 @@ SOFTWARE.\ \ \ Dependency: github.com/apple/swift-syntax\ -Version: 509.0.2\ +Version: 510.0.3\ License Content:\ Apache License\ Version 2.0, January 2004\ @@ -1761,7 +1761,7 @@ License Content:\ \ \ Dependency: github.com/ChimeHQ/JSONRPC\ -Version: 0.6.0\ +Version: 0.9.0\ License Content:\ BSD 3-Clause License\ \ @@ -1795,7 +1795,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\ \ \ Dependency: github.com/ChimeHQ/LanguageServerProtocol\ -Version: 0.8.0\ +Version: 0.13.3\ License Content:\ BSD 3-Clause License\ \ @@ -2611,7 +2611,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI \ \ Dependency: github.com/ChimeHQ/LanguageClient\ -Version: 0.3.1\ +Version: 0.8.2\ License Content:\ BSD 3-Clause License\ \ @@ -2645,7 +2645,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\ \ \ Dependency: github.com/ChimeHQ/ProcessEnv\ -Version: 0.3.1\ +Version: 1.0.1\ License Content:\ BSD 3-Clause License\ \ @@ -3322,4 +3322,31 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\ THE SOFTWARE.\ \ \ +Dependency: https://github.com/scinfu/SwiftSoup\ +Version: 2.9.6\ +License Content:\ +The MIT License\ +\ +Copyright (c) 2009-2025 Jonathan Hedley \ +Swift port copyright (c) 2016-2025 Nabil Chatbi\ +\ +Permission is hereby granted, free of charge, to any person obtaining a copy\ +of this software and associated documentation files (the "Software"), to deal\ +in the Software without restriction, including without limitation the rights\ +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ +copies of the Software, and to permit persons to whom the Software is\ +furnished to do so, subject to the following conditions:\ +\ +The above copyright notice and this permission notice shall be included in all\ +copies or substantial portions of the Software.\ +\ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\ +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\ +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\ +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\ +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\ +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ +SOFTWARE.\ +\ +\ } \ No newline at end of file diff --git a/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 5ae7a86c..33ad1c48 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -53,8 +53,7 @@ let package = Package( .package(url: "https://github.com/devm33/KeyboardShortcuts", branch: "main"), .package(url: "https://github.com/devm33/CGEventOverride", branch: "devm33/fix-stale-AXIsProcessTrusted"), .package(url: "https://github.com/devm33/Highlightr", branch: "master"), - .package(url: "https://github.com/globulus/swiftui-flow-layout", - from: "1.0.5") + .package(url: "https://github.com/globulus/swiftui-flow-layout", from: "1.0.5") ], targets: [ // MARK: - Main @@ -180,7 +179,12 @@ let package = Package( .product(name: "ConversationServiceProvider", package: "Tool"), .product(name: "GitHubCopilotService", package: "Tool"), .product(name: "Workspace", package: "Tool"), - .product(name: "Terminal", package: "Tool") + .product(name: "Terminal", package: "Tool"), + .product(name: "SystemUtils", package: "Tool"), + .product(name: "AppKitExtension", package: "Tool"), + .product(name: "WebContentExtractor", package: "Tool"), + .product(name: "GitHelper", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool") ]), .testTarget( name: "ChatServiceTests", @@ -200,8 +204,7 @@ let package = Package( .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout"), - .product(name: "Persist", package: "Tool"), - .product(name: "Terminal", package: "Tool") + .product(name: "Persist", package: "Tool") ] ), diff --git a/Core/Sources/ChatService/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 5d483a75..cfbc068c 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -14,10 +14,26 @@ import Logger 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, skillSet: [ConversationSkill], references: [FileReference], model: String?, agentMode: Bool) async throws + func send( + _ id: String, + content: String, + contentImages: [ChatCompletionContentPartImage], + contentImageReferences: [ImageReference], + skillSet: [ConversationSkill], + references: [ConversationAttachedReference], + model: String?, + modelProviderName: String?, + agentMode: Bool, + userLanguage: String?, + turnId: String? + ) async throws func stopReceivingMessage() async func upvote(_ id: String, _ rating: ConversationRating) async func downvote(_ id: String, _ rating: ConversationRating) async @@ -65,11 +81,16 @@ public struct FileEdit: Equatable { public final class ChatService: ChatServiceType, ObservableObject { + public enum RequestType: String, Equatable { + case conversation, codeReview + } + public var memory: ContextAwareAutoManagedChatMemory @Published public internal(set) var chatHistory: [ChatMessage] = [] @Published public internal(set) var isReceivingMessage = false @Published public internal(set) var fileEditMap: OrderedDictionary = [:] - public 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 @@ -79,6 +100,7 @@ public final class ChatService: ChatServiceType, ObservableObject { private var activeRequestId: String? private(set) public var conversationId: String? private var skillSet: [ConversationSkill] = [] + private var lastUserRequest: ConversationRequest? private var isRestored: Bool = false private var pendingToolCallRequests: [String: ToolCallRequest] = [:] init(provider: any ConversationServiceProvider, @@ -93,11 +115,27 @@ public final class ChatService: ChatServiceType, ObservableObject { subscribeToNotifications() subscribeToConversationContextRequest() - subscribeToWatchedFilesHandler() subscribeToClientToolInvokeEvent() subscribeToClientToolConfirmationEvent() } + deinit { + Task { [weak self] in + await self?.stopReceivingMessage() + } + + // Clear all subscriptions + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + + // 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 @@ -129,13 +167,6 @@ public final class ChatService: ChatServiceType, ObservableObject { } }).store(in: &cancellables) } - - private func subscribeToWatchedFilesHandler() { - self.watchedFilesHandler.onWatchedFiles.sink(receiveValue: { [weak self] (request, completion) in - guard let self, request.params!.workspaceFolder.uri != "/" else { return } - self.startFileChangeWatcher() - }).store(in: &cancellables) - } private func subscribeToClientToolConfirmationEvent() { ClientToolHandlerImpl.shared.onClientToolConfirmationEvent.sink(receiveValue: { [weak self] (request, completion) in @@ -184,13 +215,8 @@ public final class ChatService: ChatServiceType, ObservableObject { let chatTabId = self.chatTabInfo.id Task { let message = ChatMessage( - id: turnId, + assistantMessageWithId: turnId, chatTabID: chatTabId, - clsTurnID: turnId, - role: .assistant, - content: "", - references: [], - steps: [], editAgentRounds: editAgentRounds ) @@ -211,6 +237,10 @@ public final class ChatService: ChatServiceType, ObservableObject { } } + public func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws { + try await conversationProvider?.notifyChangeTextDocument(fileURL: fileURL, content: content, version: version, workspaceURL: getWorkspaceURL()) + } + public static func service(for chatTabInfo: ChatTabInfo) -> ChatService { let provider = BuiltinExtensionConversationServiceProvider( extension: GitHubCopilotExtension.self @@ -302,20 +332,87 @@ public final class ChatService: ChatServiceType, ObservableObject { } } } + + public enum ChatServiceError: Error, LocalizedError { + case conflictingImageFormats(String) + + public var errorDescription: String? { + switch self { + case .conflictingImageFormats(let message): + return message + } + } + } - public func send(_ id: String, content: String, skillSet: Array, references: Array, model: String? = nil, agentMode: Bool = false) async throws { + public func send( + _ id: String, + content: String, + contentImages: Array = [], + contentImageReferences: Array = [], + skillSet: Array, + references: [ConversationAttachedReference], + model: String? = nil, + modelProviderName: String? = nil, + agentMode: Bool = false, + userLanguage: String? = nil, + turnId: String? = nil + ) async throws { guard activeRequestId == nil else { return } let workDoneToken = UUID().uuidString activeRequestId = workDoneToken - let chatMessage = ChatMessage( - id: id, - chatTabID: self.chatTabInfo.id, - role: .user, + let finalImageReferences: [ImageReference] + let finalContentImages: [ChatCompletionContentPartImage] + + if !contentImageReferences.isEmpty { + // User attached images are all parsed as ImageReference + finalImageReferences = contentImageReferences + finalContentImages = contentImageReferences + .map { + ChatCompletionContentPartImage( + url: $0.dataURL(imageType: $0.source == .screenshot ? "png" : "") + ) + } + } else { + // In current implementation, only resend message will have contentImageReferences + // No need to convert ChatCompletionContentPartImage to ImageReference for persistence + finalImageReferences = [] + finalContentImages = contentImages + } + + var chatMessage = ChatMessage( + userMessageWithId: id, + chatTabId: chatTabInfo.id, content: content, + contentImageReferences: finalImageReferences, references: references.toConversationReferences() ) - await memory.appendMessage(chatMessage) + + let currentEditorSkill = skillSet.first(where: { $0.id == CurrentEditorSkill.ID }) as? CurrentEditorSkill + let currentFileReadability = currentEditorSkill == nil + ? nil + : FileUtils.checkFileReadability(at: currentEditorSkill!.currentFilePath) + var errorMessage: ChatMessage? + + var currentTurnId: String? = turnId + // If turnId is provided, it is used to update the existing message, no need to append the user message + if turnId == nil { + if let currentFileReadability, !currentFileReadability.isReadable { + // For associating error message with user message + currentTurnId = UUID().uuidString + chatMessage.clsTurnID = currentTurnId + errorMessage = ChatMessage( + errorMessageWithId: currentTurnId!, + chatTabID: chatTabInfo.id, + errorMessages: [ + currentFileReadability.errorMessage( + using: CurrentEditorSkill.readabilityErrorMessageProvider + ) + ].compactMap { $0 }.filter { !$0.isEmpty } + ) + } + await memory.appendMessage(chatMessage) + } // reset file edits self.resetFileEdits() @@ -331,12 +428,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) } @@ -344,28 +438,74 @@ public final class ChatService: ChatServiceType, ObservableObject { return } - let skillCapabilities: [String] = [ CurrentEditorSkill.ID, ProblemsInActiveDocumentSkill.ID ] + if let errorMessage { + Task { await memory.appendMessage(errorMessage) } + } + + var activeDoc: Doc? + var validSkillSet: [ConversationSkill] = skillSet + if let currentEditorSkill, currentFileReadability?.isReadable == true { + activeDoc = Doc(uri: currentEditorSkill.currentFile.url.absoluteString) + } else { + validSkillSet.removeAll(where: { $0.id == CurrentEditorSkill.ID || $0.id == ProblemsInActiveDocumentSkill.ID }) + } + + let request = createConversationRequest( + workDoneToken: workDoneToken, + content: content, + contentImages: finalContentImages, + activeDoc: activeDoc, + references: references, + model: model, + modelProviderName: modelProviderName, + agentMode: agentMode, + userLanguage: userLanguage, + turnId: currentTurnId, + skillSet: validSkillSet + ) + + self.lastUserRequest = request + self.skillSet = validSkillSet + try await sendConversationRequest(request) + } + + private func createConversationRequest( + workDoneToken: String, + content: String, + contentImages: [ChatCompletionContentPartImage] = [], + activeDoc: Doc?, + references: [ConversationAttachedReference], + model: String? = nil, + modelProviderName: String? = nil, + agentMode: Bool = false, + userLanguage: String? = nil, + turnId: String? = nil, + skillSet: [ConversationSkill] + ) -> ConversationRequest { + let skillCapabilities: [String] = [CurrentEditorSkill.ID, ProblemsInActiveDocumentSkill.ID] let supportedSkills: [String] = skillSet.map { $0.id } let ignoredSkills: [String] = skillCapabilities.filter { !supportedSkills.contains($0) } - let currentEditorSkill = skillSet.first { $0.id == CurrentEditorSkill.ID } - let activeDoc: Doc? = (currentEditorSkill as? CurrentEditorSkill).map { Doc(uri: $0.currentFile.url.absoluteString) } /// replace the `@workspace` to `@project` let newContent = replaceFirstWord(in: content, from: "@workspace", to: "@project") - let request = ConversationRequest(workDoneToken: workDoneToken, - content: newContent, - workspaceFolder: "", - activeDoc: activeDoc, - skills: skillCapabilities, - ignoredSkills: ignoredSkills, - references: references, - model: model, - agentMode: agentMode) - self.skillSet = skillSet - try await send(request) + return ConversationRequest( + workDoneToken: workDoneToken, + content: newContent, + contentImages: contentImages, + workspaceFolder: "", + activeDoc: activeDoc, + skills: skillCapabilities, + ignoredSkills: ignoredSkills, + references: references, + model: model, + modelProviderName: modelProviderName, + agentMode: agentMode, + userLanguage: userLanguage, + turnId: turnId + ) } public func sendAndWait(_ id: String, content: String) async throws -> String { @@ -379,7 +519,7 @@ public final class ChatService: ChatServiceType, ObservableObject { public func stopReceivingMessage() async { if let activeRequestId = activeRequestId { do { - try await conversationProvider?.stopReceivingMessage(activeRequestId) + try await conversationProvider?.stopReceivingMessage(activeRequestId, workspaceURL: getWorkspaceURL()) } catch { print("Failed to cancel ongoing request with WDT: \(activeRequestId)") } @@ -393,7 +533,7 @@ public final class ChatService: ChatServiceType, ObservableObject { await memory.clearHistory() if let activeRequestId = activeRequestId { do { - try await conversationProvider?.stopReceivingMessage(activeRequestId) + try await conversationProvider?.stopReceivingMessage(activeRequestId, workspaceURL: getWorkspaceURL()) } catch { print("Failed to cancel ongoing request with WDT: \(activeRequestId)") } @@ -408,15 +548,24 @@ public final class ChatService: ChatServiceType, ObservableObject { deleteChatMessageFromStorage(id) } - // Not used for now - public func resendMessage(id: String) async throws { - if let message = (await memory.history).first(where: { $0.id == id }) + public func resendMessage(id: String, model: String? = nil, modelProviderName: String? = nil) async throws { + if let _ = (await memory.history).first(where: { $0.id == id }), + let lastUserRequest { - do { - try await send(id, content: message.content, skillSet: [], references: []) - } catch { - print("Failed to resend message") - } + // TODO: clean up contents for resend message + activeRequestId = nil + try await send( + id, + content: lastUserRequest.content, + contentImages: lastUserRequest.contentImages, + skillSet: skillSet, + references: lastUserRequest.references ?? [], + model: model != nil ? model : lastUserRequest.model, + modelProviderName: modelProviderName, + agentMode: lastUserRequest.agentMode, + userLanguage: lastUserRequest.userLanguage, + turnId: id + ) } } @@ -491,13 +640,28 @@ public final class ChatService: ChatServiceType, ObservableObject { try await send(UUID().uuidString, content: templateProcessor.process(sendingMessageImmediately), skillSet: [], references: []) } } + + public func getWorkspaceURL() -> URL? { + guard !chatTabInfo.workspacePath.isEmpty else { + return nil + } + return URL(fileURLWithPath: chatTabInfo.workspacePath) + } + + public func 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) + try? await conversationProvider?.rateConversation(turnId: id, rating: rating, workspaceURL: getWorkspaceURL()) } public func downvote(_ id: String, _ rating: ConversationRating) async { - try? await conversationProvider?.rateConversation(turnId: id, rating: rating) + try? await conversationProvider?.rateConversation(turnId: id, rating: rating, workspaceURL: getWorkspaceURL()) } public func copyCode(_ id: String) async { @@ -521,6 +685,19 @@ public final class ChatService: ChatServiceType, ObservableObject { Task { if var lastUserMessage = await memory.history.last(where: { $0.role == .user }) { + + // Case: New conversation where error message was generated before CLS request + // Using clsTurnId to associate this error message with the corresponding user message + // When merging error messages with bot responses from CLS, these properties need to be updated + await memory.mutateHistory { history in + if let existingBotIndex = history.lastIndex(where: { + $0.role == .assistant && $0.clsTurnID == lastUserMessage.clsTurnID + }) { + history[existingBotIndex].id = turnId + history[existingBotIndex].clsTurnID = turnId + } + } + lastUserMessage.clsTurnID = progress.turnId saveChatMessageToStorage(lastUserMessage) } @@ -528,13 +705,7 @@ public final class ChatService: ChatServiceType, ObservableObject { /// Display an initial assistant message immediately after the user sends a message. /// This improves perceived responsiveness, especially in Agent Mode where the first /// ProgressReport may take long time. - let message = ChatMessage( - id: turnId, - chatTabID: self.chatTabInfo.id, - clsTurnID: turnId, - role: .assistant, - content: "" - ) + let message = ChatMessage(assistantMessageWithId: turnId, chatTabID: chatTabInfo.id) // will persist in resetOngoingRequest() await memory.appendMessage(message) @@ -580,10 +751,8 @@ public final class ChatService: ChatServiceType, ObservableObject { Task { let message = ChatMessage( - id: id, - chatTabID: self.chatTabInfo.id, - clsTurnID: id, - role: .assistant, + assistantMessageWithId: id, + chatTabID: chatTabInfo.id, content: messageContent, references: messageReferences, steps: messageSteps, @@ -604,67 +773,81 @@ public final class ChatService: ChatServiceType, ObservableObject { if CLSError.code == 402 { Task { await Status.shared - .updateCLSStatus(.error, busy: false, message: CLSError.message) + .updateCLSStatus(.warning, busy: false, message: CLSError.message) let errorMessage = ChatMessage( - id: progress.turnId, - chatTabID: self.chatTabInfo.id, - clsTurnID: progress.turnId, - role: .system, - content: CLSError.message + errorMessageWithId: progress.turnId, + chatTabID: chatTabInfo.id, + panelMessages: [.init(type: .error, title: String(CLSError.code ?? 0), message: CLSError.message, location: .Panel)] ) // will persist in resetongoingRequest() - await memory.removeMessage(progress.turnId) await memory.appendMessage(errorMessage) + + if let lastUserRequest, + let currentUserPlan = await Status.shared.currentUserPlan(), + currentUserPlan != "free" { + guard let fallbackModel = CopilotModelManager.getFallbackLLM( + scope: lastUserRequest.agentMode ? .agentPanel : .chatPanel + ) else { + resetOngoingRequest() + return + } + do { + CopilotModelManager.switchToFallbackModel() + try await resendMessage( + id: progress.turnId, + model: fallbackModel.id, + modelProviderName: nil + ) + } catch { + Logger.gitHubCopilot.error(error) + resetOngoingRequest() + } + return + } } } else if CLSError.code == 400 && CLSError.message.contains("model is not supported") { Task { let errorMessage = ChatMessage( - id: progress.turnId, - chatTabID: self.chatTabInfo.id, - role: .assistant, - content: "", - errorMessage: "Oops, the model is not supported. Please enable it first in [GitHub Copilot settings](https://github.com/settings/copilot)." + 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() + return } } else { Task { let errorMessage = ChatMessage( - id: progress.turnId, - chatTabID: self.chatTabInfo.id, - clsTurnID: progress.turnId, - role: .assistant, - content: "", - errorMessage: CLSError.message + errorMessageWithId: progress.turnId, + chatTabID: chatTabInfo.id, + errorMessages: [CLSError.message] ) // will persist in resetOngoingRequest() await memory.appendMessage(errorMessage) + resetOngoingRequest() + return } } - resetOngoingRequest() - return } 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 ) // will persist in resetOngoingRequest() await memory.appendMessage(message) + resetOngoingRequest() } - - resetOngoingRequest() } private func resetOngoingRequest() { activeRequestId = nil isReceivingMessage = false + requestType = nil // cancel all pending tool call requests for (_, request) in pendingToolCallRequests { @@ -708,6 +891,15 @@ public final class ChatService: ChatServiceType, ObservableObject { } } } + + if history[lastIndex].codeReviewRound != nil, + ( + history[lastIndex].codeReviewRound!.status == .waitForConfirmation + || history[lastIndex].codeReviewRound!.status == .running + ) + { + history[lastIndex].codeReviewRound!.status = .cancelled + } }) // The message of progress report could change rapidly @@ -719,13 +911,19 @@ public final class ChatService: ChatServiceType, ObservableObject { } } - private func send(_ request: ConversationRequest) async throws { + private func sendConversationRequest(_ request: ConversationRequest) async throws { guard !isReceivingMessage else { throw CancellationError() } isReceivingMessage = true + requestType = .conversation do { if let conversationId = conversationId { - try await conversationProvider?.createTurn(with: conversationId, request: request) + try await conversationProvider? + .createTurn( + with: conversationId, + request: request, + workspaceURL: getWorkspaceURL() + ) } else { var requestWithTurns = request @@ -738,7 +936,7 @@ public final class ChatService: ChatServiceType, ObservableObject { requestWithTurns.turns = turns } - try await conversationProvider?.createConversation(requestWithTurns) + try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL()) } } catch { resetOngoingRequest() @@ -754,7 +952,7 @@ public final class ChatService: ChatServiceType, ObservableObject { switch fileEdit.toolName { case .insertEditIntoFile: - try InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent, contextProvider: self) + InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent, contextProvider: self) case .createFile: try CreateFileTool.undo(for: fileURL) default: @@ -800,8 +998,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 @@ -865,26 +1061,6 @@ extension ChatService { func fetchAllChatMessagesFromStorage() -> [ChatMessage] { return ChatMessageStore.getAll(by: self.chatTabInfo.id, metadata: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username)) } - - /// for file change watcher - func startFileChangeWatcher() { - Task { [weak self] in - guard let self else { return } - let workspaceURL = URL(fileURLWithPath: self.chatTabInfo.workspacePath) - let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(workspaceURL: workspaceURL, documentURL: nil) ?? workspaceURL - await FileChangeWatcherServicePool.shared.watch( - for: workspaceURL - ) { fileEvents in - Task { [weak self] in - guard let self else { return } - try? await self.conversationProvider?.notifyDidChangeWatchedFiles( - .init(workspaceUri: projectURL.path, changes: fileEvents), - workspace: .init(workspaceURL: workspaceURL, projectURL: projectURL) - ) - } - } - } - } } func replaceFirstWord(in content: String, from oldWord: String, to newWord: String) -> String { @@ -898,21 +1074,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 @@ -951,3 +1141,164 @@ extension [ChatMessage] { return content } } + +// MARK: Copilot Code Review + +extension ChatService { + + public func requestCodeReview(_ group: GitDiffGroup) async throws { + guard activeRequestId == nil else { return } + activeRequestId = UUID().uuidString + + guard !isReceivingMessage else { + activeRequestId = nil + throw CancellationError() + } + isReceivingMessage = true + requestType = .codeReview + + do { + await CodeReviewService.shared.resetComments() + + let turnId = UUID().uuidString + + await addCodeReviewUserMessage(id: UUID().uuidString, turnId: turnId, group: group) + + let initialBotMessage = ChatMessage( + assistantMessageWithId: turnId, + chatTabID: chatTabInfo.id + ) + await memory.appendMessage(initialBotMessage) + + guard let projectRootURL = getProjectRootURL() + else { + let round = CodeReviewRound.fromError(turnId: turnId, error: "Invalid git repository.") + await appendCodeReviewRound(round) + resetOngoingRequest() + return + } + + let prChanges = await CurrentChangeService.getPRChanges( + projectRootURL, + group: group, + shouldIncludeFile: shouldIncludeFileForReview + ) + guard !prChanges.isEmpty else { + let round = CodeReviewRound.fromError( + turnId: turnId, + error: group == .index ? "No staged changes found to review." : "No unstaged changes found to review." + ) + await appendCodeReviewRound(round) + resetOngoingRequest() + return + } + + let round: CodeReviewRound = .init( + turnId: turnId, + status: .waitForConfirmation, + request: .from(prChanges) + ) + await appendCodeReviewRound(round) + } catch { + resetOngoingRequest() + throw error + } + } + + private func shouldIncludeFileForReview(url: URL) -> Bool { + let codeLanguage = CodeLanguage(fileURL: url) + + if case .builtIn = codeLanguage { + return true + } else { + return false + } + } + + private func appendCodeReviewRound(_ round: CodeReviewRound) async { + let message = ChatMessage( + assistantMessageWithId: round.turnId, chatTabID: chatTabInfo.id, codeReviewRound: round + ) + + await memory.appendMessage(message) + } + + private func getCurrentCodeReviewRound(_ id: String) async -> CodeReviewRound? { + guard let lastBotMessage = await memory.history.last, + lastBotMessage.role == .assistant, + let codeReviewRound = lastBotMessage.codeReviewRound, + codeReviewRound.id == id + else { + return nil + } + + return codeReviewRound + } + + public func acceptCodeReview(_ id: String, selectedFileUris: [DocumentUri]) async { + guard activeRequestId != nil, isReceivingMessage else { return } + + guard var round = await getCurrentCodeReviewRound(id), + var request = round.request, + round.status.canTransitionTo(.accepted) + else { return } + + guard selectedFileUris.count > 0 else { + round = round.withError("No files are selected to review.") + await appendCodeReviewRound(round) + resetOngoingRequest() + return + } + + round.status = .accepted + request.updateSelectedChanges(by: selectedFileUris) + round.request = request + await appendCodeReviewRound(round) + + round.status = .running + await appendCodeReviewRound(round) + + let (fileComments, errorMessage) = await CodeReviewProvider.invoke( + request, + context: CodeReviewServiceProvider(conversationServiceProvider: conversationProvider) + ) + + if let errorMessage = errorMessage { + round = round.withError(errorMessage) + await appendCodeReviewRound(round) + resetOngoingRequest() + return + } + + round = round.withResponse(.init(fileComments: fileComments)) + await CodeReviewService.shared.updateComments(fileComments) + await appendCodeReviewRound(round) + + round.status = .completed + await appendCodeReviewRound(round) + + resetOngoingRequest() + } + + public func cancelCodeReview(_ id: String) async { + guard activeRequestId != nil, isReceivingMessage else { return } + + guard var round = await getCurrentCodeReviewRound(id), + round.status.canTransitionTo(.cancelled) + else { return } + + round.status = .cancelled + await appendCodeReviewRound(round) + + resetOngoingRequest() + } + + private func addCodeReviewUserMessage(id: String, turnId: String, group: GitDiffGroup) async { + let content = group == .index + ? "Code review for staged changes." + : "Code review for unstaged changes." + let chatMessage = ChatMessage(userMessageWithId: id, chatTabId: chatTabInfo.id, content: content) + await memory.appendMessage(chatMessage) + saveChatMessageToStorage(chatMessage) + } +} diff --git a/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift b/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift new file mode 100644 index 00000000..2fddf1b3 --- /dev/null +++ b/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift @@ -0,0 +1,59 @@ +import ChatAPIService +import ConversationServiceProvider +import Foundation +import Logger +import GitHelper + +public struct CodeReviewServiceProvider { + public var conversationServiceProvider: (any ConversationServiceProvider)? +} + +public struct CodeReviewProvider { + public static func invoke( + _ request: CodeReviewRequest, + context: CodeReviewServiceProvider + ) async -> (fileComments: [CodeReviewResponse.FileComment], errorMessage: String?) { + var fileComments: [CodeReviewResponse.FileComment] = [] + var errorMessage: String? + + do { + if let result = try await requestReviewChanges(request.fileChange.selectedChanges, context: context) { + for comment in result.comments { + guard let change = request.fileChange.selectedChanges.first(where: { $0.uri == comment.uri }) else { + continue + } + + if let index = fileComments.firstIndex(where: { $0.uri == comment.uri }) { + var currentFileComments = fileComments[index] + currentFileComments.comments.append(comment) + fileComments[index] = currentFileComments + + } else { + fileComments.append( + .init(uri: change.uri, originalContent: change.originalContent, comments: [comment]) + ) + } + } + } + } catch { + Logger.gitHubCopilot.error("Failed to review change: \(error)") + errorMessage = "Oops, failed to review changes." + } + + return (fileComments, errorMessage) + } + + private static func requestReviewChanges( + _ changes: [PRChange], + context: CodeReviewServiceProvider + ) async throws -> CodeReviewResult? { + return try await context.conversationServiceProvider? + .reviewChanges( + .init( + changes: changes.map { + .init(uri: $0.uri, path: $0.path, baseContent: $0.baseContent, headContent: $0.headContent) + } + ) + ) + } +} diff --git a/Core/Sources/ChatService/CodeReview/CodeReviewService.swift b/Core/Sources/ChatService/CodeReview/CodeReviewService.swift new file mode 100644 index 00000000..4ae308d1 --- /dev/null +++ b/Core/Sources/ChatService/CodeReview/CodeReviewService.swift @@ -0,0 +1,48 @@ +import Collections +import ConversationServiceProvider +import Foundation +import LanguageServerProtocol + +public struct DocumentReview: Equatable { + public var comments: [ReviewComment] + public let originalContent: String +} + +public typealias DocumentReviewsByUri = OrderedDictionary + +@MainActor +public class CodeReviewService: ObservableObject { + @Published public private(set) var documentReviews: DocumentReviewsByUri = [:] + + public static let shared = CodeReviewService() + + private init() {} + + public func updateComments(for uri: DocumentUri, comments: [ReviewComment], originalContent: String) { + if var existing = documentReviews[uri] { + existing.comments.append(contentsOf: comments) + existing.comments = sortedComments(existing.comments) + documentReviews[uri] = existing + } else { + documentReviews[uri] = .init(comments: comments, originalContent: originalContent) + } + } + + public func updateComments(_ fileComments: [CodeReviewResponse.FileComment]) { + for fileComment in fileComments { + updateComments( + for: fileComment.uri, + comments: fileComment.comments, + originalContent: fileComment.originalContent + ) + } + } + + private func sortedComments(_ comments: [ReviewComment]) -> [ReviewComment] { + return comments.sorted { $0.range.end.line < $1.range.end.line } + } + + public func resetComments() { + documentReviews = [:] + } +} diff --git a/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift index e86ede8b..f185f9b1 100644 --- a/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift +++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift @@ -18,6 +18,8 @@ public final class ContextAwareAutoManagedChatMemory: ChatMemory { systemPrompt: "" ) } + + deinit { } public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) async { await memory.mutateHistory(update) diff --git a/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift index 5d9af556..19f4aa8d 100644 --- a/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift +++ b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift @@ -2,16 +2,19 @@ import ConversationServiceProvider 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 } @@ -20,14 +23,40 @@ public class CurrentEditorSkill: ConversationSkill { return params.skillId == self.id } + public static let readabilityErrorMessageProvider: FileUtils.ReadabilityErrorMessageProvider = { status in + switch status { + case .readable: + return nil + case .notFound: + return "Copilot can’t find the current file, so it's not included." + case .permissionDenied: + return "Copilot can't access the current file. Enable \"Files & Folders\" access in [System Settings](x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders)." + } + } + public func resolveSkill(request: ConversationContextRequest, completion: JSONRPCResponseHandler){ let uri: String? = self.currentFile.url.absoluteString + 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/CopilotToolRegistry.swift b/Core/Sources/ChatService/ToolCalls/CopilotToolRegistry.swift index 50408824..f03d2fe5 100644 --- a/Core/Sources/ChatService/ToolCalls/CopilotToolRegistry.swift +++ b/Core/Sources/ChatService/ToolCalls/CopilotToolRegistry.swift @@ -10,6 +10,7 @@ public class CopilotToolRegistry { tools[ToolName.getErrors.rawValue] = GetErrorsTool() tools[ToolName.insertEditIntoFile.rawValue] = InsertEditIntoFileTool() tools[ToolName.createFile.rawValue] = CreateFileTool() + tools[ToolName.fetchWebPage.rawValue] = FetchWebPageTool() } public func getTool(name: String) -> ICopilotTool? { diff --git a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift index c314724f..f811901a 100644 --- a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift @@ -1,4 +1,5 @@ import JSONRPC +import AppKit import ConversationServiceProvider import Foundation import Logger @@ -17,7 +18,7 @@ public class CreateFileTool: ICopilotTool { let filePath = input["filePath"]?.value as? String, let content = input["content"]?.value as? String else { - completeResponse(request, response: "Invalid parameters", completion: completion) + completeResponse(request, status: .error, response: "Invalid parameters", completion: completion) return true } @@ -25,39 +26,41 @@ public class CreateFileTool: ICopilotTool { guard !FileManager.default.fileExists(atPath: filePath) else { - completeResponse(request, response: "File already exists at \(filePath)", completion: completion) + Logger.client.info("CreateFileTool: File already exists at \(filePath)") + completeResponse(request, status: .error, response: "File already exists at \(filePath)", completion: completion) return true } do { + // Create intermediate directories if they don't exist + let parentDirectory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil) try content.write(to: fileURL, atomically: true, encoding: .utf8) } catch { - completeResponse(request, response: "Failed to write content to file: \(error)", completion: completion) + Logger.client.error("CreateFileTool: Failed to write content to file at \(filePath): \(error)") + completeResponse(request, status: .error, response: "Failed to write content to file: \(error)", completion: completion) return true } guard FileManager.default.fileExists(atPath: filePath), - let writtenContent = try? String(contentsOf: fileURL, encoding: .utf8), - !writtenContent.isEmpty + let writtenContent = try? String(contentsOf: fileURL, encoding: .utf8) else { - completeResponse(request, response: "Failed to verify file creation.", completion: completion) + Logger.client.info("CreateFileTool: Failed to verify file creation at \(filePath)") + completeResponse(request, status: .error, response: "Failed to verify file creation.", completion: completion) return true } contextProvider?.updateFileEdits(by: .init( fileURL: URL(fileURLWithPath: filePath), originalContent: "", - modifiedContent: content, + modifiedContent: writtenContent, toolName: CreateFileTool.name )) - do { - if let workspacePath = contextProvider?.chatTabInfo.workspacePath, - let xcodeIntance = Utils.getXcode(by: workspacePath) { - try Utils.openFileInXcode(fileURL: URL(fileURLWithPath: filePath), xcodeInstance: xcodeIntance) + NSWorkspace.openFileInXcode(fileURL: URL(fileURLWithPath: filePath)) { _, error in + if let error = error { + Logger.client.info("Failed to open file at \(filePath), \(error)") } - } catch { - Logger.client.info("Failed to open file in Xcode, \(error)") } let editAgentRounds: [AgentRound] = [ diff --git a/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift new file mode 100644 index 00000000..5ff5f6b9 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift @@ -0,0 +1,46 @@ +import AppKit +import AXExtension +import AXHelper +import ConversationServiceProvider +import Foundation +import JSONRPC +import Logger +import WebKit +import WebContentExtractor + +public class FetchWebPageTool: ICopilotTool { + public static let name = ToolName.fetchWebPage + + public func invokeTool( + _ request: InvokeClientToolRequest, + completion: @escaping (AnyJSONRPCResponse) -> Void, + chatHistoryUpdater: ChatHistoryUpdater?, + contextProvider: (any ToolContextProvider)? + ) -> Bool { + guard let params = request.params, + let input = params.input, + let urls = input["urls"]?.value as? [String] + else { + completeResponse(request, status: .error, response: "Invalid parameters", completion: completion) + return true + } + + guard !urls.isEmpty else { + completeResponse(request, status: .error, response: "No valid URLs provided", completion: completion) + return true + } + + // Use the improved WebContentFetcher to fetch content from all URLs + Task { + let results = await WebContentFetcher.fetchMultipleContentAsync(from: urls) + + completeResponses( + request, + responses: results, + completion: completion + ) + } + + return true + } +} diff --git a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift index 76a35772..cbe9e2ec 100644 --- a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift +++ b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift @@ -1,11 +1,13 @@ +import ChatTab import ConversationServiceProvider +import Foundation import JSONRPC -import ChatTab public protocol ToolContextProvider { // MARK: insert_edit_into_file var chatTabInfo: ChatTabInfo { get } func updateFileEdits(by fileEdit: FileEdit) -> Void + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws } public typealias ChatHistoryUpdater = (String, [AgentRound]) -> Void @@ -34,19 +36,52 @@ extension ICopilotTool { * Completes a tool response. * - Parameters: * - request: The original tool invocation request. + * - status: The completion status of the tool execution (success, error, or cancelled). * - response: The string value to include in the response content. * - completion: The completion handler to call with the response. */ func completeResponse( _ request: InvokeClientToolRequest, + status: ToolInvocationStatus = .success, response: String = "", completion: @escaping (AnyJSONRPCResponse) -> Void ) { - let result: JSONValue = .array([ - .hash(["content": .array([.hash(["value": .string(response)])])]), - .null - ]) - completion(AnyJSONRPCResponse(id: request.id, result: result)) + completeResponses( + request, + status: status, + responses: [response], + completion: completion + ) + } + + /// + /// Completes a tool response with multiple data entries. + /// - Parameters: + /// - request: The original tool invocation request. + /// - status: The completion status of the tool execution (success, error, or cancelled). + /// - responses: Array of string values to include in the response content. + /// - completion: The completion handler to call with the response. + /// + func completeResponses( + _ request: InvokeClientToolRequest, + status: ToolInvocationStatus = .success, + responses: [String], + completion: @escaping (AnyJSONRPCResponse) -> Void + ) { + let toolResult = LanguageModelToolResult(status: status, content: responses.map { response in + LanguageModelToolResult.Content(value: response) + }) + let jsonResult = try? JSONEncoder().encode(toolResult) + let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null + completion( + AnyJSONRPCResponse( + id: request.id, + result: JSONValue.array([ + jsonValue, + JSONValue.null, + ]) + ) + ) } } diff --git a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift index 95185c28..db89c57c 100644 --- a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift @@ -1,10 +1,11 @@ -import ConversationServiceProvider import AppKit -import JSONRPC +import AXExtension +import AXHelper +import ConversationServiceProvider import Foundation -import XcodeInspector +import JSONRPC import Logger -import AXHelper +import XcodeInspector public class InsertEditIntoFileTool: ICopilotTool { public static let name = ToolName.insertEditIntoFile @@ -21,106 +22,188 @@ public class InsertEditIntoFileTool: ICopilotTool { let filePath = input["filePath"]?.value as? String, let contextProvider else { + completeResponse(request, status: .error, response: "Invalid parameters", completion: completion) return true } - let fileURL = URL(fileURLWithPath: filePath) do { + let fileURL = URL(fileURLWithPath: filePath) let originalContent = try String(contentsOf: fileURL, encoding: .utf8) - try InsertEditIntoFileTool.applyEdit(for: fileURL, content: code, contextProvider: contextProvider) - - contextProvider.updateFileEdits( - by: .init(fileURL: fileURL, originalContent: originalContent, modifiedContent: code, toolName: InsertEditIntoFileTool.name) - ) - - let editAgentRounds: [AgentRound] = [ - .init( - roundId: params.roundId, - reply: "", - toolCalls: [ - .init( - id: params.toolCallId, - name: params.name, - status: .completed, - invokeParams: params - ) - ] + InsertEditIntoFileTool.applyEdit(for: fileURL, content: code, contextProvider: contextProvider) { newContent, error in + if let error = error { + self.completeResponse( + request, + status: .error, + response: error.localizedDescription, + completion: completion + ) + return + } + + guard let newContent = newContent + else { + self.completeResponse(request, status: .error, response: "Failed to apply edit", completion: completion) + return + } + + contextProvider.updateFileEdits( + by: .init(fileURL: fileURL, originalContent: originalContent, modifiedContent: code, toolName: InsertEditIntoFileTool.name) ) - ] - - if let chatHistoryUpdater { - chatHistoryUpdater(params.turnId, editAgentRounds) + + let editAgentRounds: [AgentRound] = [ + .init( + roundId: params.roundId, + reply: "", + toolCalls: [ + .init( + id: params.toolCallId, + name: params.name, + status: .completed, + invokeParams: params + ) + ] + ) + ] + + if let chatHistoryUpdater { + chatHistoryUpdater(params.turnId, editAgentRounds) + } + + self.completeResponse(request, response: newContent, completion: completion) } - completeResponse(request, response: code, completion: completion) } catch { - Logger.client.error("Failed to apply edits, \(error)") - completeResponse(request, response: error.localizedDescription, completion: completion) + completeResponse( + request, + status: .error, + response: error.localizedDescription, + completion: completion + ) } return true } - public static func applyEdit(for fileURL: URL, content: String, contextProvider: (any ToolContextProvider), xcodeInstance: XcodeAppInstanceInspector) throws { - - /// wait a while for opening file in xcode. (3 seconds) - var retryCount = 6 - while retryCount > 0 { - guard xcodeInstance.realtimeDocumentURL != fileURL else { break } - - retryCount -= 1 - - /// Failed to get the target documentURL - if retryCount == 0 { - return - } - - Thread.sleep(forTimeInterval: 0.5) + public static func applyEdit( + for fileURL: URL, + content: String, + contextProvider: any ToolContextProvider, + xcodeInstance: AppInstanceInspector + ) throws -> String { + // Get the focused element directly from the app (like XcodeInspector does) + guard let focusedElement: AXUIElement = try? xcodeInstance.appElement.copyValue(key: kAXFocusedUIElementAttribute) + else { + throw NSError(domain: "Failed to access xcode element", code: 0) } - guard xcodeInstance.realtimeDocumentURL == fileURL - else { throw NSError(domain: "The file \(fileURL) is not opened in Xcode", code: 0)} + // Find the source editor element using XcodeInspector's logic + guard let editorElement = focusedElement.findSourceEditorElement() else { + throw NSError(domain: "Could not find source editor element", code: 0) + } - /// keep change - guard let element: AXUIElement = try? xcodeInstance.appElement.copyValue(key: kAXFocusedUIElementAttribute) - else { - throw NSError(domain: "Failed to access xcode element", code: 0) + // Check if element supports kAXValueAttribute before reading + var value: String = "" + do { + value = try editorElement.copyValue(key: kAXValueAttribute) + } catch { + if let axError = error as? AXError { + Logger.client.error("AX Error code: \(axError.rawValue)") + } + throw error } - let value: String = (try? element.copyValue(key: kAXValueAttribute)) ?? "" + let lines = value.components(separatedBy: .newlines) var isInjectedSuccess = false - try AXHelper().injectUpdatedCodeWithAccessibilityAPI( - .init( - content: content, - newSelection: nil, - modifications: [ - .deletedSelection( - .init(start: .init(line: 0, character: 0), end: .init(line: lines.count - 1, character: (lines.last?.count ?? 100) - 1)) - ), - .inserted(0, [content]) - ] - ), - focusElement: element, - onSuccess: { - isInjectedSuccess = true + var injectionError: Error? + + do { + try AXHelper().injectUpdatedCodeWithAccessibilityAPI( + .init( + content: content, + newSelection: nil, + modifications: [ + .deletedSelection( + .init(start: .init(line: 0, character: 0), end: .init(line: lines.count - 1, character: (lines.last?.count ?? 100) - 1)) + ), + .inserted(0, [content]) + ] + ), + focusElement: editorElement, + onSuccess: { + Logger.client.info("Content injection succeeded") + isInjectedSuccess = true + }, + onError: { + Logger.client.error("Content injection failed in onError callback") + } + ) + } catch { + Logger.client.error("Content injection threw error: \(error)") + if let axError = error as? AXError { + Logger.client.error("AX Error code during injection: \(axError.rawValue)") } - ) + injectionError = error + } if !isInjectedSuccess { - throw NSError(domain: "Failed to apply edit", code: 0) + let errorMessage = injectionError?.localizedDescription ?? "Failed to apply edit" + Logger.client.error("Edit application failed: \(errorMessage)") + throw NSError(domain: "Failed to apply edit: \(errorMessage)", code: 0) + } + + // Verify the content was applied by reading it back + do { + let newContent: String = try editorElement.copyValue(key: kAXValueAttribute) + Logger.client.info("Successfully read back new content, length: \(newContent.count)") + return newContent + } catch { + Logger.client.error("Failed to read back new content: \(error)") + if let axError = error as? AXError { + Logger.client.error("AX Error code when reading back: \(axError.rawValue)") + } + throw error } - } - public static func applyEdit(for fileURL: URL, content: String, contextProvider: (any ToolContextProvider)) throws { - guard let xcodeInstance = Utils.getXcode(by: contextProvider.chatTabInfo.workspacePath) - else { - throw NSError(domain: "The workspace \(contextProvider.chatTabInfo.workspacePath) is not opened in xcode", code: 0, userInfo: nil) + public static func applyEdit( + for fileURL: URL, + content: String, + contextProvider: any ToolContextProvider, + completion: ((String?, Error?) -> Void)? = nil + ) { + NSWorkspace.openFileInXcode(fileURL: fileURL) { app, error in + do { + if let error = error { throw error } + + guard let app = app + else { + throw NSError(domain: "Failed to get the app that opens file.", code: 0) + } + + let appInstanceInspector = AppInstanceInspector(runningApplication: app) + guard appInstanceInspector.isXcode + else { + throw NSError(domain: "The file is not opened in Xcode.", code: 0) + } + + let newContent = try applyEdit( + for: fileURL, + content: content, + contextProvider: contextProvider, + xcodeInstance: appInstanceInspector + ) + + Task { + // Force to notify the CLS about the new change within the document before edit_file completion. + try? await contextProvider.notifyChangeTextDocument(fileURL: fileURL, content: newContent, version: 0) + if let completion = completion { completion(newContent, nil) } + } + } catch { + if let completion = completion { completion(nil, error) } + Logger.client.info("Failed to apply edit for file at \(fileURL), \(error)") + } } - - try Utils.openFileInXcode(fileURL: fileURL, xcodeInstance: xcodeInstance) - try applyEdit(for: fileURL, content: content, contextProvider: contextProvider, xcodeInstance: xcodeInstance) } } diff --git a/Core/Sources/ChatService/ToolCalls/Utils.swift b/Core/Sources/ChatService/ToolCalls/Utils.swift index 30cc3b06..507714cf 100644 --- a/Core/Sources/ChatService/ToolCalls/Utils.swift +++ b/Core/Sources/ChatService/ToolCalls/Utils.swift @@ -1,35 +1,14 @@ -import Foundation -import XcodeInspector import AppKit +import AppKitExtension +import Foundation import Logger +import XcodeInspector class Utils { - public static func openFileInXcode(fileURL: URL, xcodeInstance: XcodeAppInstanceInspector) throws { - /// TODO: when xcode minimized, the activate not work. - guard xcodeInstance.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) else { - throw NSError(domain: "Failed to activate xcode instance", code: 0) - } - - /// wait for a while to allow activation (especially un-minimizing) to complete - Thread.sleep(forTimeInterval: 0.3) - - let configuration = NSWorkspace.OpenConfiguration() - configuration.activates = true - - NSWorkspace.shared.open( - [fileURL], - withApplicationAt: xcodeInstance.runningApplication.bundleURL!, - configuration: configuration) { app, error in - if error != nil { - Logger.client.error("Failed to open file \(String(describing: error))") - } - } - } - public static func getXcode(by workspacePath: String) -> XcodeAppInstanceInspector? { return XcodeInspector.shared.xcodes.first( where: { - return $0.workspaceURL?.path == workspacePath + $0.workspaceURL?.path == workspacePath }) } } diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index 2ca29c96..547ad026 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -10,35 +10,55 @@ import GitHubCopilotService import Logger import OrderedCollections import SwiftUI +import GitHelper +import SuggestionBasic public struct DisplayedChatMessage: Equatable { public enum Role: Equatable { case user case assistant - case system case ignored } public var id: String public var role: Role public var text: String + public var imageReferences: [ImageReference] = [] public var references: [ConversationReference] = [] public var followUp: ConversationFollowUp? = nil public var suggestedTitle: String? = nil - public var errorMessage: String? = nil + public var errorMessages: [String] = [] public var steps: [ConversationProgressStep] = [] public var editAgentRounds: [AgentRound] = [] - - public init(id: String, role: Role, text: String, references: [ConversationReference] = [], followUp: ConversationFollowUp? = nil, suggestedTitle: String? = nil, errorMessage: String? = nil, steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = []) { + public var panelMessages: [CopilotShowMessageParams] = [] + public var codeReviewRound: CodeReviewRound? = nil + + public init( + id: String, + role: Role, + text: String, + imageReferences: [ImageReference] = [], + references: [ConversationReference] = [], + followUp: ConversationFollowUp? = nil, + suggestedTitle: String? = nil, + errorMessages: [String] = [], + steps: [ConversationProgressStep] = [], + editAgentRounds: [AgentRound] = [], + panelMessages: [CopilotShowMessageParams] = [], + codeReviewRound: CodeReviewRound? = nil + ) { self.id = id self.role = role self.text = text + self.imageReferences = imageReferences self.references = references self.followUp = followUp self.suggestedTitle = suggestedTitle - self.errorMessage = errorMessage + self.errorMessages = errorMessages self.steps = steps self.editAgentRounds = editAgentRounds + self.panelMessages = panelMessages + self.codeReviewRound = codeReviewRound } } @@ -46,6 +66,84 @@ private var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } +struct ChatContext: Equatable { + var typedMessage: String + var attachedReferences: [ConversationAttachedReference] + var attachedImages: [ImageReference] + + init(typedMessage: String, attachedReferences: [ConversationAttachedReference] = [], attachedImages: [ImageReference] = []) { + self.typedMessage = typedMessage + self.attachedReferences = attachedReferences + self.attachedImages = attachedImages + } + + static func from(_ message: DisplayedChatMessage, projectURL: URL) -> ChatContext { + .init( + typedMessage: message.text, + attachedReferences: message.references.compactMap { + guard let url = $0.url else { return nil } + if $0.isDirectory { + return .directory(.init(url: url)) + } else { + let relativePath = url.path.replacingOccurrences(of: projectURL.path, with: "") + let fileName = url.lastPathComponent + return .file(.init(url: url, relativePath: relativePath, fileName: fileName)) + } + }, + attachedImages: message.imageReferences) + } +} + +struct ChatContextProvider: Equatable { + var contextStack: [ChatContext] + + init(contextStack: [ChatContext] = []) { + self.contextStack = contextStack + } + + mutating func reset() { + contextStack = [] + } + + mutating func getNextContext() -> ChatContext? { + guard !contextStack.isEmpty else { + return nil + } + + return contextStack.removeLast() + } + + func getPreviousContext(from history: [DisplayedChatMessage], projectURL: URL) -> ChatContext? { + let previousUserMessage: DisplayedChatMessage? = { + let userMessages = history.filter { $0.role == .user } + guard !userMessages.isEmpty else { + return nil + } + + let stackCount = contextStack.count + guard userMessages.count > stackCount else { + return nil + } + + let index = userMessages.count - stackCount - 1 + guard index >= 0 else { return nil } + + return userMessages[index] + }() + + var context: ChatContext? + if let previousUserMessage { + context = .from(previousUserMessage, projectURL: projectURL) + } + + return context + } + + mutating func pushContext(_ context: ChatContext) { + contextStack.append(context) + } +} + @Reducer struct Chat { public typealias MessageID = String @@ -55,21 +153,39 @@ struct Chat { // Not use anymore. the title of history tab will get from chat tab info // Keep this var as `ChatTabItemView` reference this var title: String = "New Chat" - var typedMessage = "" + var chatContext: ChatContext = .init(typedMessage: "", attachedReferences: [], attachedImages: []) + var contextProvider: ChatContextProvider = .init() var history: [DisplayedChatMessage] = [] var isReceivingMessage = false + var requestType: ChatService.RequestType? = nil var chatMenu = ChatMenu.State() var focusedField: Field? - var currentEditor: FileReference? = nil - var selectedFiles: [FileReference] = [] + var currentEditor: ConversationFileReference? = nil + var attachedReferences: [ConversationAttachedReference] { + chatContext.attachedReferences + } + var attachedImages: [ImageReference] { + chatContext.attachedImages + } + var typedMessage: String { + get { chatContext.typedMessage } + set { + chatContext.typedMessage = newValue + // User typed in. Need to reset contextProvider + contextProvider.reset() + } + } /// Cache the original content var fileEditMap: OrderedDictionary = [:] var diffViewerController: DiffViewWindowController? = nil var isAgentMode: Bool = AppState.shared.isAgentModeEnabled() + var workspaceURL: URL? = nil enum Field: String, Hashable { case textField case fileSearchBar } + + var codeReviewState = ConversationCodeReviewFeature.State() } enum Action: Equatable, BindableAction { @@ -105,11 +221,15 @@ struct Chat { case chatMenu(ChatMenu.Action) - // context - case addSelectedFile(FileReference) - case removeSelectedFile(FileReference) + // File context case resetCurrentEditor - case setCurrentEditor(FileReference) + case setCurrentEditor(ConversationFileReference) + case addReference(ConversationAttachedReference) + case removeReference(ConversationAttachedReference) + + // Image context + case addSelectedImage(ImageReference) + case removeSelectedImage(ImageReference) case followUpButtonClicked(String, String) @@ -122,6 +242,18 @@ struct Chat { case setDiffViewerController(chat: StoreOf) case agentModeChanged(Bool) + + // Code Review + case codeReview(ConversationCodeReviewFeature.Action) + + // Chat Context + case reloadNextContext + case reloadPreviousContext + case resetContextProvider + + // External Action + case observeFixErrorNotification + case fixEditorErrorIssue(EditorErrorIssue) } let service: ChatService @@ -132,10 +264,12 @@ struct Chat { case observeIsReceivingMessageChange(UUID) case sendMessage(UUID) case observeFileEditChange(UUID) + case observeFixErrorNotification(UUID) } @Dependency(\.openURL) var openURL @AppStorage(\.enableCurrentEditorContext) var enableCurrentEditorContext: Bool + @AppStorage(\.chatResponseLocale) var chatResponseLocale var body: some ReducerOf { BindingReducer() @@ -143,6 +277,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 { @@ -154,7 +292,8 @@ struct Chat { await send(.isReceivingMessageChanged) await send(.focusOnTextField) await send(.refresh) - + await send(.observeFixErrorNotification) + let publisher = NotificationCenter.default.publisher(for: .gitHubCopilotChatModeDidChange) for await _ in publisher.values { let isAgentMode = AppState.shared.isAgentModeEnabled() @@ -175,11 +314,34 @@ 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() - return .run { _ in - try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles, model: selectedModelFamily, agentMode: agentMode) + let shouldAttachImages = selectedModel?.supportVision ?? CopilotModelManager.getDefaultChatModel( + scope: AppState.shared.modelScope() + )?.supportVision ?? false + let attachedImages: [ImageReference] = shouldAttachImages ? state.attachedImages : [] + + let references = state.attachedReferences + state.chatContext.attachedImages = [] + + return .run { send in + await send(.resetContextProvider) + + try await service + .send( + id, + content: message, + contentImageReferences: attachedImages, + skillSet: skillSet, + references: references, + model: selectedModelFamily, + modelProviderName: selectedModel?.providerName, + agentMode: agentMode, + userLanguage: chatResponseLocale + ) }.cancellable(id: CancelID.sendMessage(self.id)) case let .toolCallAccepted(toolCallId): @@ -204,11 +366,27 @@ 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() - return .run { _ in - try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles, model: selectedModelFamily) + return .run { send in + await send(.resetContextProvider) + + try await service + .send( + id, + content: message, + skillSet: skillSet, + references: references, + model: selectedModelFamily, + modelProviderName: selectedModel?.providerName, + agentMode: agentMode, + userLanguage: chatResponseLocale + ) }.cancellable(id: CancelID.sendMessage(self.id)) case .returnButtonTapped: @@ -342,24 +520,28 @@ struct Chat { id: message.id, role: { switch message.role { - case .system: return .system case .user: return .user case .assistant: return .assistant + case .system: return .ignored } }(), text: message.content, + imageReferences: message.contentImageReferences, references: message.references.map { .init( uri: $0.uri, status: $0.status, - kind: $0.kind + kind: $0.kind, + referenceType: $0.referenceType ) }, followUp: message.followUp, suggestedTitle: message.suggestedTitle, - errorMessage: message.errorMessage, + errorMessages: message.errorMessages, steps: message.steps, - editAgentRounds: message.editAgentRounds + editAgentRounds: message.editAgentRounds, + panelMessages: message.panelMessages, + codeReviewRound: message.codeReviewRound )) return all @@ -369,6 +551,7 @@ struct Chat { case .isReceivingMessageChanged: state.isReceivingMessage = service.isReceivingMessage + state.requestType = service.requestType return .none case .fileEditChanged: @@ -429,20 +612,36 @@ struct Chat { ChatInjector().insertCodeBlock(codeBlock: code) return .none - case let .addSelectedFile(fileReference): - guard !state.selectedFiles.contains(fileReference) else { return .none } - state.selectedFiles.append(fileReference) - return .none - case let .removeSelectedFile(fileReference): - guard let index = state.selectedFiles.firstIndex(of: fileReference) else { return .none } - state.selectedFiles.remove(at: index) - return .none + // MARK: - Context case .resetCurrentEditor: state.currentEditor = nil return .none case let .setCurrentEditor(fileReference): state.currentEditor = fileReference return .none + case let .addReference(ref): + guard !state.chatContext.attachedReferences.contains(ref) else { + return .none + } + state.chatContext.attachedReferences.append(ref) + return .none + + case let .removeReference(ref): + guard let index = state.chatContext.attachedReferences.firstIndex(of: ref) else { + return .none + } + state.chatContext.attachedReferences.remove(at: index) + return .none + + // MARK: - Image Context + case let .addSelectedImage(imageReference): + guard !state.attachedImages.contains(imageReference) else { return .none } + state.chatContext.attachedImages.append(imageReference) + return .run { send in await send(.resetContextProvider) } + case let .removeSelectedImage(imageReference): + guard let index = state.attachedImages.firstIndex(of: imageReference) else { return .none } + state.chatContext.attachedImages.remove(at: index) + return .run { send in await send(.resetContextProvider) } // MARK: - Agent Edits @@ -491,6 +690,106 @@ struct Chat { case let .agentModeChanged(isAgentMode): state.isAgentMode = isAgentMode return .none + + case .codeReview: + return .none + + // MARK: Chat Context + case .reloadNextContext: + guard let context = state.contextProvider.getNextContext() else { + return .none + } + + state.chatContext = context + + return .run { send in + await send(.focusOnTextField) + } + + case .reloadPreviousContext: + guard let projectURL = service.getProjectRootURL(), + let context = state.contextProvider.getPreviousContext( + from: state.history, + projectURL: projectURL) + else { + return .none + } + + let currentContext = state.chatContext + state.chatContext = context + state.contextProvider.pushContext(currentContext) + + return .run { send in + await send(.focusOnTextField) + } + + case .resetContextProvider: + state.contextProvider.reset() + return .none + + // MARK: - External action + + case .observeFixErrorNotification: + return .run { send in + let publisher = NotificationCenter.default.publisher(for: .fixEditorErrorIssue) + + for await notification in publisher.values { + guard service.chatTabInfo.isSelected, + let issue = notification.userInfo?["editorErrorIssue"] as? EditorErrorIssue + else { + continue + } + + await send(.fixEditorErrorIssue(issue)) + } + }.cancellable( + id: CancelID.observeFixErrorNotification(id), + cancelInFlight: true) + + case .fixEditorErrorIssue(let issue): + guard issue.workspaceURL == service.getWorkspaceURL(), + !issue.lineAnnotations.isEmpty + else { + return .none + } + + guard !state.isReceivingMessage else { + return .run { _ in + await MainActor.run { + NotificationCenter.default.post( + name: .fixEditorErrorIssueError, + object: nil, + userInfo: ["error": FixEditorErrorIssueFailure.isReceivingMessage(id: issue.id)] + ) + } + } + } + + let errorAnnotationMessage: String = issue.lineAnnotations + .map { "❗\($0.originalAnnotation)" } + .joined(separator: "\n\n") + let message = "Analyze and fix the following error(s): \n\n\(errorAnnotationMessage)" + + let skillSet = state.buildSkillSet(isCurrentEditorContextEnabled: enableCurrentEditorContext) + let references: [ConversationAttachedReference] = [.file(.init(url: issue.fileURL))] + let selectedModel = AppState.shared.getSelectedModel() + let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel( + scope: AppState.shared.modelScope() + )?.modelFamily + let agentMode = AppState.shared.isAgentModeEnabled() + + return .run { _ in + try await service.send( + UUID().uuidString, + content: message, + skillSet: skillSet, + references: references, + model: selectedModelFamily, + modelProviderName: selectedModel?.providerName, + agentMode: agentMode, + userLanguage: chatResponseLocale + ) + }.cancellable(id: CancelID.sendMessage(self.id)) } } } @@ -565,3 +864,30 @@ private actor TimedDebounceFunction { } } +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..8949b882 100644 --- a/Core/Sources/ConversationTab/ChatExtension.swift +++ b/Core/Sources/ConversationTab/ChatExtension.swift @@ -6,11 +6,12 @@ extension Chat.State { guard let currentFile = self.currentEditor, isCurrentEditorContextEnabled else { return [] } - let fileReference = FileReference( + let fileReference = ConversationFileReference( url: currentFile.url, relativePath: currentFile.relativePath, fileName: currentFile.fileName, - isCurrentEditor: currentFile.isCurrentEditor + isCurrentEditor: currentFile.isCurrentEditor, + selection: currentFile.selection ) return [CurrentEditorSkill(currentFile: fileReference), ProblemsInActiveDocumentSkill()] } diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index f164d760..c5608cf8 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -11,10 +11,14 @@ import SwiftUIFlowLayout import XcodeInspector import ChatTab import Workspace -import HostAppActivator import Persist +import UniformTypeIdentifiers +import Status +import GitHubCopilotService +import GitHubCopilotViewModel +import LanguageServerProtocol -private let r: Double = 8 +private let r: Double = 4 public struct ChatPanel: View { @Perception.Bindable var chat: StoreOf @@ -37,9 +41,7 @@ public struct ChatPanel: View { .accessibilityElement(children: .combine) .accessibilityLabel("Chat Messages Group") - if chat.history.last?.role == .system { - ChatCLSError(chat: chat).padding(.trailing, 16) - } else if (chat.history.last?.followUp) != nil { + if let _ = chat.history.last?.followUp { ChatFollowUp(chat: chat) .padding(.trailing, 16) .padding(.vertical, 8) @@ -56,14 +58,59 @@ public struct ChatPanel: View { } .padding(.leading, 16) .padding(.bottom, 16) - .background(Color(nsColor: .windowBackgroundColor)) + .background(.ultraThinMaterial) .onAppear { chat.send(.appear) } + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + onFileDrop(providers) + } } } + + 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 + let url: URL? = { + if let data = item as? Data { + return URL(dataRepresentation: data, relativeTo: nil) + } else if let url = item as? URL { + return url + } + return nil + }() + + guard let url else { return } + + var isDirectory: ObjCBool = false + if let isValidFile = try? WorkspaceFile.isValidFile(url), isValidFile { + DispatchQueue.main.async { + 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)))) + } + } + } + } + } + + return true + } } + + private struct ScrollViewOffsetPreferenceKey: PreferenceKey { static var defaultValue = CGFloat.zero @@ -224,6 +271,7 @@ struct ChatPanelMessages: View { } }) { Image(systemName: "chevron.down") + .scaledFrame(width: 14, height: 14) .padding(8) .background { Circle() @@ -334,20 +382,25 @@ struct ChatHistoryItem: View { let text = message.text switch message.role { case .user: - UserMessage(id: message.id, text: text, chat: chat) + UserMessage( + id: message.id, + text: text, + imageReferences: message.imageReferences, + chat: chat + ) case .assistant: BotMessage( id: message.id, text: text, references: message.references, followUp: message.followUp, - errorMessage: message.errorMessage, + errorMessages: message.errorMessages, chat: chat, steps: message.steps, - editAgentRounds: message.editAgentRounds + editAgentRounds: message.editAgentRounds, + panelMessages: message.panelMessages, + codeReviewRound: message.codeReviewRound ) - case .system: - FunctionMessage(chat: chat, id: message.id, text: text) case .ignored: EmptyView() } @@ -368,10 +421,11 @@ struct ChatFollowUp: View { }) { HStack(spacing: 4) { Image(systemName: "sparkles") + .scaledFont(.body) .foregroundColor(.blue) Text(followUp.message) - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) .foregroundColor(.blue) } } @@ -438,8 +492,10 @@ struct ChatPanelInputArea: View { Group { if #available(macOS 13.0, *) { Image(systemName: "eraser.line.dashed.fill") + .scaledFont(.body) } else { Image(systemName: "trash.fill") + .scaledFont(.body) } } .padding(6) @@ -460,16 +516,54 @@ struct ChatPanelInputArea: View { var focusedField: FocusState.Binding @State var cancellable = Set() @State private var isFilePickerPresented = false - @State private var allFiles: [FileReference] = [] + @State private var allFiles: [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, + focusedField: FocusState.Binding + ) { + self.chat = chat + self.focusedField = focusedField + self.isCCRFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.ccr + } + + var isRequestingConversation: Bool { + if chat.isReceivingMessage, + let requestType = chat.requestType, + requestType == .conversation { + return true + } + return false + } + + var isRequestingCodeReview: Bool { + if chat.isReceivingMessage, + let requestType = chat.requestType, + requestType == .codeReview { + return true + } + + return false + } var body: some View { WithPerceptionTracking { @@ -479,8 +573,9 @@ struct ChatPanelInputArea: View { if isFilePickerPresented { FilePicker( allFiles: $allFiles, - onSubmit: { file in - chat.send(.addSelectedFile(file)) + workspaceURL: chat.workspaceURL, + onSubmit: { ref in + chat.send(.addReference(ref)) }, onExit: { isFilePickerPresented = false @@ -488,10 +583,14 @@ struct ChatPanelInputArea: View { } ) .onAppear() { - allFiles = ContextUtils.getFilesInActiveWorkspace() + allFiles = ContextUtils.getFilesFromWorkspaceIndex(workspaceURL: chat.workspaceURL) } } + if !chat.state.attachedImages.isEmpty { + ImagesScrollView(chat: chat) + } + ZStack(alignment: .topLeading) { if chat.typedMessage.isEmpty { Group { @@ -499,7 +598,7 @@ struct ChatPanelInputArea: View { Text("Edit files in your workspace in agent mode") : Text("Ask Copilot or type / for commands") } - .font(.system(size: 14)) + .scaledFont(size: 14) .foregroundColor(Color(nsColor: .placeholderTextColor)) .padding(8) .padding(.horizontal, 4) @@ -508,7 +607,7 @@ struct ChatPanelInputArea: View { HStack(spacing: 0) { AutoresizingCustomTextEditor( text: $chat.typedMessage, - font: .systemFont(ofSize: 14), + font: .systemFont(ofSize: 14 * fontScale), isEditable: true, maxHeight: 400, onSubmit: { @@ -517,7 +616,11 @@ struct ChatPanelInputArea: View { } dropDownShowingType = nil }, - completions: chatAutoCompletion + onTextEditorStateChanged: { (state: TextEditorState?) in + DispatchQueue.main.async { + textEditorState = state + } + } ) .focused(focusedField, equals: .textField) .bind($chat.focusedField, to: focusedField) @@ -544,11 +647,19 @@ struct ChatPanelInputArea: View { Spacer() - Group { - if chat.isReceivingMessage { stopButton } - else { sendButton } + codeReviewButton + .buttonStyle(HoverButtonStyle(padding: 0)) + .disabled(isRequestingConversation) + + ZStack { + sendButton + .opacity(isRequestingConversation ? 0 : 1) + + stopButton + .opacity(isRequestingConversation ? 1 : 0) } .buttonStyle(HoverButtonStyle(padding: 0)) + .disabled(isRequestingCodeReview) } .padding(8) .padding(.top, -4) @@ -558,6 +669,16 @@ struct ChatPanelInputArea: View { } .onAppear() { subscribeToActiveDocumentChangeEvent() + // Check quota for CCR + Task { + if status.quotaInfo == nil, + let service = try? GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() { + _ = try? await service.checkQuota() + } + } + } + .task { + subscribeToFeatureFlagsDidChangeEvent() } .background { RoundedRectangle(cornerRadius: 6) @@ -565,7 +686,7 @@ struct ChatPanelInputArea: View { } .overlay { RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .controlColor), lineWidth: 1) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) } .background { Button(action: { @@ -583,7 +704,54 @@ struct ChatPanelInputArea: View { } .keyboardShortcut("l", modifiers: [.command]) .accessibilityHidden(true) + + buildReloadContextButtons() } + + } + } + + private var reloadNextContextButton: some View { + Button(action: { + chat.send(.reloadNextContext) + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.downArrow, modifiers: []) + .accessibilityHidden(true) + } + + private var reloadPreviousContextButton: some View { + Button(action: { + chat.send(.reloadPreviousContext) + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.upArrow, modifiers: []) + .accessibilityHidden(true) + } + + @ViewBuilder + private func buildReloadContextButtons() -> some View { + if let textEditorState = textEditorState { + switch textEditorState { + case .empty, .singleLine: + ZStack { + reloadPreviousContextButton + reloadNextContextButton + } + case .multipleLines(let cursorAt): + switch cursorAt { + case .first: + reloadPreviousContextButton + case .last: + reloadNextContextButton + case .middle: + EmptyView() + } + } + } else { + EmptyView() } } @@ -592,6 +760,7 @@ struct ChatPanelInputArea: View { submitChatMessage() }) { Image(systemName: "paperplane.fill") + .scaledFont(.body) .padding(4) } .keyboardShortcut(KeyEquivalent.return, modifiers: []) @@ -603,9 +772,75 @@ struct ChatPanelInputArea: View { chat.send(.stopRespondingButtonTapped) }) { Image(systemName: "stop.circle") + .scaledFont(.body) .padding(4) } - .help("Stop") + } + + 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 { @@ -639,38 +874,21 @@ struct ChatPanelInputArea: View { } } - enum ChatContextButtonType { case mcpConfig, contextAttach} + enum ChatContextButtonType { case imageAttach, contextAttach} private var chatContextView: some View { - let buttonItems: [ChatContextButtonType] = chat.isAgentMode ? [.mcpConfig, .contextAttach] : [.contextAttach] - let currentEditorItem: [FileReference] = [chat.state.currentEditor].compactMap { + let buttonItems: [ChatContextButtonType] = [.contextAttach, .imageAttach] + let currentEditorItem: [ConversationFileReference] = [chat.state.currentEditor].compactMap { $0 } - let selectedFileItems = chat.state.selectedFiles + let references = chat.state.attachedReferences let chatContextItems: [Any] = buttonItems.map { $0 as ChatContextButtonType - } + currentEditorItem + selectedFileItems + } + currentEditorItem + references return FlowLayout(mode: .scrollable, items: chatContextItems, itemSpacing: 4) { item in if let buttonType = item as? ChatContextButtonType { - if buttonType == .mcpConfig { - // MCP Settings button - Button(action: { - try? launchHostAppMCPSettings() - }) { - Image(systemName: "wrench.and.screwdriver") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundColor(.primary.opacity(0.85)) - .padding(4) - } - .buttonStyle(HoverButtonStyle(padding: 0)) - .help("Configure your MCP server") - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: r) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) + if buttonType == .imageAttach { + VisionMenuView(chat: chat) } else if buttonType == .contextAttach { // File picker button Button(action: { @@ -680,137 +898,123 @@ struct ChatPanelInputArea: View { focusedField.wrappedValue = .textField } } - }) { - HStack(spacing: 4) { - Image(systemName: "paperclip") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundColor(.primary.opacity(0.85)) - Text("Add Context...") - .foregroundColor(.primary.opacity(0.85)) - .lineLimit(1) - } - .padding(4) + }) { + Image(systemName: "paperclip") + .resizable() + .aspectRatio(contentMode: .fill) + .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) - .overlay( - RoundedRectangle(cornerRadius: r) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) } - } 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) - - Text(select.url.lastPathComponent) - .lineLimit(1) - .truncationMode(.middle) - .foregroundColor( - select.isCurrentEditor && !isCurrentEditorContextEnabled - ? .secondary - : .primary.opacity(0.85) - ) - .font(select.isCurrentEditor && !isCurrentEditorContextEnabled - ? .body.italic() - : .body - ) - .help(select.getPathRelativeToHome()) - - if select.isCurrentEditor { - Text("Current file") - .foregroundStyle(.secondary) - .font(select.isCurrentEditor && !isCurrentEditorContextEnabled - ? .callout.italic() - : .callout - ) - .padding(.leading, 4) - } - - Button(action: { - if select.isCurrentEditor { - enableCurrentEditorContext.toggle() - isCurrentEditorContextEnabled = enableCurrentEditorContext - } else { - chat.send(.removeSelectedFile(select)) - } - }) { - if select.isCurrentEditor { - if isCurrentEditorContextEnabled { - Image("Eye") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundColor(.secondary) - .help("Disable current file context") - } else { - Image("EyeClosed") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundColor(.secondary) - .help("Enable current file context") - } - } else { - Image(systemName: "xmark") - .resizable() - .frame(width: 8, height: 8) - .foregroundColor(.secondary) - .padding(4) - } - } - .buttonStyle(HoverButtonStyle()) - } - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: r) - .stroke( - Color(nsColor: .separatorColor), - style: .init( - lineWidth: 1, - dash: select.isCurrentEditor && !isCurrentEditorContextEnabled ? [4, 2] : [] - ) - ) - ) - } + } else if let select = item as? ConversationFileReference, select.isCurrentEditor { + makeCurrentEditorView(select) + } else if let select = item as? ConversationAttachedReference { + makeReferenceItemView(select) + } } .padding(.horizontal, 8) .padding(.top, 8) } + + @ViewBuilder + func makeCurrentEditorView(_ ref: ConversationFileReference) -> some View { + HStack(spacing: 0) { + makeContextFileNameView(url: ref.url, isCurrentEditor: true, selection: ref.selection) + + Toggle("", isOn: $isCurrentEditorContextEnabled) + .toggleStyle(SwitchToggleStyle(tint: .blue)) + .controlSize(.mini) + .padding(.trailing, 4) + .onChange(of: isCurrentEditorContextEnabled) { newValue in + enableCurrentEditorContext = newValue + } + } + .chatContextReferenceStyle(isCurrentEditor: true, r: r) + } + + @ViewBuilder + func makeReferenceItemView(_ ref: ConversationAttachedReference) -> some View { + HStack(spacing: 0) { + makeContextFileNameView(url: ref.url, isCurrentEditor: false, isDirectory: ref.isDirectory) + + Button(action: { chat.send(.removeReference(ref)) }) { + Image(systemName: "xmark") + .resizable() + .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 = text.dropFirst() - var promptTemplates: [ChatTemplate] = [] + let prefix = String(text.dropFirst()).lowercased() + let promptTemplates: [ChatTemplate] = await SharedChatService.shared.loadChatTemplates() ?? [] let releaseNotesTemplate: ChatTemplate = .init( id: "releaseNotes", description: "What's New", shortDescription: "What's New", scopes: [.chatPanel, .agentPanel] ) - - if !chat.isAgentMode { - promptTemplates = await SharedChatService.shared.loadChatTemplates() ?? [] - } - - guard !promptTemplates.isEmpty else { - return [releaseNotesTemplate] - } - + let templates = promptTemplates + [releaseNotesTemplate] let skippedTemplates = [ "feedback", "help" ] return templates.filter { $0.scopes.contains(chat.isAgentMode ? .agentPanel : .chatPanel) && - $0.id.hasPrefix(prefix) && + $0.id.lowercased().hasPrefix(prefix) && !skippedTemplates.contains($0.id) } } @@ -831,51 +1035,73 @@ struct ChatPanelInputArea: View { return chatAgents.filter { $0.slug.hasPrefix(prefix) && includedAgents.contains($0.slug) } } - func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] { - guard text.count == 1 else { return [] } - let plugins = [String]() // chat.pluginIdentifiers.map { "/\($0)" } - let availableFeatures = plugins + [ -// "/exit", - "@code", - "@sense", - "@project", - "@web", - ] - - let result: [String] = availableFeatures - .filter { $0.hasPrefix(text) && $0 != text } - .compactMap { - guard let index = $0.index( - $0.startIndex, - offsetBy: range.location, - limitedBy: $0.endIndex - ) else { return nil } - return String($0[index...]) - } - return result - } func subscribeToActiveDocumentChangeEvent() { - Publishers.CombineLatest( + var task: Task? + var currentFocusedEditor: SourceEditor? + + Publishers.CombineLatest3( XcodeInspector.shared.$latestActiveXcode, XcodeInspector.shared.$activeDocumentURL + .removeDuplicates(), + XcodeInspector.shared.$focusedEditor .removeDuplicates() - ) - .receive(on: DispatchQueue.main) - .sink { newXcode, newDocURL in - // First check for realtimeWorkspaceURL if activeWorkspaceURL is nil - if let realtimeURL = newXcode?.realtimeDocumentURL, newDocURL == nil { - if supportedFileExtensions.contains(realtimeURL.pathExtension) { - let currentEditor = FileReference(url: realtimeURL, isCurrentEditor: true) - chat.send(.setCurrentEditor(currentEditor)) - } - } else { - if supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") { - let currentEditor = FileReference(url: newDocURL!, isCurrentEditor: true) - chat.send(.setCurrentEditor(currentEditor)) + ) + .receive(on: DispatchQueue.main) + .sink { newXcode, newDocURL, newFocusedEditor in + var currentEditor: ConversationFileReference? + + // First check for realtimeWorkspaceURL if activeWorkspaceURL is nil + if let realtimeURL = newXcode?.realtimeDocumentURL, newDocURL == nil { + if supportedFileExtensions.contains(realtimeURL.pathExtension) { + currentEditor = ConversationFileReference(url: realtimeURL, isCurrentEditor: true) + } + } else if let docURL = newDocURL, supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") { + currentEditor = ConversationFileReference(url: docURL, isCurrentEditor: true) + } + + if var currentEditor = currentEditor { + if let selection = newFocusedEditor?.getContent().selections.first, + selection.start != selection.end { + currentEditor.selection = .init(start: selection.start, end: selection.end) + } + + chat.send(.setCurrentEditor(currentEditor)) + } + + if currentFocusedEditor != newFocusedEditor { + task?.cancel() + task = nil + currentFocusedEditor = newFocusedEditor + + if let editor = currentFocusedEditor { + task = Task { @MainActor in + for await _ in await editor.axNotifications.notifications() + .filter({ $0.kind == .selectedTextChanged }) { + handleSourceEditorSelectionChanged(editor) + } } } } - .store(in: &cancellable) + } + .store(in: &cancellable) + } + + private func handleSourceEditorSelectionChanged(_ sourceEditor: SourceEditor) { + guard let fileURL = sourceEditor.realtimeDocumentURL, + let currentEditorURL = chat.currentEditor?.url, + fileURL == currentEditorURL + else { + return + } + + var currentEditor: ConversationFileReference = .init(url: fileURL, isCurrentEditor: true) + + if let selection = sourceEditor.getContent().selections.first, + selection.start != selection.end { + currentEditor.selection = .init(start: selection.start, end: selection.end) + } + + chat.send(.setCurrentEditor(currentEditor)) } func submitChatMessage() { @@ -883,6 +1109,20 @@ struct ChatPanelInputArea: View { } } } + +extension URL { + func getPathRelativeToHome() -> String { + let filePath = self.path + guard !filePath.isEmpty else { return "" } + + let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path + if !homeDirectory.isEmpty { + return filePath.replacingOccurrences(of: homeDirectory, with: "~") + } + + return filePath + } +} // MARK: - Previews struct ChatPanel_Preview: PreviewProvider { @@ -906,7 +1146,8 @@ struct ChatPanel_Preview: PreviewProvider { .init( uri: "Hi Hi Hi Hi", status: .included, - kind: .class + kind: .class, + referenceType: .file ), ] ), @@ -991,8 +1232,8 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider { ChatPanel( chat: .init( initialState: .init( - typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.", - + chatContext: .init( + typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum."), history: ChatPanel_Preview.history, isReceivingMessage: false ), diff --git a/Core/Sources/ConversationTab/ContextUtils.swift b/Core/Sources/ConversationTab/ContextUtils.swift index 84517df2..6a646248 100644 --- a/Core/Sources/ConversationTab/ContextUtils.swift +++ b/Core/Sources/ConversationTab/ContextUtils.swift @@ -3,10 +3,36 @@ import XcodeInspector import Foundation import Logger import Workspace +import SystemUtils public struct ContextUtils { - public static func getFilesInActiveWorkspace() -> [FileReference] { + 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?) -> [ConversationFileReference] { + if let workspaceURL = workspaceURL, let info = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) { + return WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: info.workspaceURL, workspaceRootURL: info.projectURL) + } + guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL, let workspaceRootURL = XcodeInspector.shared.realtimeActiveProjectURL else { return [] @@ -16,4 +42,14 @@ public struct ContextUtils { return files } + + public static let workspaceReadabilityErrorMessageProvider: FileUtils.ReadabilityErrorMessageProvider = { status in + switch status { + case .readable: return nil + case .notFound: + return "Copilot can't access this workspace. It may have been removed or is temporarily unavailable." + case .permissionDenied: + return "Copilot can't access this workspace. Enable \"Files & Folders\" access in [System Settings](x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders)" + } + } } diff --git a/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift b/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift index 26651abd..e4da4784 100644 --- a/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift +++ b/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift @@ -16,16 +16,34 @@ class DiffViewWindowController: NSObject, NSWindowDelegate { private var diffWindow: NSWindow? private var hostingView: NSHostingView? - private let chat: StoreOf + private weak var chat: StoreOf? public private(set) var currentFileEdit: FileEdit? = nil public private(set) var diffViewerState: DiffViewerState = .closed public init(chat: StoreOf) { self.chat = chat } + + deinit { + // Break the delegate cycle + diffWindow?.delegate = nil + + // Close and release the wi + diffWindow?.close() + diffWindow = nil + + // Clear hosting view + hostingView = nil + + // Reset state + currentFileEdit = nil + diffViewerState = .closed + } @MainActor func showDiffWindow(fileEdit: FileEdit) { + guard let chat else { return } + currentFileEdit = fileEdit // Create diff view let newDiffView = DiffView(chat: chat, fileEdit: fileEdit) diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift index 5d6d5014..2884f332 100644 --- a/Core/Sources/ConversationTab/ConversationTab.swift +++ b/Core/Sources/ConversationTab/ConversationTab.swift @@ -8,6 +8,9 @@ import Foundation import ChatAPIService import Preferences import SwiftUI +import AppKit +import Workspace +import ConversationServiceProvider /// A chat tab that provides a context aware chat bot, powered by Chat. public class ConversationTab: ChatTab { @@ -114,7 +117,7 @@ public class ConversationTab: ChatTab { let service = ChatService.service(for: info) self.service = service - chat = .init(initialState: .init(), reducer: { Chat(service: service) }) + chat = .init(initialState: .init(workspaceURL: service.getWorkspaceURL()), reducer: { Chat(service: service) }) super.init(store: store) // Start to observe changes of Chat Message @@ -128,10 +131,27 @@ public class ConversationTab: ChatTab { @MainActor public init(service: ChatService, store: StoreOf, with chatTabInfo: ChatTabInfo) { self.service = service - chat = .init(initialState: .init(), reducer: { Chat(service: service) }) + chat = .init(initialState: .init(workspaceURL: service.getWorkspaceURL()), reducer: { Chat(service: service) }) super.init(store: store) } + deinit { + // Cancel all Combine subscriptions + cancellable.forEach { $0.cancel() } + cancellable.removeAll() + + // Stop the debounce runner + Task { @MainActor [weak self] in + await self?.updateContentDebounce.cancel() + } + + // Clear observer + observer = NSObject() + + // The deallocation of ChatService will be called automatically + // The TCA Store (chat) handles its own cleanup automatically + } + @MainActor public static func restoreConversation(by chatTabInfo: ChatTabInfo, store: StoreOf) -> ConversationTab { let service = ChatService.service(for: chatTabInfo) @@ -224,5 +244,44 @@ public class ConversationTab: ChatTab { } } } + + public func handlePasteEvent() -> Bool { + let pasteboard = NSPasteboard.general + if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], !urls.isEmpty { + for url in urls { + // 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 = 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()) { + DispatchQueue.main.async { + self.chat.send(.addSelectedImage(ImageReference(data: data, fileUrl: url))) + } + } + } + } else if let data = pasteboard.data(forType: .png) { + chat.send(.addSelectedImage(ImageReference(data: data, source: .pasted))) + } else if let tiffData = pasteboard.data(forType: .tiff), + let imageRep = NSBitmapImageRep(data: tiffData), + let pngData = imageRep.representation(using: .png, properties: [:]) { + chat.send(.addSelectedImage(ImageReference(data: pngData, source: .pasted))) + } else { + return false + } + + return true + } + + public func updateChatTabInfo(_ tabInfo: ChatTabInfo) { + // Sync tabInfo for service + service.updateChatTabInfo(tabInfo) + } } diff --git a/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift new file mode 100644 index 00000000..c85ca212 --- /dev/null +++ b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift @@ -0,0 +1,90 @@ +import ComposableArchitecture +import ChatService +import Foundation +import ConversationServiceProvider +import GitHelper +import LanguageServerProtocol +import Terminal +import Combine + +@MainActor +public class CodeReviewStateService: ObservableObject { + public static let shared = CodeReviewStateService() + + public let fileClickedEvent = PassthroughSubject() + + private init() { } + + func notifyFileClicked() { + fileClickedEvent.send() + } +} + +@Reducer +public struct ConversationCodeReviewFeature { + @ObservableState + public struct State: Equatable { + + public init() { } + } + + public enum Action: Equatable { + case request(GitDiffGroup) + case accept(id: String, selectedFiles: [DocumentUri]) + case cancel(id: String) + + case onFileClicked(URL, Int) + } + + public let service: ChatService + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .request(let group): + + return .run { _ in + try await service.requestCodeReview(group) + } + + case let .accept(id, selectedFileUris): + + return .run { _ in + await service.acceptCodeReview(id, selectedFileUris: selectedFileUris) + } + + case .cancel(let id): + + return .run { _ in + await service.cancelCodeReview(id) + } + + // lineNumber: 0-based + case .onFileClicked(let fileURL, let lineNumber): + + return .run { _ in + if FileManager.default.fileExists(atPath: fileURL.path) { + let terminal = Terminal() + do { + _ = try await terminal.runCommand( + "/bin/bash", + arguments: [ + "-c", + "xed -l \(lineNumber+1) \"\(fileURL.path)\"" + ], + environment: [:] + ) + } catch { + print(error) + } + } + + Task { @MainActor in + CodeReviewStateService.shared.notifyFileClicked() + } + } + + } + } + } +} diff --git a/Core/Sources/ConversationTab/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift index f1a00966..e6eebc2b 100644 --- a/Core/Sources/ConversationTab/FilePicker.swift +++ b/Core/Sources/ConversationTab/FilePicker.swift @@ -2,23 +2,70 @@ import ComposableArchitecture import ConversationServiceProvider import SharedUIComponents import SwiftUI +import SystemUtils public struct FilePicker: View { - @Binding var allFiles: [FileReference] - var onSubmit: (_ file: FileReference) -> Void + @Binding var allFiles: [ConversationAttachedReference]? + let workspaceURL: URL? + 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) + } + } + + private static let defaultEmptyStateText = "No results found." + private static let isIndexingStateText = "Indexing files, try later..." + + private var emptyStateAttributedString: AttributedString? { + var message = allFiles == nil ? FilePicker.isIndexingStateText : FilePicker.defaultEmptyStateText + if let workspaceURL = workspaceURL { + let status = FileUtils.checkFileReadability(at: workspaceURL.path) + if let errorMessage = status.errorMessage(using: ContextUtils.workspaceReadabilityErrorMessageProvider) { + message = errorMessage + } + } + + return try? AttributedString(markdown: message) + } + + private var emptyStateView: some View { + Group { + if let attributedString = emptyStateAttributedString { + Text(attributedString) + } else { + Text(FilePicker.defaultEmptyStateText) + } } } @@ -30,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) @@ -46,6 +94,7 @@ public struct FilePicker: View { } }) { Image(systemName: "xmark.circle.fill") + .scaledFont(.body) .foregroundColor(.secondary) } .buttonStyle(HoverButtonStyle()) @@ -63,25 +112,25 @@ public struct FilePicker: View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 4) { - ForEach(Array(filteredFiles.enumerated()), id: \.element) { index, doc in - FileRowView(doc: doc, id: index, selectedId: $selectedId) - .contentShape(Rectangle()) - .onTapGesture { - onSubmit(doc) - selectedId = index - isSearchBarFocused = true - } - .id(index) - } - - if filteredFiles.isEmpty { - Text("No results found") + if allFiles == nil || filteredReferences?.isEmpty == true { + emptyStateView .foregroundColor(.secondary) .padding(.leading, 4) .padding(.vertical, 4) + } else { + ForEach(Array((filteredReferences ?? []).enumerated()), id: \.element) { index, ref in + FileRowView(ref: ref, id: index, selectedId: $selectedId) + .contentShape(Rectangle()) + .onTapGesture { + onSubmit(ref) + selectedId = index + isSearchBarFocused = true + } + .id(index) + } } } - .id(filteredFiles.hashValue) + .id(filteredReferences?.hashValue) } .frame(maxHeight: 200) .padding(.horizontal, 4) @@ -132,43 +181,52 @@ public struct FilePicker: View { } private func moveSelection(up: Bool, proxy: ScrollViewProxy) { - let files = filteredFiles - guard !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() { - let files = filteredFiles - guard !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) - Text(doc.relativePath ?? doc.url.path) - .font(.caption) - .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .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() @@ -180,7 +238,7 @@ struct FileRowView: View { .onHover(perform: { hovering in isHovered = hovering }) - .transition(.move(edge: .bottom)) + .help(ref.url.path) } } } diff --git a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift b/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift index 5e61b4c0..44d8c8cc 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift @@ -1,63 +1,96 @@ import SwiftUI import Persist import ConversationServiceProvider +import GitHubCopilotService +import Combine +import SharedUIComponents 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 { - HStack(spacing: -1) { - ModeButton( - title: "Ask", - isSelected: chatMode == "Ask", - activeBackground: colorScheme == .dark ? Color.white.opacity(0.25) : Color.white, - activeTextColor: Color.primary, - inactiveTextColor: Color.primary.opacity(0.5), - action: { - chatMode = "Ask" - AppState.shared.setSelectedChatMode("Ask") - onScopeChange(.chatPanel) - NotificationCenter.default.post( - name: .gitHubCopilotChatModeDidChange, - object: nil + VStack { + if isAgentModeFFEnabled { + HStack(spacing: -1) { + ModeButton( + title: "Ask", + isSelected: chatMode == "Ask", + activeBackground: colorScheme == .dark ? Color.white.opacity(0.25) : Color.white, + activeTextColor: Color.primary, + inactiveTextColor: Color.primary.opacity(0.5), + action: { + setChatMode(mode: .Ask) + } ) - } - ) - - ModeButton( - title: "Agent", - isSelected: chatMode == "Agent", - activeBackground: Color.blue, - activeTextColor: Color.white, - inactiveTextColor: Color.primary.opacity(0.5), - action: { - chatMode = "Agent" - AppState.shared.setSelectedChatMode("Agent") - onScopeChange(.agentPanel) - NotificationCenter.default.post( - name: .gitHubCopilotChatModeDidChange, - object: nil + + ModeButton( + title: "Agent", + isSelected: chatMode == "Agent", + activeBackground: Color.blue, + activeTextColor: Color.white, + inactiveTextColor: Color.primary.opacity(0.5), + action: { + setChatMode(mode: .Agent) + } ) } - ) + .scaledPadding(1) + .scaledFrame(height: 20, alignment: .topLeading) + .background(.primary.opacity(0.1)) + .cornerRadius(5) + .padding(4) + .help("Set Mode") + } else { + EmptyView() + } + } + .task { + subscribeToFeatureFlagsDidChangeEvent() + if !isAgentModeFFEnabled { + setChatMode(mode: .Ask) + } + } + .onChange(of: isAgentModeFFEnabled) { newAgentModeFFEnabled in + if !newAgentModeFFEnabled { + setChatMode(mode: .Ask) + } } - .padding(1) - .frame(height: 20, alignment: .topLeading) - .background(.primary.opacity(0.1)) - .cornerRadius(5) - .padding(4) - .help("Set Mode") } } diff --git a/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift b/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift index b204e04c..53106ba2 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedUIComponents public struct ModeButton: View { let title: String @@ -11,8 +12,9 @@ public struct ModeButton: View { public var body: some View { Button(action: action) { Text(title) - .padding(.horizontal, 6) - .padding(.vertical, 0) + .scaledFont(.body) + .scaledPadding(.horizontal, 6) + .scaledPadding(.vertical, 0) .frame(maxHeight: .infinity, alignment: .center) .background(isSelected ? activeBackground : Color.clear) .foregroundColor(isSelected ? activeTextColor : inactiveTextColor) diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift b/Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift new file mode 100644 index 00000000..92af4af9 --- /dev/null +++ b/Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift @@ -0,0 +1,253 @@ +import Foundation +import Combine +import Persist +import GitHubCopilotService +import ConversationServiceProvider + +public let SELECTED_LLM_KEY = "selectedLLM" +public let SELECTED_CHATMODE_KEY = "selectedChatMode" + +public extension Notification.Name { + static let gitHubCopilotSelectedModelDidChange = Notification.Name("com.github.CopilotForXcode.SelectedModelDidChange") +} + +public extension AppState { + func isSelectedModelSupportVision() -> Bool? { + if let savedModel = get(key: SELECTED_LLM_KEY) { + return savedModel["supportVision"]?.boolValue + } + return nil + } + + func getSelectedModel() -> LLMModel? { + guard let savedModel = get(key: SELECTED_LLM_KEY) else { + return nil + } + + guard let modelName = savedModel["modelName"]?.stringValue, + let modelFamily = savedModel["modelFamily"]?.stringValue else { + return nil + } + + let displayName = savedModel["displayName"]?.stringValue + let providerName = savedModel["providerName"]?.stringValue + let supportVision = savedModel["supportVision"]?.boolValue ?? false + + // Try to reconstruct billing info if available + var billing: CopilotModelBilling? + if let isPremium = savedModel["billing"]?["isPremium"]?.boolValue, + let multiplier = savedModel["billing"]?["multiplier"]?.numberValue { + billing = CopilotModelBilling( + isPremium: isPremium, + multiplier: Float(multiplier) + ) + } + + return LLMModel( + displayName: displayName, + modelName: modelName, + modelFamily: modelFamily, + billing: billing, + providerName: providerName, + supportVision: supportVision + ) + } + + func setSelectedModel(_ model: LLMModel) { + update(key: SELECTED_LLM_KEY, value: model) + NotificationCenter.default.post(name: .gitHubCopilotSelectedModelDidChange, object: nil) + } + + func modelScope() -> PromptTemplateScope { + return isAgentModeEnabled() ? .agentPanel : .chatPanel + } + + func getSelectedChatMode() -> String { + if let savedMode = get(key: SELECTED_CHATMODE_KEY), + let modeName = savedMode.stringValue { + return convertChatMode(modeName) + } + + // Default to "Agent" + return "Agent" + } + + func setSelectedChatMode(_ mode: String) { + update(key: SELECTED_CHATMODE_KEY, value: mode) + } + + func isAgentModeEnabled() -> Bool { + return getSelectedChatMode() == "Agent" + } + + private func convertChatMode(_ mode: String) -> String { + switch mode { + case "Ask": + return "Ask" + default: + return "Agent" + } + } +} + +public class CopilotModelManagerObservable: ObservableObject { + static let shared = CopilotModelManagerObservable() + + @Published var availableChatModels: [LLMModel] = [] + @Published var availableAgentModels: [LLMModel] = [] + @Published var defaultChatModel: LLMModel? + @Published var defaultAgentModel: LLMModel? + @Published var availableChatBYOKModels: [LLMModel] = [] + @Published var availableAgentBYOKModels: [LLMModel] = [] + private var cancellables = Set() + + private init() { + // Initial load + availableChatModels = CopilotModelManager.getAvailableChatLLMs(scope: .chatPanel) + availableAgentModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel) + defaultChatModel = CopilotModelManager.getDefaultChatModel(scope: .chatPanel) + defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel) + availableChatBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .chatPanel) + availableAgentBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel) + + // Setup notification to update when models change + NotificationCenter.default.publisher(for: .gitHubCopilotModelsDidChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.availableChatModels = CopilotModelManager.getAvailableChatLLMs(scope: .chatPanel) + self?.availableAgentModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel) + self?.defaultChatModel = CopilotModelManager.getDefaultChatModel(scope: .chatPanel) + self?.defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel) + self?.availableChatBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .chatPanel) + self?.availableAgentBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel) + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .gitHubCopilotShouldSwitchFallbackModel) + .receive(on: DispatchQueue.main) + .sink { _ in + if let fallbackModel = CopilotModelManager.getFallbackLLM( + scope: AppState.shared + .isAgentModeEnabled() ? .agentPanel : .chatPanel + ) { + AppState.shared.setSelectedModel( + .init( + modelName: fallbackModel.modelName, + modelFamily: fallbackModel.id, + billing: fallbackModel.billing, + supportVision: fallbackModel.capabilities.supports.vision + ) + ) + } + } + .store(in: &cancellables) + } +} + +// MARK: - Copilot Model Manager +public extension CopilotModelManager { + static func getAvailableChatLLMs(scope: PromptTemplateScope = .chatPanel) -> [LLMModel] { + let LLMs = CopilotModelManager.getAvailableLLMs() + return LLMs.filter( + { $0.scopes.contains(scope) } + ).map { + return LLMModel( + modelName: $0.modelName, + modelFamily: $0.isChatFallback ? $0.id : $0.modelFamily, + billing: $0.billing, + supportVision: $0.capabilities.supports.vision + ) + } + } + + static func getDefaultChatModel(scope: PromptTemplateScope = .chatPanel) -> LLMModel? { + let LLMs = CopilotModelManager.getAvailableLLMs() + let LLMsInScope = LLMs.filter({ $0.scopes.contains(scope) }) + let defaultModel = LLMsInScope.first(where: { $0.isChatDefault }) + // If a default model is found, return it + if let defaultModel = defaultModel { + return LLMModel( + modelName: defaultModel.modelName, + modelFamily: defaultModel.modelFamily, + billing: defaultModel.billing, + supportVision: defaultModel.capabilities.supports.vision + ) + } + + // Fallback to gpt-4.1 if available + let gpt4_1 = LLMsInScope.first(where: { $0.modelFamily == "gpt-4.1" }) + if let gpt4_1 = gpt4_1 { + return LLMModel( + modelName: gpt4_1.modelName, + modelFamily: gpt4_1.modelFamily, + billing: gpt4_1.billing, + supportVision: gpt4_1.capabilities.supports.vision + ) + } + + // If no default model is found, fallback to the first available model + if let firstModel = LLMsInScope.first { + return LLMModel( + modelName: firstModel.modelName, + modelFamily: firstModel.modelFamily, + billing: firstModel.billing, + supportVision: firstModel.capabilities.supports.vision + ) + } + + return nil + } +} + +// MARK: - BYOK Model Manager +public extension BYOKModelManager { + static func getAvailableChatLLMs(scope: PromptTemplateScope = .chatPanel) -> [LLMModel] { + var BYOKModels = BYOKModelManager.getRegisteredBYOKModels() + if scope == .agentPanel { + BYOKModels = BYOKModels.filter( + { $0.modelCapabilities?.toolCalling == true } + ) + } + return BYOKModels.map { + return LLMModel( + displayName: $0.modelCapabilities?.name, + modelName: $0.modelId, + modelFamily: $0.modelId, + billing: nil, + providerName: $0.providerName.rawValue, + supportVision: $0.modelCapabilities?.vision ?? false + ) + } + } +} + +public struct LLMModel: Codable, Hashable, Equatable { + let displayName: String? + let modelName: String + let modelFamily: String + let billing: CopilotModelBilling? + let providerName: String? + let supportVision: Bool + + public init( + displayName: String? = nil, + modelName: String, + modelFamily: String, + billing: CopilotModelBilling?, + providerName: String? = nil, + supportVision: Bool + ) { + self.displayName = displayName + self.modelName = modelName + self.modelFamily = modelFamily + self.billing = billing + self.providerName = providerName + self.supportVision = supportVision + } +} + +public struct ScopeCache { + var modelMultiplierCache: [String: String] = [:] + var cachedMaxWidth: CGFloat = 0 + var lastModelsHash: Int = 0 +} diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift index dc71303d..228ad965 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift +++ b/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift @@ -4,156 +4,172 @@ 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" +struct ModelPicker: View { + @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) -extension AppState { - func getSelectedModelFamily() -> String? { - if let savedModel = get(key: SELECTED_LLM_KEY), - let modelFamily = savedModel["modelFamily"]?.stringValue { - return modelFamily - } - return nil + @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 private var cancellables = Set() + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale } - func getSelectedModelName() -> String? { - if let savedModel = get(key: SELECTED_LLM_KEY), - let modelName = savedModel["modelName"]?.stringValue { - return modelName - } - return nil + let minimumPadding: Int = 48 + let attributes: [NSAttributedString.Key: NSFont] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] + + var spaceWidth: CGFloat { + "\u{200A}".size(withAttributes: attributes).width } - func setSelectedModel(_ model: LLMModel) { - update(key: SELECTED_LLM_KEY, value: model) + var minimumPaddingWidth: CGFloat { + spaceWidth * CGFloat(minimumPadding) } - func modelScope() -> PromptTemplateScope { - return isAgentModeEnabled() ? .agentPanel : .chatPanel + init() { + let initialModel = AppState.shared.getSelectedModel() ?? + CopilotModelManager.getDefaultChatModel() + self._selectedModel = State(initialValue: initialModel) + self.isMCPFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.mcp + self.isBYOKFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.byok + updateAgentPicker() } - func getSelectedChatMode() -> String { - if let savedMode = get(key: SELECTED_CHATMODE_KEY), - let modeName = savedMode.stringValue { - return convertChatMode(modeName) - } - return "Ask" + private func subscribeToFeatureFlagsDidChangeEvent() { + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in + isMCPFFEnabled = featureFlags.mcp + isBYOKFFEnabled = featureFlags.byok + }) + .store(in: &cancellables) } - func setSelectedChatMode(_ mode: String) { - update(key: SELECTED_CHATMODE_KEY, value: mode) + var copilotModels: [LLMModel] { + AppState.shared.isAgentModeEnabled() ? + modelManager.availableAgentModels : modelManager.availableChatModels } - - func isAgentModeEnabled() -> Bool { - return getSelectedChatMode() == "Agent" + + var byokModels: [LLMModel] { + AppState.shared.isAgentModeEnabled() ? + modelManager.availableAgentBYOKModels : modelManager.availableChatBYOKModels } - private func convertChatMode(_ mode: String) -> String { - switch mode { - case "Agent": - return "Agent" - default: - return "Ask" - } + var defaultModel: LLMModel? { + AppState.shared.isAgentModeEnabled() ? modelManager.defaultAgentModel : modelManager.defaultChatModel } -} -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) + // Get the current cache based on scope + var currentCache: ScopeCache { + AppState.shared.isAgentModeEnabled() ? agentScopeCache : askScopeCache } -} -extension CopilotModelManager { - static func getAvailableChatLLMs(scope: PromptTemplateScope = .chatPanel) -> [LLMModel] { - let LLMs = CopilotModelManager.getAvailableLLMs() - return LLMs.filter( - { $0.scopes.contains(scope) } - ).map { - LLMModel(modelName: $0.modelName, modelFamily: $0.modelFamily) + // 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" } } - - 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) + + // 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 - // 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) + for model in models { + var multiplierText = "" + if model.billing != nil { + multiplierText = formatMultiplierText(for: model.billing) + } else if let providerName = model.providerName, !providerName.isEmpty { + // For BYOK models, show the provider name + multiplierText = providerName + } + newCache[model.modelName.appending(model.providerName ?? "")] = multiplierText + + let displayName = "✓ \(model.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 no default model is found, fallback to the first available model - if let firstModel = LLMsInScope.first { - return LLMModel(modelName: firstModel.modelName, modelFamily: firstModel.modelFamily) + if maxWidth == 0, let selectedModel = selectedModel { + maxWidth = (selectedModel.displayName ?? selectedModel.modelName).size(withAttributes: attributes).width } - - return nil - } -} - -struct LLMModel: Codable, Hashable { - let modelName: String - let modelFamily: String -} - -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 - - init() { - let initialModel = AppState.shared.getSelectedModelName() ?? CopilotModelManager.getDefaultChatModel()?.modelName ?? "" - self._selectedModel = State(initialValue: initialModel) - updateAgentPicker() - } - - var models: [LLMModel] { - AppState.shared.isAgentModeEnabled() ? modelManager.availableAgentModels : modelManager.availableChatModels - } - - var defaultModel: LLMModel? { - AppState.shared.isAgentModeEnabled() ? modelManager.defaultAgentModel : modelManager.defaultChatModel + + return ScopeCache( + modelMultiplierCache: newCache, + cachedMaxWidth: maxWidth, + lastModelsHash: currentHash + ) } func updateCurrentModel() { - selectedModel = AppState.shared.getSelectedModelName() ?? defaultModel?.modelName ?? "" + let currentModel = AppState.shared.getSelectedModel() + var allAvailableModels = copilotModels + if isBYOKFFEnabled { + allAvailableModels += byokModels + } + + // Check if current model exists in available models for current scope using model comparison + let modelExists = allAvailableModels.contains { model in + 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() { @@ -161,25 +177,126 @@ struct ModelPicker: View { } func switchModelsForScope(_ scope: PromptTemplateScope) { - let newModeModels = CopilotModelManager.getAvailableChatLLMs(scope: scope) + let newModeModels = CopilotModelManager.getAvailableChatLLMs( + scope: scope + ) + BYOKModelManager.getAvailableChatLLMs(scope: scope) - if let currentModel = AppState.shared.getSelectedModelName() { - if !newModeModels.isEmpty && !newModeModels.contains(where: { $0.modelName == currentModel }) { + if let currentModel = AppState.shared.getSelectedModel() { + if !newModeModels.isEmpty && !newModeModels.contains(where: { $0 == currentModel }) { let defaultModel = CopilotModelManager.getDefaultChatModel(scope: scope) if let defaultModel = defaultModel { - selectedModel = defaultModel.modelName AppState.shared.setSelectedModel(defaultModel) } else { - selectedModel = newModeModels[0].modelName AppState.shared.setSelectedModel(newModeModels[0]) } } } - // Force refresh models self.updateCurrentModel() + updateModelCacheIfNeeded(for: scope) } + + // Model picker menu component + private var modelPickerMenu: some View { + Menu { + // Group models by premium status + let premiumModels = copilotModels.filter { + $0.billing?.isPremium == true + } + let standardModels = copilotModels.filter { + $0.billing?.isPremium == false || $0.billing == nil + } + + // Display standard models section if available + modelSection(title: "Standard Models", models: standardModels) + + // Display premium models section if available + modelSection(title: "Premium Models", models: premiumModels) + + if isBYOKFFEnabled { + // Display byok models section if available + modelSection(title: "Other Models", models: byokModels) + Button("Manage Models...") { + try? launchHostAppBYOKSettings() + } + } + + if standardModels.isEmpty { + Link("Add Premium Models", destination: URL(string: "https://aka.ms/github-copilot-upgrade-plan")!) + } + } label: { + Text(selectedModel?.displayName ?? selectedModel?.modelName ?? "") + // scaledFont not work here. workaround by direclty use the fontScale + .font(.system(size: 13 * fontScale)) + } + .menuStyle(BorderlessButtonMenuStyle()) + .frame(maxWidth: labelWidth()) + .scaledPadding(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.displayName ?? model.modelName, + isSelected: selectedModel == model, + cachedMultiplierText: currentCache.modelMultiplierCache[model.modelName.appending(model.providerName ?? "")] ?? "" + )) + } + } + + private var mcpButton: some View { + Group { + if isMCPFFEnabled { + Button(action: { + try? launchHostAppToolsSettings() + }) { + 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) { @@ -189,34 +306,14 @@ struct ModelPicker: View { updateAgentPicker() } - Group{ - // Model Picker - if !models.isEmpty && !selectedModel.isEmpty { - - Menu(selectedModel) { - ForEach(models, id: \.self) { option in - Button { - selectedModel = option.modelName - AppState.shared.setSelectedModel(option) - } label: { - if selectedModel == option.modelName { - Text("✓ \(option.modelName)") - } else { - Text(" \(option.modelName)") - } - } - } - } - .menuStyle(BorderlessButtonMenuStyle()) - .frame(maxWidth: labelWidth()) - .padding(4) - .background( - RoundedRectangle(cornerRadius: 5) - .fill(isHovered ? Color.gray.opacity(0.1) : Color.clear) - ) - .onHover { hovering in - isHovered = hovering - } + if chatMode == "Agent" { + mcpButton + } + + // Model Picker + Group { + if !copilotModels.isEmpty && selectedModel != nil { + modelPickerMenu } else { EmptyView() } @@ -224,6 +321,9 @@ struct ModelPicker: View { } .onAppear() { updateCurrentModel() + // Initialize both caches + updateModelCacheIfNeeded(for: .chatPanel) + updateModelCacheIfNeeded(for: .agentPanel) Task { await refreshModels() } @@ -231,27 +331,44 @@ struct ModelPicker: View { .onChange(of: defaultModel) { _ in updateCurrentModel() } - .onChange(of: models) { _ in + .onChange(of: modelManager.availableChatModels) { _ in updateCurrentModel() + updateModelCacheIfNeeded(for: .chatPanel) + } + .onChange(of: modelManager.availableAgentModels) { _ in + updateCurrentModel() + updateModelCacheIfNeeded(for: .agentPanel) + } + .onChange(of: 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() + } + .onReceive(NotificationCenter.default.publisher(for: .gitHubCopilotSelectedModelDidChange)) { _ in + updateCurrentModel() + } + .task { + subscribeToFeatureFlagsDidChangeEvent() + } } } func labelWidth() -> CGFloat { - let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) - let attributes = [NSAttributedString.Key.font: font] - let width = selectedModel.size(withAttributes: attributes).width - return CGFloat(width + 20) - } - - func agentPickerLabelWidth() -> CGFloat { - let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) - let attributes = [NSAttributedString.Key.font: font] - let width = chatMode.size(withAttributes: attributes).width - return CGFloat(width + 20) + 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 @@ -267,6 +384,39 @@ struct ModelPicker: View { 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, + options: .backwards + ) { + attributedString[range].foregroundColor = .secondary + } + } + + return attributedString + } } struct ModelPicker_Previews: PreviewProvider { diff --git a/Core/Sources/ConversationTab/Styles.swift b/Core/Sources/ConversationTab/Styles.swift index a4b5ddf1..f2309324 100644 --- a/Core/Sources/ConversationTab/Styles.swift +++ b/Core/Sources/ConversationTab/Styles.swift @@ -35,6 +35,7 @@ extension NSAppearance { extension View { var messageBubbleCornerRadius: Double { 8 } + var hoverableImageCornerRadius: Double { 4 } func codeBlockLabelStyle() -> some View { relativeLineSpacing(.em(0.225)) @@ -51,7 +52,7 @@ extension View { _ configuration: CodeBlockConfiguration, backgroundColor: Color, labelColor: Color, - insertAction: (() -> Void)? = nil + context: MarkdownActionProvider? = nil ) -> some View { background(backgroundColor) .clipShape(RoundedRectangle(cornerRadius: 6)) @@ -70,9 +71,11 @@ extension View { NSPasteboard.general.setString(configuration.content, forType: .string) } - InsertButton { - if let insertAction = insertAction { - insertAction() + if let context = context, context.supportInsert { + InsertButton { + if let onInsert = context.onInsert { + onInsert(configuration.content) + } } } } @@ -180,9 +183,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: 4) + .stroke(.black.opacity(0.17), lineWidth: 1) + .background(Color.gray.opacity(0.05)) + } +} + +struct CodeReviewHeaderBackground: View { + var body: some View { + RoundedRectangle(cornerRadius: 4) + .stroke(.black.opacity(0.17), lineWidth: 1) + .background(Color.gray.opacity(0.1)) + } +} diff --git a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift index 8113d793..5d57b03d 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 @@ -13,9 +14,10 @@ struct RunInTerminalToolView: View { private var title: String = "Run command in terminal" @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight + @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark + @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark @AppStorage(\.chatFontSize) var chatFontSize - @AppStorage(\.chatCodeFont) var chatCodeFont @Environment(\.colorScheme) var colorScheme init(tool: AgentToolCall, chat: StoreOf) { @@ -69,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) @@ -102,20 +103,29 @@ struct RunInTerminalToolView: View { return Color(nsColor: .textBackgroundColor).opacity(0.7) } + var codeForegroundColor: Color { + if colorScheme == .light, let color = codeForegroundColorLight.value { + return color.swiftUIColor + } else if let color = codeForegroundColorDark.value { + return color.swiftUIColor + } + return Color(nsColor: .textColor) + } + var toolView: some View { WithPerceptionTracking { VStack { if command != nil { HStack(spacing: 4) { statusIcon - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) Text(command!) .textSelection(.enabled) - .font(.system(size: chatFontSize, design: .monospaced)) + .scaledFont(size: chatFontSize, design: .monospaced) .padding(8) .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle(.primary) + .foregroundStyle(codeForegroundColor) .background(codeBackgroundColor) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay { @@ -140,10 +150,12 @@ struct RunInTerminalToolView: View { Button("Cancel") { chat.send(.toolCallCancelled(tool.id)) } + .scaledFont(.body) Button("Continue") { chat.send(.toolCallAccepted(tool.id)) } + .scaledFont(.body) .buttonStyle(BorderedProminentButtonStyle()) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Core/Sources/ConversationTab/ViewExtension.swift b/Core/Sources/ConversationTab/ViewExtension.swift index e619f5a4..912c9687 100644 --- a/Core/Sources/ConversationTab/ViewExtension.swift +++ b/Core/Sources/ConversationTab/ViewExtension.swift @@ -25,13 +25,14 @@ struct HoverRadiusBackgroundModifier: ViewModifier { RoundedRectangle(cornerRadius: cornerRadius) .fill(isHovered ? hoverColor ?? ITEM_SELECTED_COLOR : Color.clear) ) + .clipShape( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + ) .overlay( - Group { - if isHovered && showBorder { - RoundedRectangle(cornerRadius: cornerRadius) - .stroke(borderColor, lineWidth: borderWidth) - } - } + (isHovered && showBorder) ? + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(borderColor, lineWidth: borderWidth) : + nil ) } } @@ -58,8 +59,16 @@ extension View { self.modifier(HoverRadiusBackgroundModifier(isHovered: isHovered, hoverColor: hoverColor, cornerRadius: cornerRadius)) } - public func hoverRadiusBackground(isHovered: Bool, hoverColor: Color?, cornerRadius: CGFloat, showBorder: Bool) -> some View { - self.modifier(HoverRadiusBackgroundModifier(isHovered: isHovered, hoverColor: hoverColor, cornerRadius: cornerRadius, showBorder: showBorder)) + public func hoverRadiusBackground(isHovered: Bool, hoverColor: Color?, cornerRadius: CGFloat, showBorder: Bool, borderColor: Color = .white.opacity(0.07)) -> some View { + self.modifier( + HoverRadiusBackgroundModifier( + isHovered: isHovered, + hoverColor: hoverColor, + cornerRadius: cornerRadius, + showBorder: true, + borderColor: borderColor + ) + ) } public func hoverForeground(isHovered: Bool, defaultColor: Color) -> some View { diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index 91586d6b..98a6173a 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -14,10 +14,12 @@ struct BotMessage: View { let text: String let references: [ConversationReference] let followUp: ConversationFollowUp? - let errorMessage: String? + let errorMessages: [String] let chat: StoreOf let steps: [ConversationProgressStep] let editAgentRounds: [AgentRound] + let panelMessages: [CopilotShowMessageParams] + let codeReviewRound: CodeReviewRound? @Environment(\.colorScheme) var colorScheme @AppStorage(\.chatFontSize) var chatFontSize @@ -79,9 +81,12 @@ struct BotMessage: View { }, label: { HStack(spacing: 4) { Image(systemName: isReferencesPresented ? "chevron.down" : "chevron.right") + .resizable() + .scaledToFit() + .scaledFrame(width: 14, height: 14) Text(MakeReferenceTitle(references: references)) - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) } .background { RoundedRectangle(cornerRadius: r - 4) @@ -111,7 +116,7 @@ struct BotMessage: View { .scaleEffect(0.7) Text("Working...") - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) .foregroundColor(.secondary) } } @@ -120,7 +125,6 @@ struct BotMessage: View { HStack { VStack(alignment: .leading, spacing: 8) { CopilotMessageHeader() - .padding(.leading, 6) if !references.isEmpty { WithPerceptionTracking { @@ -137,6 +141,14 @@ struct BotMessage: View { ProgressStep(steps: steps) } + if !panelMessages.isEmpty { + WithPerceptionTracking { + ForEach(panelMessages.indices, id: \.self) { index in + FunctionMessage(text: panelMessages[index].message, chat: chat) + } + } + } + if editAgentRounds.count > 0 { ProgressAgentRound(rounds: editAgentRounds, chat: chat) } @@ -144,11 +156,22 @@ struct BotMessage: View { if !text.isEmpty { ThemedMarkdownText(text: text, chat: chat) } + + if let codeReviewRound = codeReviewRound { + CodeReviewMainView( + store: chat, round: codeReviewRound + ) + } - if errorMessage != nil { - HStack(spacing: 4) { - Image(systemName: "info.circle") - ThemedMarkdownText(text: errorMessage!, chat: chat) + if !errorMessages.isEmpty { + VStack(spacing: 4) { + ForEach(errorMessages.indices, id: \.self) { index in + if let attributedString = try? AttributedString(markdown: errorMessages[index]) { + NotificationBanner(style: .warning) { + Text(attributedString) + } + } + } } } @@ -166,16 +189,19 @@ struct BotMessage: View { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) } + .scaledFont(.body) Button("Set as Extra System Prompt") { chat.send(.setAsExtraPromptButtonTapped(id)) } + .scaledFont(.body) Divider() Button("Delete") { chat.send(.deleteMessageButtonTapped(id)) } + .scaledFont(.body) } } } @@ -233,15 +259,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: 16, height: 16) Text(reference.fileName) .truncationMode(.middle) .lineLimit(1) .layoutPriority(1) - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -304,7 +330,7 @@ struct BotMessage_Previews: PreviewProvider { status: .running) ]) ] - + static var previews: some View { let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") BotMessage( @@ -318,13 +344,16 @@ struct BotMessage_Previews: PreviewProvider { references: .init(repeating: .init( uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", status: .included, - kind: .class + kind: .class, + referenceType: .file ), count: 2), followUp: ConversationFollowUp(message: "followup question", id: "id", type: "type"), - errorMessage: "Sorry, an error occurred while generating a response.", + errorMessages: ["Sorry, an error occurred while generating a response."], chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }), steps: steps, - editAgentRounds: agentRounds + editAgentRounds: agentRounds, + panelMessages: [], + codeReviewRound: nil ) .padding() .fixedSize(horizontal: true, vertical: true) @@ -338,32 +367,38 @@ struct ReferenceList_Previews: PreviewProvider { .init( uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", status: .included, - kind: .class + kind: .class, + referenceType: .file ), .init( uri: "/Core/Sources/ConversationTab/Views", status: .included, - kind: .struct + kind: .struct, + referenceType: .file ), .init( uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", status: .included, - kind: .function + kind: .function, + referenceType: .file ), .init( uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", status: .included, - kind: .case + kind: .case, + referenceType: .file ), .init( uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", status: .included, - kind: .extension + kind: .extension, + referenceType: .file ), .init( uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", status: .included, - kind: .webpage + kind: .webpage, + referenceType: .file ), ], chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) })) } diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift new file mode 100644 index 00000000..8c0132f9 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift @@ -0,0 +1,122 @@ +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 statusIcon: some View { + Group { + switch round.status { + case .running: + ProgressView() + .controlSize(.small) + .frame(width: 16, height: 16) + .scaledScaleEffect(0.7) + case .completed: + Image(systemName: "checkmark") + .foregroundColor(.green) + .scaledFont(.body) + case .error: + Image(systemName: "xmark.circle") + .foregroundColor(.red) + .scaledFont(.body) + case .cancelled: + Image(systemName: "slash.circle") + .foregroundColor(.gray) + .scaledFont(.body) + case .waitForConfirmation: + EmptyView() + case .accepted: + EmptyView() + } + } + } + + var statusView: some View { + Group { + switch round.status { + case .waitForConfirmation, .accepted: + EmptyView() + default: + HStack(spacing: 4) { + statusIcon + .scaledFrame(width: 16, height: 16) + + Text("Running Code Review...") + .scaledFont(.system(size: chatFontSize)) + .foregroundColor(.secondary) + + Spacer() + } + } + } + } + + var shouldShowHelloMessage: Bool { round.statusHistory.contains(.waitForConfirmation) } + var shouldShowRunningStatus: Bool { round.statusHistory.contains(.running) } + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 8) { + if shouldShowHelloMessage { + helloMessageView + } + + if hasChangedFiles { + FileSelectionSection( + store: store, + round: round, + changedFileUris: changedFileUris, + selectedFileUris: $selectedFileUris + ) + } + + if shouldShowRunningStatus { + statusView + } + + if hasFileComments { + ReviewResultsSection(store: store, round: round) + } + + if round.status == .completed || round.status == .error { + ReviewSummarySection(round: round) + } + } + } + } +} diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift new file mode 100644 index 00000000..16921189 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift @@ -0,0 +1,277 @@ +import ComposableArchitecture +import ConversationServiceProvider +import LanguageServerProtocol +import SharedUIComponents +import SwiftUI + +// MARK: - File Selection Section + +struct FileSelectionSection: View { + let store: StoreOf + let round: CodeReviewRound + let changedFileUris: [DocumentUri] + @Binding var selectedFileUris: [DocumentUri] + @AppStorage(\.chatFontSize) private var chatFontSize + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + FileSelectionHeader(fileCount: selectedFileUris.count) + .frame(maxWidth: .infinity, alignment: .leading) + + FileSelectionList( + store: store, + fileUris: changedFileUris, + reviewStatus: round.status, + selectedFileUris: $selectedFileUris + ) + + if round.status == .waitForConfirmation { + FileSelectionActions( + store: store, + roundId: round.id, + selectedFileUris: selectedFileUris + ) + } + } + .padding(12) + .background(CodeReviewCardBackground()) + } +} + +// MARK: - File Selection Components + +private struct FileSelectionHeader: View { + let fileCount: Int + @AppStorage(\.chatFontSize) private var chatFontSize + + var body: some View { + HStack(alignment: .top, spacing: 6) { + Image("Sparkle") + .resizable() + .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) + + 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..cb6548a8 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift @@ -0,0 +1,45 @@ +import SwiftUI +import ConversationServiceProvider +import SharedUIComponents + +struct ReviewSummarySection: View { + var round: CodeReviewRound + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + 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)) + } + } +} + +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..7114a5ee 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] @@ -67,11 +67,13 @@ struct ToolConfirmationView: View { Button("Cancel") { chat.send(.toolCallCancelled(tool.id)) } + .scaledFont(.body) Button("Continue") { chat.send(.toolCallAccepted(tool.id)) } .buttonStyle(BorderedProminentButtonStyle()) + .scaledFont(.body) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 4) @@ -97,12 +99,12 @@ struct GenericToolTitleView: View { HStack(spacing: 4) { Text(toolStatus) .textSelection(.enabled) - .font(.system(size: chatFontSize, weight: fontWeight)) + .scaledFont(size: chatFontSize, weight: fontWeight) .foregroundStyle(.primary) .background(Color.clear) Text(toolName) .textSelection(.enabled) - .font(.system(size: chatFontSize, weight: fontWeight)) + .scaledFont(size: chatFontSize, weight: fontWeight) .foregroundStyle(.primary) .padding(.vertical, 2) .padding(.horizontal, 4) @@ -130,16 +132,19 @@ struct ToolStatusItemView: View { case .running: ProgressView() .controlSize(.small) - .scaleEffect(0.7) + .scaledScaleEffect(0.7) case .completed: Image(systemName: "checkmark") .foregroundColor(.green.opacity(0.5)) + .scaledFont(.body) case .error: Image(systemName: "xmark.circle") .foregroundColor(.red.opacity(0.5)) + .scaledFont(.body) case .cancelled: Image(systemName: "slash.circle") .foregroundColor(.gray.opacity(0.5)) + .scaledFont(.body) case .waitForConfirmation: EmptyView() case .accepted: @@ -185,10 +190,10 @@ struct ToolStatusItemView: View { WithPerceptionTracking { HStack(spacing: 4) { statusIcon - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) progressTitleText - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) .lineLimit(1) Spacer() diff --git a/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift index 7b6c845e..b6c0f524 100644 --- a/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift @@ -3,6 +3,7 @@ import ConversationServiceProvider import ComposableArchitecture import Combine import ChatService +import SharedUIComponents struct ProgressStep: View { let steps: [ConversationProgressStep] @@ -30,17 +31,19 @@ struct StatusItemView: View { case .running: ProgressView() .controlSize(.small) - .frame(width: 16, height: 16) - .scaleEffect(0.7) + .scaledScaleEffect(0.7) case .completed: Image(systemName: "checkmark") .foregroundColor(.green) + .scaledFont(.body) case .failed: Image(systemName: "xmark.circle") .foregroundColor(.red) + .scaledFont(.body) case .cancelled: Image(systemName: "slash.circle") .foregroundColor(.gray) + .scaledFont(.body) } } } @@ -57,10 +60,10 @@ struct StatusItemView: View { WithPerceptionTracking { HStack(spacing: 4) { statusIcon - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) statusTitle - .font(.system(size: chatFontSize)) + .scaledFont(size: chatFontSize) .lineLimit(1) Spacer() diff --git a/Core/Sources/ConversationTab/Views/FunctionMessage.swift b/Core/Sources/ConversationTab/Views/FunctionMessage.swift index 578ed1d3..4a883f97 100644 --- a/Core/Sources/ConversationTab/Views/FunctionMessage.swift +++ b/Core/Sources/ConversationTab/Views/FunctionMessage.swift @@ -4,82 +4,84 @@ import ChatService import SharedUIComponents import ComposableArchitecture import ChatTab +import GitHubCopilotService struct FunctionMessage: View { - let chat: StoreOf - let id: String let text: String + let chat: StoreOf @AppStorage(\.chatFontSize) var chatFontSize @Environment(\.openURL) private var openURL - private let displayFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .long - formatter.timeStyle = .short - return formatter - }() - - private func extractDate(from text: String) -> Date? { - guard let match = (try? NSRegularExpression(pattern: "until (.*?) for"))? - .firstMatch(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)), - let dateRange = Range(match.range(at: 1), in: text) else { - return nil + private var isFreePlanUser: Bool { + text.contains("30-day free trial") + } + + private var isOrgUser: Bool { + text.contains("reach out to your organization's Copilot admin") + } + + private var switchToFallbackModelText: String { + if let fallbackModelName = CopilotModelManager.getFallbackLLM( + scope: chat.isAgentMode ? .agentPanel : .chatPanel + )?.modelName { + return "We have automatically switched you to \(fallbackModelName) which is included with your plan." + } else { + return "" } + } + + private var errorContent: Text { + switch (isFreePlanUser, isOrgUser) { + case (true, _): + return Text("Monthly message limit reached. Upgrade to Copilot Pro (30-day free trial) or wait for your limit to reset.") + + case (_, true): + let parts = [ + "You have exceeded your free request allowance.", + switchToFallbackModelText, + "To enable additional paid premium requests, contact your organization admin." + ].filter { !$0.isEmpty } + return Text(attributedString(from: parts)) - let dateString = String(text[dateRange]) - let formatter = DateFormatter() - formatter.dateFormat = "M/d/yyyy, h:mm:ss a" - return formatter.date(from: dateString) + default: + let parts = [ + "You have exceeded your premium request allowance.", + switchToFallbackModelText, + "[Enable additional paid premium requests](https://aka.ms/github-copilot-manage-overage) to continue using premium models." + ].filter { !$0.isEmpty } + return Text(attributedString(from: parts)) + } + } + + private func attributedString(from parts: [String]) -> AttributedString { + do { + return try AttributedString(markdown: parts.joined(separator: " ")) + } catch { + return AttributedString(parts.joined(separator: " ")) + } } var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Image("CopilotLogo") - .resizable() - .renderingMode(.template) - .scaledToFill() - .frame(width: 12, height: 12) - .overlay( - Circle() - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - .frame(width: 24, height: 24) - ) - .padding(.leading, 8) - - Text("GitHub Copilot") - .font(.system(size: 13)) - .fontWeight(.semibold) - .padding(4) - - Spacer() - } + NotificationBanner(style: .warning) { + errorContent - VStack(alignment: .leading, spacing: 16) { - Text("You've reached your monthly chat limit for GitHub Copilot Free.") - .font(.system(size: 13)) - .fontWeight(.medium) - - if let date = extractDate(from: text) { - Text("Upgrade to Copilot Pro with a 30-day free trial or wait until \(displayFormatter.string(from: date)) for your limit to reset.") - .font(.system(size: 13)) - } - + if isFreePlanUser { Button("Update to Copilot Pro") { - if let url = URL(string: "https://github.com/github-copilot/signup/copilot_individual") { + if let url = URL(string: "https://aka.ms/github-copilot-upgrade-plan") { openURL(url) } } .buttonStyle(.borderedProminent) - .controlSize(.large) + .controlSize(.regular) + .scaledFont(.body) + .onHover { isHovering in + if isHovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } } - .padding(.vertical, 10) - .padding(.horizontal, 12) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) - .padding(.vertical, 4) } } } @@ -88,9 +90,8 @@ struct FunctionMessage_Previews: PreviewProvider { static var previews: some View { let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") FunctionMessage( - chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }), - id: "1", - text: "You've reached your monthly chat limit. Upgrade to Copilot Pro (30-day free trial) or wait until 1/17/2025, 8:00:00 AM for your limit to reset." + text: "You've reached your monthly chat limit. Upgrade to Copilot Pro (30-day free trial) or wait until 1/17/2025, 8:00:00 AM for your limit to reset.", + chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }) ) .padding() .fixedSize() diff --git a/Core/Sources/ConversationTab/Views/ImageReferenceItemView.swift b/Core/Sources/ConversationTab/Views/ImageReferenceItemView.swift new file mode 100644 index 00000000..bc57dc18 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ImageReferenceItemView.swift @@ -0,0 +1,70 @@ +import ConversationServiceProvider +import SwiftUI +import Foundation +import SharedUIComponents + +struct ImageReferenceItemView: View { + let item: ImageReference + @State private var showPopover = false + + private func getImageTitle() -> String { + switch item.source { + case .file: + if let fileUrl = item.fileUrl { + return fileUrl.lastPathComponent + } else { + return "Attached Image" + } + case .pasted: + return "Pasted Image" + case .screenshot: + return "Screenshot" + } + } + + var body: some View { + HStack(alignment: .center, spacing: 4) { + let image = loadImageFromData(data: item.data).image + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 28, height: 28) + .clipShape(RoundedRectangle(cornerRadius: 1.72)) + .overlay( + RoundedRectangle(cornerRadius: 1.72) + .inset(by: 0.21) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.43) + ) + + let text = getImageTitle() + let font = NSFont.systemFont(ofSize: 12) + let attributes = [NSAttributedString.Key.font: font] + let size = (text as NSString).size(withAttributes: attributes) + let textWidth = min(size.width, 105) + + Text(text) + .lineLimit(1) + .scaledFont(size: 12) + .foregroundColor(.primary.opacity(0.85)) + .truncationMode(.middle) + .frame(width: textWidth, alignment: .leading) + } + .padding(4) + .background( + Color(nsColor: .windowBackgroundColor).opacity(0.5) + ) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .inset(by: 0.5) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .popover(isPresented: $showPopover, arrowEdge: .bottom) { + PopoverImageView(data: item.data) + } + .onTapGesture { + self.showPopover = true + } + } +} + diff --git a/Core/Sources/ConversationTab/Views/NotificationBanner.swift b/Core/Sources/ConversationTab/Views/NotificationBanner.swift new file mode 100644 index 00000000..68c40d57 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/NotificationBanner.swift @@ -0,0 +1,44 @@ +import SwiftUI + +public enum BannerStyle { + case warning + + var iconName: String { + switch self { + case .warning: return "exclamationmark.triangle" + } + } + + var color: Color { + switch self { + case .warning: return .orange + } + } +} + +struct NotificationBanner: View { + var style: BannerStyle + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 6) { + Image(systemName: style.iconName) + .font(Font.system(size: 12)) + .foregroundColor(style.color) + + VStack(alignment: .leading, spacing: 8) { + content() + } + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .padding(.vertical, 4) + } +} diff --git a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift index d08d7abc..b3b73599 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,7 @@ struct ThemedMarkdownText_Previews: PreviewProvider { } ``` """, - chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) })) + context: .init(onInsert: {_ in print("Inserted") })) } } diff --git a/Core/Sources/ConversationTab/Views/UserMessage.swift b/Core/Sources/ConversationTab/Views/UserMessage.swift index f2aea5f6..858c802b 100644 --- a/Core/Sources/ConversationTab/Views/UserMessage.swift +++ b/Core/Sources/ConversationTab/Views/UserMessage.swift @@ -7,11 +7,16 @@ import SwiftUI import Status import Cache import ChatTab +import ConversationServiceProvider +import SwiftUIFlowLayout + +private let MAX_TEXT_LENGTH = 10000 // Maximum characters to prevent crashes struct UserMessage: View { var r: Double { messageBubbleCornerRadius } let id: String let text: String + let imageReferences: [ImageReference] let chat: StoreOf @Environment(\.colorScheme) var colorScheme @ObservedObject private var statusObserver = StatusObserver.shared @@ -24,16 +29,25 @@ struct UserMessage: View { avatarImage .resizable() .aspectRatio(contentMode: .fill) - .frame(width: 24, height: 24) + .scaledFrame(width: 24, height: 24) .clipShape(Circle()) } else { Image(systemName: "person.circle") .resizable() - .frame(width: 24, height: 24) + .scaledToFit() + .scaledFrame(width: 24, height: 24) } } } + // Truncate the displayed user message if it's too long. + private var displayText: String { + if text.count > MAX_TEXT_LENGTH { + return String(text.prefix(MAX_TEXT_LENGTH)) + "\n… (message too long, rest hidden)" + } + return text + } + var body: some View { HStack { VStack(alignment: .leading, spacing: 8) { @@ -41,14 +55,20 @@ struct UserMessage: View { AvatarView() Text(statusObserver.authStatus.username ?? "") - .chatMessageHeaderTextStyle() + .scaledFont(size: 13, weight: .semibold) .padding(2) Spacer() } - ThemedMarkdownText(text: text, chat: chat) + ThemedMarkdownText(text: displayText, chat: chat) .frame(maxWidth: .infinity, alignment: .leading) + + if !imageReferences.isEmpty { + FlowLayout(mode: .scrollable, items: imageReferences, itemSpacing: 4) { item in + ImageReferenceItemView(item: item) + } + } } } .shadow(color: .black.opacity(0.05), radius: 6) @@ -73,6 +93,7 @@ struct UserMessage_Previews: PreviewProvider { - (void)bar {} ``` """#, + imageReferences: [], chat: .init( initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) } diff --git a/Core/Sources/ConversationTab/Views/WorkingSetView.swift b/Core/Sources/ConversationTab/Views/WorkingSetView.swift index 677c44dc..8709a4c8 100644 --- a/Core/Sources/ConversationTab/Views/WorkingSetView.swift +++ b/Core/Sources/ConversationTab/Views/WorkingSetView.swift @@ -62,6 +62,7 @@ struct WorkingSetHeader: View { ) -> some View { Button(action: action) { Text(text) + .scaledFont(.body) .foregroundColor(textForegroundColor) .padding(.horizontal, 6) .padding(.vertical, 2) @@ -81,7 +82,7 @@ struct WorkingSetHeader: View { HStack(spacing: 0) { Text(getTitle()) .foregroundColor(.secondary) - .font(.system(size: 13)) + .scaledFont(size: 13) Spacer() @@ -138,17 +139,17 @@ struct FileEditView: View { switch imageType { case .system(let name): Image(systemName: name) - .font(.system(size: 16, weight: .regular)) + .scaledFont(size: 15, weight: .regular) case .asset(let name): Image(name) .renderingMode(.template) .resizable() .aspectRatio(contentMode: .fit) - .frame(height: 16) + .scaledFrame(height: 16) } } .foregroundColor(.white) - .frame(width: 22) + .scaledFrame(width: 22) .frame(maxHeight: .infinity) } .buttonStyle(HoverButtonStyle(padding: 0, hoverColor: .white.opacity(0.2))) @@ -192,11 +193,11 @@ struct FileEditView: View { drawFileIcon(fileEdit.fileURL) .resizable() .scaledToFit() - .frame(width: 16, height: 16) + .scaledFrame(width: 16, height: 16) .foregroundColor(.secondary) Text(fileEdit.fileURL.lastPathComponent) - .font(.system(size: 13)) + .scaledFont(size: 13) .foregroundColor(isHovering ? .white : Color("WorkingSetItemColor")) } diff --git a/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift b/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift new file mode 100644 index 00000000..42ec5eb5 --- /dev/null +++ b/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift @@ -0,0 +1,160 @@ +import SwiftUI +import ComposableArchitecture +import Persist +import ConversationServiceProvider +import GitHubCopilotService +import SharedUIComponents + +public struct HoverableImageView: View { + @Environment(\.colorScheme) var colorScheme + + let image: ImageReference + let chat: StoreOf + @State private var isHovered = false + @State private var hoverTask: Task? + @State private var isSelectedModelSupportVision = AppState.shared.isSelectedModelSupportVision() ?? CopilotModelManager.getDefaultChatModel(scope: AppState.shared.modelScope())?.supportVision ?? false + @State private var showPopover = false + + let maxWidth: CGFloat = 330 + let maxHeight: CGFloat = 160 + + private var visionNotSupportedOverlay: some View { + Group { + if !isSelectedModelSupportVision { + ZStack { + Color.clear + .background(.regularMaterial) + .opacity(0.4) + .clipShape(RoundedRectangle(cornerRadius: hoverableImageCornerRadius)) + + VStack(alignment: .center, spacing: 8) { + Image(systemName: "eye.slash") + .font(.system(size: 14, weight: .semibold)) + Text("Vision not supported by current model") + .font(.system(size: 12, weight: .semibold)) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + .foregroundColor(colorScheme == .dark ? .primary : .white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .colorScheme(colorScheme == .dark ? .light : .dark) + } + } + } + + private var borderOverlay: some View { + RoundedRectangle(cornerRadius: hoverableImageCornerRadius) + .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 1) + } + + private var removeButton: some View { + Button(action: { + chat.send(.removeSelectedImage(image)) + }) { + Image(systemName: "xmark") + .foregroundColor(.primary) + .scaledFont(.system(size: 13)) + .frame(width: 24, height: 24) + .background( + RoundedRectangle(cornerRadius: hoverableImageCornerRadius) + .fill(Color.contentBackground.opacity(0.72)) + .shadow(color: .black.opacity(0.3), radius: 1.5, x: 0, y: 0) + .shadow(color: .black.opacity(0.25), radius: 50, x: 0, y: 36) + ) + } + .buttonStyle(.plain) + .padding(1) + .onHover { buttonHovering in + hoverTask?.cancel() + if buttonHovering { + isHovered = true + } + } + } + + private var hoverOverlay: some View { + Group { + if isHovered { + VStack { + Spacer() + HStack { + removeButton + Spacer() + } + } + } + } + } + + private var baseImageView: some View { + let (image, nsImage) = loadImageFromData(data: image.data) + let imageSize = nsImage?.size ?? CGSize(width: maxWidth, height: maxHeight) + let isWideImage = imageSize.height < 160 && imageSize.width >= maxWidth + + return image + .resizable() + .aspectRatio(contentMode: isWideImage ? .fill : .fit) + .blur(radius: !isSelectedModelSupportVision ? 2.5 : 0) + .frame( + width: isWideImage ? min(imageSize.width, maxWidth) : nil, + height: isWideImage ? min(imageSize.height, maxHeight) : maxHeight, + alignment: .leading + ) + .clipShape( + RoundedRectangle(cornerRadius: hoverableImageCornerRadius), + style: .init(eoFill: true, antialiased: true) + ) + } + + private func handleHover(_ hovering: Bool) { + hoverTask?.cancel() + + if hovering { + isHovered = true + } else { + // Add a small delay before hiding to prevent flashing + hoverTask = Task { + try? await Task.sleep(nanoseconds: 10_000_000) // 0.01 seconds + if !Task.isCancelled { + isHovered = false + } + } + } + } + + private func updateVisionSupport() { + isSelectedModelSupportVision = AppState.shared.isSelectedModelSupportVision() ?? CopilotModelManager.getDefaultChatModel(scope: AppState.shared.modelScope())?.supportVision ?? false + } + + public var body: some View { + if NSImage(data: image.data) != nil { + baseImageView + .frame(height: maxHeight, alignment: .leading) + .background( + Color(nsColor: .windowBackgroundColor).opacity(0.5) + ) + .overlay(visionNotSupportedOverlay) + .overlay(borderOverlay) + .onHover(perform: handleHover) + .overlay(hoverOverlay) + .onReceive(NotificationCenter.default.publisher(for: .gitHubCopilotSelectedModelDidChange)) { _ in + updateVisionSupport() + } + .onTapGesture { + showPopover.toggle() + } + .popover(isPresented: $showPopover, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) { + PopoverImageView(data: image.data) + } + } + } +} + +public func loadImageFromData(data: Data) -> (image: Image, nsImage: NSImage?) { + if let nsImage = NSImage(data: data) { + return (Image(nsImage: nsImage), nsImage) + } else { + return (Image(systemName: "photo.trianglebadge.exclamationmark"), nil) + } +} diff --git a/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift b/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift new file mode 100644 index 00000000..87e7179a --- /dev/null +++ b/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift @@ -0,0 +1,19 @@ +import SwiftUI +import ComposableArchitecture + +public struct ImagesScrollView: View { + let chat: StoreOf + + public var body: some View { + let attachedImages = chat.state.attachedImages.reversed() + return ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { + ForEach(attachedImages, id: \.self) { image in + HoverableImageView(image: image, chat: chat) + } + } + } + .padding(.horizontal, 8) + .padding(.top, 8) + } +} diff --git a/Core/Sources/ConversationTab/VisionViews/PopoverImageView.swift b/Core/Sources/ConversationTab/VisionViews/PopoverImageView.swift new file mode 100644 index 00000000..0beddb8c --- /dev/null +++ b/Core/Sources/ConversationTab/VisionViews/PopoverImageView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +public struct PopoverImageView: View { + let data: Data + + public var body: some View { + let maxHeight: CGFloat = 400 + let (image, nsImage) = loadImageFromData(data: data) + let height = nsImage.map { min($0.size.height, maxHeight) } ?? maxHeight + + return image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: height) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(10) + } +} diff --git a/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift b/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift new file mode 100644 index 00000000..ca12c71a --- /dev/null +++ b/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift @@ -0,0 +1,137 @@ +import SwiftUI +import SharedUIComponents +import Logger +import ComposableArchitecture +import ConversationServiceProvider +import AppKit +import UniformTypeIdentifiers + +public struct VisionMenuView: View { + let chat: StoreOf + @AppStorage(\.capturePermissionShown) var capturePermissionShown: Bool + @State private var shouldPresentScreenRecordingPermissionAlert: Bool = false + + func showImagePicker() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.png, .jpeg, .bmp, .gif, .tiff, .webP] + panel.allowsMultipleSelection = true + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.level = .modalPanel + + // Position the panel relative to the current window + if let window = NSApplication.shared.keyWindow { + let windowFrame = window.frame + let panelSize = CGSize(width: 600, height: 400) + let x = windowFrame.midX - panelSize.width / 2 + let y = windowFrame.midY - panelSize.height / 2 + panel.setFrame(NSRect(origin: CGPoint(x: x, y: y), size: panelSize), display: true) + } + + panel.begin { response in + if response == .OK { + let selectedImageURLs = panel.urls + handleSelectedImages(selectedImageURLs) + } + } + } + + func handleSelectedImages(_ urls: [URL]) { + for url in urls { + let gotAccess = url.startAccessingSecurityScopedResource() + if gotAccess { + // Process the image file + if let imageData = try? Data(contentsOf: url) { + // imageData now contains the binary data of the image + Logger.client.info("Add selected image from URL: \(url)") + let imageReference = ImageReference(data: imageData, fileUrl: url) + chat.send(.addSelectedImage(imageReference)) + } + + url.stopAccessingSecurityScopedResource() + } + } + } + + func runScreenCapture(args: [String] = []) { + let hasScreenRecordingPermission = CGPreflightScreenCaptureAccess() + if !hasScreenRecordingPermission { + if capturePermissionShown { + shouldPresentScreenRecordingPermissionAlert = true + } else { + CGRequestScreenCaptureAccess() + capturePermissionShown = true + } + return + } + + let task = Process() + task.launchPath = "/usr/sbin/screencapture" + task.arguments = args + task.terminationHandler = { _ in + DispatchQueue.main.async { + if task.terminationStatus == 0 { + if let data = NSPasteboard.general.data(forType: .png) { + chat.send(.addSelectedImage(ImageReference(data: data, source: .screenshot))) + } else if let tiffData = NSPasteboard.general.data(forType: .tiff), + let imageRep = NSBitmapImageRep(data: tiffData), + let pngData = imageRep.representation(using: .png, properties: [:]) { + chat.send(.addSelectedImage(ImageReference(data: pngData, source: .screenshot))) + } + } + } + } + task.launch() + task.waitUntilExit() + } + + public var body: some View { + Menu { + Button(action: { runScreenCapture(args: ["-w", "-c"]) }) { + Image(systemName: "macwindow") + Text("Capture Window") + } + .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) + .scaledFrame(width: 16, height: 16) + .scaledPadding(4) + .foregroundColor(.primary.opacity(0.85)) + .scaledFont(size: 11, weight: .semibold) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .help("Attach images") + .cornerRadius(6) + .alert( + "Enable Screen & System Recording Permission", + isPresented: $shouldPresentScreenRecordingPermissionAlert + ) { + Button( + "Open System Settings", + action: { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture")!) + }).keyboardShortcut(.defaultAction) + .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 7acff3cd..bca4079f 100644 --- a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift +++ b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift @@ -17,7 +17,6 @@ public class GitHubCopilotViewModel: ObservableObject { public static let shared = GitHubCopilotViewModel() @Dependency(\.toast) var toast - @Dependency(\.openURL) var openURL @AppStorage("username") var username: String = "" @@ -107,6 +106,7 @@ public class GitHubCopilotViewModel: ObservableObject { status = try await service.signOut() await Status.shared.updateAuthStatus(.notLoggedIn) await Status.shared.updateCLSStatus(.unknown, busy: false, message: "") + await Status.shared.updateQuotaInfo(nil) username = "" broadcastStatusChange() } catch { @@ -136,10 +136,8 @@ public class GitHubCopilotViewModel: ObservableObject { pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil) pasteboard.setString(signInResponse.userCode, forType: NSPasteboard.PasteboardType.string) toast("Sign-in code \(signInResponse.userCode) copied", .info) - Task { - await openURL(signInResponse.verificationURL) - waitForSignIn() - } + NSWorkspace.shared.open(signInResponse.verificationURL) + waitForSignIn() } public func waitForSignIn() { @@ -162,18 +160,196 @@ public class GitHubCopilotViewModel: ObservableObject { CopilotModelManager.updateLLMs(models) } } catch let error as GitHubCopilotError { - if case .languageServerError(.timeout) = error { - // TODO figure out how to extend the default timeout on a Chime LSP request - // Until then, reissue request + switch error { + case .languageServerError(.timeout): waitForSignIn() return + case .languageServerError( + .serverError( + code: CLSErrorCode.deviceFlowFailed.rawValue, + message: _, + data: _ + ) + ): + await showSignInFailedAlert(error: error) + waitingForSignIn = false + return + default: + throw error } - throw error } catch { toast(error.localizedDescription, .error) } } } + + private func extractSigninErrorMessage(error: GitHubCopilotError) -> String { + let errorDescription = error.localizedDescription + + // Handle specific EACCES permission denied errors + if errorDescription.contains("EACCES") { + // Look for paths wrapped in single quotes + let pattern = "'([^']+)'" + if let regex = try? NSRegularExpression(pattern: pattern, options: []) { + let range = NSRange(location: 0, length: errorDescription.utf16.count) + if let match = regex.firstMatch(in: errorDescription, options: [], range: range) { + let pathRange = Range(match.range(at: 1), in: errorDescription)! + let path = String(errorDescription[pathRange]) + return path + } + } + } + + return errorDescription + } + + private func getSigninErrorTitle(error: GitHubCopilotError) -> String { + let errorDescription = error.localizedDescription + + if errorDescription.contains("EACCES") { + return "Can't sign you in. The app couldn't create or access files in" + } + + return "Error details:" + } + + private var accessPermissionCommands: String { + """ + sudo mkdir -p ~/.config/github-copilot + sudo chown -R $(whoami):staff ~/.config + chmod -N ~/.config ~/.config/github-copilot + """ + } + + private var containerBackgroundColor: CGColor { + let isDarkMode = NSApp.effectiveAppearance.name == .darkAqua + return isDarkMode + ? NSColor.black.withAlphaComponent(0.85).cgColor + : NSColor.white.withAlphaComponent(0.85).cgColor + } + + // MARK: - Alert Building Functions + + private func showSignInFailedAlert(error: GitHubCopilotError) async { + let alert = NSAlert() + alert.messageText = "GitHub Copilot Sign-in Failed" + alert.alertStyle = .critical + + let accessoryView = createAlertAccessoryView(error: error) + alert.accessoryView = accessoryView + alert.addButton(withTitle: "Copy Commands") + alert.addButton(withTitle: "Cancel") + + let response = alert.runModal() + + if response == .alertFirstButtonReturn { + copyCommandsToClipboard() + } + } + + private func createAlertAccessoryView(error: GitHubCopilotError) -> NSView { + let accessoryView = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 142)) + + let detailsHeader = createDetailsHeader(error: error) + accessoryView.addSubview(detailsHeader) + + let errorContainer = createErrorContainer(error: error) + accessoryView.addSubview(errorContainer) + + let terminalHeader = createTerminalHeader() + accessoryView.addSubview(terminalHeader) + + let commandsContainer = createCommandsContainer() + accessoryView.addSubview(commandsContainer) + + return accessoryView + } + + private func createDetailsHeader(error: GitHubCopilotError) -> NSView { + let detailsHeader = NSView(frame: NSRect(x: 16, y: 122, width: 368, height: 20)) + + let warningIcon = NSImageView(frame: NSRect(x: 0, y: 4, width: 16, height: 16)) + warningIcon.image = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Warning") + warningIcon.contentTintColor = NSColor.systemOrange + detailsHeader.addSubview(warningIcon) + + let detailsLabel = NSTextField(wrappingLabelWithString: getSigninErrorTitle(error: error)) + detailsLabel.frame = NSRect(x: 20, y: 0, width: 346, height: 20) + detailsLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) + detailsLabel.textColor = NSColor.labelColor + detailsHeader.addSubview(detailsLabel) + + return detailsHeader + } + + private func createErrorContainer(error: GitHubCopilotError) -> NSView { + let errorContainer = NSView(frame: NSRect(x: 16, y: 96, width: 368, height: 22)) + errorContainer.wantsLayer = true + errorContainer.layer?.backgroundColor = containerBackgroundColor + errorContainer.layer?.borderColor = NSColor.separatorColor.cgColor + errorContainer.layer?.borderWidth = 1 + errorContainer.layer?.cornerRadius = 6 + + let errorMessage = NSTextField(wrappingLabelWithString: extractSigninErrorMessage(error: error)) + errorMessage.frame = NSRect(x: 8, y: 4, width: 368, height: 14) + errorMessage.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + errorMessage.textColor = NSColor.labelColor + errorMessage.backgroundColor = .clear + errorMessage.isBordered = false + errorMessage.isEditable = false + errorMessage.drawsBackground = false + errorMessage.usesSingleLineMode = true + errorContainer.addSubview(errorMessage) + + return errorContainer + } + + private func createTerminalHeader() -> NSView { + let terminalHeader = NSView(frame: NSRect(x: 16, y: 66, width: 368, height: 20)) + + let toolIcon = NSImageView(frame: NSRect(x: 0, y: 4, width: 16, height: 16)) + toolIcon.image = NSImage(systemSymbolName: "terminal.fill", accessibilityDescription: "Terminal") + toolIcon.contentTintColor = NSColor.secondaryLabelColor + terminalHeader.addSubview(toolIcon) + + let terminalLabel = NSTextField(wrappingLabelWithString: "Copy and run the commands below in Terminal, then retry.") + terminalLabel.frame = NSRect(x: 20, y: 0, width: 346, height: 20) + terminalLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) + terminalLabel.textColor = NSColor.labelColor + terminalHeader.addSubview(terminalLabel) + + return terminalHeader + } + + private func createCommandsContainer() -> NSView { + let commandsContainer = NSView(frame: NSRect(x: 16, y: 4, width: 368, height: 58)) + commandsContainer.wantsLayer = true + commandsContainer.layer?.backgroundColor = containerBackgroundColor + commandsContainer.layer?.borderColor = NSColor.separatorColor.cgColor + commandsContainer.layer?.borderWidth = 1 + commandsContainer.layer?.cornerRadius = 6 + + let commandsText = NSTextField(wrappingLabelWithString: accessPermissionCommands) + commandsText.frame = NSRect(x: 8, y: 8, width: 344, height: 42) + commandsText.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + commandsText.textColor = NSColor.labelColor + commandsText.backgroundColor = .clear + commandsText.isBordered = false + commandsText.isEditable = false + commandsText.isSelectable = true + commandsText.drawsBackground = false + commandsContainer.addSubview(commandsText) + + return commandsContainer + } + + private func copyCommandsToClipboard() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString( + self.accessPermissionCommands.replacingOccurrences(of: "\n", with: " && "), + forType: .string + ) + } public func broadcastStatusChange() { DistributedNotificationCenter.default().post( diff --git a/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift b/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift index 384daad4..f0cfbaca 100644 --- a/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift +++ b/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift @@ -5,6 +5,7 @@ struct AdvancedSettings: View { ScrollView { VStack(alignment: .leading, spacing: 30) { SuggestionSection() + ChatSection() EnterpriseSection() ProxySection() LoggingSection() diff --git a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift new file mode 100644 index 00000000..569b0a56 --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift @@ -0,0 +1,383 @@ +import Client +import ComposableArchitecture +import SwiftUI +import Toast +import XcodeInspector +import SharedUIComponents +import Logger + +struct ChatSection: View { + @AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode + @AppStorage(\.enableFixError) var enableFixError + @State private var isEditorPreviewEnabled: Bool = false + + var body: some View { + SettingsSection(title: "Chat Settings") { + // 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 isEditorPreviewEnabled { + // Custom Prompts - .github/prompts/*.prompt.md + PromptFileSetting(promptType: .prompt) + .padding(SettingsToggle.defaultPadding) + + Divider() + } + + // Auto Attach toggle + SettingsToggle( + title: "Auto-attach Chat Window to Xcode", + isOn: $autoAttachChatToXcode + ) + + Divider() + + // Fix error toggle + SettingsToggle( + title: "Quick fix for error", + isOn: $enableFixError + ) + + Divider() + + // Response language picker + ResponseLanguageSetting() + .padding(SettingsToggle.defaultPadding) + + Divider() + + // Font Size + FontSizeSetting() + .padding(SettingsToggle.defaultPadding) + } + .onAppear { + Task { + await updateEditorPreviewFeatureFlag() + } + } + .onReceive(DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in + Task { + await updateEditorPreviewFeatureFlag() + } + } + } + + private func updateEditorPreviewFeatureFlag() async { + do { + let service = try getService() + if let featureFlags = try await service.getCopilotFeatureFlags() { + isEditorPreviewEnabled = featureFlags.editorPreviewFeatures + } + } catch { + Logger.client.error("Failed to get copilot feature flags: \(error)") + } + } +} + +struct ResponseLanguageSetting: View { + @AppStorage(\.chatResponseLocale) var chatResponseLocale + + // Locale codes mapped to language display names + // reference: https://code.visualstudio.com/docs/configure/locales#_available-locales + private let localeLanguageMap: [String: String] = [ + "en": "English", + "zh-cn": "Chinese, Simplified", + "zh-tw": "Chinese, Traditional", + "fr": "French", + "de": "German", + "it": "Italian", + "es": "Spanish", + "ja": "Japanese", + "ko": "Korean", + "ru": "Russian", + "pt-br": "Portuguese (Brazil)", + "tr": "Turkish", + "pl": "Polish", + "cs": "Czech", + "hu": "Hungarian", + ] + + var selectedLanguage: String { + if chatResponseLocale == "" { + return "English" + } + + return localeLanguageMap[chatResponseLocale] ?? "English" + } + + // Display name to locale code mapping (for the picker UI) + var sortedLanguageOptions: [(displayName: String, localeCode: String)] { + localeLanguageMap.map { (displayName: $0.value, localeCode: $0.key) } + .sorted { $0.displayName < $1.displayName } + } + + var body: some View { + WithPerceptionTracking { + HStack { + VStack(alignment: .leading) { + Text("Response Language") + .font(.body) + Text("This change applies only to new chat sessions. Existing ones won't be impacted.") + .font(.footnote) + } + + Spacer() + + Picker("", selection: $chatResponseLocale) { + ForEach(sortedLanguageOptions, id: \.localeCode) { option in + Text(option.displayName).tag(option.localeCode) + } + } + .frame(maxWidth: 200, alignment: .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 CopilotInstructionSetting: View { + @State var isGlobalInstructionsViewOpen = false + @Environment(\.toast) var toast + + var body: some View { + WithPerceptionTracking { + HStack { + VStack(alignment: .leading) { + Text("Copilot Instructions") + .font(.body) + Text("Configure `.github/copilot-instructions.md` to apply to all chat requests.") + .font(.footnote) + } + + Spacer() + + Button("Current Workspace") { + openCustomInstructions() + } + + Button("Global") { + isGlobalInstructionsViewOpen = true + } + } + .sheet(isPresented: $isGlobalInstructionsViewOpen) { + GlobalInstructionsView(isOpen: $isGlobalInstructionsViewOpen) + } + } + } + + func openCustomInstructions() { + Task { + 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 + ) + ) + .font(.footnote) + } + + Spacer() + + Button("Create") { + isCreateSheetPresented = true + } + + Button("Open \(promptType.directoryName.capitalized) Folder") { + openDirectory() + } + } + .sheet(isPresented: $isCreateSheetPresented) { + CreateCustomCopilotFileView( + isOpen: $isCreateSheetPresented, + promptType: promptType + ) + } + } + } + + private func openDirectory() { + Task { + guard let projectURL = await getCurrentProjectURL() else { + toast("No active workspace found", .error) + return + } + + let directory = promptType.getDirectoryPath(projectURL: projectURL) + + do { + try ensureDirectoryExists(at: directory) + NSWorkspace.shared.open(directory) + } catch { + toast("Failed to create \(promptType.directoryName) directory: \(error)", .error) + } + } + } +} + +#Preview { + ChatSection() + .frame(width: 600) +} diff --git a/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift b/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift new file mode 100644 index 00000000..d3b45f07 --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift @@ -0,0 +1,192 @@ +import Client +import SwiftUI +import XcodeInspector + +struct CreateCustomCopilotFileView: View { + var isOpen: Binding + let promptType: PromptType + + @State private var fileName = "" + @State private var projectURL: URL? + @State private var fileAlreadyExists = false + + @Environment(\.toast) var toast + + init(isOpen: Binding, promptType: PromptType) { + self.isOpen = isOpen + self.promptType = promptType + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center) { + Button(action: { self.isOpen.wrappedValue = false }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + + Text("Create \(promptType.displayName)") + .font(.system(size: 13, weight: .bold)) + Spacer() + + AdaptiveHelpLink(action: openHelpLink) + .padding() + } + .frame(height: 28) + .background(Color(nsColor: .separatorColor)) + + // Content + VStack(alignment: .leading, spacing: 8) { + Text("Enter the name of \(promptType.rawValue) file:") + .font(.body) + + TextField("File name", text: $fileName) + .textFieldStyle(.roundedBorder) + .onSubmit { + Task { await createPromptFile() } + } + .onChange(of: fileName) { _ in + updateFileExistence() + } + + validationMessageView + + Spacer() + + HStack(spacing: 12) { + Spacer() + + Button("Cancel") { + self.isOpen.wrappedValue = false + } + .buttonStyle(.bordered) + + Button("Create") { + Task { await createPromptFile() } + } + .buttonStyle(.borderedProminent) + .disabled(disableCreateButton) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 20) + } + .frame(width: 350, height: 160) + .onAppear { + fileName = "" + Task { await resolveProjectURL() } + } + } + + // MARK: - Derived values + + private var trimmedFileName: String { + fileName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var disableCreateButton: Bool { + trimmedFileName.isEmpty || fileAlreadyExists + } + + @ViewBuilder + private var validationMessageView: some View { + HStack(alignment: .center, spacing: 6) { + if fileAlreadyExists && !trimmedFileName.isEmpty { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + Text("'.github/\(promptType.directoryName)/\(trimmedFileName)\(promptType.fileExtension)' already exists") + .font(.caption) + .foregroundColor(.red) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + } else if trimmedFileName.isEmpty { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + Text("Enter a file name") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text(".github/\(promptType.directoryName)/\(trimmedFileName)\(promptType.fileExtension)") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + } + } + .transition(.opacity) + } + + // MARK: - Actions / Helpers + + private func openHelpLink() { + if let url = URL(string: promptType.helpLink) { + NSWorkspace.shared.open(url) + } + } + + /// Resolves the active project URL (if any) and updates state. + private func resolveProjectURL() async { + let projectURL = await getCurrentProjectURL() + await MainActor.run { + self.projectURL = projectURL + updateFileExistence() + } + } + + private func updateFileExistence() { + let name = trimmedFileName + guard !name.isEmpty, let projectURL else { + fileAlreadyExists = false + return + } + let filePath = promptType.getFilePath(fileName: name, projectURL: projectURL) + fileAlreadyExists = FileManager.default.fileExists(atPath: filePath.path) + } + + /// Creates the prompt file if it doesn't already exist. + private func createPromptFile() async { + guard let projectURL else { + await MainActor.run { + toast("No active workspace found", .error) + } + return + } + + let directoryPath = promptType.getDirectoryPath(projectURL: projectURL) + let filePath = promptType.getFilePath(fileName: trimmedFileName, projectURL: projectURL) + + // Re-check existence to avoid race with external creation. + if FileManager.default.fileExists(atPath: filePath.path) { + await MainActor.run { + self.fileAlreadyExists = true + toast("\(promptType.displayName) '\(trimmedFileName)\(promptType.fileExtension)' already exists", .warning) + } + return + } + + do { + try FileManager.default.createDirectory( + at: directoryPath, + withIntermediateDirectories: true + ) + + try promptType.defaultTemplate.write(to: filePath, atomically: true, encoding: .utf8) + + await MainActor.run { + toast("Created \(promptType.rawValue) file '\(trimmedFileName)\(promptType.fileExtension)'", .info) + NSWorkspace.shared.open(filePath) + self.isOpen.wrappedValue = false + } + } catch { + await MainActor.run { + toast("Failed to create \(promptType.rawValue) file: \(error)", .error) + } + } + } +} diff --git a/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift new file mode 100644 index 00000000..072dd21f --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift @@ -0,0 +1,140 @@ +import AppKit +import Client +import Foundation +import SwiftUI +import Toast +import XcodeInspector +import SystemUtils + +public enum PromptType: String, CaseIterable, Equatable { + case instructions = "instructions" + case prompt = "prompt" + + /// The directory name under .github where files of this type are stored + var directoryName: String { + switch self { + case .instructions: + return "instructions" + case .prompt: + return "prompts" + } + } + + /// The file extension for this prompt type + var fileExtension: String { + switch self { + case .instructions: + return ".instructions.md" + case .prompt: + return ".prompt.md" + } + } + + /// Human-readable name for display purposes + var displayName: String { + switch self { + case .instructions: + return "Instruction File" + case .prompt: + return "Prompt File" + } + } + + /// Human-readable name for settings + var settingTitle: String { + switch self { + case .instructions: + return "Custom Instructions" + case .prompt: + return "Prompt Files" + } + } + + /// Description for the prompt type + var description: String { + switch self { + case .instructions: + return "Configure `.github/instructions/*.instructions.md` files scoped to specific file patterns or tasks." + case .prompt: + return "Configure `.github/prompts/*.prompt.md` files for reusable prompts. Trigger with '/' commands in the Chat view." + } + } + + /// Default template content for new files + var defaultTemplate: String { + switch self { + case .instructions: + return """ + --- + applyTo: '**' + --- + Provide project context and coding guidelines that AI should follow when generating code, or answering questions. + + """ + case .prompt: + return """ + --- + description: Prompt Description + --- + Define the task to achieve, including specific requirements, constraints, and success criteria. + + """ + } + } + + var helpLink: String { + var editorPluginVersion = SystemUtils.editorPluginVersionString + if editorPluginVersion == "0.0.0" { + editorPluginVersion = "main" + } + + switch self { + case .instructions: + return "https://github.com/github/CopilotForXcode/blob/\(editorPluginVersion)/Docs/CustomInstructions.md" + case .prompt: + return "https://github.com/github/CopilotForXcode/blob/\(editorPluginVersion)/Docs/PromptFiles.md" + } + } + + /// Get the full file path for a given name and project URL + func getFilePath(fileName: String, projectURL: URL) -> URL { + let directory = getDirectoryPath(projectURL: projectURL) + return directory.appendingPathComponent("\(fileName)\(fileExtension)") + } + + /// Get the directory path for this prompt type + func getDirectoryPath(projectURL: URL) -> URL { + return projectURL.appendingPathComponent(".github/\(directoryName)") + } +} + +func getCurrentProjectURL() async -> URL? { + let service = try? getService() + let inspectorData = try? await service?.getXcodeInspectorData() + var currentWorkspace: URL? + + if let url = inspectorData?.realtimeActiveWorkspaceURL, + let workspaceURL = URL(string: url), + workspaceURL.path != "/" { + currentWorkspace = workspaceURL + } else if let url = inspectorData?.latestNonRootWorkspaceURL { + currentWorkspace = URL(string: url) + } + + guard let workspaceURL = currentWorkspace, + let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: workspaceURL, + documentURL: nil + ) else { + return nil + } + + return projectURL +} + +func ensureDirectoryExists(at url: URL) throws { + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: url.path) { + try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + } +} diff --git a/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift index cec78edc..d869b9ca 100644 --- a/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift +++ b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift @@ -33,19 +33,24 @@ struct DisabledLanguageList: View { var body: some View { VStack(spacing: 0) { - HStack { - Button(action: { - self.isOpen.wrappedValue = false - }) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - .padding() + ZStack(alignment: .topLeading) { + Rectangle().fill(Color(nsColor: .separatorColor)).frame(height: 28) + + HStack { + Button(action: { + self.isOpen.wrappedValue = false + }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + Text("Disabled Languages") + .font(.system(size: 13, weight: .bold)) + Spacer() } - .buttonStyle(.plain) - Text("Disabled Languages") - Spacer() + .frame(height: 28) } - .background(Color(nsColor: .separatorColor)) List { ForEach( diff --git a/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift b/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift index bcd0adf2..f0a21a57 100644 --- a/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift @@ -1,4 +1,5 @@ import Combine +import Client import SwiftUI import Toast @@ -11,7 +12,8 @@ struct EnterpriseSection: View { SettingsTextField( title: "Auth provider URL", prompt: "https://your-enterprise.ghe.com", - text: DebouncedBinding($gitHubCopilotEnterpriseURI, handler: urlChanged).binding + text: $gitHubCopilotEnterpriseURI, + onDebouncedChange: { url in urlChanged(url)} ) } } @@ -24,15 +26,26 @@ struct EnterpriseSection: View { name: .gitHubCopilotShouldRefreshEditorInformation, object: nil ) + Task { + do { + let service = try getService() + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + } catch { + toast(error.localizedDescription, .error) + } + } } func validateAuthURL(_ url: String) { let maybeURL = URL(string: url) - guard let parsedURl = maybeURL else { + guard let parsedURL = maybeURL else { toast("Invalid URL", .error) return } - if parsedURl.scheme != "https" { + if parsedURL.scheme != "https" { toast("URL scheme must be https://", .error) return } diff --git a/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift b/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift new file mode 100644 index 00000000..264002a2 --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift @@ -0,0 +1,83 @@ +import Client +import SwiftUI +import Toast + +struct GlobalInstructionsView: View { + var isOpen: Binding + @State var initValue: String = "" + @AppStorage(\.globalCopilotInstructions) var globalInstructions: String + @Environment(\.toast) var toast + + init(isOpen: Binding) { + self.isOpen = isOpen + self.initValue = globalInstructions + } + + var body: some View { + VStack(spacing: 0) { + ZStack(alignment: .topLeading) { + Rectangle().fill(Color(nsColor: .separatorColor)).frame(height: 28) + + HStack { + Button(action: { + self.isOpen.wrappedValue = false + }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + Text("Global Copilot Instructions") + .font(.system(size: 13, weight: .bold)) + Spacer() + } + .frame(height: 28) + } + + ZStack(alignment: .topLeading) { + TextEditor(text: $globalInstructions) + .font(.body) + + if globalInstructions.isEmpty { + Text("Type your global instructions here...") + .foregroundColor(Color(nsColor: .placeholderTextColor)) + .font(.body) + .allowsHitTesting(false) + .padding(.horizontal, 6) + } + } + .padding(8) + .background(Color(nsColor: .textBackgroundColor)) + } + .focusable(false) + .frame(width: 300, height: 400) + .onAppear() { + self.initValue = globalInstructions + } + .onDisappear(){ + self.isOpen.wrappedValue = false + if globalInstructions != initValue { + refreshConfiguration() + } + } + } + + func refreshConfiguration() { + NotificationCenter.default.post( + name: .gitHubCopilotShouldRefreshEditorInformation, + object: nil + ) + Task { + do { + let service = try getService() + // Notify extension service process to refresh all its CLS subprocesses to apply new configuration + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + } catch { + toast(error.localizedDescription, .error) + } + } + } +} diff --git a/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift b/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift index 168bdb1f..ab2062c7 100644 --- a/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift @@ -15,37 +15,38 @@ struct ProxySection: View { SettingsTextField( title: "Proxy URL", prompt: "http://host:port", - text: wrapBinding($gitHubCopilotProxyUrl) + text: $gitHubCopilotProxyUrl, + onDebouncedChange: { _ in refreshConfiguration() } ) SettingsTextField( title: "Proxy username", prompt: "username", - text: wrapBinding($gitHubCopilotProxyUsername) + text: $gitHubCopilotProxyUsername, + onDebouncedChange: { _ in refreshConfiguration() } ) - SettingsSecureField( + SettingsTextField( title: "Proxy password", prompt: "password", - text: wrapBinding($gitHubCopilotProxyPassword) + text: $gitHubCopilotProxyPassword, + isSecure: true, + onDebouncedChange: { _ in refreshConfiguration() } ) SettingsToggle( title: "Proxy strict SSL", - isOn: wrapBinding($gitHubCopilotUseStrictSSL) + isOn: $gitHubCopilotUseStrictSSL ) + .onChange(of: gitHubCopilotUseStrictSSL) { _ in refreshConfiguration() } } } - private func wrapBinding(_ b: Binding) -> Binding { - DebouncedBinding(b, handler: refreshConfiguration).binding - } - - func refreshConfiguration(_: Any) { + func refreshConfiguration() { NotificationCenter.default.post( name: .gitHubCopilotShouldRefreshEditorInformation, object: nil ) Task { - let service = try getService() do { + let service = try getService() try await service.postNotification( name: Notification.Name .gitHubCopilotShouldRefreshEditorInformation.rawValue diff --git a/Core/Sources/HostApp/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..e3ce7ba9 --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift @@ -0,0 +1,152 @@ +import GitHubCopilotService +import SwiftUI + +struct ApiKeySheet: View { + @ObservedObject var dataManager: BYOKModelManagerObservable + @Environment(\.dismiss) private var dismiss + + @State private var apiKey = "" + @State private var showDeleteConfirmation = false + @State private var showPopOver = false + @State private var keepCustomModels = true + let provider: BYOKProvider + + private var hasExistingApiKey: Bool { + dataManager.hasApiKey(for: provider) + } + + private var isFormInvalid: Bool { + apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + Form { + VStack(alignment: .center, spacing: 20) { + HStack(alignment: .center) { + Spacer() + Text("\(provider.title)").font(.headline) + Spacer() + 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..4474dff4 --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift @@ -0,0 +1,170 @@ +import GitHubCopilotService +import SwiftUI + +struct ModelSheet: View { + @ObservedObject var dataManager: BYOKModelManagerObservable + @Environment(\.dismiss) private var dismiss + + @State private var modelId = "" + @State private var deploymentUrl = "" + @State private var apiKey = "" + @State private var customModelName = "" + @State private var supportToolCalling: Bool = true + @State private var supportVision: Bool = true + + let provider: BYOKProvider + let existingModel: BYOKModelInfo? + + // Computed property to determine if this is a per-model deployment provider + private var isPerModelDeployment: Bool { + provider.authType == .PerModelDeployment + } + + // Computed property to determine if we're editing vs adding + private var isEditing: Bool { + existingModel != nil + } + + var body: some View { + Form { + VStack(alignment: .center, spacing: 20) { + HStack(alignment: .center) { + Spacer() + Text("\(provider.title)").font(.headline) + Spacer() + 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..b29ed3cc --- /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 { + SearchBar(isVisible: $isSearchBarVisible, text: $searchText) + + Button(action: { Task { + await dataManager.listModelsWithFetch(providerName: provider) + }}) { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(HoverButtonStyle()) + + Button(action: openAddApiKeySheetType) { + Image(systemName: "key") + } + .buttonStyle(HoverButtonStyle()) + + Button(action: { showDeleteConfirmation = true }) { + Image(systemName: "trash") + } + .confirmationDialog( + "Delete \(provider.title) API Key?", + isPresented: $showDeleteConfirmation + + ) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { deleteApiKey() } + } message: { + Text("This will remove all linked models and configurations. Still want to delete it?") + } + .buttonStyle(HoverButtonStyle()) + } + + Toggle("", isOn: Binding( + get: { isProviderEnabled }, + set: { newValue in updateAllModels(isRegistered: newValue) } + )) + .toggleStyle(.switch) + .controlSize(.mini) + } + } + + private var UnconfiguredProviderAction: some View { + Button( + provider.authType == .PerModelDeployment ? "Add Model" : "Add", + systemImage: "plus" + ) { + openAddApiKeySheetType() + } + } + + private var ModelsListSection: some View { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(filteredModels, id: \.modelId) { model in + Divider() + ModelRowView( + model: model, + dataManager: dataManager, + isSelected: selectedModelId == model.modelId, + onSelection: { + selectedModelId = selectedModelId == model.modelId ? nil : model.modelId + isSelectedCustomModel = selectedModelId != nil && model.isCustomModel + }, + onEditRequested: { model in + openEditModelSheet(for: model) + } + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var FooterToolBar: some View { + VStack(spacing: 0) { + Divider() + HStack(spacing: 8) { + Button(action: openAddModelSheet) { + Image(systemName: "plus") + } + .foregroundColor(.primary) + .font(.title2) + .buttonStyle(.borderless) + + Divider() + + Group { + if isSelectedCustomModel { + Button(action: deleteSelectedModel) { + Image(systemName: "minus") + } + .buttonStyle(.borderless) + } else { + Image(systemName: "minus") + } + } + .font(.title2) + .foregroundColor( + isSelectedCustomModel ? .primary : Color( + nsColor: .quaternaryLabelColor + ) + ) + .help(deleteModelTooltip) + + Spacer() + } + .frame(height: 20) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(TertiarySystemFillColor) + } + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + + // MARK: - Actions + + private func openAddApiKeySheetType() { + switch provider.authType { + case .GlobalApiKey: + onSheetRequested(.apiKey(provider)) + case .PerModelDeployment: + onSheetRequested(.model(provider)) + } + } + + private func openAddModelSheet() { + onSheetRequested(.model(provider, nil)) // nil for adding new model + } + + private func openEditModelSheet(for model: BYOKModelInfo) { + onSheetRequested(.model(provider, model)) // pass model for editing + } + + private func deleteApiKey() { + Task { + do { + try await dataManager.deleteApiKey(providerName: provider) + } catch { + Logger.client.error("Failed to delete API key for \(provider.title): \(error)") + } + } + } + + private func deleteSelectedModel() { + guard let selectedModelId = selectedModelId, + let selectedModel = allModels.first(where: { $0.modelId == selectedModelId }) else { + return + } + + self.selectedModelId = nil + isSelectedCustomModel = false + + Task { + do { + try await dataManager.deleteModel(selectedModel) + } catch { + Logger.client.error("Failed to delete model for \(provider.title): \(error)") + } + } + } + + private func updateAllModels(isRegistered: Bool) { + Task { + do { + try await dataManager.updateAllModels(providerName: provider, isRegistered: isRegistered) + } catch { + Logger.client.error("Failed to register models for \(provider.title): \(error)") + } + } + } +} diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index f2b2abe8..92d78a25 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -12,8 +12,10 @@ public struct General { @ObservableState public struct State: Equatable { var xpcServiceVersion: String? + var xpcCLSVersion: String? var isAccessibilityPermissionGranted: ObservedAXStatus = .unknown var isExtensionPermissionGranted: ExtensionPermissionStatus = .unknown + var xpcServiceAuthStatus: AuthStatus = .init(status: .unknown) var isReloading = false } @@ -24,8 +26,10 @@ public struct General { case reloadStatus case finishReloading( xpcServiceVersion: String, + xpcCLSVersion: String?, axStatus: ObservedAXStatus, - extensionStatus: ExtensionPermissionStatus + extensionStatus: ExtensionPermissionStatus, + authStatus: AuthStatus ) case failedReloading case retryReloading @@ -90,10 +94,14 @@ public struct General { let isAccessibilityPermissionGranted = try await service .getXPCServiceAccessibilityPermission() let isExtensionPermissionGranted = try await service.getXPCServiceExtensionPermission() + let xpcServiceAuthStatus = try await service.getXPCServiceAuthStatus() ?? .init(status: .unknown) + let xpcCLSVersion = try await service.getXPCCLSVersion() await send(.finishReloading( xpcServiceVersion: xpcServiceVersion, + xpcCLSVersion: xpcCLSVersion, axStatus: isAccessibilityPermissionGranted, - extensionStatus: isExtensionPermissionGranted + extensionStatus: isExtensionPermissionGranted, + authStatus: xpcServiceAuthStatus )) } else { toast("Launching service app.", .info) @@ -114,10 +122,12 @@ public struct General { } }.cancellable(id: ReloadStatusCancellableId(), cancelInFlight: true) - case let .finishReloading(version, axStatus, extensionStatus): + case let .finishReloading(version, clsVersion, axStatus, extensionStatus, authStatus): state.xpcServiceVersion = version state.isAccessibilityPermissionGranted = axStatus state.isExtensionPermissionGranted = extensionStatus + state.xpcServiceAuthStatus = authStatus + state.xpcCLSVersion = clsVersion state.isReloading = false return .none diff --git a/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift b/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift index 837f3047..0cf5e8af 100644 --- a/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift +++ b/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift @@ -1,6 +1,5 @@ import ComposableArchitecture import GitHubCopilotService -import GitHubCopilotViewModel import SwiftUI struct AppInfoView: View { @@ -15,7 +14,6 @@ struct AppInfoView: View { @Environment(\.toast) var toast @StateObject var settings = Settings() - @StateObject var viewModel: GitHubCopilotViewModel @State var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String @State var automaticallyCheckForUpdates: Bool? @@ -23,53 +21,54 @@ struct AppInfoView: View { let store: StoreOf var body: some View { - HStack(alignment: .center, spacing: 16) { - let appImage = if let nsImage = NSImage(named: "AppIcon") { - Image(nsImage: nsImage) - } else { - Image(systemName: "app") - } - appImage - .resizable() - .frame(width: 110, height: 110) - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String ?? "GitHub Copilot for Xcode") - .font(.title) - Text("(\(appVersion ?? ""))") - .font(.title) + WithPerceptionTracking { + HStack(alignment: .center, spacing: 16) { + let appImage = if let nsImage = NSImage(named: "AppIcon") { + Image(nsImage: nsImage) + } else { + Image(systemName: "app") } - Text("Language Server Version: \(viewModel.version ?? "Loading...")") - Button(action: { - updateChecker.checkForUpdates() - }) { - HStack(spacing: 2) { - Text("Check for Updates") + appImage + .resizable() + .frame(width: 110, height: 110) + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String ?? "GitHub Copilot for Xcode") + .font(.title) + Text("(\(appVersion ?? ""))") + .font(.title) } - } - HStack { - Toggle(isOn: .init( - get: { automaticallyCheckForUpdates ?? updateChecker.getAutomaticallyChecksForUpdates() }, - set: { updateChecker.setAutomaticallyChecksForUpdates($0); automaticallyCheckForUpdates = $0 } - )) { - Text("Automatically Check for Updates") + Text("Language Server Version: \(store.xpcCLSVersion ?? "Loading...")") + Button(action: { + updateChecker.checkForUpdates() + }) { + HStack(spacing: 2) { + Text("Check for Updates") + } } - - Toggle(isOn: $settings.installPrereleases) { - Text("Install pre-releases") + HStack { + Toggle(isOn: .init( + get: { automaticallyCheckForUpdates ?? updateChecker.getAutomaticallyChecksForUpdates() }, + set: { updateChecker.setAutomaticallyChecksForUpdates($0); automaticallyCheckForUpdates = $0 } + )) { + Text("Automatically Check for Updates") + } + + Toggle(isOn: $settings.installPrereleases) { + Text("Install pre-releases") + } } } + Spacer() } - Spacer() + .padding(.horizontal, 2) + .padding(.vertical, 15) } - .padding(.horizontal, 2) - .padding(.vertical, 15) } } #Preview { AppInfoView( - viewModel: GitHubCopilotViewModel.shared, store: .init(initialState: .init(), reducer: { General() }) ) } diff --git a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift index aeb8bd70..5a454b7a 100644 --- a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift +++ b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import GitHubCopilotViewModel import SwiftUI +import Client struct CopilotConnectionView: View { @AppStorage("username") var username: String = "" @@ -18,23 +19,36 @@ struct CopilotConnectionView: View { } } } + + var accountStatusString: String { + switch store.xpcServiceAuthStatus.status { + case .loggedIn: + return "Active" + case .notLoggedIn: + return "Not Signed In" + case .notAuthorized: + return "No Subscription" + case .unknown: + return "Loading..." + } + } var accountStatus: some View { SettingsButtonRow( title: "GitHub Account Status Permissions", - subtitle: "GitHub Account: \(viewModel.status?.description ?? "Loading...")" + subtitle: "GitHub Account: \(accountStatusString)" ) { if viewModel.isRunningAction || viewModel.waitingForSignIn { ProgressView().controlSize(.small) } Button("Refresh Connection") { - viewModel.checkStatus() + store.send(.reloadStatus) } if viewModel.waitingForSignIn { Button("Cancel") { viewModel.cancelWaiting() } - } else if viewModel.status == .notSignedIn { + } else if store.xpcServiceAuthStatus.status == .notLoggedIn { Button("Log in to GitHub") { viewModel.signIn() } @@ -54,21 +68,31 @@ struct CopilotConnectionView: View { """) } } - if viewModel.status == .ok || viewModel.status == .alreadySignedIn || - viewModel.status == .notAuthorized - { - Button("Log Out from GitHub") { viewModel.signOut() - viewModel.isSignInAlertPresented = false + if store.xpcServiceAuthStatus.status == .loggedIn || store.xpcServiceAuthStatus.status == .notAuthorized { + Button("Log Out from GitHub") { + Task { + viewModel.signOut() + viewModel.isSignInAlertPresented = false + let service = try getService() + do { + try await service.signOutAllGitHubCopilotService() + } catch { + toast(error.localizedDescription, .error) + } + } } } } } var connection: some View { - SettingsSection(title: "Account Settings", showWarning: viewModel.status == .notAuthorized) { + SettingsSection( + title: "Account Settings", + showWarning: store.xpcServiceAuthStatus.status == .notAuthorized + ) { accountStatus Divider() - if viewModel.status == .notAuthorized { + if store.xpcServiceAuthStatus.status == .notAuthorized { SettingsLink( url: "https://github.com/features/copilot/plans", title: "Enable powerful AI features for free with the GitHub Copilot Free plan" @@ -80,6 +104,9 @@ struct CopilotConnectionView: View { title: "GitHub Copilot Account Settings" ) } + .onReceive(DistributedNotificationCenter.default().publisher(for: .authStatusDidChange)) { _ in + store.send(.reloadStatus) + } } var copilotResources: some View { diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index 7ba62833..e80c9491 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -7,24 +7,25 @@ struct GeneralView: View { @StateObject private var viewModel = GitHubCopilotViewModel.shared var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - generalView.padding(20) - Divider() - rightsView.padding(20) + WithPerceptionTracking { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + generalView.padding(20) + Divider() + rightsView.padding(20) + } + .frame(maxWidth: .infinity) + } + .task { + if isPreview { return } + await store.send(.appear).finish() } - .frame(maxWidth: .infinity) - } - .task { - if isPreview { return } - viewModel.checkStatus() - await store.send(.appear).finish() } } private var generalView: some View { VStack(alignment: .leading, spacing: 30) { - AppInfoView(viewModel: viewModel, store: store) + AppInfoView(store: store) GeneralSettingsView(store: store) CopilotConnectionView(viewModel: viewModel, store: store) } diff --git a/Core/Sources/HostApp/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/MCPSettings/CopilotMCPToolManagerObservable.swift b/Core/Sources/HostApp/MCPSettings/CopilotMCPToolManagerObservable.swift deleted file mode 100644 index 5799c58d..00000000 --- a/Core/Sources/HostApp/MCPSettings/CopilotMCPToolManagerObservable.swift +++ /dev/null @@ -1,32 +0,0 @@ -import SwiftUI -import Combine -import Persist -import GitHubCopilotService - -class CopilotMCPToolManagerObservable: ObservableObject { - static let shared = CopilotMCPToolManagerObservable() - - @Published var availableMCPServerTools: [MCPServerToolsCollection] = [] - private var cancellables = Set() - - private init() { - // Initial load - availableMCPServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() - - // Setup notification to update when MCP server tools collections change - NotificationCenter.default - .publisher(for: .gitHubCopilotMCPToolsDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - self.refreshTools() - } - .store(in: &cancellables) - } - - private func refreshTools() { - self.availableMCPServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() - AppState.shared.cleanupMCPToolsStatus(availableTools: self.availableMCPServerTools) - AppState.shared.createMCPToolsStatus(self.availableMCPServerTools) - } -} diff --git a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift b/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift deleted file mode 100644 index 3a0c6cab..00000000 --- a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Client -import Foundation -import Logger -import SharedUIComponents -import SwiftUI -import Toast - -struct MCPIntroView: View { - var exampleConfig: String { - """ - { - "servers": { - "my-mcp-server": { - "type": "stdio", - "command": "my-command", - "args": [] - } - } - } - """ - } - - @State private var isExpanded = true - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - 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()) - - DisclosureGroup(isExpanded: $isExpanded) { - exampleConfigView() - } label: { - sectionHeader() - } - .padding(.horizontal, 0) - .padding(.vertical, 10) - - Button { - openConfigFile() - } label: { - HStack(spacing: 0) { - Image(systemName: "square.and.pencil") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12, height: 12, alignment: .center) - .padding(4) - Text("Edit Config") - } - .conditionalFontWeight(.semibold) - } - .buttonStyle(.borderedProminentWhite) - .help("Configure your MCP server") - } - } - - @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(red: 0.9, green: 0.9, blue: 0.9), 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) - } -} - -#Preview { - MCPIntroView() - .frame(width: 800) -} diff --git a/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift b/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift deleted file mode 100644 index 04c591da..00000000 --- a/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift +++ /dev/null @@ -1,188 +0,0 @@ -import SwiftUI -import Persist -import GitHubCopilotService - -/// 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 } - - // Function to check if the MCP config contains unsupported server types - private func hasUnsupportedServerType() -> Bool { - let mcpConfig = UserDefaults.shared.value(for: \.gitHubCopilotMCPConfig) - // Check if config contains a URL field for this server - guard !mcpConfig.isEmpty else { return false } - - do { - guard let jsonData = mcpConfig.data(using: .utf8), - let jsonObject = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any], - let serverConfig = jsonObject[serverTools.name] as? [String: Any], - let url = serverConfig["url"] as? String else { - return false - } - - return true - } catch { - return false - } - } - - // Get the warning message for unsupported server types - private func getUnsupportedServerTypeMessage() -> String { - return "SSE/HTTP transport is not yet supported" - } - - var body: some View { - VStack(spacing: 0) { - DisclosureGroup(isExpanded: $isExpanded) { - VStack(spacing: 0) { - Divider() - .padding(.leading, 32) - .padding(.top, 2) - .padding(.bottom, 4) - ForEach(serverTools.tools, id: \.name) { tool in - MCPToolRow( - tool: tool, - isServerEnabled: isServerEnabled, - isToolEnabled: toolBindingFor(tool), - onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) } - ) - } - } - } label: { - // Server name with checkbox - Toggle(isOn: Binding( - get: { isServerEnabled }, - set: { updateAllToolsStatus(enabled: $0) } - )) { - HStack(spacing: 8) { - Text("MCP Server: \(serverTools.name)").fontWeight(.medium) - if serverTools.status == .error { - if hasUnsupportedServerType() { - Badge(text: getUnsupportedServerTypeMessage(), level: .danger, icon: "xmark.circle.fill") - } else { - let message = extractErrorMessage(serverTools.error?.description ?? "") - Badge(text: message, level: .danger, icon: "xmark.circle.fill") - } - } - } - } - .toggleStyle(.checkbox) - .padding(.leading, 4) - .disabled(serverTools.status == .error) - } - .onAppear { - initializeToolStates() - if forceExpand { - isExpanded = true - } - } - .onChange(of: forceExpand) { newForceExpand in - if newForceExpand { - isExpanded = true - } - } - - if !isExpanded { - Divider() - .padding(.leading, 32) - .padding(.top, 2) - .padding(.bottom, 4) - } - } - } - - 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)] - ) - - AppState.shared.updateMCPToolsStatus([serverUpdate]) - CopilotMCPToolManager.updateMCPToolsStatus([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) - } - ) - - AppState.shared.updateMCPToolsStatus([serverUpdate]) - CopilotMCPToolManager.updateMCPToolsStatus([serverUpdate]) - } -} diff --git a/Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift b/Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift deleted file mode 100644 index 96f3fbc4..00000000 --- a/Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift +++ /dev/null @@ -1,168 +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() { - let savedToolsStatus = AppState.shared.getMCPToolsStatus() - serverToggleStates = mcpToolManager.availableMCPServerTools.reduce(into: [:]) { result, server in - // Find saved status for this server - let savedServerStatus = savedToolsStatus?.first(where: { $0.name == server.name }) - if let savedStatus = savedServerStatus { - // Check if all tools in this server are disabled - result[server.name] = !savedStatus.tools.allSatisfy { $0.status != .enabled } - } else { - // Preserve existing state or default to enabled - result[server.name] = serverToggleStates[server.name] ?? true - } - } - } - - 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/AdaptiveHelpLink.swift b/Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift new file mode 100644 index 00000000..54ee7e9c --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// A small adaptive help link button that uses the native `HelpLink` on macOS 14+ +/// and falls back to a styled question-mark button on earlier versions. +struct AdaptiveHelpLink: View { + let action: () -> Void + var controlSize: ControlSize = .small + + init(controlSize: ControlSize = .small, action: @escaping () -> Void) { + self.controlSize = controlSize + self.action = action + } + + var body: some View { + Group { + if #available(macOS 14.0, *) { + HelpLink(action: action) + } else { + Button(action: action) { + Image(systemName: "questionmark") + } + .clipShape(Circle()) + .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0) + .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5) + } + } + .controlSize(controlSize) + } +} diff --git a/Core/Sources/HostApp/SharedComponents/Badge.swift b/Core/Sources/HostApp/SharedComponents/Badge.swift index d3a9dd6e..615d03e9 100644 --- a/Core/Sources/HostApp/SharedComponents/Badge.swift +++ b/Core/Sources/HostApp/SharedComponents/Badge.swift @@ -4,15 +4,19 @@ struct BadgeItem { enum Level: String, Equatable { case warning = "Warning" case danger = "Danger" + case info = "Info" } + let text: String let level: Level let icon: String? - - init(text: String, level: Level, icon: String? = nil) { + let isSelected: Bool + + init(text: String, level: Level, icon: String? = nil, isSelected: Bool = false) { self.text = text self.level = level self.icon = icon + self.isSelected = isSelected } } @@ -20,39 +24,43 @@ struct Badge: View { let text: String let level: BadgeItem.Level let icon: String? - + let isSelected: Bool + init(badgeItem: BadgeItem) { - self.text = badgeItem.text - self.level = badgeItem.level - self.icon = badgeItem.icon + text = badgeItem.text + level = badgeItem.level + icon = badgeItem.icon + isSelected = badgeItem.isSelected } - - init(text: String, level: BadgeItem.Level, icon: String? = nil) { + + init(text: String, level: BadgeItem.Level, icon: String? = nil, isSelected: Bool = false) { self.text = text self.level = level self.icon = icon + self.isSelected = isSelected } - + var body: some View { - HStack(spacing: 4) { + HStack(alignment: .center, spacing: 2) { if let icon = icon { Image(systemName: icon) - .resizable() - .scaledToFit() - .frame(width: 11, height: 11) + .font(.caption2) + .padding(.vertical, 1) } Text(text) .fontWeight(.semibold) - .font(.system(size: 11)) + .font(.caption2) .lineLimit(1) } - .padding(.vertical, 2) - .padding(.horizontal, 4) + .padding(.vertical, 1) + .padding(.horizontal, 3) .foregroundColor( - Color("\(level.rawValue)ForegroundColor") + level == .info ? Color(nsColor: isSelected ? .white : .secondaryLabelColor) + : Color("\(level.rawValue)ForegroundColor") ) .background( - Color("\(level.rawValue)BackgroundColor"), + level == .info ? Color(nsColor: .clear) + : Color("\(level.rawValue)BackgroundColor"), in: RoundedRectangle( cornerRadius: 9999, style: .circular @@ -63,7 +71,11 @@ struct Badge: View { cornerRadius: 9999, style: .circular ) - .stroke(Color("\(level.rawValue)StrokeColor"), lineWidth: 1) + .stroke( + level == .info ? Color(nsColor: isSelected ? .white : .tertiaryLabelColor) + : Color("\(level.rawValue)StrokeColor"), + lineWidth: 1 + ) ) } } diff --git a/Core/Sources/HostApp/SharedComponents/BorderedProminentWhiteButtonStyle.swift b/Core/Sources/HostApp/SharedComponents/BorderedProminentWhiteButtonStyle.swift index c4af1cc5..7cc5db2a 100644 --- a/Core/Sources/HostApp/SharedComponents/BorderedProminentWhiteButtonStyle.swift +++ b/Core/Sources/HostApp/SharedComponents/BorderedProminentWhiteButtonStyle.swift @@ -23,7 +23,6 @@ public struct BorderedProminentWhiteButtonStyle: ButtonStyle { .overlay( RoundedRectangle(cornerRadius: 5).stroke(.clear, lineWidth: 1) ) - .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0) - .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5) } } + diff --git a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift index 35b9fe6a..1cc98444 100644 --- a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift +++ b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift @@ -1,6 +1,15 @@ import SwiftUI public struct CardGroupBoxStyle: GroupBoxStyle { + public var backgroundColor: Color + 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) @@ -8,12 +17,12 @@ public struct CardGroupBoxStyle: GroupBoxStyle { } .padding(8) .frame(maxWidth: .infinity, alignment: .topLeading) - .background(Color("GroupBoxBackgroundColor")) - .cornerRadius(4) + .background(backgroundColor) + .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/Color.swift b/Core/Sources/HostApp/SharedComponents/Color.swift new file mode 100644 index 00000000..2d5a7682 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/Color.swift @@ -0,0 +1,33 @@ +import SwiftUI + +public var QuinarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .quinarySystemFill) + } else { + return Color("QuinarySystemFillColor") + } +} + +public var QuaternarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .quaternarySystemFill) + } else { + return Color("QuaternarySystemFillColor") + } +} + +public var TertiarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .tertiarySystemFill) + } else { + return Color("TertiarySystemFillColor") + } +} + +public var SecondarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .secondarySystemFill) + } else { + return Color("SecondarySystemFillColor") + } +} diff --git a/Core/Sources/HostApp/SharedComponents/DebouncedBinding.swift b/Core/Sources/HostApp/SharedComponents/DebouncedBinding.swift deleted file mode 100644 index 6b4224b2..00000000 --- a/Core/Sources/HostApp/SharedComponents/DebouncedBinding.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Combine -import SwiftUI - -class DebouncedBinding { - private let subject = PassthroughSubject() - private let cancellable: AnyCancellable - private let wrappedBinding: Binding - - init(_ binding: Binding, handler: @escaping (T) -> Void) { - self.wrappedBinding = binding - self.cancellable = subject - .debounce(for: .seconds(1.0), scheduler: RunLoop.main) - .sink { handler($0) } - } - - var binding: Binding { - return Binding( - get: { self.wrappedBinding.wrappedValue }, - set: { - self.wrappedBinding.wrappedValue = $0 - self.subject.send($0) - } - ) - } -} diff --git a/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift b/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift new file mode 100644 index 00000000..38559dcc --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift @@ -0,0 +1,77 @@ +import SwiftUI + +public struct DisclosureSettingsRow: View { + @Binding private var isExpanded: Bool + private let isEnabled: Bool + private let background: Color + private let padding: EdgeInsets + private let spacing: CGFloat + private let accessibilityLabel: (Bool) -> String + private let onToggle: ((Bool, Bool) -> Void)? + @ViewBuilder private let title: () -> Title + @ViewBuilder private let subtitle: () -> Subtitle + @ViewBuilder private let actions: () -> Actions + + public init( + isExpanded: Binding, + isEnabled: Bool = true, + background: Color = QuaternarySystemFillColor.opacity(0.75), + padding: EdgeInsets = EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20), + spacing: CGFloat = 16, + accessibilityLabel: @escaping (Bool) -> String = { expanded in expanded ? "collapse" : "expand" }, + onToggle: ((Bool, Bool) -> Void)? = nil, + @ViewBuilder title: @escaping () -> Title, + @ViewBuilder subtitle: @escaping (() -> Subtitle) = { EmptyView() }, + @ViewBuilder actions: @escaping () -> Actions + ) { + _isExpanded = isExpanded + self.isEnabled = isEnabled + self.background = background + self.padding = padding + self.spacing = spacing + self.accessibilityLabel = accessibilityLabel + self.onToggle = onToggle + self.title = title + self.subtitle = subtitle + self.actions = actions + } + + public var body: some View { + HStack(alignment: .center, spacing: spacing) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "chevron.right") + .font(.footnote.bold()) + .foregroundColor(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .animation(.easeInOut(duration: 0.3), value: isExpanded) + .opacity(isEnabled ? 1 : 0) + .allowsHitTesting(isEnabled) + title() + } + .padding(.vertical, 4) + + subtitle() + .padding(.leading, 16) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + actions() + } + .padding(padding) + .background(background) + .contentShape(Rectangle()) + .onTapGesture { + guard isEnabled else { return } + let previous = isExpanded + withAnimation(.easeInOut) { + isExpanded.toggle() + } + onToggle?(previous, isExpanded) + } + .accessibilityAddTraits(.isButton) + .accessibilityLabel(accessibilityLabel(isExpanded)) + } +} diff --git a/Core/Sources/HostApp/SharedComponents/SearchBar.swift b/Core/Sources/HostApp/SharedComponents/SearchBar.swift new file mode 100644 index 00000000..5104d29c --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/SearchBar.swift @@ -0,0 +1,100 @@ +import SharedUIComponents +import SwiftUI + +/// Reusable search control with a toggleable magnifying glass button that expands +/// into a styled search field with clear button, focus handling, and auto-hide +/// when focus is lost and the text is empty. +/// +/// Usage: +/// SearchBar(isVisible: $isSearchBarVisible, text: $searchText) +struct SearchBar: View { + @Binding var isVisible: Bool + @Binding var text: String + + @FocusState private var isFocused: Bool + + var placeholder: String = "Search..." + var accessibilityIdentifier: String = "searchTextField" + + var body: some View { + Group { + if isVisible { + HStack(spacing: 5) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .onTapGesture { withAnimation(.easeInOut) { + isVisible = false + } } + + TextField(placeholder, text: $text) + .accessibilityIdentifier(accessibilityIdentifier) + .textFieldStyle(PlainTextFieldStyle()) + .focused($isFocused) + + if !text.isEmpty { + Button(action: { text = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + .help("Clear search") + } + } + .padding(.leading, 7) + .padding(.trailing, 3) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(Color(.textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke( + isFocused + ? Color(red: 0, green: 0.48, blue: 1).opacity(0.5) + : Color.gray.opacity(0.4), + lineWidth: isFocused ? 3 : 1 + ) + ) + .cornerRadius(5) + .frame(width: 212, height: 20, alignment: .leading) + .shadow(color: Color(red: 0, green: 0.48, blue: 1).opacity(0.5), radius: isFocused ? 1.25 : 0, x: 0, y: 0) + .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0) + .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5) + .padding(2) + // Removed the move(edge: .trailing) to prevent overlap; keep a clean fade instead + .transition(.asymmetric(insertion: .opacity, removal: .opacity)) + .onChange(of: isFocused) { focused in + if !focused && text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + withAnimation(.easeInOut) { + isVisible = false + } + } + } + .onChange(of: isVisible) { newValue in + if newValue { + // Delay to ensure the field is mounted before requesting focus. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isFocused = true + } + } + } + } else { + Button(action: { + withAnimation(.easeInOut) { + isVisible = true + } + }) { + Image(systemName: "magnifyingglass") + .padding(.trailing, 2) + } + .buttonStyle(HoverButtonStyle()) + .frame(height: 24) + .transition(.opacity) + .help("Show search") + } + } + .contentShape(Rectangle()) + .onTapGesture { if isFocused { isFocused = false } } + } +} diff --git a/Core/Sources/HostApp/SharedComponents/SettingsButtonRow.swift b/Core/Sources/HostApp/SharedComponents/SettingsButtonRow.swift index fa35afb7..2b583302 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsButtonRow.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsButtonRow.swift @@ -1,4 +1,5 @@ import SwiftUI +import Perception struct SettingsButtonRow: View { let title: String @@ -6,20 +7,22 @@ struct SettingsButtonRow: View { @ViewBuilder let content: () -> Content var body: some View { - HStack(alignment: .center, spacing: 8) { - VStack(alignment: .leading) { - Text(title) - .font(.body) - if let subtitle = subtitle { - Text(subtitle) - .font(.footnote) + WithPerceptionTracking{ + HStack(alignment: .center, spacing: 8) { + VStack(alignment: .leading) { + Text(title) + .font(.body) + if let subtitle = subtitle { + Text(subtitle) + .font(.footnote) + } } + Spacer() + content() } - Spacer() - content() + .foregroundStyle(.primary) + .padding(10) } - .foregroundStyle(.primary) - .padding(10) } } diff --git a/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift b/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift index 580ef886..ae135ee5 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift @@ -4,31 +4,47 @@ struct SettingsTextField: View { let title: String let prompt: String @Binding var text: String - - var body: some View { - Form { - TextField(text: $text, prompt: Text(prompt)) { - Text(title) - } - .textFieldStyle(PlainTextFieldStyle()) - .multilineTextAlignment(.trailing) - } - .padding(10) + let isSecure: Bool + + @State private var localText: String = "" + @State private var debounceTimer: Timer? + + var onDebouncedChange: ((String) -> Void)? + + init(title: String, prompt: String, text: Binding, isSecure: Bool = false, onDebouncedChange: ((String) -> Void)? = nil) { + self.title = title + self.prompt = prompt + self._text = text + self.isSecure = isSecure + self.onDebouncedChange = onDebouncedChange + self._localText = State(initialValue: text.wrappedValue) } -} - -struct SettingsSecureField: View { - let title: String - let prompt: String - @Binding var text: String var body: some View { Form { - SecureField(text: $text, prompt: Text(prompt)) { - Text(title) + Group { + if isSecure { + SecureField(text: $localText, prompt: Text(prompt)) { + Text(title) + } + } else { + TextField(text: $localText, prompt: Text(prompt)) { + Text(title) + } + } } .textFieldStyle(.plain) .multilineTextAlignment(.trailing) + .onChange(of: localText) { newValue in + text = newValue + debounceTimer?.invalidate() + debounceTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in + onDebouncedChange?(newValue) + } + } + .onAppear { + localText = text + } } .padding(10) } @@ -42,10 +58,11 @@ struct SettingsSecureField: View { text: .constant("") ) Divider() - SettingsSecureField( + SettingsTextField( title: "Password", prompt: "pass", - text: .constant("") + text: .constant(""), + isSecure: true ) } .padding(.vertical, 10) diff --git a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift index af681465..2576dc1c 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift @@ -1,6 +1,8 @@ import SwiftUI struct SettingsToggle: View { + static let defaultPadding: CGFloat = 10 + let title: String let isOn: Binding @@ -9,9 +11,11 @@ struct SettingsToggle: View { Text(title) Spacer() Toggle(isOn: isOn) {} + .controlSize(.mini) .toggleStyle(.switch) + .padding(.vertical, 4) } - .padding(10) + .padding(SettingsToggle.defaultPadding) } } diff --git a/Core/Sources/HostApp/SharedComponents/TextFieldsContainer.swift b/Core/Sources/HostApp/SharedComponents/TextFieldsContainer.swift new file mode 100644 index 00000000..6cf592f3 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/TextFieldsContainer.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct TextFieldsContainer: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + VStack(spacing: 8) { + content + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(QuaternarySystemFillColor.opacity(0.75)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .inset(by: 0.5) + .stroke(SecondarySystemFillColor, lineWidth: 1) + ) + } +} diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 3a4bb494..3b81636a 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -5,6 +5,9 @@ import LaunchAgentManager import SwiftUI import Toast import UpdateChecker +import Client +import Logger +import Combine @MainActor public let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() }) @@ -13,7 +16,9 @@ public struct TabContainer: View { let store: StoreOf @ObservedObject var toastController: ToastController @State private var tabBarItems = [TabBarItem]() - @Binding var tag: Int + @State private var isAgentModeFFEnabled = true + @State private var isBYOKFFEnabled = true + @Binding var tag: TabIndex public init() { toastController = ToastControllerDependencyKey.liveValue @@ -32,6 +37,23 @@ public struct TabContainer: View { set: { store.send(.setActiveTab($0)) } ) } + + private func updateHostAppFeatureFlags() async { + do { + let service = try getService() + let featureFlags = try await service.getCopilotFeatureFlags() + isAgentModeFFEnabled = featureFlags?.agentMode ?? true + isBYOKFFEnabled = featureFlags?.byok ?? true + if hostAppStore.state.activeTabIndex == .tools && !isAgentModeFFEnabled { + hostAppStore.send(.setActiveTab(.general)) + } + if hostAppStore.state.activeTabIndex == .byok && !isBYOKFFEnabled { + hostAppStore.send(.setActiveTab(.general)) + } + } catch { + Logger.client.error("Failed to get copilot feature flags: \(error)") + } + } public var body: some View { WithPerceptionTracking { @@ -39,44 +61,43 @@ 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" - ) - 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 isAgentModeFFEnabled { + MCPConfigView().tabBarItem(for: .tools) + } + if isBYOKFFEnabled { + BYOKConfigView().tabBarItem(for: .byok) + } } .environment(\.tabBarTabTag, tag) .frame(minHeight: 400) } .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 updateHostAppFeatureFlags() + } } + .onReceive(DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in + Task { + await updateHostAppFeatureFlags() + } + } } } } struct TabBar: View { - @Binding var tag: Int + @Binding var tag: TabIndex fileprivate var tabBarItems: [TabBarItem] var body: some View { @@ -95,9 +116,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 @@ -128,7 +149,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) @@ -149,7 +170,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 @@ -171,25 +192,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 @@ -203,11 +219,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/MCPConfigView.swift b/Core/Sources/HostApp/ToolsConfigView.swift similarity index 66% rename from Core/Sources/HostApp/MCPConfigView.swift rename to Core/Sources/HostApp/ToolsConfigView.swift index b151a9c6..16b1bb8e 100644 --- a/Core/Sources/HostApp/MCPConfigView.swift +++ b/Core/Sources/HostApp/ToolsConfigView.swift @@ -6,6 +6,7 @@ import SwiftUI import Toast import ConversationServiceProvider import GitHubCopilotService +import ComposableArchitecture struct MCPConfigView: View { @State private var mcpConfig: String = "" @@ -14,28 +15,77 @@ struct MCPConfigView: View { @State private var isMonitoring: Bool = false @State private var lastModificationDate: Date? = nil @State private var fileMonitorTask: Task? = nil + @State private var isMCPFFEnabled = false + @State private var selectedOption = ToolType.MCP @Environment(\.colorScheme) var colorScheme + private static var lastSyncTimestamp: Date? = nil + + enum ToolType: String, CaseIterable, Identifiable { + case MCP, BuiltIn + var id: Self { self } + } + var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 8) { - MCPIntroView() - MCPToolsListView() - } - .padding(20) - .onAppear { - setupConfigFilePath() - startMonitoringConfigFile() - refreshConfiguration(()) - } - .onDisappear { - stopMonitoringConfigFile() + WithPerceptionTracking { + ScrollView { + Picker("", selection: $selectedOption) { + Text("MCP").tag(ToolType.MCP) + Text("Built-In").tag(ToolType.BuiltIn) + } + .pickerStyle(.segmented) + .frame(width: 400) + + Group { + if selectedOption == .MCP { + VStack(alignment: .leading, spacing: 8) { + MCPIntroView(isMCPFFEnabled: $isMCPFFEnabled) + if isMCPFFEnabled { + MCPManualInstallView() + MCPToolsListView() + } + } + .onAppear { + setupConfigFilePath() + Task { + await updateMCPFeatureFlag() + } + } + .onDisappear { + stopMonitoringConfigFile() + } + .onChange(of: isMCPFFEnabled) { newMCPFFEnabled in + if newMCPFFEnabled { + startMonitoringConfigFile() + refreshConfiguration(()) + } else { + stopMonitoringConfigFile() + } + } + .onReceive(DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in + Task { + await updateMCPFeatureFlag() + } + } + } else { + BuiltInToolsListView() + } + } + .padding(20) } } } - - private func wrapBinding(_ b: Binding) -> Binding { - DebouncedBinding(b, handler: refreshConfiguration).binding + + 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() { @@ -145,19 +195,20 @@ struct MCPConfigView: View { } func refreshConfiguration(_: Any) { + if MCPConfigView.lastSyncTimestamp == lastModificationDate { + return + } + + MCPConfigView.lastSyncTimestamp = lastModificationDate + let fileURL = URL(fileURLWithPath: configFilePath) if let jsonString = readAndValidateJSON(from: fileURL) { UserDefaults.shared.set(jsonString, for: \.gitHubCopilotMCPConfig) } - NotificationCenter.default.post( - name: .gitHubCopilotShouldRefreshEditorInformation, - object: nil - ) - Task { - let service = try getService() do { + let service = try getService() try await service.postNotification( name: Notification.Name .gitHubCopilotShouldRefreshEditorInformation.rawValue diff --git a/Core/Sources/HostApp/ToolsSettings/AppState+LanguageModelTools.swift b/Core/Sources/HostApp/ToolsSettings/AppState+LanguageModelTools.swift new file mode 100644 index 00000000..867a0df1 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AppState+LanguageModelTools.swift @@ -0,0 +1,24 @@ +import ConversationServiceProvider +import Foundation +import Persist + +public let LANGUAGE_MODEL_TOOLS_STATUS = "languageModelToolsStatus" + +extension AppState { + public func getLanguageModelToolsStatus() -> [ToolStatusUpdate]? { + guard let savedJSON = get(key: LANGUAGE_MODEL_TOOLS_STATUS), + let data = try? JSONEncoder().encode(savedJSON), + let savedStatus = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data) else { + return nil + } + return savedStatus + } + + public func updateLanguageModelToolsStatus(_ updates: [ToolStatusUpdate]) { + update(key: LANGUAGE_MODEL_TOOLS_STATUS, value: updates) + } + + public func clearLanguageModelToolsStatus() { + update(key: LANGUAGE_MODEL_TOOLS_STATUS, value: "") + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift b/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift new file mode 100644 index 00000000..021ea437 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift @@ -0,0 +1,136 @@ +import Client +import Combine +import ConversationServiceProvider +import GitHubCopilotService +import Logger +import Persist +import SwiftUI + +struct BuiltInToolsListView: View { + @ObservedObject private var builtInToolManager = CopilotBuiltInToolManagerObservable.shared + @State private var isSearchBarVisible: Bool = false + @State private var searchText: String = "" + @State private var toolEnabledStates: [String: Bool] = [:] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + GroupBox(label: headerView) { + contentView + } + .groupBoxStyle(CardGroupBoxStyle()) + } + .onAppear { + initializeToolStates() + } + .onChange(of: builtInToolManager.availableLanguageModelTools) { _ in + initializeToolStates() + } + } + + // MARK: - Header View + + private var headerView: some View { + HStack(alignment: .center) { + Text("Built-In Tools").fontWeight(.bold) + Spacer() + SearchBar(isVisible: $isSearchBarVisible, text: $searchText) + } + .clipped() + } + + // MARK: - Content View + + private var contentView: some View { + let filteredTools = filteredLanguageModelTools() + + if filteredTools.isEmpty { + return AnyView(EmptyStateView()) + } else { + return AnyView(toolsListView(tools: filteredTools)) + } + } + + // MARK: - Tools List View + + private func toolsListView(tools: [LanguageModelTool]) -> some View { + VStack(spacing: 0) { + ForEach(tools, id: \.name) { tool in + ToolRow( + toolName: tool.displayName ?? tool.name, + toolDescription: tool.displayDescription, + toolStatus: tool.status, + isServerEnabled: true, + isToolEnabled: toolBindingFor(tool), + onToolToggleChanged: { isEnabled in + handleToolToggleChange(tool: tool, isEnabled: isEnabled) + } + ) + } + } + } + + // MARK: - Helper Methods + + private func initializeToolStates() { + var map: [String: Bool] = [:] + for tool in builtInToolManager.availableLanguageModelTools { + // Preserve existing state if already toggled locally + if let existing = toolEnabledStates[tool.name] { + map[tool.name] = existing + } else { + map[tool.name] = (tool.status == .enabled) + } + } + toolEnabledStates = map + } + + private func toolBindingFor(_ tool: LanguageModelTool) -> Binding { + Binding( + get: { toolEnabledStates[tool.name] ?? (tool.status == .enabled) }, + set: { newValue in + toolEnabledStates[tool.name] = newValue + } + ) + } + + private func filteredLanguageModelTools() -> [LanguageModelTool] { + let key = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !key.isEmpty else { return builtInToolManager.availableLanguageModelTools } + + return builtInToolManager.availableLanguageModelTools.filter { tool in + tool.name.lowercased().contains(key) || + (tool.description?.lowercased().contains(key) ?? false) || + (tool.displayName?.lowercased().contains(key) ?? false) + } + } + + private func handleToolToggleChange(tool: LanguageModelTool, isEnabled: Bool) { + // Optimistically update local state already done in binding. + let toolUpdate = ToolStatusUpdate(name: tool.name, status: isEnabled ? .enabled : .disabled) + updateToolStatus([toolUpdate]) + } + + private func updateToolStatus(_ toolUpdates: [ToolStatusUpdate]) { + Task { + do { + let service = try getService() + let updatedTools = try await service.updateToolsStatus(toolUpdates) + if updatedTools == nil { + Logger.client.error("Failed to update built-in tool status: No updated tools returned") + } + // CopilotLanguageModelToolManager will broadcast changes; our local + // toolEnabledStates keep rows visible even if disabled. + } catch { + Logger.client.error("Failed to update built-in tool status: \(error.localizedDescription)") + } + } + } +} + +/// Empty state view when no tools are available +private struct EmptyStateView: View { + var body: some View { + Text("No built-in tools available. Make sure background permissions are granted.") + .foregroundColor(.secondary) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/CopilotBuiltInToolManagerObservable.swift b/Core/Sources/HostApp/ToolsSettings/CopilotBuiltInToolManagerObservable.swift new file mode 100644 index 00000000..ae36f221 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/CopilotBuiltInToolManagerObservable.swift @@ -0,0 +1,51 @@ +import Client +import Combine +import ConversationServiceProvider +import Logger +import Persist +import SwiftUI + +class CopilotBuiltInToolManagerObservable: ObservableObject { + static let shared = CopilotBuiltInToolManagerObservable() + + @Published var availableLanguageModelTools: [LanguageModelTool] = [] + private var cancellables = Set() + + private init() { + DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotToolsDidChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + Task { + await self.refreshLanguageModelTools() + } + } + .store(in: &cancellables) + + Task { + await refreshLanguageModelTools() + } + } + + @MainActor + public func refreshLanguageModelTools() async { + do { + let service = try getService() + let languageModelTools = try await service.getAvailableLanguageModelTools() + + guard let tools = languageModelTools else { return } + + // Update the published list with all tools (both enabled and disabled) + availableLanguageModelTools = tools + + // Update AppState for persistence + let statusUpdates = tools.map { + ToolStatusUpdate(name: $0.name, status: $0.status) + } + AppState.shared.updateLanguageModelToolsStatus(statusUpdates) + } catch { + Logger.client.error("Failed to fetch language model tools: \(error)") + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift new file mode 100644 index 00000000..d493b8be --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift @@ -0,0 +1,53 @@ +import SwiftUI +import Combine +import Persist +import GitHubCopilotService +import Client +import Logger + +class CopilotMCPToolManagerObservable: ObservableObject { + static let shared = CopilotMCPToolManagerObservable() + + @Published var availableMCPServerTools: [MCPServerToolsCollection] = [] + private var cancellables = Set() + + private init() { + DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotMCPToolsDidChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + Task { + await self.refreshMCPServerTools() + } + } + .store(in: &cancellables) + + Task { + // Initial load of MCP server tools collections from ExtensionService process + await refreshMCPServerTools() + } + } + + @MainActor + private func refreshMCPServerTools() async { + do { + let service = try getService() + let mcpTools = try await service.getAvailableMCPServerToolsCollections() + refreshTools(tools: mcpTools) + } catch { + Logger.client.error("Failed to fetch MCP server tools: \(error)") + } + } + + private func refreshTools(tools: [MCPServerToolsCollection]?) { + guard let tools = tools else { + // nil means the tools data is ready, and skip it first. + return + } + + AppState.shared.cleanupMCPToolsStatus(availableTools: tools) + AppState.shared.createMCPToolsStatus(tools) + self.availableMCPServerTools = tools + } +} diff --git a/Core/Sources/HostApp/MCPSettings/MCPAppState.swift b/Core/Sources/HostApp/ToolsSettings/MCPAppState.swift similarity index 100% rename from Core/Sources/HostApp/MCPSettings/MCPAppState.swift rename to Core/Sources/HostApp/ToolsSettings/MCPAppState.swift diff --git a/Core/Sources/HostApp/MCPSettings/MCPConfigConstants.swift b/Core/Sources/HostApp/ToolsSettings/MCPConfigConstants.swift similarity index 100% rename from Core/Sources/HostApp/MCPSettings/MCPConfigConstants.swift rename to Core/Sources/HostApp/ToolsSettings/MCPConfigConstants.swift diff --git a/Core/Sources/HostApp/ToolsSettings/MCPIntroView.swift b/Core/Sources/HostApp/ToolsSettings/MCPIntroView.swift new file mode 100644 index 00000000..84b754a5 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPIntroView.swift @@ -0,0 +1,45 @@ +import Client +import Foundation +import Logger +import SharedUIComponents +import SwiftUI + +struct MCPIntroView: View { + @Binding private var isMCPFFEnabled: Bool + + public init(isMCPFFEnabled: Binding) { + _isMCPFFEnabled = isMCPFFEnabled + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if !isMCPFFEnabled { + GroupBox { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "info.circle.fill") + .font(.body) + .foregroundColor(.gray) + Text( + "MCP servers are disabled by your organization’s policy. To enable them, please contact your administrator. [Get More Info about Copilot policies](https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-organization/manage-policies)" + ) + } + } + .groupBoxStyle( + CardGroupBoxStyle( + backgroundColor: Color(nsColor: .textBackgroundColor) + ) + ) + } + } + } +} + +#Preview { + MCPIntroView(isMCPFFEnabled: .constant(true)) + .frame(width: 800) +} + +#Preview { + MCPIntroView(isMCPFFEnabled: .constant(false)) + .frame(width: 800) +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift new file mode 100644 index 00000000..36334622 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift @@ -0,0 +1,160 @@ +import AppKit +import Logger +import SharedUIComponents +import SwiftUI + +struct MCPManualInstallView: View { + @State private var isExpanded: Bool = false + + var body: some View { + VStack(spacing: 0) { + DisclosureSettingsRow( + isExpanded: $isExpanded, + accessibilityLabel: { $0 ? "Collapse manual install section" : "Expand manual install section" }, + title: { Text("Manual Install").font(.headline) }, + subtitle: { Text("Add MCP Servers to power AI with tools for files, databases, and external APIs.") }, + actions: { + HStack(spacing: 8) { + Button { + openMCPRunTimeLogFolder() + } label: { + HStack(spacing: 0) { + Image(systemName: "folder") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Open MCP Log Folder") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .help("Open MCP Runtime Log Folder") + + Button { + openConfigFile() + } label: { + HStack(spacing: 0) { + Image(systemName: "square.and.pencil") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Edit Config") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .help("Configure your MCP server") + } + .padding(.vertical, 12) + } + ) + + if isExpanded { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("Example Configuration").foregroundColor(.primary.opacity(0.85)) + + CopyButton( + copy: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(exampleConfig, forType: .string) + }, + foregroundColor: .primary.opacity(0.85), + fontWeight: .semibold + ) + .frame(width: 10, height: 10) + } + .padding(.leading, 4) + + exampleConfigView() + } + .padding(.top, 8) + .padding([.leading, .trailing, .bottom], 20) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + } + .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .inset(by: 0.5) + .stroke(SecondarySystemFillColor, lineWidth: 1) + .animation(.easeInOut(duration: 0.3), value: isExpanded) + ) + .animation(.easeInOut(duration: 0.3), value: isExpanded) + } + + var exampleConfig: String { + """ + { + "servers": { + "my-mcp-server": { + "type": "stdio", + "command": "my-command", + "args": [], + "env": { + "TOKEN": "my_token" + } + } + } + } + """ + } + + @ViewBuilder + private func exampleConfigView() -> some View { + Text(exampleConfig) + .font(.system(.body, design: .monospaced)) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + Color(nsColor: .textBackgroundColor).opacity(0.5) + ) + .textSelection(.enabled) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .inset(by: 0.5) + .stroke(Color("GroupBoxStrokeColor"), lineWidth: 1) + ) + } + + private func openMCPRunTimeLogFolder() { + let url = URL( + fileURLWithPath: FileLoggingLocation.mcpRuntimeLogsPath.description, + isDirectory: true + ) + + // Create directory if it doesn't exist + if !FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.createDirectory( + atPath: url.path, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + Logger.client.error("Failed to create MCP runtime log folder: \(error)") + return + } + } + + NSWorkspace.shared.open(url) + } + + private func openConfigFile() { + let url = URL(fileURLWithPath: mcpConfigFilePath) + NSWorkspace.shared.open(url) + } +} + +#Preview { + MCPManualInstallView() + .padding() + .frame(width: 900) +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift new file mode 100644 index 00000000..f9c45687 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift @@ -0,0 +1,202 @@ +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 + ToolRow( + toolName: tool.name, + toolDescription: tool.description, + toolStatus: tool._status, + isServerEnabled: isServerEnabled, + isToolEnabled: toolBindingFor(tool), + onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) } + ) + .padding(.leading, 36) + } + } + .onChange(of: serverTools) { newValue in + 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/MCPToolsListContainerView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift similarity index 100% rename from Core/Sources/HostApp/MCPSettings/MCPToolsListContainerView.swift rename to Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift diff --git a/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift new file mode 100644 index 00000000..2cb6f530 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift @@ -0,0 +1,92 @@ +import Combine +import GitHubCopilotService +import Persist +import SwiftUI + +struct MCPToolsListView: View { + @ObservedObject private var mcpToolManager = CopilotMCPToolManagerObservable.shared + @State private var serverToggleStates: [String: Bool] = [:] + @State private var isSearchBarVisible: Bool = false + @State private var searchText: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + GroupBox( + label: + HStack(alignment: .center) { + Text("Available MCP Tools").fontWeight(.bold) + Spacer() + SearchBar(isVisible: $isSearchBarVisible, text: $searchText) + } + .clipped() + ) { + let filteredServerTools = filteredMCPServerTools() + if filteredServerTools.isEmpty { + EmptyStateView() + } else { + ToolsListView( + mcpServerTools: filteredServerTools, + serverToggleStates: $serverToggleStates, + searchKey: searchText, + expandedServerNames: expandedServerNames(filteredServerTools: filteredServerTools) + ) + } + } + .groupBoxStyle(CardGroupBoxStyle()) + } + .onAppear(perform: updateServerToggleStates) + .onChange(of: mcpToolManager.availableMCPServerTools) { _ 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 }) + } +} + +/// 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 71% rename from Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift rename to Core/Sources/HostApp/ToolsSettings/ToolRowView.swift index 21dd6b87..66a0ab81 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift +++ b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift @@ -1,9 +1,11 @@ import SwiftUI -import GitHubCopilotService +import ConversationServiceProvider /// Individual tool row -struct MCPToolRow: View { - let tool: MCPTool +struct ToolRow: View { + let toolName: String + let toolDescription: String? + let toolStatus: ToolStatus let isServerEnabled: Bool @Binding var isToolEnabled: Bool let onToolToggleChanged: (Bool) -> Void @@ -16,13 +18,14 @@ struct MCPToolRow: View { )) { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .center, spacing: 8) { - Text(tool.name).fontWeight(.medium) + Text(toolName).fontWeight(.medium) - if let description = tool.description { + if let description = toolDescription { Text(description) .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) + .help(description) } } @@ -30,9 +33,8 @@ struct MCPToolRow: View { } } } - .padding(.leading, 32) .padding(.vertical, 0) - .onChange(of: tool._status) { isToolEnabled = $0 == .enabled } + .onChange(of: toolStatus) { isToolEnabled = $0 == .enabled } .onChange(of: isServerEnabled) { if !$0 { isToolEnabled = false } } } } diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift index 263c9ee9..77d91bb0 100644 --- a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift +++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift @@ -12,9 +12,10 @@ extension ChatMessage { var references: [ConversationReference] var followUp: ConversationFollowUp? var suggestedTitle: String? - var errorMessage: String? + var errorMessages: [String] = [] var steps: [ConversationProgressStep] var editAgentRounds: [AgentRound] + var panelMessages: [CopilotShowMessageParams] // Custom decoder to provide default value for steps init(from decoder: Decoder) throws { @@ -24,21 +25,33 @@ extension ChatMessage { references = try container.decode([ConversationReference].self, forKey: .references) followUp = try container.decodeIfPresent(ConversationFollowUp.self, forKey: .followUp) suggestedTitle = try container.decodeIfPresent(String.self, forKey: .suggestedTitle) - errorMessage = try container.decodeIfPresent(String.self, forKey: .errorMessage) + errorMessages = try container.decodeIfPresent([String].self, forKey: .errorMessages) ?? [] steps = try container.decodeIfPresent([ConversationProgressStep].self, forKey: .steps) ?? [] editAgentRounds = try container.decodeIfPresent([AgentRound].self, forKey: .editAgentRounds) ?? [] + panelMessages = try container.decodeIfPresent([CopilotShowMessageParams].self, forKey: .panelMessages) ?? [] } // Default memberwise init for encoding - init(content: String, rating: ConversationRating, references: [ConversationReference], followUp: ConversationFollowUp?, suggestedTitle: String?, errorMessage: String?, steps: [ConversationProgressStep]?, editAgentRounds: [AgentRound]? = nil) { + init( + content: String, + rating: ConversationRating, + references: [ConversationReference], + followUp: ConversationFollowUp?, + suggestedTitle: String?, + errorMessages: [String] = [], + steps: [ConversationProgressStep]?, + editAgentRounds: [AgentRound]? = nil, + panelMessages: [CopilotShowMessageParams]? = nil + ) { self.content = content self.rating = rating self.references = references self.followUp = followUp self.suggestedTitle = suggestedTitle - self.errorMessage = errorMessage + self.errorMessages = errorMessages self.steps = steps ?? [] self.editAgentRounds = editAgentRounds ?? [] + self.panelMessages = panelMessages ?? [] } } @@ -49,9 +62,10 @@ extension ChatMessage { references: self.references, followUp: self.followUp, suggestedTitle: self.suggestedTitle, - errorMessage: self.errorMessage, + errorMessages: self.errorMessages, steps: self.steps, - editAgentRounds: self.editAgentRounds + editAgentRounds: self.editAgentRounds, + panelMessages: self.panelMessages ) // TODO: handle exception @@ -79,10 +93,11 @@ extension ChatMessage { references: turnItemData.references, followUp: turnItemData.followUp, suggestedTitle: turnItemData.suggestedTitle, - errorMessage: turnItemData.errorMessage, + errorMessages: turnItemData.errorMessages, rating: turnItemData.rating, steps: turnItemData.steps, editAgentRounds: turnItemData.editAgentRounds, + panelMessages: turnItemData.panelMessages, createdAt: turnItem.createdAt, updatedAt: turnItem.updatedAt ) diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift index 849f10b2..f642cb71 100644 --- a/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift +++ b/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift @@ -35,3 +35,14 @@ extension Array where Element == ChatTabInfo { return self.map { $0.toConversationItem() } } } + +extension ChatTabPreviewInfo { + static func from(_ conversationPreviewItem: ConversationPreviewItem) -> ChatTabPreviewInfo { + return .init( + id: conversationPreviewItem.id, + title: conversationPreviewItem.title, + isSelected: conversationPreviewItem.isSelected, + updatedAt: conversationPreviewItem.updatedAt + ) + } +} diff --git a/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift b/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift index ddbe2dac..da9bccd3 100644 --- a/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift +++ b/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift @@ -16,13 +16,37 @@ public struct ChatTabInfoStore { } public static func getAll(with metadata: StorageMetadata) -> [ChatTabInfo] { - var chatTabInfos: [ChatTabInfo] = [] + return fetchChatTabInfos(.all, metadata: metadata) + } + + public static func getSelected(with metadata: StorageMetadata) -> ChatTabInfo? { + return fetchChatTabInfos(.selected, metadata: metadata).first + } + + public static func getLatest(with metadata: StorageMetadata) -> ChatTabInfo? { + return fetchChatTabInfos(.latest, metadata: metadata).first + } + + public static func getByID(_ id: String, with metadata: StorageMetadata) -> ChatTabInfo? { + return fetchChatTabInfos(.id(id), metadata: metadata).first + } + + private static func fetchChatTabInfos(_ type: ConversationFetchType, metadata: StorageMetadata) -> [ChatTabInfo] { + let items = ConversationStorageService.shared.fetchConversationItems(type, metadata: metadata) + + return items.compactMap { ChatTabInfo.from($0, with: metadata) } + } +} + +public struct ChatTabPreviewInfoStore { + public static func getAll(with metadata: StorageMetadata) -> [ChatTabPreviewInfo] { + var previewInfos: [ChatTabPreviewInfo] = [] - let conversationItems = ConversationStorageService.shared.fetchConversationItems(.all, metadata: metadata) - if conversationItems.count > 0 { - chatTabInfos = conversationItems.compactMap { ChatTabInfo.from($0, with: metadata) } + let conversationPreviewItems = ConversationStorageService.shared.fetchConversationPreviewItems(metadata: metadata) + if conversationPreviewItems.count > 0 { + previewInfos = conversationPreviewItems.compactMap { ChatTabPreviewInfo.from($0) } } - return chatTabInfos + return previewInfos } } diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index af7f3bb1..5489bf3c 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -10,6 +10,7 @@ import SuggestionBasic import SuggestionWidget import PersistMiddleware import ChatService +import Persist #if canImport(ChatTabPersistent) import ChatTabPersistent @@ -86,6 +87,23 @@ struct GUI { await send(.appendAndSelectTab(chatTabInfo)) } } + case .restoreTabByInfo(let info): + guard let currentChatWorkspace = state.currentChatWorkspace else { return .none } + + return .run { send in + if let _ = await chatTabPool.restoreTab(by: info, with: currentChatWorkspace) { + await send(.appendAndSelectTab(info)) + } + } + + case .createNewTabByID(let id): + guard let currentChatWorkspace = state.currentChatWorkspace else { return .none } + + return .run { send in + if let (_, info) = await chatTabPool.createTab(id: id, with: currentChatWorkspace) { + await send(.appendAndSelectTab(info)) + } + } // case let .closeTabButtonClicked(id): // return .run { _ in @@ -263,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])))) } } @@ -421,11 +440,17 @@ extension ChatTabPool { @MainActor func createTab( id: String = UUID().uuidString, - from builder: ChatTabBuilder, + from builder: ChatTabBuilder? = nil, with chatWorkspace: ChatWorkspace ) async -> (any ChatTab, ChatTabInfo)? { let id = id let info = ChatTabInfo(id: id, workspacePath: chatWorkspace.workspacePath, username: chatWorkspace.username) + guard let builder else { + let chatTab = ConversationTab(store: createStore(info), with: info) + setTab(chatTab) + return (chatTab, info) + } + guard let chatTab = await builder.build(store: createStore(info)) else { return nil } setTab(chatTab) return (chatTab, info) @@ -448,6 +473,16 @@ extension ChatTabPool { setTab(chatTab) return (chatTab, info) } + + @MainActor + func restoreTab( + by info: ChatTabInfo, + with chaWorkspace: ChatWorkspace + ) async -> (any ChatTab)? { + let chatTab = ConversationTab.restoreConversation(by: info, store: createStore(info)) + setTab(chatTab) + return chatTab + } } @@ -461,23 +496,22 @@ extension GraphicalUserInterfaceController { // only restore once regardless of success or fail restoredChatHistory.insert(workspaceIdentifier) - let storedChatTabInfos = ChatTabInfoStore.getAll(with: .init(workspacePath: workspacePath, username: username)) - if storedChatTabInfos.count > 0 - { - var tabInfo: IdentifiedArray = [] - for info in storedChatTabInfos { - tabInfo[id: info.id] = info - let chatTab = ConversationTab.restoreConversation(by: info, store: chatTabPool.createStore(info)) - chatTabPool.setTab(chatTab) - } + let metadata = StorageMetadata(workspacePath: workspacePath, username: username) + let selectedChatTabInfo = ChatTabInfoStore.getSelected(with: metadata) ?? ChatTabInfoStore.getLatest(with: metadata) + + if let selectedChatTabInfo { + let chatTab = ConversationTab.restoreConversation(by: selectedChatTabInfo, store: chatTabPool.createStore(selectedChatTabInfo)) + chatTabPool.setTab(chatTab) let chatWorkspace = ChatWorkspace( id: .init(path: workspacePath, username: username), - tabInfo: tabInfo, + tabInfo: [selectedChatTabInfo], tabCollection: [], - selectedTabId: storedChatTabInfos.first(where: { $0.isSelected })?.id - ) - self.store.send(.suggestionWidget(.chatPanel(.restoreWorkspace(chatWorkspace)))) + selectedTabId: selectedChatTabInfo.id + ) { [weak self] in + self?.chatTabPool.removeTab(of: $0) + } + await self.store.send(.suggestionWidget(.chatPanel(.restoreWorkspace(chatWorkspace)))).finish() } } } diff --git a/Core/Sources/Service/Helpers.swift b/Core/Sources/Service/Helpers.swift index 0dfede82..90ac6344 100644 --- a/Core/Sources/Service/Helpers.swift +++ b/Core/Sources/Service/Helpers.swift @@ -1,4 +1,5 @@ import Foundation +import GitHubCopilotService import LanguageServerProtocol extension NSError { @@ -34,6 +35,8 @@ extension NSError { message = "Invalid request: \(error?.localizedDescription ?? "Unknown")." case .timeout: message = "Timeout." + case .unknownError: + message = "Unknown error: \(error.localizedDescription)." } return NSError(domain: "com.github.CopilotForXcode", code: -1, userInfo: [ NSLocalizedDescriptionKey: message, diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 517717be..899865f1 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -153,7 +153,7 @@ public actor RealtimeSuggestionController { // check if user loggin let authStatus = await Status.shared.getAuthStatus() - guard authStatus == .loggedIn else { return } + guard authStatus.status == .loggedIn else { return } guard UserDefaults.shared.value(for: \.realtimeSuggestionToggle) else { return } diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 9327d8f6..72769162 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -7,6 +7,9 @@ import Preferences import Status import XPCShared import HostAppActivator +import XcodeInspector +import GitHubCopilotViewModel +import ConversationServiceProvider public class XPCService: NSObject, XPCServiceProtocol { // MARK: - Service @@ -17,6 +20,19 @@ public class XPCService: NSObject, XPCServiceProtocol { Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "N/A" ) } + + public func getXPCCLSVersion(withReply reply: @escaping (String?) -> Void) { + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let version = try await service.version() + reply(version) + } catch { + Logger.service.error("Failed to get CLS version: \(error.localizedDescription)") + reply(nil) + } + } + } public func getXPCServiceAccessibilityPermission(withReply reply: @escaping (ObservedAXStatus) -> Void) { Task { @@ -239,6 +255,322 @@ public class XPCService: NSObject, XPCServiceProtocol { reply: reply ) } + + // MARK: - XcodeInspector + + public func getXcodeInspectorData(withReply reply: @escaping (Data?, Error?) -> Void) { + do { + // Capture current XcodeInspector data + let inspectorData = XcodeInspectorData( + activeWorkspaceURL: XcodeInspector.shared.activeWorkspaceURL?.absoluteString, + activeProjectRootURL: XcodeInspector.shared.activeProjectRootURL?.absoluteString, + realtimeActiveWorkspaceURL: XcodeInspector.shared.realtimeActiveWorkspaceURL?.absoluteString, + realtimeActiveProjectURL: XcodeInspector.shared.realtimeActiveProjectURL?.absoluteString, + latestNonRootWorkspaceURL: XcodeInspector.shared.latestNonRootWorkspaceURL?.absoluteString + ) + + // Encode and send the data + let data = try JSONEncoder().encode(inspectorData) + reply(data, nil) + } catch { + Logger.service.error("Failed to encode XcodeInspector data: \(error.localizedDescription)") + reply(nil, error) + } + } + + // MARK: - MCP Server Tools + public func getAvailableMCPServerToolsCollections(withReply reply: @escaping (Data?) -> Void) { + let availableMCPServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() + if let availableMCPServerTools = availableMCPServerTools { + // Encode and send the data + let data = try? JSONEncoder().encode(availableMCPServerTools) + reply(data) + } else { + reply(nil) + } + } + + public func updateMCPServerToolsStatus(tools: Data) { + // Decode the data + let decoder = JSONDecoder() + var collections: [UpdateMCPToolsStatusServerCollection] = [] + do { + collections = try decoder.decode([UpdateMCPToolsStatusServerCollection].self, from: tools) + if collections.isEmpty { + return + } + } catch { + Logger.service.error("Failed to decode MCP server collections: \(error)") + return + } + + Task { @MainActor in + await GitHubCopilotService.updateAllClsMCP(collections: collections) + } + } + + // MARK: - MCP Registry + + public func listMCPRegistryServers(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) { + let decoder = JSONDecoder() + var listMCPRegistryServersParams: MCPRegistryListServersParams? + do { + listMCPRegistryServersParams = try decoder.decode(MCPRegistryListServersParams.self, from: params) + } catch { + Logger.service.error("Failed to decode MCP Registry list servers parameters: \(error)") + return + } + + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.listMCPRegistryServers(listMCPRegistryServersParams!) + let data = try? JSONEncoder().encode(response) + reply(data, nil) + } catch { + Logger.service.error("Failed to list MCP Registry servers: \(error)") + reply(nil, error) + } + } + } + + public func getMCPRegistryServer(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) { + let decoder = JSONDecoder() + var getMCPRegistryServerParams: MCPRegistryGetServerParams? + do { + getMCPRegistryServerParams = try decoder.decode(MCPRegistryGetServerParams.self, from: params) + } catch { + Logger.service.error("Failed to decode MCP Registry get server parameters: \(error)") + return + } + + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.getMCPRegistryServer(getMCPRegistryServerParams!) + let data = try? JSONEncoder().encode(response) + reply(data, nil) + } catch { + Logger.service.error("Failed to get MCP Registry servers: \(error)") + reply(nil, error) + } + } + } + + // MARK: - Language Model Tools + public func getAvailableLanguageModelTools(withReply reply: @escaping (Data?) -> Void) { + let availableLanguageModelTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() + if let availableLanguageModelTools = availableLanguageModelTools { + // Encode and send the data + let data = try? JSONEncoder().encode(availableLanguageModelTools) + reply(data) + } else { + reply(nil) + } + } + + public func updateToolsStatus(tools: Data, withReply reply: @escaping (Data?) -> Void) { + // Decode the data + let decoder = JSONDecoder() + var toolStatusUpdates: [ToolStatusUpdate] = [] + do { + toolStatusUpdates = try decoder.decode([ToolStatusUpdate].self, from: tools) + if toolStatusUpdates.isEmpty { + let emptyData = try JSONEncoder().encode([LanguageModelTool]()) + reply(emptyData) + return + } + } catch { + Logger.service.error("Failed to decode built-in tools: \(error)") + reply(nil) + return + } + + Task { @MainActor in + let updatedTools = await GitHubCopilotService.updateAllCLSTools(tools: toolStatusUpdates) + + // Encode and return the updated tools + do { + let data = try JSONEncoder().encode(updatedTools) + reply(data) + } catch { + Logger.service.error("Failed to encode updated tools: \(error)") + reply(nil) + } + } + } + + // MARK: - FeatureFlags + public func getCopilotFeatureFlags( + withReply reply: @escaping (Data?) -> Void + ) { + let featureFlags = FeatureFlagNotifierImpl.shared.featureFlags + let data = try? JSONEncoder().encode(featureFlags) + reply(data) + } + + // MARK: - Auth + public func signOutAllGitHubCopilotService() { + Task { @MainActor in + do { + try await GitHubCopilotService.signOutAll() + } catch { + Logger.service.error("Failed to sign out all: \(error)") + } + } + } + + public func getXPCServiceAuthStatus(withReply reply: @escaping (Data?) -> Void) { + Task { @MainActor in + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + _ = try await service.checkStatus() + let authStatus = await Status.shared.getAuthStatus() + let data = try? JSONEncoder().encode(authStatus) + reply(data) + } + } + + // 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/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index 9cdabd21..543afb3e 100644 --- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -3,12 +3,15 @@ import ChatTab 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 = {} @@ -76,6 +79,13 @@ final class ChatPanelWindow: NSWindow { } } } + + setInitialFrame() + } + + private func setInitialFrame() { + let frame = UpdateLocationStrategy.getChatPanelFrame() + setFrame(frame, display: false, animate: true) } func setFloatOnTop(_ isFloatOnTop: Bool) { @@ -113,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 73b891d9..1c956781 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUI import ChatTab import SharedUIComponents +import PersistMiddleware struct ChatHistoryView: View { @@ -15,7 +16,6 @@ struct ChatHistoryView: View { var body: some View { WithPerceptionTracking { - let _ = store.currentChatWorkspace?.tabInfo VStack(alignment: .center, spacing: 0) { Header(isChatHistoryVisible: $isChatHistoryVisible) @@ -42,7 +42,7 @@ struct ChatHistoryView: View { var body: some View { HStack { Text("Chat History") - .font(.system(size: 13, weight: .bold)) + .scaledFont(size: 13, weight: .bold) .lineLimit(nil) Spacer() @@ -51,6 +51,7 @@ struct ChatHistoryView: View { isChatHistoryVisible = false }) { Image(systemName: "xmark") + .scaledFont(.body) } .buttonStyle(HoverButtonStyle()) .help("Close") @@ -62,6 +63,7 @@ struct ChatHistoryView: View { let store: StoreOf @Binding var searchText: String @Binding var isChatHistoryVisible: Bool + @State private var storedChatTabPreviewInfos: [ChatTabPreviewInfo] = [] @Environment(\.chatTabPool) var chatTabPool @@ -69,41 +71,43 @@ struct ChatHistoryView: View { WithPerceptionTracking { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(filteredTabInfo, id: \.id) { info in - if let _ = chatTabPool.getTab(of: info.id){ - ChatHistoryItemView( - store: store, - info: info, - isChatHistoryVisible: $isChatHistoryVisible - ) - .id(info.id) - .frame(height: 61) - } - else { - EmptyView() + ForEach(filteredTabInfo, id: \.id) { previewInfo in + ChatHistoryItemView( + store: store, + previewInfo: previewInfo, + isChatHistoryVisible: $isChatHistoryVisible + ) { + refreshStoredChatTabInfos() } + .id(previewInfo.id) + .frame(height: 61) } } } + .onAppear { refreshStoredChatTabInfos() } } } - var filteredTabInfo: IdentifiedArray { - guard let tabInfo = store.currentChatWorkspace?.tabInfo else { - return [] + func refreshStoredChatTabInfos() -> Void { + Task { + if let workspacePath = store.chatHistory.selectedWorkspacePath, + let username = store.chatHistory.currentUsername + { + storedChatTabPreviewInfos = ChatTabPreviewInfoStore.getAll(with: .init(workspacePath: workspacePath, username: username)) + } + } + } + + var filteredTabInfo: IdentifiedArray { + // Only compute when view is visible to prevent unnecessary computation + if !isChatHistoryVisible { + return IdentifiedArray(uniqueElements: []) } - // sort by updatedAt by descending order - let sortedTabInfo = tabInfo.sorted { $0.updatedAt > $1.updatedAt } - - guard !searchText.isEmpty else { return IdentifiedArray(uniqueElements: sortedTabInfo) } + guard !searchText.isEmpty else { return IdentifiedArray(uniqueElements: storedChatTabPreviewInfos) } - let result = sortedTabInfo.filter { info in - if let tab = chatTabPool.getTab(of: info.id) { - return tab.title.localizedCaseInsensitiveContains(searchText) - } - - return false + let result = storedChatTabPreviewInfos.filter { info in + return (info.title ?? "New Chat").localizedCaseInsensitiveContains(searchText) } return IdentifiedArray(uniqueElements: result) @@ -120,8 +124,10 @@ struct ChatHistorySearchBarView: View { HStack(spacing: 5) { Image(systemName: "magnifyingglass") .foregroundColor(.secondary) + .scaledFont(.body) TextField("Search", text: $searchText) + .scaledFont(.body) .textFieldStyle(PlainTextFieldStyle()) .focused($isSearchBarFocused) .foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor)) @@ -141,12 +147,15 @@ struct ChatHistorySearchBarView: View { struct ChatHistoryItemView: View { let store: StoreOf - let info: ChatTabInfo + let previewInfo: ChatTabPreviewInfo + @Environment(\.colorScheme) var colorScheme @Binding var isChatHistoryVisible: Bool @State private var isHovered = false + let onDelete: () -> Void + func isTabSelected() -> Bool { - return store.state.currentChatWorkspace?.selectedTabId == info.id + return store.state.currentChatWorkspace?.selectedTabId == previewInfo.id } func formatDate(_ date: Date) -> String { @@ -163,11 +172,11 @@ struct ChatHistoryItemView: View { HStack(spacing: 8) { // Do not use the `ChatConversationItemView` any more // directly get title from chat tab info - Text(info.title ?? "New Chat") + Text(previewInfo.title ?? "New Chat") .frame(alignment: .leading) - .font(.system(size: 14, weight: .regular)) + .scaledFont(size: 14, weight: .semibold) + .foregroundColor(.primary) .lineLimit(1) - .hoverPrimaryForeground(isHovered: isHovered) if isTabSelected() { Text("Current") @@ -178,9 +187,10 @@ struct ChatHistoryItemView: View { } HStack(spacing: 0) { - Text(formatDate(info.updatedAt)) + Text(formatDate(previewInfo.updatedAt)) .frame(alignment: .leading) - .font(.system(size: 13, weight: .thin)) + .scaledFont(size: 13, weight: .regular) + .foregroundColor(.secondary) .lineLimit(1) Spacer() @@ -190,15 +200,20 @@ struct ChatHistoryItemView: View { Spacer() if !isTabSelected() { - if isHovered { - Button(action: { - store.send(.chatHisotryDeleteButtonClicked(id: info.id)) - }) { - Image(systemName: "trash") + Button(action: { + Task { @MainActor in + await store.send(.chatHistoryDeleteButtonClicked(id: previewInfo.id)).finish() + onDelete() } - .buttonStyle(HoverButtonStyle()) - .help("Delete") + }) { + Image(systemName: "trash") + .foregroundColor(.primary) + .scaledFont(.body) + .opacity(isHovered ? 1 : 0) } + .buttonStyle(HoverButtonStyle()) + .help("Delete") + .allowsHitTesting(isHovered) } } .padding(.horizontal, 12) @@ -207,10 +222,21 @@ struct ChatHistoryItemView: View { .onHover(perform: { isHovered = $0 }) - .hoverRadiusBackground(isHovered: isHovered, cornerRadius: 4) + .hoverRadiusBackground( + isHovered: isHovered, + hoverColor: Color( + nsColor: .controlColor + .withAlphaComponent(colorScheme == .dark ? 0.1 : 0.55) + ), + cornerRadius: 8, + showBorder: isHovered, + borderColor: Color(nsColor: .separatorColor) + ) .onTapGesture { - store.send(.chatHistoryItemClicked(id: info.id)) - isChatHistoryVisible = false + Task { @MainActor in + await store.send(.chatHistoryItemClicked(id: previewInfo.id)).finish() + isChatHistoryVisible = false + } } } } @@ -239,7 +265,7 @@ struct ChatHistoryView_Previews: PreviewProvider { .init(id: "6", title: "Empty-6", workspacePath: "path", username: "username") ] as IdentifiedArray, selectedTabId: "2" - )] as IdentifiedArray, + ) { _ in }] as IdentifiedArray, selectedWorkspacePath: "activeWorkspacePath", selectedWorkspaceName: "activeWorkspacePath" ), diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift index 871dd24e..5ca14718 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift @@ -17,15 +17,15 @@ struct ChatLoginView: View { .resizable() .renderingMode(.template) .scaledToFill() - .frame(width: 60.0, height: 60.0) + .scaledFrame(width: 60.0, height: 60.0) .foregroundColor(.secondary) Text("Welcome to Copilot") - .font(.largeTitle) + .scaledFont(.largeTitle) .multilineTextAlignment(.center) Text("Your AI-powered coding assistant") - .font(.body) + .scaledFont(.body) .multilineTextAlignment(.center) } @@ -37,11 +37,15 @@ struct ChatLoginView: View { openURL(url) } } + .scaledFont(.body) .buttonStyle(.borderedProminent) HStack{ Text("Already have an account?") + .scaledFont(.body) + Button("Sign In") { viewModel.signIn() } + .scaledFont(.body) .buttonStyle(.borderless) .foregroundColor(Color("TextLinkForegroundColor")) @@ -55,7 +59,7 @@ struct ChatLoginView: View { Spacer() Text("Copilot Free and Copilot Pro may show [public code](https://aka.ms/github-copilot-match-public-code) suggestions and collect telemetry. You can change these [GitHub settings](https://aka.ms/github-copilot-settings) at any time. By continuing, you agree to our [terms](https://github.com/customer-terms/github-copilot-product-specific-terms) and [privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).") - .font(.system(size: 12)) + .scaledFont(.system(size: 12)) } .padding() .frame( @@ -71,7 +75,10 @@ struct ChatLoginView: View { presenting: viewModel.signInResponse ) { _ in Button("Cancel", role: .cancel, action: {}) + .scaledFont(.body) + Button("Copy Code and Open", action: viewModel.copyAndOpen) + .scaledFont(.body) } message: { response in Text(""" Please enter the above code in the GitHub website \ diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift index 299c46cc..4a82fb61 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift @@ -18,11 +18,11 @@ struct ChatNoAXPermissionView: View { .foregroundColor(.primary) Text("Accessibility Permission Required") - .font(.largeTitle) + .scaledFont(.largeTitle) .multilineTextAlignment(.center) Text("Please grant accessibility permission for Github Copilot to work with Xcode.") - .font(.body) + .scaledFont(.body) .multilineTextAlignment(.center) HStack{ @@ -31,6 +31,7 @@ struct ChatNoAXPermissionView: View { openURL(url) } } + .scaledFont(.body) .buttonStyle(.borderedProminent) } diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift index 5c052411..a453d633 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift @@ -20,11 +20,11 @@ struct ChatNoSubscriptionView: View { .foregroundColor(.primary) Text("No Copilot Subscription Found") - .font(.system(size: 24)) + .scaledFont(.system(size: 24)) .multilineTextAlignment(.center) Text("Request a license from your organization manager \nor start a 30-day [free trial](https://github.com/github-copilot/signup/copilot_individual) to explore Copilot") - .font(.system(size: 12)) + .scaledFont(.system(size: 12)) .multilineTextAlignment(.center) HStack{ @@ -33,9 +33,11 @@ struct ChatNoSubscriptionView: View { openURL(url) } } + .scaledFont(.body) .buttonStyle(.borderedProminent) Button("Retry") { viewModel.checkStatus() } + .scaledFont(.body) .buttonStyle(.bordered) if viewModel.isRunningAction || viewModel.waitingForSignIn { @@ -47,7 +49,7 @@ struct ChatNoSubscriptionView: View { Spacer() Text("Copilot Free and Copilot Pro may show [public code](https://aka.ms/github-copilot-match-public-code) suggestions and collect telemetry. You can change these [GitHub settings](https://aka.ms/github-copilot-settings) at any time. By continuing, you agree to our [terms](https://github.com/customer-terms/github-copilot-product-specific-terms) and [privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).") - .font(.system(size: 12)) + .scaledFont(.system(size: 12)) } .padding() .frame( diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift index 8d7cbf60..172a477c 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift @@ -13,15 +13,15 @@ struct ChatNoWorkspaceView: View { .resizable() .renderingMode(.template) .scaledToFill() - .frame(width: 64.0, height: 64.0) + .scaledFrame(width: 64.0, height: 64.0) .foregroundColor(.secondary) Text("No Active Xcode Workspace") - .font(.largeTitle) + .scaledFont(.largeTitle) .multilineTextAlignment(.center) Text("To use Copilot, open Xcode with an active workspace in focus") - .font(.body) + .scaledFont(.body) .multilineTextAlignment(.center) } diff --git a/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift b/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift index b3d5eb5b..a7fdcec7 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift @@ -76,15 +76,15 @@ struct CopilotIntroItemView: View { .padding(.leading, 8) Text(title) - .font(.body) .kerning(0.096) + .scaledFont(.body) .multilineTextAlignment(.center) .foregroundColor(.primary) } .frame(maxWidth: .infinity, alignment: .leading) Text(description) - .font(.body) + .scaledFont(.body) .foregroundColor(.secondary) .padding(.leading, 28) .padding(.top, 4) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index f85cdce2..a7e05e88 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -7,6 +7,8 @@ import SwiftUI import SharedUIComponents import GitHubCopilotViewModel import Status +import ChatService +import Workspace private let r: Double = 8 @@ -20,7 +22,7 @@ struct ChatWindowView: View { WithPerceptionTracking { // Force re-evaluation when workspace state changes let currentWorkspace = store.currentChatWorkspace - let selectedTabId = currentWorkspace?.selectedTabId + let _ = currentWorkspace?.selectedTabId ZStack { if statusObserver.observedAXStatus == .notGranted { ChatNoAXPermissionView() @@ -38,8 +40,8 @@ struct ChatWindowView: View { ChatLoginView(viewModel: GitHubCopilotViewModel.shared) case .notAuthorized: ChatNoSubscriptionView(viewModel: GitHubCopilotViewModel.shared) - default: - ChatLoadingView() + case .unknown: + ChatLoginView(viewModel: GitHubCopilotViewModel.shared) } } } @@ -57,7 +59,7 @@ struct ChatView: View { var body: some View { VStack(spacing: 0) { - Rectangle().fill(.regularMaterial).frame(height: 28) + Rectangle().fill(Material.bar).frame(height: 28) Divider() @@ -65,7 +67,7 @@ struct ChatView: View { VStack(spacing: 0) { ChatBar(store: store, isChatHistoryVisible: $isChatHistoryVisible) .frame(height: 32) - .background(Color(nsColor: .windowBackgroundColor)) + .background(.ultraThinMaterial) Divider() @@ -87,7 +89,7 @@ struct ChatHistoryViewWrapper: View { var body: some View { WithPerceptionTracking { VStack(spacing: 0) { - Rectangle().fill(.regularMaterial).frame(height: 28) + Rectangle().fill(Material.bar).frame(height: 28) Divider() @@ -95,7 +97,7 @@ struct ChatHistoryViewWrapper: View { store: store, isChatHistoryVisible: $isChatHistoryVisible ) - .background(Color(nsColor: .windowBackgroundColor)) + .background(.ultraThinMaterial) .frame( maxWidth: .infinity, maxHeight: .infinity @@ -134,13 +136,14 @@ struct ChatLoadingView: View { .xcodeStyleFrame(cornerRadius: 10) .ignoresSafeArea(edges: .top) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .windowBackgroundColor)) + .background(.ultraThinMaterial) } } struct ChatTitleBar: View { let store: StoreOf @State var isHovering = false + @AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode var body: some View { WithPerceptionTracking { @@ -160,25 +163,27 @@ 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]) Spacer() - TrafficLightButton( - isHovering: isHovering, - isActive: store.isDetached, - color: Color(nsColor: .systemCyan), - action: { - store.send(.toggleChatPanelDetachedButtonClicked) + if !autoAttachChatToXcode { + TrafficLightButton( + isHovering: isHovering, + isActive: store.isDetached, + color: Color(nsColor: .systemCyan), + action: { + store.send(.toggleChatPanelDetachedButtonClicked) + } + ) { + Image(systemName: "pin.fill") + .foregroundStyle(.black.opacity(0.5)) + .scaledFont(Font.system(size: 6).weight(.black)) + .transformEffect(.init(translationX: 0, y: 0.5)) } - ) { - Image(systemName: "pin.fill") - .foregroundStyle(.black.opacity(0.5)) - .font(Font.system(size: 6).weight(.black)) - .transformEffect(.init(translationX: 0, y: 0.5)) } } .buttonStyle(.plain) @@ -208,7 +213,7 @@ struct ChatTitleBar: View { ? color : Color(nsColor: .separatorColor) ) - .frame( + .scaledFrame( width: Style.trafficLightButtonSize, height: Style.trafficLightButtonSize ) @@ -248,7 +253,7 @@ struct ChatBar: View { var body: some View { WithPerceptionTracking { HStack(spacing: 0) { - if let name = store.chatHistory.selectedWorkspaceName { + if store.chatHistory.selectedWorkspaceName != nil { ChatWindowHeader(store: store) } @@ -317,10 +322,10 @@ struct ChatBar: View { .resizable() .renderingMode(.original) .scaledToFit() - .frame(width: 24, height: 24) + .scaledFrame(width: 24, height: 24) Text(store.chatHistory.selectedWorkspaceName!) - .font(.system(size: 13, weight: .bold)) + .scaledFont(size: 13, weight: .bold) .padding(.leading, 4) .truncationMode(.tail) .frame(maxWidth: 192, alignment: .leading) @@ -339,6 +344,7 @@ struct ChatBar: View { store.send(.createNewTapButtonClicked(kind: nil)) }) { Image(systemName: "plus.bubble") + .scaledFont(.body) } .buttonStyle(HoverButtonStyle()) .padding(.horizontal, 4) @@ -359,8 +365,10 @@ struct ChatBar: View { }) { if #available(macOS 15.0, *) { Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .scaledFont(.body) } else { Image(systemName: "clock.arrow.circlepath") + .scaledFont(.body) } } .buttonStyle(HoverButtonStyle()) @@ -380,6 +388,7 @@ struct ChatBar: View { store.send(.openSettings) }) { Image(systemName: "gearshape") + .scaledFont(.body) } .buttonStyle(HoverButtonStyle()) .padding(.horizontal, 4) @@ -416,6 +425,7 @@ struct ChatTabBarButton: View { struct ChatTabContainer: View { let store: StoreOf @Environment(\.chatTabPool) var chatTabPool + @State private var pasteMonitor: Any? var body: some View { WithPerceptionTracking { @@ -434,6 +444,12 @@ struct ChatTabContainer: View { EmptyView().frame(maxWidth: .infinity, maxHeight: .infinity) } } + .onAppear { + setupPasteMonitor() + } + .onDisappear { + removePasteMonitor() + } } // View displayed when there are active tabs @@ -441,23 +457,52 @@ struct ChatTabContainer: View { tabInfoArray: IdentifiedArray, selectedTabId: String ) -> some View { - ZStack { - ForEach(tabInfoArray) { tabInfo in - if let tab = chatTabPool.getTab(of: tabInfo.id) { - let isActive = tab.id == selectedTabId - tab.body - .opacity(isActive ? 1 : 0) - .disabled(!isActive) - .allowsHitTesting(isActive) - .frame(maxWidth: .infinity, maxHeight: .infinity) - // Inactive tabs are rotated out of view - .rotationEffect( - isActive ? .zero : .degrees(90), - anchor: .topLeading - ) + GeometryReader { geometry in + 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() + } + } + } + + private func setupPasteMonitor() { + pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + guard event.modifierFlags.contains(.command), + event.charactersIgnoringModifiers?.lowercased() == "v" else { + return event + } + + // Find the active chat tab and forward paste event to it + if let activeConversationTab = getActiveConversationTab() { + if !activeConversationTab.handlePasteEvent() { + return event } } + + return nil + } + } + + private func removePasteMonitor() { + if let monitor = pasteMonitor { + NSEvent.removeMonitor(monitor) + pasteMonitor = nil + } + } + + private func getActiveConversationTab() -> ConversationTab? { + guard let selectedTabId = store.currentChatWorkspace?.selectedTabId, + let chatTab = chatTabPool.getTab(of: selectedTabId) as? ConversationTab else { + return nil } + return chatTab } } @@ -465,7 +510,7 @@ struct CreateOtherChatTabMenuStyle: MenuStyle { func makeBody(configuration: Configuration) -> some View { Image(systemName: "chevron.down") .resizable() - .frame(width: 7, height: 4) + .scaledFrame(width: 7, height: 4) .frame(maxHeight: .infinity) .padding(.leading, 4) .padding(.trailing, 8) @@ -499,7 +544,7 @@ struct ChatWindowView_Previews: PreviewProvider { .init(id: "7", title: "Empty-7", workspacePath: "path", username: "username"), ] as IdentifiedArray, selectedTabId: "2" - ) + ) { _ in } ] as IdentifiedArray, selectedWorkspacePath: "activeWorkspacePath", selectedWorkspaceName: "activeWorkspacePath" diff --git a/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift new file mode 100644 index 00000000..1a64a7dc --- /dev/null +++ b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift @@ -0,0 +1,448 @@ +import SwiftUI +import Combine +import XcodeInspector +import ComposableArchitecture +import ConversationServiceProvider +import LanguageServerProtocol +import ChatService +import SharedUIComponents +import ConversationTab + +private typealias CodeReviewPanelViewStore = ViewStore + +private struct ViewState: Equatable { + let reviewComments: [ReviewComment] + let currentSelectedComment: ReviewComment? + let currentIndex: Int + let operatedCommentIds: Set + var hasNextComment: Bool + var hasPreviousComment: Bool + + var commentsCount: Int { reviewComments.count } + + init(state: CodeReviewPanelFeature.State) { + self.reviewComments = state.currentDocumentReview?.comments ?? [] + self.currentSelectedComment = state.currentSelectedComment + self.currentIndex = state.currentIndex + self.operatedCommentIds = state.operatedCommentIds + self.hasNextComment = state.hasNextComment + self.hasPreviousComment = state.hasPreviousComment + } +} + +struct CodeReviewPanelView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: ViewState.init) { viewStore in + WithPerceptionTracking { + VStack(spacing: 0) { + VStack(spacing: 0) { + HeaderView(viewStore: viewStore) + .padding(.bottom, 4) + + Divider() + + ContentView( + comment: viewStore.currentSelectedComment, + viewStore: viewStore + ) + .padding(.top, 16) + } + .padding(.vertical, 10) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: Style.codeReviewPanelHeight, alignment: .top) + .fixedSize(horizontal: false, vertical: true) + .xcodeStyleFrame(cornerRadius: 10) + .onAppear { viewStore.send(.appear) } + + Spacer() + } + } + } + } +} + +// MARK: - Header View +private struct HeaderView: View { + let viewStore: CodeReviewPanelViewStore + + var body: some View { + HStack(alignment: .center, spacing: 8) { + ZStack { + Circle() + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + .frame(width: 24, height: 24) + + Image("CopilotLogo") + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: 12, height: 12) + } + + Text("Code Review Comment") + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + + if viewStore.commentsCount > 0 { + Text("(\(viewStore.currentIndex + 1) of \(viewStore.commentsCount))") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + + Spacer() + + NavigationControls(viewStore: viewStore) + } + .fixedSize(horizontal: false, vertical: true) + } +} + +// MARK: - Navigation Controls +private struct NavigationControls: View { + let viewStore: CodeReviewPanelViewStore + + var body: some View { + HStack(spacing: 4) { + if viewStore.hasPreviousComment { + Button(action: { + viewStore.send(.previous) + }) { + Image(systemName: "arrow.up") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 13, height: 13) + } + .buttonStyle(HoverButtonStyle()) + .buttonStyle(PlainButtonStyle()) + .help("Previous") + } + + if viewStore.hasNextComment { + Button(action: { + viewStore.send(.next) + }) { + Image(systemName: "arrow.down") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 13, height: 13) + } + .buttonStyle(HoverButtonStyle()) + .buttonStyle(PlainButtonStyle()) + .help("Next") + } + + Button(action: { + if let id = viewStore.currentSelectedComment?.id { + viewStore.send(.close(commentId: id)) + } + }) { + Image(systemName: "xmark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 13, height: 13) + } + .buttonStyle(HoverButtonStyle()) + .buttonStyle(PlainButtonStyle()) + .help("Close") + } + } +} + +// MARK: - Content View +private struct ContentView: View { + let comment: ReviewComment? + let viewStore: CodeReviewPanelViewStore + + var body: some View { + if let comment = comment { + CommentDetailView(comment: comment, viewStore: viewStore) + } else { + EmptyView() + } + } +} + +// MARK: - Comment Detail View +private struct CommentDetailView: View { + let comment: ReviewComment + let viewStore: CodeReviewPanelViewStore + @AppStorage(\.chatFontSize) var chatFontSize + + var lineInfoContent: String { + let displayStartLine = comment.range.start.line + 1 + let displayEndLine = comment.range.end.line + 1 + + if displayStartLine == displayEndLine { + return "Line \(displayStartLine)" + } else { + return "Line \(displayStartLine)-\(displayEndLine)" + } + } + + var lineInfoView: some View { + Text(lineInfoContent) + .font(.system(size: chatFontSize)) + } + + var kindView: some View { + Text(comment.kind) + .font(.system(size: chatFontSize)) + .padding(.horizontal, 6) + .frame(maxHeight: 20) + .background( + RoundedRectangle(cornerRadius: 4) + .foregroundColor(.hoverColor) + ) + } + + var messageView: some View { + ScrollView { + ThemedMarkdownText( + text: comment.message, + context: .init(supportInsert: false) + ) + } + } + + var dismissButton: some View { + Button(action: { + viewStore.send(.dismiss(commentId: comment.id)) + }) { + Text("Dismiss") + } + .buttonStyle(.bordered) + .foregroundColor(.primary) + .help("Dismiss") + } + + var acceptButton: some View { + Button(action: { + viewStore.send(.accept(commentId: comment.id)) + }) { + Text("Accept") + } + .buttonStyle(.borderedProminent) + .help("Accept") + } + + private var fileURL: URL? { + URL(string: comment.uri) + } + + var fileNameView: some View { + HStack(spacing: 8) { + drawFileIcon(fileURL) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + + Text(fileURL?.lastPathComponent ?? comment.uri) + .fontWeight(.semibold) + .lineLimit(1) + .truncationMode(.middle) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Compact header with range info and badges in one line + HStack(alignment: .center, spacing: 8) { + fileNameView + + Spacer() + + lineInfoView + + kindView + } + + messageView + .frame(maxHeight: 100) + .fixedSize(horizontal: false, vertical: true) + + // Add suggested change view if suggestion exists + if let suggestion = comment.suggestion, + !suggestion.isEmpty, + let fileUrl = URL(string: comment.uri), + let content = try? String(contentsOf: fileUrl) + { + SuggestedChangeView( + suggestion: suggestion, + content: content, + range: comment.range, + chatFontSize: chatFontSize + ) + + if !viewStore.operatedCommentIds.contains(comment.id) { + HStack(spacing: 9) { + Spacer() + + dismissButton + + acceptButton + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Suggested Change View +private struct SuggestedChangeView: View { + let suggestion: String + let content: String + let range: LSPRange + let chatFontSize: CGFloat + + struct DiffLine { + let content: String + let lineNumber: Int + let type: DiffLineType + } + + enum DiffLineType { + case removed + case added + } + + var diffLines: [DiffLine] { + var lines: [DiffLine] = [] + + // Add removed lines + let contentLines = content.components(separatedBy: .newlines) + if range.start.line >= 0 && range.end.line < contentLines.count { + let removedLines = Array(contentLines[range.start.line...range.end.line]) + for (index, lineContent) in removedLines.enumerated() { + lines.append(DiffLine( + content: lineContent, + lineNumber: range.start.line + index + 1, + type: .removed + )) + } + } + + // Add suggested lines + let suggestionLines = suggestion.components(separatedBy: .newlines) + for (index, lineContent) in suggestionLines.enumerated() { + lines.append(DiffLine( + content: lineContent, + lineNumber: range.start.line + index + 1, + type: .added + )) + } + + return lines + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Suggested change") + .font(.system(size: chatFontSize, weight: .regular)) + .foregroundColor(.secondary) + + Spacer() + } + .padding(.leading, 8) + .padding(.vertical, 6) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(NSColor.separatorColor), lineWidth: 0.5) + ) + + Rectangle() + .fill(.ultraThickMaterial) + .frame(height: 1) + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(diffLines.indices, id: \.self) { index in + DiffLineView( + line: diffLines[index], + chatFontSize: chatFontSize + ) + } + } + } + .frame(maxHeight: 150) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(.ultraThickMaterial) + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } +} + +// MARK: - Diff Line View +private struct DiffLineView: View { + let line: SuggestedChangeView.DiffLine + let chatFontSize: CGFloat + @State private var contentHeight: CGFloat = 0 + + private var backgroundColor: SwiftUICore.Color { + switch line.type { + case .removed: + return Color("editorOverviewRuler.inlineChatRemoved") + case .added: + return Color("editor.focusedStackFrameHighlightBackground") + } + } + + private var lineNumberBackgroundColor: SwiftUICore.Color { + switch line.type { + case .removed: + return Color("gitDecoration.deletedResourceForeground") + case .added: + return Color("gitDecoration.addedResourceForeground") + } + } + + private var prefix: String { + switch line.type { + case .removed: + return "-" + case .added: + return "+" + } + } + + var body: some View { + HStack(spacing: 0) { + HStack(alignment: .top, spacing: 0) { + HStack(spacing: 4) { + Text("\(line.lineNumber)") + Text(prefix) + } + } + .font(.system(size: chatFontSize)) + .foregroundColor(.white) + .frame(width: 60, height: contentHeight) // TODO: dynamic set height by font size + .background(lineNumberBackgroundColor) + + // Content section with text wrapping + VStack(alignment: .leading) { + Text(line.content) + .font(.system(size: chatFontSize)) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .padding(.vertical, 4) + .padding(.leading, 8) + .background(backgroundColor) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { contentHeight = geometry.size.height } + } + ) + } + } +} diff --git a/Core/Sources/SuggestionWidget/Extensions/Helper.swift b/Core/Sources/SuggestionWidget/Extensions/Helper.swift new file mode 100644 index 00000000..06b55832 --- /dev/null +++ b/Core/Sources/SuggestionWidget/Extensions/Helper.swift @@ -0,0 +1,36 @@ +import AppKit + +struct LocationStrategyHelper { + + /// `lineNumber` is 0-based + static func getLineFrame(_ lineNumber: Int, in editor: AXUIElement, with lines: [String]) -> CGRect? { + guard editor.isSourceEditor, + lineNumber < lines.count && lineNumber >= 0 + else { + return nil + } + + var characterPosition = 0 + for i in 0.. Void public init( id: WorkspaceIdentifier, tabInfo: IdentifiedArray = [], tabCollection: [ChatTabBuilderCollection] = [], - selectedTabId: String? = nil + selectedTabId: String? = nil, + onTabInfoDeleted: @escaping (String) -> Void ) { self.id = id self.tabInfo = tabInfo self.tabCollection = tabCollection self.selectedTabId = selectedTabId + self.onTabInfoDeleted = onTabInfoDeleted + } + + /// Walkaround `Equatable` error for `onTabInfoDeleted` + public static func == (lhs: ChatWorkspace, rhs: ChatWorkspace) -> Bool { + lhs.id == rhs.id && + lhs.tabInfo == rhs.tabInfo && + lhs.tabCollection == rhs.tabCollection && + lhs.selectedTabId == rhs.selectedTabId + } + + public mutating func applyLRULimit(maxSize: Int = 5) { + guard tabInfo.count > maxSize else { return } + + // Tabs not selected + let nonSelectedTabs = Array(tabInfo.filter { $0.id != selectedTabId }) + let sortedByUpdatedAt = nonSelectedTabs.sorted { $0.updatedAt < $1.updatedAt } + + let tabsToRemove = Array(sortedByUpdatedAt.prefix(tabInfo.count - maxSize)) + + // Remove Tabs + for tab in tabsToRemove { + // destroy tab + onTabInfoDeleted(tab.id) + + // remove from workspace + tabInfo.remove(id: tab.id) + } } } @@ -135,6 +166,8 @@ public struct ChatPanelFeature { // case createNewTapButtonHovered case closeTabButtonClicked(id: String) case createNewTapButtonClicked(kind: ChatTabKind?) + case restoreTabByInfo(info: ChatTabInfo) + case createNewTabByID(id: String) case tabClicked(id: String) case appendAndSelectTab(ChatTabInfo) case appendTabToWorkspace(ChatTabInfo, ChatWorkspace) @@ -145,13 +178,19 @@ public struct ChatPanelFeature { // Chat History case chatHistoryItemClicked(id: String) - case chatHisotryDeleteButtonClicked(id: String) + case chatHistoryDeleteButtonClicked(id: String) case chatTab(id: String, action: ChatTabItem.Action) // persist case saveChatTabInfo([ChatTabInfo?], ChatWorkspace) case deleteChatTabInfo(id: String, ChatWorkspace) case restoreWorkspace(ChatWorkspace) + + case syncChatTabInfo([ChatTabInfo?]) + + // ChatWorkspace cleanup + case scheduleLRUCleanup(ChatWorkspace) + case performLRUCleanup(ChatWorkspace) } @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @@ -245,7 +284,9 @@ public struct ChatPanelFeature { state.chatHistory.currentUsername = username if state.chatHistory.currentChatWorkspace == nil { let identifier = WorkspaceIdentifier(path: path, username: username) - state.chatHistory.addWorkspace(ChatWorkspace(id: identifier)) + state.chatHistory.addWorkspace( + ChatWorkspace(id: identifier) { chatTabPool.removeTab(of: $0) } + ) } return .none case .openSettings: @@ -296,7 +337,7 @@ public struct ChatPanelFeature { state.chatHistory.updateHistory(currentChatWorkspace) return .none - case let .chatHisotryDeleteButtonClicked(id): + case let .chatHistoryDeleteButtonClicked(id): // the current chat should not be deleted guard var currentChatWorkspace = state.currentChatWorkspace, id != currentChatWorkspace.selectedTabId else { return .none @@ -314,7 +355,13 @@ public struct ChatPanelFeature { // return .none case .createNewTapButtonClicked: - return .none // handled elsewhere + return .none // handled in GUI Reducer + + case .restoreTabByInfo(_): + return .none // handled in GUI Reducer + + case .createNewTabByID(_): + return .none // handled in GUI Reducer case let .tabClicked(id): guard var currentChatWorkspace = state.currentChatWorkspace, @@ -330,31 +377,53 @@ 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): guard var chatWorkspace = state.currentChatWorkspace, - var chatTabInfo = chatWorkspace.tabInfo.first(where: { $0.id == id }), // No Need to swicth selected Tab when already selected id != chatWorkspace.selectedTabId - else { -// state.chatGroupCollection.selectedChatGroup?.selectedTabId = nil - return .none + else { return .none } + + // Try to find the tab in three places: + // 1. In current workspace's open tabs + let existingTab = chatWorkspace.tabInfo.first(where: { $0.id == id }) + + // 2. In persistent storage + let storedTab = existingTab == nil + ? ChatTabInfoStore.getByID(id, with: .init(workspacePath: chatWorkspace.workspacePath, username: chatWorkspace.username)) + : nil + + if var tabInfo = existingTab ?? storedTab { + // Tab found in workspace or storage - switch to it + let (originalTab, currentTab) = chatWorkspace.switchTab(to: &tabInfo) + state.chatHistory.updateHistory(chatWorkspace) + + let workspace = chatWorkspace + let info = tabInfo + return .run { send in + // For stored tabs that aren't in the workspace yet, restore them first + if storedTab != nil { + await send(.restoreTabByInfo(info: info)) + } + + // as converstaion tab is lazy restore + // should restore tab when switching + if let chatTab = chatTabPool.getTab(of: id), + let conversationTab = chatTab as? ConversationTab { + await conversationTab.restoreIfNeeded() + } + + await send(.saveChatTabInfo([originalTab, currentTab], workspace)) + + await send(.syncChatTabInfo([originalTab, currentTab])) + } } - let (originalTab, currentTab) = chatWorkspace.switchTab(to: &chatTabInfo) - state.chatHistory.updateHistory(chatWorkspace) - let currentChatWorkspace = chatWorkspace + // 3. Tab not found - create a new one return .run { send in - // as converstaion tab is lazy restore - // should restore tab when switching - if let chatTab = chatTabPool.getTab(of: id), - let conversationTab = chatTab as? ConversationTab { - await conversationTab.restoreIfNeeded() - } - - await send(.focusActiveChatTab) - await send(.saveChatTabInfo([originalTab, currentTab], currentChatWorkspace)) + await send(.createNewTabByID(id: id)) } case var .appendAndSelectTab(tab): @@ -370,6 +439,8 @@ public struct ChatPanelFeature { return .run { send in 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 }) @@ -379,9 +450,11 @@ public struct ChatPanelFeature { let (originalTab, currentTab) = targetWorkspace.switchTab(to: &tab) state.chatHistory.updateHistory(targetWorkspace) - let currentChatWorkspace = chatWorkspace + let currentChatWorkspace = targetWorkspace return .run { send in await send(.saveChatTabInfo([originalTab, currentTab], currentChatWorkspace)) + await send(.scheduleLRUCleanup(currentChatWorkspace)) + await send(.syncChatTabInfo([originalTab, currentTab])) } // case .switchToNextTab: @@ -499,8 +572,11 @@ public struct ChatPanelFeature { let workspacePath = chatWorkspace.workspacePath let username = chatWorkspace.username - ChatTabInfoStore.saveAll(toSaveInfo, with: .init(workspacePath: workspacePath, username: username)) - return .none + return .run { _ in + Task(priority: .background) { + ChatTabInfoStore.saveAll(toSaveInfo, with: .init(workspacePath: workspacePath, username: username)) + } + } case let .deleteChatTabInfo(id, chatWorkspace): let workspacePath = chatWorkspace.workspacePath @@ -525,21 +601,46 @@ public struct ChatPanelFeature { state.chatHistory.updateHistory(existChatWorkspace) let chatTabInfo = selectedChatTabInfo - let workspace = chatWorkspace + let workspace = existChatWorkspace return .run { send in // update chat tab info await send(.saveChatTabInfo([chatTabInfo], workspace)) + await send(.scheduleLRUCleanup(workspace)) } } // merge tab info existChatWorkspace.tabInfo.append(contentsOf: chatWorkspace.tabInfo) state.chatHistory.updateHistory(existChatWorkspace) - return .none + + let workspace = existChatWorkspace + return .run { send in + await send(.scheduleLRUCleanup(workspace)) + } } state.chatHistory.addWorkspace(chatWorkspace) return .none + + 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 + await send(.performLRUCleanup(chatWorkspace)) + }.cancellable(id: "lru-cleanup-\(chatWorkspace.id)", cancelInFlight: true) // apply built-in race condition prevention + + case .performLRUCleanup(var chatWorkspace): + chatWorkspace.applyLRULimit() + state.chatHistory.updateHistory(chatWorkspace) + return .none } } // .forEach(\.chatGroupCollection.selectedChatGroup?.tabInfo, action: /Action.chatTab) { @@ -548,6 +649,16 @@ public struct ChatPanelFeature { } } +extension ChatPanelFeature { + + func restoreConversationTabIfNeeded(_ id: String) async { + if let chatTab = chatTabPool.getTab(of: id), + let conversationTab = chatTab as? ConversationTab { + await conversationTab.restoreIfNeeded() + } + } +} + extension ChatWorkspace { public mutating func switchTab(to chatTabInfo: inout ChatTabInfo) -> (originalTab: ChatTabInfo?, currentTab: ChatTabInfo) { guard self.selectedTabId != chatTabInfo.id else { return (nil, chatTabInfo) } @@ -564,7 +675,12 @@ extension ChatWorkspace { chatTabInfo.isSelected = true // update tab back to chatWorkspace + let isNewTab = self.tabInfo[id: chatTabInfo.id] == nil self.tabInfo[id: chatTabInfo.id] = chatTabInfo + if isNewTab { + applyLRULimit() + } + if let originalTabInfo { self.tabInfo[id: originalTabInfo.id] = originalTabInfo } diff --git a/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/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index e0af56cb..16d0041d 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 @@ -111,6 +119,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 +148,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 { @@ -399,6 +417,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/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index c2720772..ee6d05ca 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -6,6 +6,7 @@ import SwiftUI enum Style { static let panelHeight: Double = 560 static let panelWidth: Double = 504 + static let minChatPanelWidth: Double = 242 // Following the minimal width of Navigator in Xcode static let inlineSuggestionMaxHeight: Double = 400 static let inlineSuggestionPadding: Double = 25 static let widgetHeight: Double = 20 @@ -13,6 +14,9 @@ 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 } extension Color { diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift index b5791d17..2ef813dc 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedUIComponents struct ErrorPanel: View { var description: String @@ -16,6 +17,7 @@ struct ErrorPanel: View { // close button Button(action: onCloseButtonTap) { Image(systemName: "xmark") + .scaledFont(.body) .padding([.leading, .bottom], 16) .padding([.top, .trailing], 8) .foregroundColor(.white) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift index f6c429c2..d5a1719e 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift @@ -1,6 +1,7 @@ import SwiftUI import SharedUIComponents import XcodeInspector +import ComposableArchitecture struct WarningPanel: View { let message: String @@ -17,62 +18,64 @@ struct WarningPanel: View { } var body: some View { - if !isDismissedUntilRelaunch { - HStack(spacing: 12) { - HStack(spacing: 8) { - Image("CopilotLogo") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(.primary) - .frame(width: 14, height: 14) + WithPerceptionTracking { + if !isDismissedUntilRelaunch { + HStack(spacing: 12) { + HStack(spacing: 8) { + Image("CopilotLogo") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(.primary) + .scaledFrame(width: 14, height: 14) + + Text("Monthly completion limit reached.") + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineLimit(1) + } + .padding(.horizontal, 9) + .background( + Capsule() + .fill(foregroundColor.opacity(0.1)) + .frame(height: 17) + ) + .fixedSize() - Text("Monthly completion limit reached.") - .font(.system(size: 12)) - .foregroundColor(.primary) - .lineLimit(1) - } - .padding(.horizontal, 9) - .background( - Capsule() - .fill(foregroundColor.opacity(0.1)) - .frame(height: 17) - ) - .fixedSize() - - HStack(spacing: 8) { - if let url = url { - Button("Upgrade Now") { - NSWorkspace.shared.open(URL(string: url)!) + HStack(spacing: 8) { + if let url = url { + Button("Upgrade Now") { + NSWorkspace.shared.open(URL(string: url)!) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color(nsColor: .controlAccentColor)) + .foregroundColor(Color(nsColor: .white)) + .cornerRadius(6) + .font(.system(size: 12)) + .fixedSize() + } + + Button("Dismiss") { + isDismissedUntilRelaunch = true + onDismiss() } - .buttonStyle(.plain) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(Color(nsColor: .controlAccentColor)) - .foregroundColor(Color(nsColor: .white)) - .cornerRadius(6) + .buttonStyle(.bordered) .font(.system(size: 12)) + .keyboardShortcut(.escape, modifiers: []) .fixedSize() } - - Button("Dismiss") { - isDismissedUntilRelaunch = true - onDismiss() - } - .buttonStyle(.bordered) - .font(.system(size: 12)) - .keyboardShortcut(.escape, modifiers: []) - .fixedSize() } - } - .padding(.top, 24) - .padding( - .leading, - firstLineIndent + 20 + CGFloat( - cursorPositionTracker.cursorPosition.character + .padding(.top, 24) + .padding( + .leading, + firstLineIndent + 20 + CGFloat( + cursorPositionTracker.cursorPosition.character + ) ) - ) - .background(.clear) + .background(.clear) + } } } } diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index a7dcae3f..45ef1aeb 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -1,5 +1,7 @@ import AppKit import Foundation +import XcodeInspector +import ConversationServiceProvider public struct WidgetLocation: Equatable { struct PanelLocation: Equatable { @@ -319,14 +321,92 @@ enum UpdateLocationStrategy { return selectionFrame } - static func getChatPanelFrame(_ screen: NSScreen) -> CGRect { + static func getChatPanelFrame(_ screen: NSScreen? = nil) -> CGRect { + let screen = screen ?? NSScreen.main ?? NSScreen.screens.first! + let visibleScreenFrame = screen.visibleFrame - // avoid too wide + + // Default Frame let width = min(Style.panelWidth, visibleScreenFrame.width * 0.3) let height = visibleScreenFrame.height - let x = visibleScreenFrame.width - width - - return CGRect(x: x, y: visibleScreenFrame.height, width: width, height: height) + let x = visibleScreenFrame.maxX - width + let y = visibleScreenFrame.minY + + return CGRect(x: x, y: y, width: width, height: height) + } + + static func getAttachedChatPanelFrame(_ screen: NSScreen, workspaceWindowElement: AXUIElement) -> CGRect { + guard let xcodeScreen = workspaceWindowElement.maxIntersectionScreen, + let xcodeRect = workspaceWindowElement.rect, + let mainDisplayScreen = NSScreen.screens.first(where: { $0.frame.origin == .zero }) + else { + return getChatPanelFrame() + } + + let minWidth = Style.minChatPanelWidth + let visibleXcodeScreenFrame = xcodeScreen.visibleFrame + + let width = max(visibleXcodeScreenFrame.maxX - xcodeRect.maxX, minWidth) + let height = xcodeRect.height + let x = visibleXcodeScreenFrame.maxX - width + + // AXUIElement coordinates: Y=0 at top-left + // NSWindow coordinates: Y=0 at bottom-left + let y = mainDisplayScreen.frame.maxY - xcodeRect.maxY + mainDisplayScreen.frame.minY + + return CGRect(x: x, y: y, width: width, height: height) } } +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) + } +} diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index e21f4fb0..cb75004c 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() @@ -17,6 +18,9 @@ actor WidgetWindowsController: NSObject { nonisolated let chatTabPool: ChatTabPool var currentApplicationProcessIdentifier: pid_t? + + weak var currentXcodeApp: XcodeAppInstanceInspector? + weak var previousXcodeApp: XcodeAppInstanceInspector? var cancellable: Set = [] var observeToAppTask: Task? @@ -57,6 +61,10 @@ actor WidgetWindowsController: NSObject { }.store(in: &cancellable) xcodeInspector.$focusedEditor.sink { [weak self] editor in + Task { @MainActor [weak self] in + self?.store.send(.fixErrorPanel(.onFocusedEditorChanged(editor))) + } + guard let editor else { return } Task { [weak self] in await self?.observe(toEditor: editor) } }.store(in: &cancellable) @@ -67,12 +75,57 @@ 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() + } + + 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) + } } } @@ -84,12 +137,20 @@ private extension WidgetWindowsController { if app.isXcode { updateWindowLocation(animated: false, immediately: true) updateWindowOpacity(immediately: false) + + if let xcodeApp = app as? XcodeAppInstanceInspector { + previousXcodeApp = currentXcodeApp ?? xcodeApp + currentXcodeApp = xcodeApp + } + } else { updateWindowOpacity(immediately: true) updateWindowLocation(animated: false, immediately: false) await hideSuggestionPanelWindow() } await adjustChatPanelWindowLevel() + + await updateFixErrorPanelWindowLocation() } guard currentApplicationProcessIdentifier != app.processIdentifier else { return } currentApplicationProcessIdentifier = app.processIdentifier @@ -142,13 +203,16 @@ private extension WidgetWindowsController { await updateWidgetsAndNotifyChangeOfEditor(immediately: false) case .mainWindowChanged: await updateWidgetsAndNotifyChangeOfEditor(immediately: false) - case .moved, - .resized, - .windowMoved, - .windowResized, - .windowMiniaturized, - .windowDeminiaturized: + case .windowMiniaturized, .windowDeminiaturized: await updateWidgets(immediately: false) + 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 @@ -166,11 +230,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() @@ -182,9 +249,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() @@ -195,6 +265,9 @@ private extension WidgetWindowsController { updateWindowLocation(animated: false, immediately: false) updateWindowOpacity(immediately: false) + await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) + + await handleFixErrorEditorNotification(notification: notification) } } } @@ -232,6 +305,19 @@ extension WidgetWindowsController { send(.panel(.hidePanel)) } + @MainActor + func hideCodeReviewWindow() { + windows.codeReviewPanelWindow.alphaValue = 0 + windows.codeReviewPanelWindow.setIsVisible(false) + } + + @MainActor + func displayCodeReviewWindow() { + windows.codeReviewPanelWindow.setIsVisible(true) + windows.codeReviewPanelWindow.alphaValue = 1 + windows.codeReviewPanelWindow.orderFrontRegardless() + } + func generateWidgetLocation() -> WidgetLocation? { // Default location when no active application/window let defaultLocation = generateDefaultLocation() @@ -339,8 +425,7 @@ extension WidgetWindowsController { // Generate a default location when no workspace is opened private func generateDefaultLocation() -> WidgetLocation { - let mainScreen = NSScreen.main ?? NSScreen.screens.first! - let chatPanelFrame = UpdateLocationStrategy.getChatPanelFrame(mainScreen) + let chatPanelFrame = UpdateLocationStrategy.getChatPanelFrame() return WidgetLocation( widgetFrame: .zero, @@ -444,6 +529,61 @@ extension WidgetWindowsController { updateWindowOpacityTask = task } + + @MainActor + func updateAttachedChatWindowLocation(_ notif: XcodeAppInstanceInspector.AXNotification? = nil) async { + guard let currentXcodeApp = (await currentXcodeApp), + let currentFocusedWindow = currentXcodeApp.appElement.focusedWindow, + let currentXcodeScreen = currentXcodeApp.appScreen, + let currentXcodeRect = currentFocusedWindow.rect, + 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 { + return + } + } + + let isAttachedToXcodeEnabled = UserDefaults.shared.value(for: \.autoAttachChatToXcode) + guard isAttachedToXcodeEnabled else { return } + + guard notif.element.isXcodeWorkspaceWindow else { return } + + let state = store.withState { $0 } + if state.chatPanelState.isPanelDisplayed && !windows.chatPanelWindow.isWindowHidden { + var frame = UpdateLocationStrategy.getAttachedChatPanelFrame( + NSScreen.main ?? NSScreen.screens.first!, + workspaceWindowElement: notif.element + ) + + let screenMaxX = currentXcodeScreen.visibleFrame.maxX + if screenMaxX - currentXcodeRect.maxX < Style.minChatPanelWidth + { + if let previousXcodeRect = (await previousXcodeApp?.appElement.focusedWindow?.rect), + screenMaxX - previousXcodeRect.maxX < Style.minChatPanelWidth + { + let isSameScreen = currentXcodeScreen.visibleFrame.intersects(windows.chatPanelWindow.frame) + // Only update y and height + frame = .init( + x: isSameScreen ? windows.chatPanelWindow.frame.minX : frame.minX, + y: frame.minY, + width: isSameScreen ? windows.chatPanelWindow.frame.width : frame.width, + height: frame.height + ) + } + } + + windows.chatPanelWindow.setFrame(frame, display: true, animate: true) + + await adjustChatPanelWindowLevel() + } + } func updateWindowLocation( animated: Bool, @@ -481,8 +621,11 @@ extension WidgetWindowsController { animate: animated ) } - - if isChatPanelDetached { + + let isAttachedToXcodeEnabled = UserDefaults.shared.value(for: \.autoAttachChatToXcode) + if isAttachedToXcodeEnabled { + // update in `updateAttachedChatWindowLocation` + } else if isChatPanelDetached { // don't update it! } else { windows.chatPanelWindow.setFrame( @@ -493,6 +636,8 @@ extension WidgetWindowsController { } await adjustChatPanelWindowLevel() + + await updateFixErrorPanelWindowLocation() } let now = Date() @@ -523,10 +668,10 @@ extension WidgetWindowsController { @MainActor func adjustChatPanelWindowLevel() async { + let window = windows.chatPanelWindow + let disableFloatOnTopWhenTheChatPanelIsDetached = UserDefaults.shared .value(for: \.disableFloatOnTopWhenTheChatPanelIsDetached) - - let window = windows.chatPanelWindow guard disableFloatOnTopWhenTheChatPanelIsDetached else { window.setFloatOnTop(true) return @@ -549,7 +694,7 @@ extension WidgetWindowsController { } else { false } - + if !floatOnTopWhenOverlapsXcode || !latestAppIsXcodeOrExtension { window.setFloatOnTop(false) } else { @@ -573,6 +718,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 { @@ -736,6 +1010,72 @@ public final class WidgetWindows { return it }() + @MainActor + lazy var codeReviewPanelWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init( + x: 0, + y: 0, + width: Style.codeReviewPanelWidth, + height: Style.codeReviewPanelHeight + ), + styleMask: .borderless, + backing: .buffered, + defer: true + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.hasShadow = true + it.level = widgetLevel(2) + it.contentView = NSHostingView( + rootView: CodeReviewPanelView( + store: store.scope( + state: \.codeReviewPanelState, + action: \.codeReviewPanel + ) + ) + ) + it.canBecomeKeyChecker = { true } + it.alphaValue = 0 + it.setIsVisible(false) + return it + }() + + @MainActor + lazy var 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 chatPanelWindow = { let it = ChatPanelWindow( @@ -794,6 +1134,7 @@ public final class WidgetWindows { toastWindow.orderFrontRegardless() sharedPanelWindow.orderFrontRegardless() suggestionPanelWindow.orderFrontRegardless() + fixErrorPanelWindow.orderFrontRegardless() if chatPanelWindow.level.rawValue > NSWindow.Level.normal.rawValue { chatPanelWindow.orderFrontRegardless() } @@ -813,4 +1154,3 @@ func widgetLevel(_ addition: Int) -> NSWindow.Level { minimumWidgetLevel = NSWindow.Level.floating.rawValue return .init(minimumWidgetLevel + addition) } - 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/chat_dark.gif b/Docs/Images/chat_dark.gif similarity index 100% rename from Docs/chat_dark.gif rename to Docs/Images/chat_dark.gif diff --git a/Docs/connect-comm-bridge-failed.png b/Docs/Images/connect-comm-bridge-failed.png similarity index 100% rename from Docs/connect-comm-bridge-failed.png rename to Docs/Images/connect-comm-bridge-failed.png diff --git a/Docs/copilot-menu_dark.png b/Docs/Images/copilot-menu_dark.png similarity index 100% rename from Docs/copilot-menu_dark.png rename to Docs/Images/copilot-menu_dark.png diff --git a/Docs/demo.gif b/Docs/Images/demo.gif similarity index 100% rename from Docs/demo.gif rename to Docs/Images/demo.gif diff --git a/Docs/device-code.png b/Docs/Images/device-code.png similarity index 100% rename from Docs/device-code.png rename to Docs/Images/device-code.png diff --git a/Docs/dmg-open.png b/Docs/Images/dmg-open.png similarity index 100% rename from Docs/dmg-open.png rename to Docs/Images/dmg-open.png diff --git a/Docs/extension-permission.png b/Docs/Images/extension-permission.png similarity index 100% rename from Docs/extension-permission.png rename to Docs/Images/extension-permission.png diff --git a/Docs/macos-download-open-confirm.png b/Docs/Images/macos-download-open-confirm.png similarity index 100% rename from Docs/macos-download-open-confirm.png rename to Docs/Images/macos-download-open-confirm.png diff --git a/Docs/signin-button.png b/Docs/Images/signin-button.png similarity index 100% rename from Docs/signin-button.png rename to Docs/Images/signin-button.png diff --git a/Docs/update-message.png b/Docs/Images/update-message.png similarity index 100% rename from Docs/update-message.png rename to Docs/Images/update-message.png diff --git a/Docs/xcode-menu.png b/Docs/Images/xcode-menu.png similarity index 100% rename from Docs/xcode-menu.png rename to Docs/Images/xcode-menu.png diff --git a/Docs/xcode-menu_dark.png b/Docs/Images/xcode-menu_dark.png similarity index 100% rename from Docs/xcode-menu_dark.png rename to Docs/Images/xcode-menu_dark.png diff --git a/Docs/PromptFiles.md b/Docs/PromptFiles.md new file mode 100644 index 00000000..79f34caf --- /dev/null +++ b/Docs/PromptFiles.md @@ -0,0 +1,78 @@ +# Use prompt files in GitHub Copilot for Xcode + +Prompt files are Markdown files that define reusable prompts for common development tasks like generating code, performing code reviews, or scaffolding project components. They are standalone prompts that you can run directly in chat, enabling the creation of a library of standardized development workflows. + +They can include task-specific guidelines or reference custom instructions to ensure consistent execution. Unlike custom instructions that apply to all requests, prompt files are triggered on-demand for specific tasks. + +> [!NOTE] +> Prompt files are currently experimental and may change in future releases. + +GitHub Copilot for Xcode currently supports workspace prompt files, which are only available within the workspace and are stored in the `.github/prompts` folder of the workspace. + +## Prompt file examples + +The following examples demonstrate how to use prompt files. For more community-contributed examples, see the [Awesome Copilot repository](https://github.com/github/awesome-copilot/tree/main). + +
+Example: generate a Swift form component + + +```markdown +--- +description: 'Generate a new Swift sheet component' +--- +Your goal is to generate a new Swift sheet component. + +Ask for the sheet name and fields if not provided. + +Requirements for the form: +* Use sheet design system components: [design-system/Sheet.md](../docs/design-system/Sheet.md) +* Always define Swift types for your sheet data +* Create previews for the component +``` + +
+ +## Prompt file format + +Prompt files are Markdown files and use the `.prompt.md` extension and have this structure: + +* **Header** (optional): YAML frontmatter + * `description`: Short description of the prompt + +* **Body**: Prompt instructions in Markdown format + + Reference other workspace files, prompt files, or instruction files by using Markdown links. Use relative paths to reference these files, and ensure that the paths are correct based on the location of the prompt file. + + +## Create a prompt file + +1. **Open Settings > Advanced > Chat Settings** + +1. To the right of "Prompt Files", click **Create** to create a new `*.prompt.md` file. + +1. Enter a name for your prompt file. + +1. Author the chat prompt by using Markdown formatting. + + Within a prompt file, reference additional workspace files as Markdown links (`[App](../App.swift)`). + + You can also reference other `.prompt.md` files to create a hierarchy of prompts. You can also reference [instructions files](CustomInstructions.md) in the same way. + +To modify or view an existing prompt file, click **Open Prompts Folder** to open the prompts file directory. + +## Use a prompt file in chat + +In the Chat view, type `/` followed by the prompt file name in the chat input field. + +This option enables you to pass additional information in the chat input field. For example, `/create-swift-sheet`. + +## Tips for defining prompt files + +* Clearly describe what the prompt should accomplish and what output format is expected. +* Provide examples of the expected input and output to guide the AI's responses. +* Use Markdown links to reference custom instructions rather than duplicating guidelines in each prompt. + +## Related resources + +* [Community contributed instructions, prompts, and chat modes](https://github.com/github/awesome-copilot) \ No newline at end of file diff --git a/Docs/welcome.png b/Docs/welcome.png deleted file mode 100644 index de2da42b..00000000 Binary files a/Docs/welcome.png and /dev/null differ diff --git a/EditorExtension/Info.plist b/EditorExtension/Info.plist index 13a9bdb6..b6fa3b60 100644 --- a/EditorExtension/Info.plist +++ b/EditorExtension/Info.plist @@ -44,5 +44,7 @@ $(TeamIdentifierPrefix) STANDARD_TELEMETRY_CHANNEL_KEY $(STANDARD_TELEMETRY_CHANNEL_KEY) + GITHUB_APP_ID + $(GITHUB_APP_ID) diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 02445af5..4dfc0da1 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -6,6 +6,7 @@ import SuggestionBasic import XcodeInspector import Logger import StatusBarItemView +import GitHubCopilotViewModel extension AppDelegate { fileprivate var statusBarMenuIdentifier: NSUserInterfaceItemIdentifier { @@ -101,13 +102,28 @@ extension AppDelegate { keyEquivalent: "" ) authStatusItem.isHidden = true - - upSellItem = NSMenuItem( - title: "", - action: #selector(openUpSellLink), - keyEquivalent: "" + + quotaItem = NSMenuItem() + quotaItem.view = QuotaView( + chat: .init( + percentRemaining: 0, + unlimited: false, + overagePermitted: false + ), + completions: .init( + percentRemaining: 0, + unlimited: false, + overagePermitted: false + ), + premiumInteractions: .init( + percentRemaining: 0, + unlimited: false, + overagePermitted: false + ), + resetDate: "", + copilotPlan: "" ) - upSellItem.isHidden = true + quotaItem.isHidden = true let openDocs = NSMenuItem( title: "View Documentation", @@ -136,7 +152,8 @@ extension AppDelegate { statusBarMenu.addItem(accountItem) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(authStatusItem) - statusBarMenu.addItem(upSellItem) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(quotaItem) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(axStatusItem) statusBarMenu.addItem(extensionStatusItem) @@ -188,6 +205,11 @@ extension AppDelegate: NSMenuDelegate { } } + Task { + await forceAuthStatusCheck() + updateStatusBarItem() + } + case xcodeInspectorDebugMenuIdentifier: let inspector = XcodeInspector.shared menu.items.removeAll() @@ -349,15 +371,8 @@ private extension AppDelegate { @objc func openUpSellLink() { Task { - let status = await Status.shared.getStatus() - if status.authStatus == AuthStatus.Status.notAuthorized { - if let url = URL(string: "https://github.com/features/copilot/plans") { - NSWorkspace.shared.open(url) - } - } else { - if let url = URL(string: "https://github.com/github-copilot/signup/copilot_individual") { - NSWorkspace.shared.open(url) - } + if let url = URL(string: "https://aka.ms/github-copilot-settings") { + NSWorkspace.shared.open(url) } } } diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index dc81bcd6..7f89e6cf 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -39,7 +39,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { var openCopilotForXcodeItem: NSMenuItem! var accountItem: NSMenuItem! var authStatusItem: NSMenuItem! - var upSellItem: NSMenuItem! + var quotaItem: NSMenuItem! var toggleCompletions: NSMenuItem! var toggleIgnoreLanguage: NSMenuItem! var openChat: NSMenuItem! @@ -239,8 +239,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let notifications = DistributedNotificationCenter.default().notifications(named: .authStatusDidChange) Task { [weak self] in for await _ in notifications { - guard let self else { return } - await self.forceAuthStatusCheck() + guard self != nil else { return } + do { + let service = try await GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let accountStatus = try await service.checkStatus() + if accountStatus == .notSignedIn { + try await GitHubCopilotService.signOutAll() + } + } catch { + Logger.service.error("Failed to watch auth status: \(error)") + } } } } @@ -248,7 +256,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func setInitialStatusBarStatus() { Task { let authStatus = await Status.shared.getAuthStatus() - if authStatus == .unknown { + if authStatus.status == .unknown { // temporarily kick off a language server instance to prime the initial auth status await forceAuthStatusCheck() } @@ -259,7 +267,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func forceAuthStatusCheck() async { do { let service = try await GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() - _ = try await service.checkStatus() + let accountStatus = try await service.checkStatus() + if accountStatus == .ok || accountStatus == .maybeOk { + let quota = try await service.checkQuota() + Logger.service.info("User quota checked successfully: \(quota)") + } } catch { Logger.service.error("Failed to read auth status: \(error)") } @@ -271,7 +283,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { action: #selector(signIntoGitHub) ) self.authStatusItem.isHidden = true - self.upSellItem.isHidden = true + self.quotaItem.isHidden = true self.toggleCompletions.isHidden = true self.toggleIgnoreLanguage.isHidden = true self.signOutItem.isHidden = true @@ -283,36 +295,61 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { action: nil, userName: status.userName ?? "" ) - if !status.clsMessage.isEmpty { - self.authStatusItem.isHidden = false + if !status.clsMessage.isEmpty { let CLSMessageSummary = getCLSMessageSummary(status.clsMessage) - self.authStatusItem.title = CLSMessageSummary.summary - - let submenu = NSMenu() - let attributedCLSErrorItem = NSMenuItem() - attributedCLSErrorItem.view = ErrorMessageView( - errorMessage: CLSMessageSummary.detail - ) - submenu.addItem(attributedCLSErrorItem) - submenu.addItem(.separator()) - submenu.addItem( - NSMenuItem( - title: "View Details on GitHub", - action: #selector(openGitHubDetailsLink), - keyEquivalent: "" + // If the quota is nil, keep the original auth status item + // Else only log the CLS error other than quota limit reached error + if CLSMessageSummary.summary == CLSMessageType.other.summary || status.quotaInfo == nil { + self.authStatusItem.isHidden = false + self.authStatusItem.title = CLSMessageSummary.summary + + let submenu = NSMenu() + let attributedCLSErrorItem = NSMenuItem() + attributedCLSErrorItem.view = ErrorMessageView( + errorMessage: CLSMessageSummary.detail ) - ) - - self.authStatusItem.submenu = submenu - self.authStatusItem.isEnabled = true - - self.upSellItem.title = "Upgrade Now" - self.upSellItem.isHidden = false - self.upSellItem.isEnabled = true + submenu.addItem(attributedCLSErrorItem) + submenu.addItem(.separator()) + submenu.addItem( + NSMenuItem( + title: "View Details on GitHub", + action: #selector(openGitHubDetailsLink), + keyEquivalent: "" + ) + ) + + self.authStatusItem.submenu = submenu + self.authStatusItem.isEnabled = true + } } else { self.authStatusItem.isHidden = true - self.upSellItem.isHidden = true } + + if let quotaInfo = status.quotaInfo, !quotaInfo.resetDate.isEmpty { + self.quotaItem.isHidden = false + self.quotaItem.view = QuotaView( + chat: .init( + percentRemaining: quotaInfo.chat.percentRemaining, + unlimited: quotaInfo.chat.unlimited, + overagePermitted: quotaInfo.chat.overagePermitted + ), + completions: .init( + percentRemaining: quotaInfo.completions.percentRemaining, + unlimited: quotaInfo.completions.unlimited, + overagePermitted: quotaInfo.completions.overagePermitted + ), + premiumInteractions: .init( + percentRemaining: quotaInfo.premiumInteractions.percentRemaining, + unlimited: quotaInfo.premiumInteractions.unlimited, + overagePermitted: quotaInfo.premiumInteractions.overagePermitted + ), + resetDate: quotaInfo.resetDate, + copilotPlan: quotaInfo.copilotPlan + ) + } else { + self.quotaItem.isHidden = true + } + self.toggleCompletions.isHidden = false self.toggleIgnoreLanguage.isHidden = false self.signOutItem.isHidden = false @@ -338,9 +375,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.authStatusItem.submenu = submenu self.authStatusItem.isEnabled = true - self.upSellItem.title = "Check Subscription Plans" - self.upSellItem.isHidden = false - self.upSellItem.isEnabled = true + self.quotaItem.isHidden = true self.toggleCompletions.isHidden = true self.toggleIgnoreLanguage.isHidden = true self.signOutItem.isHidden = false @@ -353,7 +388,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { userName: "Unknown User" ) self.authStatusItem.isHidden = true - self.upSellItem.isHidden = true + self.quotaItem.isHidden = true self.toggleCompletions.isHidden = false self.toggleIgnoreLanguage.isHidden = false self.signOutItem.isHidden = false @@ -453,6 +488,23 @@ extension NSRunningApplication { } } +enum CLSMessageType { + case chatLimitReached + case completionLimitReached + case other + + var summary: String { + switch self { + case .chatLimitReached: + return "Monthly Chat Limit Reached" + case .completionLimitReached: + return "Monthly Completion Limit Reached" + case .other: + return "CLS Error" + } + } +} + struct CLSMessage { let summary: String let detail: String @@ -467,13 +519,15 @@ func extractDateFromCLSMessage(_ message: String) -> String? { } func getCLSMessageSummary(_ message: String) -> CLSMessage { - let summary: String - if message.contains("You've reached your monthly chat messages limit") { - summary = "Monthly Chat Limit Reached" + let messageType: CLSMessageType + + if message.contains("You've reached your monthly chat messages limit") || + message.contains("You've reached your monthly chat messages quota") { + messageType = .chatLimitReached } else if message.contains("Completions limit reached") { - summary = "Monthly Completion Limit Reached" + messageType = .completionLimitReached } else { - summary = "CLS Error" + messageType = .other } let detail: String @@ -483,5 +537,5 @@ func getCLSMessageSummary(_ message: String) -> CLSMessage { detail = message } - return CLSMessage(summary: summary, detail: detail) + return CLSMessage(summary: messageType.summary, detail: detail) } diff --git a/ExtensionService/Assets.xcassets/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/MenuBarErrorIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarErrorIcon.imageset/Contents.json new file mode 100644 index 00000000..4ebbfc18 --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarErrorIcon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Status=error, Mode=dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Status=error, Mode=dark.svg b/ExtensionService/Assets.xcassets/MenuBarErrorIcon.imageset/Status=error, Mode=dark.svg similarity index 100% rename from ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Status=error, Mode=dark.svg rename to ExtensionService/Assets.xcassets/MenuBarErrorIcon.imageset/Status=error, Mode=dark.svg diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json index 4ebbfc18..c9b66241 100644 --- a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Status=error, Mode=dark.svg", + "filename" : "Status=warning, Mode=dark.svg", "idiom" : "universal" } ], diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Status=warning, Mode=dark.svg b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Status=warning, Mode=dark.svg new file mode 100644 index 00000000..6f037e5d --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Status=warning, Mode=dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/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/codeReview.imageset/Contents.json b/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json new file mode 100644 index 00000000..ddb0a503 --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "codeReview.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "codeReview 1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview 1.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview 1.svg new file mode 100644 index 00000000..44ce60ee --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview 1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview.svg new file mode 100644 index 00000000..6084e72c --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ExtensionService/Assets.xcassets/editor.focusedStackFrameHighlightBackground.colorset/Contents.json b/ExtensionService/Assets.xcassets/editor.focusedStackFrameHighlightBackground.colorset/Contents.json new file mode 100644 index 00000000..e475d8e3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/editor.focusedStackFrameHighlightBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "202", + "green" : "223", + "red" : "203" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "57", + "green" : "77", + "red" : "57" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/editorOverviewRuler.inlineChatRemoved.colorset/Contents.json b/ExtensionService/Assets.xcassets/editorOverviewRuler.inlineChatRemoved.colorset/Contents.json new file mode 100644 index 00000000..abd021c3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/editorOverviewRuler.inlineChatRemoved.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "211", + "green" : "214", + "red" : "242" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "25", + "green" : "25", + "red" : "55" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/gitDecoration.addedResourceForeground.colorset/Contents.json b/ExtensionService/Assets.xcassets/gitDecoration.addedResourceForeground.colorset/Contents.json new file mode 100644 index 00000000..a19edf2b --- /dev/null +++ b/ExtensionService/Assets.xcassets/gitDecoration.addedResourceForeground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "52", + "green" : "138", + "red" : "56" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "52", + "green" : "138", + "red" : "56" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/gitDecoration.deletedResourceForeground.colorset/Contents.json b/ExtensionService/Assets.xcassets/gitDecoration.deletedResourceForeground.colorset/Contents.json new file mode 100644 index 00000000..f8b5d709 --- /dev/null +++ b/ExtensionService/Assets.xcassets/gitDecoration.deletedResourceForeground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "57", + "green" : "78", + "red" : "199" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "57", + "green" : "78", + "red" : "199" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/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 9d6a8d72..c6b9553a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GitHub Copilot for Xcode +# GitHub Copilot for Xcode [GitHub Copilot](https://github.com/features/copilot) is an AI pair programmer tool that helps you write code faster and smarter. Copilot for Xcode is an Xcode extension that provides inline coding suggestions as you type and a chat assistant to answer your coding questions. @@ -6,12 +6,24 @@ tool that helps you write code faster and smarter. Copilot for Xcode is an Xcode ## Chat GitHub Copilot Chat provides suggestions to your specific coding tasks via chat. -Chat of GitHub Copilot for Xcode +Chat of GitHub Copilot for Xcode + +## Agent Mode + +GitHub Copilot Agent Mode provides AI-powered assistance that can understand and modify your codebase directly. With Agent Mode, you can: +- Get intelligent code edits applied directly to your files +- Run terminal commands and view their output without leaving the interface +- Search through your codebase to find relevant files and code snippets +- Create new files and directories as needed for your project +- Get assistance with enhanced context awareness across multiple files and folders +- Run Model Context Protocol (MCP) tools you configured to extend the capabilities + +Agent Mode integrates with Xcode's environment, creating a seamless development experience where Copilot can help implement features, fix bugs, and refactor code with comprehensive understanding of your project. ## Code Completion You can receive auto-complete type suggestions from GitHub Copilot either by starting to write the code you want to use, or by writing a natural language comment describing what you want the code to do. -Code Completion of GitHub Copilot for Xcode +Code Completion of GitHub Copilot for Xcode ## Requirements @@ -32,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). @@ -53,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 @@ -62,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 @@ -70,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` @@ -78,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 @@ -98,22 +110,18 @@ You can receive auto-complete type suggestions from GitHub Copilot either by sta 1. Press `tab` to accept the first line of a suggestion, hold `option` to view the full suggestion, and press `option` + `tab` to accept the full suggestion. -

- Screenshot of welcome screen -

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

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

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

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

## How to use Code Completion diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 20b05455..3a34e1c1 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,14 +1,20 @@ -### GitHub Copilot for Xcode 0.34.0 +### GitHub Copilot for Xcode 0.42.0 **🚀 Highlights** -* **New Models**: OpenAI GPT-4.1, o3 and o4-mini, Gemini 2.5 Pro are now available in the Copilot Chat model selector. +* Support for Bring Your Own Keys (BYOK) with model providers including Azure, OpenAI, Anthropic, Gemini, Groq, and OpenRouter. See [BYOK.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/BYOK.md). +* Support for custom instruction files at `.github/instructions/*.instructions.md`. See [CustomInstructions.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/CustomInstructions.md). +* Support for prompt files at `.github/prompts/*.prompt.md`. See [PromptFiles.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/PromptFiles.md). +* Default chat mode is now set to “Agent”. + **💪 Improvements** -* Switched default model to GPT-4.1 for new installations -* Enhanced model selection interface +* Use the current selection as chat context. +* Add folders as chat context. +* Shortcut to quickly fix errors in Xcode. +* Use ↑/↓ keys to reuse previous chat context in the chat view. **🛠️ Bug Fixes** -* Resolved critical error handling issues +* Cannot copy url from Safari browser to chat view. diff --git a/Server/package-lock.json b/Server/package-lock.json index f83bc5af..691f9447 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,9 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.319.0", + "@github/copilot-language-server": "^1.373.0", + "@github/copilot-language-server-darwin-arm64": "^1.373.0", + "@github/copilot-language-server-darwin-x64": "^1.373.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -21,7 +23,7 @@ "terser-webpack-plugin": "^5.3.14", "ts-loader": "^9.5.2", "typescript": "^5.8.3", - "webpack": "^5.99.8", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1" } }, @@ -36,17 +38,87 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.319.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.319.0.tgz", - "integrity": "sha512-SicoidG61WNUs/EJRglJEry6j8ZaJrKKcx/ZznDMxorVAQp7fTeNoE+fbM2lH+qgieZIt/f+pVagYePFIxsMVg==", - "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.373.0.tgz", + "integrity": "sha512-tcRyxEvm36M30x5v3u/OuPnPENZJsmbMkcY+6A45Fsr0ZtUJF7BtAS/Si/2QTCVJndA2Oi7taicIuqSDucAR/Q==", + "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" }, "bin": { "copilot-language-server": "dist/language-server.js" + }, + "optionalDependencies": { + "@github/copilot-language-server-darwin-arm64": "1.373.0", + "@github/copilot-language-server-darwin-x64": "1.373.0", + "@github/copilot-language-server-linux-arm64": "1.373.0", + "@github/copilot-language-server-linux-x64": "1.373.0", + "@github/copilot-language-server-win32-x64": "1.373.0" } }, + "node_modules/@github/copilot-language-server-darwin-arm64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.373.0.tgz", + "integrity": "sha512-pzZZnQX3jIYmQ0/LgcB54xfnbFTmCmymSL1v5OemH9qpG3Xi4ekTnRy/YRGStxHAbM5mvPX9QDJJ+/CFTvSBGg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "os": [ + "darwin" + ] + }, + "node_modules/@github/copilot-language-server-darwin-x64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.373.0.tgz", + "integrity": "sha512-1yfXy5cum7it3jUJ43ruymtj9StERUPEEY2nM9lCGgtv+Wn7ip0k2IFQvzfp/ql0FCivH0O954pqkrHO7GUYZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "os": [ + "darwin" + ] + }, + "node_modules/@github/copilot-language-server-linux-arm64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.373.0.tgz", + "integrity": "sha512-dijhk5AlP3SQuECFXEHyNlzGxV0HClWM3yP54pod8Wu3Yb6Xo5Ek9ClEiNPc1f0FOiVT3DJ0ldmtm6Tb2/2xTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@github/copilot-language-server-linux-x64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.373.0.tgz", + "integrity": "sha512-YCjhxglxPEneJUAycT90GWpNpswWsl1/RCYe7hG7lxKN6At0haE9XF/i/bisvwyqSBB9vUOFp2TB/XhwD9dQWg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@github/copilot-language-server-win32-x64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.373.0.tgz", + "integrity": "sha512-lxMIjKwVbpg2JAgo11Ddwv7i0FSgCxjC+2XG6f/3ItG8M0dRkGzJzVNl9sQaTbZPria8T4vNB9nRM0Lpe92LUA==", + "cpu": [ + "x64" + ], + "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", @@ -1953,9 +2025,9 @@ } }, "node_modules/webpack": { - "version": "5.99.8", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz", - "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", + "version": "5.99.9", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.9.tgz", + "integrity": "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/Server/package.json b/Server/package.json index 8eddbeab..a67ea506 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,9 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.319.0", + "@github/copilot-language-server": "^1.373.0", + "@github/copilot-language-server-darwin-arm64": "^1.373.0", + "@github/copilot-language-server-darwin-x64": "^1.373.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -20,7 +22,7 @@ "terser-webpack-plugin": "^5.3.14", "ts-loader": "^9.5.2", "typescript": "^5.8.3", - "webpack": "^5.99.8", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1" } } diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 4c179941..68be3768 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -60,14 +60,14 @@ GitHub Copilot for Xcode requires background permission to connect with the host

- Background Permission + Background Permission

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

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

Please ensure that this permission is enabled. You can manually navigate to the background permission setting based on your macOS version: diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 091e7fe5..a46ddf32 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -93,10 +93,6 @@ } }, { - "skippedTests" : [ - "FileChangeWatcherServiceTests\/testProjectMonitoringDetectsAddedProjects()", - "FileChangeWatcherServiceTests\/testProjectMonitoringDetectsRemovedProjects()" - ], "target" : { "containerPath" : "container:Tool", "identifier" : "WorkspaceTests", @@ -109,6 +105,13 @@ "identifier" : "ChatServiceTests", "name" : "ChatServiceTests" } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SystemUtilsTests", + "name" : "SystemUtilsTests" + } } ], "version" : 1 diff --git a/Tool/Package.swift b/Tool/Package.swift index 725385da..541990d8 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -22,6 +22,7 @@ let package = Package( .library(name: "Persist", targets: ["Persist"]), .library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]), .library(name: "Workspace", targets: ["Workspace", "WorkspaceSuggestionService"]), + .library(name: "WebContentExtractor", targets: ["WebContentExtractor"]), .library( name: "SuggestionProvider", targets: ["SuggestionProvider"] @@ -63,14 +64,16 @@ 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: "GitHelper", targets: ["GitHelper"]) ], dependencies: [ // TODO: Update LanguageClient some day. - .package(url: "https://github.com/ChimeHQ/LanguageClient", exact: "0.3.1"), - .package(url: "https://github.com/ChimeHQ/LanguageServerProtocol", exact: "0.8.0"), + .package(url: "https://github.com/ChimeHQ/LanguageClient", exact: "0.8.2"), + .package(url: "https://github.com/ChimeHQ/LanguageServerProtocol", exact: "0.13.3"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), - .package(url: "https://github.com/ChimeHQ/JSONRPC", exact: "0.6.0"), + .package(url: "https://github.com/ChimeHQ/JSONRPC", exact: "0.9.0"), .package(url: "https://github.com/devm33/Highlightr", branch: "master"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", @@ -79,18 +82,21 @@ let package = Package( .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), // TODO: remove CopilotForXcodeKit dependency once extension provider logic is removed. .package(url: "https://github.com/devm33/CopilotForXcodeKit", branch: "main"), - .package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.15.3") + .package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.15.3"), + .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.9.6") ], targets: [ // MARK: - Helpers - .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger", "Status", "HostAppActivator"]), + .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger", "Status", "HostAppActivator", "GitHubCopilotService"]), .target(name: "Configs"), .target(name: "Preferences", dependencies: ["Configs"]), - .target(name: "Terminal", dependencies: ["Logger"]), + .target(name: "Terminal", dependencies: ["Logger", "SystemUtils"]), + + .target(name: "WebContentExtractor", dependencies: ["Logger", "SwiftSoup", "Preferences"]), .target(name: "Logger"), @@ -105,10 +111,10 @@ let package = Package( .target( name: "Toast", - dependencies: [.product( - name: "ComposableArchitecture", - package: "swift-composable-architecture" - )] + dependencies: [ + "AppKitExtension", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture") + ] ), .target(name: "DebounceFunction"), @@ -134,6 +140,7 @@ let package = Package( name: "SuggestionBasic", dependencies: [ "LanguageClient", + "AXExtension", .product(name: "Parsing", package: "swift-parsing"), .product(name: "CodableWrappers", package: "CodableWrappers"), ] @@ -249,7 +256,7 @@ let package = Package( .target( name: "Status", - dependencies: ["Cache"] + dependencies: ["Cache", "Preferences"] ), .target( @@ -271,6 +278,7 @@ let package = Package( .testTarget(name: "SuggestionProviderTests", dependencies: ["SuggestionProvider"]), .target(name: "ConversationServiceProvider", dependencies: [ + "GitHelper", .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), ]), @@ -307,6 +315,7 @@ let package = Package( "Status", "SystemUtils", "Workspace", + "Persist", .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] @@ -346,7 +355,25 @@ let package = Package( // MARK: - SystemUtils - .target(name: "SystemUtils") + .target( + name: "SystemUtils", + dependencies: ["Logger"] + ), + .testTarget(name: "SystemUtilsTests", dependencies: ["SystemUtils"]), + + // MARK: - 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.swift b/Tool/Sources/AXExtension/AXUIElement.swift index f32f4d44..b7366398 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -59,6 +59,14 @@ public extension AXUIElement { var isSourceEditor: Bool { description == "Source Editor" } + + var isEditorArea: Bool { + description == "editor area" + } + + var isXcodeWorkspaceWindow: Bool { + description == "Xcode.WorkspaceWindow" || identifier == "Xcode.WorkspaceWindow" + } var selectedTextRange: ClosedRange? { guard let value: AXValue = try? copyValue(key: kAXSelectedTextRangeAttribute) @@ -237,6 +245,19 @@ public extension AXUIElement { var verticalScrollBar: AXUIElement? { try? copyValue(key: kAXVerticalScrollBarAttribute) } + + func retrieveSourceEditor() -> AXUIElement? { + if self.isSourceEditor { return self } + + if self.isXcodeWorkspaceWindow { + return self.firstChild(where: \.isSourceEditor) + } + + guard let xcodeWorkspaceWindowElement = self.firstParent(where: \.isXcodeWorkspaceWindow) + else { return nil } + + return xcodeWorkspaceWindowElement.firstChild(where: \.isSourceEditor) + } } public extension AXUIElement { @@ -313,6 +334,56 @@ public extension AXUIElement { } } +// MARK: - Xcode Specific +public extension AXUIElement { + func findSourceEditorElement(shouldRetry: Bool = true) -> AXUIElement? { + + // 1. Check if the current element is a source editor + if isSourceEditor { + return self + } + + // 2. Search for child that is a source editor + if let sourceEditorChild = firstChild(where: \.isSourceEditor) { + return sourceEditorChild + } + + // 3. Search for parent that is a source editor (XcodeInspector's approach) + if let sourceEditorParent = firstParent(where: \.isSourceEditor) { + return sourceEditorParent + } + + // 4. Search for parent that is an editor area + if let editorAreaParent = firstParent(where: \.isEditorArea) { + // 3.1 Search for child that is a source editor + if let sourceEditorChild = editorAreaParent.firstChild(where: \.isSourceEditor) { + return sourceEditorChild + } + } + + // 5. Search for the workspace window + if let xcodeWorkspaceWindowParent = firstParent(where: \.isXcodeWorkspaceWindow) { + // 4.1 Search for child that is an editor area + if let editorAreaChild = xcodeWorkspaceWindowParent.firstChild(where: \.isEditorArea) { + // 4.2 Search for child that is a source editor + if let sourceEditorChild = editorAreaChild.firstChild(where: \.isSourceEditor) { + return sourceEditorChild + } + } + } + + // 6. retry + if shouldRetry { + Thread.sleep(forTimeInterval: 0.5) + return findSourceEditorElement(shouldRetry: false) + } + + + return nil + + } +} + #if hasFeature(RetroactiveAttribute) extension AXError: @retroactive Error {} #else diff --git a/Tool/Sources/AXHelper/AXHelper.swift b/Tool/Sources/AXHelper/AXHelper.swift index c6e7405a..5af9a206 100644 --- a/Tool/Sources/AXHelper/AXHelper.swift +++ b/Tool/Sources/AXHelper/AXHelper.swift @@ -56,15 +56,43 @@ public struct AXHelper { if let oldScrollPosition, let scrollBar = focusElement.parent?.verticalScrollBar { - AXUIElementSetAttributeValue( - scrollBar, - kAXValueAttribute as CFString, - oldScrollPosition as CFTypeRef - ) + Self.setScrollBarValue(scrollBar, value: oldScrollPosition) } if let onSuccess = onSuccess { onSuccess() } } + + /// Helper method to set scroll bar value using Accessibility API + private static func setScrollBarValue(_ scrollBar: AXUIElement, value: Double) { + AXUIElementSetAttributeValue( + scrollBar, + kAXValueAttribute as CFString, + value as CFTypeRef + ) + } + + private static func getScrollPositionForLine(_ lineNumber: Int, content: String) -> Double? { + let lines = content.components(separatedBy: .newlines) + let linesCount = lines.count + + guard lineNumber > 0 && lineNumber <= linesCount + else { return nil } + + // Calculate relative position (0.0 to 1.0) + let relativePosition = Double(lineNumber - 1) / Double(linesCount - 1) + + // Ensure valid range + return (0.0 <= relativePosition && relativePosition <= 1.0) ? relativePosition : nil + } + + public static func scrollSourceEditorToLine(_ lineNumber: Int, content: String, focusedElement: AXUIElement) { + guard focusedElement.isSourceEditor, + let scrollBar = focusedElement.parent?.verticalScrollBar, + let linePosition = Self.getScrollPositionForLine(lineNumber, content: content) + else { return } + + Self.setScrollBarValue(scrollBar, value: linePosition) + } } diff --git a/Tool/Sources/AppKitExtension/NSWorkspace+Extension.swift b/Tool/Sources/AppKitExtension/NSWorkspace+Extension.swift new file mode 100644 index 00000000..46d1aa98 --- /dev/null +++ b/Tool/Sources/AppKitExtension/NSWorkspace+Extension.swift @@ -0,0 +1,51 @@ +import AppKit +import Logger + +extension NSWorkspace { + public static func getXcodeBundleURL() -> URL? { + var xcodeBundleURL: URL? + + // Get currently running Xcode application URL + if let xcodeApp = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == "com.apple.dt.Xcode" }) { + xcodeBundleURL = xcodeApp.bundleURL + } + + // Fallback to standard path if we couldn't get the running instance + if xcodeBundleURL == nil { + let standardPath = "/Applications/Xcode.app" + if FileManager.default.fileExists(atPath: standardPath) { + xcodeBundleURL = URL(fileURLWithPath: standardPath) + } + } + + return xcodeBundleURL + } + + public static func openFileInXcode( + fileURL: URL, + completion: ((NSRunningApplication?, Error?) -> Void)? = nil + ) { + guard let xcodeBundleURL = Self.getXcodeBundleURL() else { + if let completion = completion { + completion(nil, NSError(domain: "The Xcode app is not found.", code: 0)) + } + return + } + + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + configuration.promptsUserIfNeeded = false + + Self.shared.open( + [fileURL], + withApplicationAt: xcodeBundleURL, + configuration: configuration + ) { app, error in + if let completion = completion { + completion(app, error) + } else if let error = error { + Logger.client.error("Failed to open file \(String(describing: error))") + } + } + } +} diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift index aca37267..0b62e141 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -3,10 +3,25 @@ import CopilotForXcodeKit import Foundation import Logger import XcodeInspector +import Workspace public final class BuiltinExtensionConversationServiceProvider< T: BuiltinExtension >: ConversationServiceProvider { + public func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspaceURL: URL?) async throws { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return + } + + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { + Logger.service.error("Could not get active workspace info") + return + } + + try? await conversationService.notifyChangeTextDocument(fileURL: fileURL, content: content, version: version, workspace: workspaceInfo) + } + private let extensionManager: BuiltinExtensionManager @@ -21,7 +36,13 @@ public final class BuiltinExtensionConversationServiceProvider< extensionManager.extensions.first { $0 is T }?.conversationService } - private func activeWorkspace() async -> WorkspaceInfo? { + private func activeWorkspace(_ workspaceURL: URL? = nil) async -> WorkspaceInfo? { + if let workspaceURL = workspaceURL { + if let workspaceBinding = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) { + return workspaceBinding + } + } + guard let workspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL, let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL else { return nil } @@ -35,12 +56,12 @@ public final class BuiltinExtensionConversationServiceProvider< } } - public func createConversation(_ request: ConversationRequest) async throws { + public func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } @@ -48,25 +69,30 @@ public final class BuiltinExtensionConversationServiceProvider< try await conversationService.createConversation(request, workspace: workspaceInfo) } - public func createTurn(with conversationId: String, request: ConversationRequest) async throws { + public func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } - try await conversationService.createTurn(with: conversationId, request: request, workspace: workspaceInfo) + try await conversationService + .createTurn( + with: conversationId, + request: request, + workspace: workspaceInfo + ) } - public func stopReceivingMessage(_ workDoneToken: String) async throws { + public func stopReceivingMessage(_ workDoneToken: String, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } @@ -74,24 +100,24 @@ public final class BuiltinExtensionConversationServiceProvider< try await conversationService.cancelProgress(workDoneToken, workspace: workspaceInfo) } - public func rateConversation(turnId: String, rating: ConversationRating) async throws { + public func rateConversation(turnId: String, rating: ConversationRating, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } try? await conversationService.rateConversation(turnId: turnId, rating: rating, workspace: workspaceInfo) } - public func copyCode(_ request: CopyCodeRequest) async throws { + public func copyCode(_ request: CopyCodeRequest, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } @@ -145,4 +171,17 @@ public final class BuiltinExtensionConversationServiceProvider< return (try? await conversationService.agents(workspace: workspaceInfo)) } + + public func reviewChanges(_ params: ReviewChangesParams) async throws -> CodeReviewResult? { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return nil + } + guard let workspaceInfo = await activeWorkspace() else { + Logger.service.error("Could not get active workspace info") + return nil + } + + return (try? await conversationService.reviewChanges(workspace: workspaceInfo, params: params)) + } } diff --git a/Tool/Sources/ChatAPIService/APIs/ChatCompletionsAPIDefinition.swift b/Tool/Sources/ChatAPIService/APIs/ChatCompletionsAPIDefinition.swift index 2b7dede5..165ea645 100644 --- a/Tool/Sources/ChatAPIService/APIs/ChatCompletionsAPIDefinition.swift +++ b/Tool/Sources/ChatAPIService/APIs/ChatCompletionsAPIDefinition.swift @@ -5,14 +5,11 @@ import Preferences struct ChatCompletionsRequestBody: Codable, Equatable { struct Message: Codable, Equatable { enum Role: String, Codable, Equatable { - case system case user case assistant var asChatMessageRole: ChatMessage.Role { switch self { - case .system: - return .system case .user: return .user case .assistant: diff --git a/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift index 556e008c..5460fb00 100644 --- a/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift @@ -63,6 +63,13 @@ public actor AutoManagedChatMemory: ChatMemory { contextSystemPrompt = "" self.composeHistory = composeHistory } + + deinit { + history.removeAll() + onHistoryChange = {} + + retrievedContent.removeAll() + } public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) { update(&history) diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift index 23691491..9bcbcf97 100644 --- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift @@ -1,4 +1,5 @@ import Foundation +import ConversationServiceProvider public protocol ChatMemory { /// The message history. @@ -13,7 +14,6 @@ public extension ChatMemory { await mutateHistory { history in if let index = history.firstIndex(where: { $0.id == message.id }) { history[index].mergeMessage(with: message) - } else { history.append(message) } @@ -50,9 +50,9 @@ extension ChatMessage { self.suggestedTitle = message.suggestedTitle ?? self.suggestedTitle // merge error message - if let errorMessage = message.errorMessage { - self.errorMessage = (self.errorMessage ?? "") + errorMessage - } + self.errorMessages = self.errorMessages + message.errorMessages + + self.panelMessages = self.panelMessages + message.panelMessages // merge steps if !message.steps.isEmpty { @@ -71,38 +71,49 @@ extension ChatMessage { // merge agent steps if !message.editAgentRounds.isEmpty { - var mergedAgentRounds = self.editAgentRounds + let mergedAgentRounds = mergeEditAgentRounds( + oldRounds: self.editAgentRounds, + newRounds: message.editAgentRounds + ) - for newRound in message.editAgentRounds { - if let index = mergedAgentRounds.firstIndex(where: { $0.roundId == newRound.roundId }) { - mergedAgentRounds[index].reply = mergedAgentRounds[index].reply + newRound.reply - - if newRound.toolCalls != nil, !newRound.toolCalls!.isEmpty { - var mergedToolCalls = mergedAgentRounds[index].toolCalls ?? [] - for newToolCall in newRound.toolCalls! { - if let toolCallIndex = mergedToolCalls.firstIndex(where: { $0.id == newToolCall.id }) { - mergedToolCalls[toolCallIndex].status = newToolCall.status - if let progressMessage = newToolCall.progressMessage, !progressMessage.isEmpty { - mergedToolCalls[toolCallIndex].progressMessage = newToolCall.progressMessage - } - if let error = newToolCall.error, !error.isEmpty { - mergedToolCalls[toolCallIndex].error = newToolCall.error - } - if let invokeParams = newToolCall.invokeParams { - mergedToolCalls[toolCallIndex].invokeParams = invokeParams - } - } else { - mergedToolCalls.append(newToolCall) + self.editAgentRounds = mergedAgentRounds + } + + self.codeReviewRound = message.codeReviewRound + } + + private func mergeEditAgentRounds(oldRounds: [AgentRound], newRounds: [AgentRound]) -> [AgentRound] { + var mergedAgentRounds = oldRounds + + for newRound in newRounds { + if let index = mergedAgentRounds.firstIndex(where: { $0.roundId == newRound.roundId }) { + mergedAgentRounds[index].reply = mergedAgentRounds[index].reply + newRound.reply + + if newRound.toolCalls != nil, !newRound.toolCalls!.isEmpty { + var mergedToolCalls = mergedAgentRounds[index].toolCalls ?? [] + for newToolCall in newRound.toolCalls! { + if let toolCallIndex = mergedToolCalls.firstIndex(where: { $0.id == newToolCall.id }) { + mergedToolCalls[toolCallIndex].status = newToolCall.status + if let progressMessage = newToolCall.progressMessage, !progressMessage.isEmpty { + mergedToolCalls[toolCallIndex].progressMessage = newToolCall.progressMessage + } + if let error = newToolCall.error, !error.isEmpty { + mergedToolCalls[toolCallIndex].error = newToolCall.error } + if let invokeParams = newToolCall.invokeParams { + mergedToolCalls[toolCallIndex].invokeParams = invokeParams + } + } else { + mergedToolCalls.append(newToolCall) } - mergedAgentRounds[index].toolCalls = mergedToolCalls } - } else { - mergedAgentRounds.append(newRound) + mergedAgentRounds[index].toolCalls = mergedToolCalls } + } else { + mergedAgentRounds.append(newRound) } - - self.editAgentRounds = mergedAgentRounds } + + return mergedAgentRounds } } diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index 0cd4bbb8..0da9335c 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -21,18 +21,23 @@ public struct ConversationReference: Codable, Equatable, Hashable { case webpage case other // reference for turn - request - case fileReference(FileReference) + case fileReference(ConversationAttachedReference) // reference from turn - response - case reference(Reference) + case reference(FileReference) } public enum Status: String, Codable { case included, blocked, notfound, empty } + + public enum ReferenceType: String, Codable { + case file, directory + } public var uri: String public var status: Status? public var kind: Kind + public var referenceType: ReferenceType public var ext: String { return url?.pathExtension ?? "" @@ -49,16 +54,19 @@ public struct ConversationReference: Codable, Equatable, Hashable { public var url: URL? { return URL(string: uri) } + + public var isDirectory: Bool { referenceType == .directory } public init( uri: String, status: Status?, - kind: Kind + kind: Kind, + referenceType: ReferenceType = .file ) { self.uri = uri self.status = status self.kind = kind - + self.referenceType = referenceType } } @@ -67,9 +75,9 @@ public struct ChatMessage: Equatable, Codable { public typealias ID = String public enum Role: String, Codable, Equatable { - case system case user case assistant + case system } /// The role of a message. @@ -77,6 +85,9 @@ public struct ChatMessage: Equatable, Codable { /// The content of the message, either the chat message, or a result of a function call. public var content: String + + /// The attached image content of the message + public var contentImageReferences: [ImageReference] /// The id of the message. public var id: ID @@ -99,13 +110,17 @@ public struct ChatMessage: Equatable, Codable { public var suggestedTitle: String? /// The error occurred during responding chat in server - public var errorMessage: String? + public var errorMessages: [String] /// The steps of conversation progress public var steps: [ConversationProgressStep] public var editAgentRounds: [AgentRound] + public var panelMessages: [CopilotShowMessageParams] + + public var codeReviewRound: CodeReviewRound? + /// The timestamp of the message. public var createdAt: Date public var updatedAt: Date @@ -116,33 +131,99 @@ public struct ChatMessage: Equatable, Codable { clsTurnID: String? = nil, role: Role, content: String, + contentImageReferences: [ImageReference] = [], references: [ConversationReference] = [], followUp: ConversationFollowUp? = nil, suggestedTitle: String? = nil, - errorMessage: String? = nil, + errorMessages: [String] = [], rating: ConversationRating = .unrated, steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], + panelMessages: [CopilotShowMessageParams] = [], + codeReviewRound: CodeReviewRound? = nil, createdAt: Date? = nil, updatedAt: Date? = nil ) { self.role = role self.content = content + self.contentImageReferences = contentImageReferences self.id = id self.chatTabID = chatTabID self.clsTurnID = clsTurnID self.references = references self.followUp = followUp self.suggestedTitle = suggestedTitle - self.errorMessage = errorMessage + self.errorMessages = errorMessages self.rating = rating self.steps = steps self.editAgentRounds = editAgentRounds + self.panelMessages = panelMessages + self.codeReviewRound = codeReviewRound let now = Date.now self.createdAt = createdAt ?? now self.updatedAt = updatedAt ?? now } + + public init( + userMessageWithId id: String, + chatTabId: String, + content: String, + contentImageReferences: [ImageReference] = [], + references: [ConversationReference] = [] + ) { + self.init( + id: id, + chatTabID: chatTabId, + role: .user, + content: content, + contentImageReferences: contentImageReferences, + references: references + ) + } + + public init( + assistantMessageWithId id: String, // TurnId + chatTabID: String, + content: String = "", + references: [ConversationReference] = [], + followUp: ConversationFollowUp? = nil, + suggestedTitle: String? = nil, + steps: [ConversationProgressStep] = [], + editAgentRounds: [AgentRound] = [], + codeReviewRound: CodeReviewRound? = nil + ) { + self.init( + id: id, + chatTabID: chatTabID, + clsTurnID: id, + role: .assistant, + content: content, + references: references, + followUp: followUp, + suggestedTitle: suggestedTitle, + steps: steps, + editAgentRounds: editAgentRounds, + codeReviewRound: codeReviewRound + ) + } + + public init( + errorMessageWithId id: String, // TurnId + chatTabID: String, + errorMessages: [String] = [], + panelMessages: [CopilotShowMessageParams] = [] + ) { + self.init( + id: id, + chatTabID: chatTabID, + clsTurnID: id, + role: .assistant, + content: "", + errorMessages: errorMessages, + panelMessages: panelMessages + ) + } } extension ConversationReference { diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 54bc5781..0612cca5 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -2,6 +2,21 @@ import ComposableArchitecture import Foundation import SwiftUI +/// Preview info used in ChatHistoryView +public struct ChatTabPreviewInfo: Identifiable, Equatable, Codable { + public let id: String + public let title: String? + public let isSelected: Bool + public let updatedAt: Date + + public init(id: String, title: String?, isSelected: Bool, updatedAt: Date) { + self.id = id + self.title = title + self.isSelected = isSelected + self.updatedAt = updatedAt + } +} + /// The information of a tab. @ObservableState public struct ChatTabInfo: Identifiable, Equatable, Codable { diff --git a/Tool/Sources/ChatTab/ChatTabPool.swift b/Tool/Sources/ChatTab/ChatTabPool.swift index 6a3769d1..116070fd 100644 --- a/Tool/Sources/ChatTab/ChatTabPool.swift +++ b/Tool/Sources/ChatTab/ChatTabPool.swift @@ -27,6 +27,8 @@ public final class ChatTabPool { } public func removeTab(of id: String) { + guard getTab(of: id) != nil else { return } + pool.removeValue(forKey: id) } } diff --git a/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift b/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift new file mode 100644 index 00000000..d9e2c7e9 --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift @@ -0,0 +1,154 @@ +import Foundation +import LanguageServerProtocol +import GitHelper + +public struct CodeReviewRequest: Equatable, Codable { + public struct FileChange: Equatable, Codable { + public let changes: [PRChange] + public var selectedChanges: [PRChange] + + public init(changes: [PRChange]) { + self.changes = changes + self.selectedChanges = changes + } + } + + public var fileChange: FileChange + + public var changedFileUris: [DocumentUri] { fileChange.changes.map { $0.uri } } + public var selectedFileUris: [DocumentUri] { fileChange.selectedChanges.map { $0.uri } } + + public init(fileChange: FileChange) { + self.fileChange = fileChange + } + + public static func from(_ changes: [PRChange]) -> CodeReviewRequest { + return .init(fileChange: .init(changes: changes)) + } + + public mutating func updateSelectedChanges(by fileUris: [DocumentUri]) { + fileChange.selectedChanges = fileChange.selectedChanges.filter { fileUris.contains($0.uri) } + } +} + +public struct CodeReviewResponse: Equatable, Codable { + public struct FileComment: Equatable, Codable, Hashable { + public let uri: DocumentUri + public let originalContent: String + public var comments: [ReviewComment] + + public var url: URL? { URL(string: uri) } + + public init(uri: DocumentUri, originalContent: String, comments: [ReviewComment]) { + self.uri = uri + self.originalContent = originalContent + self.comments = comments + } + } + + public var fileComments: [FileComment] + + public var allComments: [ReviewComment] { + fileComments.flatMap { $0.comments } + } + + public init(fileComments: [FileComment]) { + self.fileComments = fileComments + } + + public func merge(with other: CodeReviewResponse) -> CodeReviewResponse { + var mergedResponse = self + + for newFileComment in other.fileComments { + if let index = mergedResponse.fileComments.firstIndex(where: { $0.uri == newFileComment.uri }) { + // Merge comments for existing URI + var mergedComments = mergedResponse.fileComments[index].comments + newFileComment.comments + mergedComments.sortByEndLine() + mergedResponse.fileComments[index].comments = mergedComments + } else { + // Append new URI with sorted comments + var newReview = newFileComment + newReview.comments.sortByEndLine() + mergedResponse.fileComments.append(newReview) + } + } + + return mergedResponse + } +} + +public struct CodeReviewRound: Equatable, Codable { + public enum Status: Equatable, Codable { + case waitForConfirmation, accepted, running, completed, error, cancelled + + public func canTransitionTo(_ newStatus: Status) -> Bool { + switch (self, newStatus) { + case (.waitForConfirmation, .accepted): return true + case (.waitForConfirmation, .cancelled): return true + case (.accepted, .running): return true + case (.accepted, .cancelled): return true + case (.running, .completed): return true + case (.running, .error): return true + case (.running, .cancelled): return true + default: return false + } + } + } + + public let id: String + public let turnId: String + public var status: Status { + didSet { statusHistory.append(status) } + } + public private(set) var statusHistory: [Status] + public var request: CodeReviewRequest? + public var response: CodeReviewResponse? + public var error: String? + + public init( + id: String = UUID().uuidString, + turnId: String, + status: Status, + request: CodeReviewRequest? = nil, + response: CodeReviewResponse? = nil, + error: String? = nil + ) { + self.id = id + self.turnId = turnId + self.status = status + self.request = request + self.response = response + self.error = error + self.statusHistory = [status] + } + + public static func fromError(turnId: String, error: String) -> CodeReviewRound { + .init(turnId: turnId, status: .error, error: error) + } + + public func withResponse(_ response: CodeReviewResponse) -> CodeReviewRound { + var round = self + round.response = response + return round + } + + public func withStatus(_ status: Status) -> CodeReviewRound { + var round = self + round.status = status + return round + } + + public func withError(_ error: String) -> CodeReviewRound { + var round = self + round.error = error + round.status = .error + return round + } +} + +extension Array where Element == ReviewComment { + // Order in asc + public mutating func sortByEndLine() { + self.sort(by: { $0.range.end.line < $1.range.end.line }) + } +} diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index 3e67a6c9..f97260c4 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -13,71 +13,312 @@ public protocol ConversationServiceType { func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]? func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws func agents(workspace: WorkspaceInfo) async throws -> [ChatAgent]? + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspace: WorkspaceInfo) async throws + func reviewChanges(workspace: WorkspaceInfo, params: ReviewChangesParams) async throws -> CodeReviewResult? } public protocol ConversationServiceProvider { - func createConversation(_ request: ConversationRequest) async throws - func createTurn(with conversationId: String, request: ConversationRequest) async throws - func stopReceivingMessage(_ workDoneToken: String) async throws - func rateConversation(turnId: String, rating: ConversationRating) async throws - func copyCode(_ request: CopyCodeRequest) async throws + func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws + func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws + func stopReceivingMessage(_ workDoneToken: String, workspaceURL: URL?) async throws + func rateConversation(turnId: String, rating: ConversationRating, workspaceURL: URL?) async throws + func copyCode(_ request: CopyCodeRequest, workspaceURL: URL?) async throws func templates() async throws -> [ChatTemplate]? func models() async throws -> [CopilotModel]? func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws func agents() async throws -> [ChatAgent]? + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspaceURL: URL?) async throws + func reviewChanges(_ params: ReviewChangesParams) async throws -> CodeReviewResult? } -public struct FileReference: Hashable, Codable, Equatable { +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 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 + } + } +} + +public enum ImageReferenceSource: String, Codable { + case file = "file" + case pasted = "pasted" + case screenshot = "screenshot" +} + +public struct ImageReference: Equatable, Codable, Hashable { + public var data: Data + public var fileUrl: URL? + public var source: ImageReferenceSource + + public init(data: Data, source: ImageReferenceSource) { + self.data = data + self.source = source + } + + public init(data: Data, fileUrl: URL) { + self.data = data + self.fileUrl = fileUrl + self.source = .file + } + + public func dataURL(imageType: String = "") -> String { + let base64String = data.base64EncodedString() + var type = imageType + if let url = fileUrl, imageType.isEmpty { + type = url.pathExtension + } + + let mimeType: String + switch type { + case "png": + mimeType = "image/png" + case "jpeg", "jpg": + mimeType = "image/jpeg" + case "bmp": + mimeType = "image/bmp" + case "gif": + mimeType = "image/gif" + case "webp": + mimeType = "image/webp" + case "tiff", "tif": + mimeType = "image/tiff" + default: + mimeType = "image/png" } - return filePath + return "data:\(mimeType);base64,\(base64String)" + } +} + +public enum MessageContentType: String, Codable { + case text = "text" + case imageUrl = "image_url" +} + +public enum ImageDetail: String, Codable { + case low = "low" + case high = "high" +} + +public struct ChatCompletionImageURL: Codable,Equatable { + let url: String + let detail: ImageDetail? + + public init(url: String, detail: ImageDetail? = nil) { + self.url = url + self.detail = detail + } +} + +public struct ChatCompletionContentPartText: Codable, Equatable { + public let type: MessageContentType + public let text: String + + public init(text: String) { + self.type = .text + self.text = text + } +} + +public struct ChatCompletionContentPartImage: Codable, Equatable { + public let type: MessageContentType + public let imageUrl: ChatCompletionImageURL + + public init(imageUrl: ChatCompletionImageURL) { + self.type = .imageUrl + self.imageUrl = imageUrl + } + + public init(url: String, detail: ImageDetail? = nil) { + self.type = .imageUrl + self.imageUrl = ChatCompletionImageURL(url: url, detail: detail) + } +} + +public enum ChatCompletionContentPart: Codable, Equatable { + case text(ChatCompletionContentPartText) + case imageUrl(ChatCompletionContentPartImage) + + private enum CodingKeys: String, CodingKey { + case type + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(MessageContentType.self, forKey: .type) + + switch type { + case .text: + self = .text(try ChatCompletionContentPartText(from: decoder)) + case .imageUrl: + self = .imageUrl(try ChatCompletionContentPartImage(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .text(let content): + try content.encode(to: encoder) + case .imageUrl(let content): + try content.encode(to: encoder) + } + } +} + +public enum MessageContent: Codable, Equatable { + case string(String) + case messageContentArray([ChatCompletionContentPart]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + } else if let arrayValue = try? container.decode([ChatCompletionContentPart].self) { + self = .messageContentArray(arrayValue) + } else { + throw DecodingError.typeMismatch(MessageContent.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Expected String or Array of MessageContent")) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .messageContentArray(let value): + try container.encode(value) + } } } public struct TurnSchema: Codable { - public var request: String + public var request: MessageContent public var response: String? public var agentSlug: String? public var turnId: String? public init(request: String, response: String? = nil, agentSlug: String? = nil, turnId: String? = nil) { + self.request = .string(request) + self.response = response + self.agentSlug = agentSlug + self.turnId = turnId + } + + public init( + request: [ChatCompletionContentPart], + response: String? = nil, + agentSlug: String? = nil, + turnId: String? = nil + ) { + self.request = .messageContentArray(request) + self.response = response + self.agentSlug = agentSlug + self.turnId = turnId + } + + public init(request: MessageContent, response: String? = nil, agentSlug: String? = nil, turnId: String? = nil) { self.request = request self.response = response self.agentSlug = agentSlug @@ -88,37 +329,49 @@ public struct TurnSchema: Codable { public struct ConversationRequest { public var workDoneToken: String public var content: String + public var contentImages: [ChatCompletionContentPartImage] = [] public var workspaceFolder: String public var activeDoc: Doc? public var skills: [String] public var ignoredSkills: [String]? - public var references: [FileReference]? + public var references: [ConversationAttachedReference]? public var model: String? + public var modelProviderName: String? public var turns: [TurnSchema] public var agentMode: Bool = false + public var userLanguage: String? = nil + public var turnId: String? = nil public init( workDoneToken: String, content: String, + contentImages: [ChatCompletionContentPartImage] = [], workspaceFolder: String, 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 + agentMode: Bool = false, + userLanguage: String?, + turnId: String? = nil ) { self.workDoneToken = workDoneToken self.content = content + self.contentImages = contentImages self.workspaceFolder = workspaceFolder self.activeDoc = activeDoc self.skills = skills self.ignoredSkills = ignoredSkills self.references = references self.model = model + self.modelProviderName = modelProviderName self.turns = turns self.agentMode = agentMode + self.userLanguage = userLanguage + self.turnId = turnId } } diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift index 1b2f4ccf..125dfd3d 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift @@ -1,5 +1,6 @@ import Foundation import JSONRPC +import LanguageServerProtocol // MARK: Conversation template public struct ChatTemplate: Codable, Equatable { @@ -59,9 +60,14 @@ public struct CopilotModelCapabilitiesSupports: Codable, Equatable { public let vision: Bool } -public struct CopilotModelBilling: Codable, Equatable { +public struct CopilotModelBilling: Codable, Equatable, Hashable { public let isPremium: Bool public let multiplier: Float + + public init(isPremium: Bool, multiplier: Float) { + self.isPremium = isPremium + self.multiplier = multiplier + } } // MARK: Conversation Agents @@ -89,6 +95,29 @@ public struct RegisterToolsParams: Codable, Equatable { } } +public struct UpdateToolsStatusParams: Codable, Equatable { + public let tools: [ToolStatusUpdate] + + public init(tools: [ToolStatusUpdate]) { + self.tools = tools + } +} + +public struct ToolStatusUpdate: Codable, Equatable { + public let name: String + public let status: ToolStatus + + public init(name: String, status: ToolStatus) { + self.name = name + self.status = status + } +} + +public enum ToolStatus: String, Codable, Equatable, Hashable { + case enabled = "enabled" + case disabled = "disabled" +} + public struct LanguageModelToolInformation: Codable, Equatable { /// The name of the tool. public let name: String @@ -152,6 +181,68 @@ public struct LanguageModelToolConfirmationMessages: Codable, Equatable { } } +public struct LanguageModelTool: Codable, Equatable { + public let id: String + public let type: ToolType + public let toolProvider: ToolProvider + public let nameForModel: String + public let name: String + public let displayName: String? + public let description: String? + public let displayDescription: String + public let inputSchema: [String: AnyCodable]? + public let annotations: ToolAnnotations? + public let status: ToolStatus + + public init( + id: String, + type: ToolType, + toolProvider: ToolProvider, + nameForModel: String, + name: String, + displayName: String?, + description: String?, + displayDescription: String, + inputSchema: [String : AnyCodable]?, + annotations: ToolAnnotations?, + status: ToolStatus + ) { + self.id = id + self.type = type + self.toolProvider = toolProvider + self.nameForModel = nameForModel + self.name = name + self.displayName = displayName + self.description = description + self.displayDescription = displayDescription + self.inputSchema = inputSchema + self.annotations = annotations + self.status = status + } +} + +public enum ToolType: String, Codable, CaseIterable { + case shared = "shared" + case client = "client" + case mcp = "mcp" +} + +public struct ToolProvider: Codable, Equatable { + public let id: String + public let displayName: String + public let displayNamePrefix: String? + public let description: String + public let isFirstPartyTool: Bool +} + +public struct ToolAnnotations: Codable, Equatable { + public let title: String? + public let readOnlyHint: Bool? + public let destructiveHint: Bool? + public let idempotentHint: Bool? + public let openWorldHint: Bool? +} + public struct InvokeClientToolParams: Codable, Equatable { /// The name of the tool to be invoked. public let name: String @@ -246,6 +337,12 @@ public struct AnyCodable: Codable, Equatable { public typealias InvokeClientToolRequest = JSONRPCRequest +public enum ToolInvocationStatus: String, Codable { + case success + case error + case cancelled +} + public struct LanguageModelToolResult: Codable, Equatable { public struct Content: Codable, Equatable { public let value: AnyCodable @@ -255,9 +352,11 @@ public struct LanguageModelToolResult: Codable, Equatable { } } + public let status: ToolInvocationStatus public let content: [Content] - public init(content: [Content]) { + public init(status: ToolInvocationStatus = .success, content: [Content]) { + self.status = status self.content = content } } @@ -287,3 +386,112 @@ public struct LanguageModelToolConfirmationResult: Codable, Equatable { } public typealias InvokeClientToolConfirmationRequest = JSONRPCRequest + +// MARK: CLS ShowMessage Notification +public struct CopilotShowMessageParams: Codable, Equatable, Hashable { + public var type: MessageType + public var title: String + public var message: String + public var actions: [CopilotMessageActionItem]? + public var location: CopilotMessageLocation + public var panelContext: CopilotMessagePanelContext? + + public init( + type: MessageType, + title: String, + message: String, + actions: [CopilotMessageActionItem]? = nil, + location: CopilotMessageLocation, + panelContext: CopilotMessagePanelContext? = nil + ) { + self.type = type + self.title = title + self.message = message + self.actions = actions + self.location = location + self.panelContext = panelContext + } +} + +public enum CopilotMessageLocation: String, Codable, Equatable, Hashable { + case Panel = "Panel" + case Inline = "Inline" +} + +public struct CopilotMessagePanelContext: Codable, Equatable, Hashable { + public var conversationId: String + public var turnId: String +} + +public struct CopilotMessageActionItem: Codable, Equatable, Hashable { + public var title: String + public var command: ActionCommand? +} + +public struct ActionCommand: Codable, Equatable, Hashable { + public var commandId: String + public var args: LSPAny? +} + +// MARK: - Copilot Code Review + +public struct ReviewChangesParams: Codable, Equatable { + public struct Change: Codable, Equatable { + public let uri: DocumentUri + public let path: String + // The original content of the file before changes were made. Will be empty string if the file is new. + public let baseContent: String + // The current content of the file with changes applied. Will be empty string if the file is deleted. + public let headContent: String + + public init(uri: DocumentUri, path: String, baseContent: String, headContent: String) { + self.uri = uri + self.path = path + self.baseContent = baseContent + self.headContent = headContent + } + } + + public let changes: [Change] + + public init(changes: [Change]) { + self.changes = changes + } +} + +public struct ReviewComment: Codable, Equatable, Hashable { + // Self-defined `id` for using in comment operation. Add an init value to bypass decoding + public let id: String = UUID().uuidString + public let uri: DocumentUri + public let range: LSPRange + public let message: String + // enum: bug, performance, consistency, documentation, naming, readability, style, other + public let kind: String + // enum: low, medium, high + public let severity: String + public let suggestion: String? + + public init( + uri: DocumentUri, + range: LSPRange, + message: String, + kind: String, + severity: String, + suggestion: String? + ) { + self.uri = uri + self.range = range + self.message = message + self.kind = kind + self.severity = severity + self.suggestion = suggestion + } +} + +public struct CodeReviewResult: Codable, Equatable { + public let comments: [ReviewComment] + + public init(comments: [ReviewComment]) { + self.comments = comments + } +} diff --git a/Tool/Sources/ConversationServiceProvider/ToolNames.swift b/Tool/Sources/ConversationServiceProvider/ToolNames.swift index 4bc31857..7b9d12c9 100644 --- a/Tool/Sources/ConversationServiceProvider/ToolNames.swift +++ b/Tool/Sources/ConversationServiceProvider/ToolNames.swift @@ -5,4 +5,5 @@ public enum ToolName: String { case getErrors = "get_errors" case insertEditIntoFile = "insert_edit_into_file" case createFile = "create_file" + case fetchWebPage = "fetch_webpage" } diff --git a/Tool/Sources/GitHelper/CurrentChange.swift b/Tool/Sources/GitHelper/CurrentChange.swift new file mode 100644 index 00000000..d7680f25 --- /dev/null +++ b/Tool/Sources/GitHelper/CurrentChange.swift @@ -0,0 +1,74 @@ +import Foundation +import LanguageServerProtocol + +public struct PRChange: Equatable, Codable { + public let uri: DocumentUri + public let path: String + public let baseContent: String + public let headContent: String + + public var originalContent: String { headContent } +} + +public enum CurrentChangeService { + public static func getPRChanges( + _ repositoryURL: URL, + group: GitDiffGroup, + shouldIncludeFile: (URL) -> Bool + ) async -> [PRChange] { + let gitStats = await GitDiff.getDiffFiles(repositoryURL: repositoryURL, group: group) + + var changes: [PRChange] = [] + + for stat in gitStats { + guard shouldIncludeFile(stat.url) else { continue } + + guard let content = try? String(contentsOf: stat.url, encoding: .utf8) + else { continue } + let uri = stat.url.absoluteString + + let relativePath = Self.getRelativePath(fileURL: stat.url, repositoryURL: repositoryURL) + + switch stat.status { + case .untracked, .indexAdded: + changes.append(.init(uri: uri, path: relativePath, baseContent: "", headContent: content)) + + case .modified: + guard let originalContent = GitShow.showHeadContent(of: relativePath, repositoryURL: repositoryURL) else { + continue + } + changes.append(.init(uri: uri, path: relativePath, baseContent: originalContent, headContent: content)) + + case .deleted, .indexRenamed: + continue + } + } + + // Include untracked files + if group == .workingTree { + let untrackedGitStats = GitStatus.getStatus(repositoryURL: repositoryURL, untrackedFilesOption: .all) + for stat in untrackedGitStats { + guard !changes.contains(where: { $0.uri == stat.url.absoluteString }), + let content = try? String(contentsOf: stat.url, encoding: .utf8) + else { continue } + + let relativePath = Self.getRelativePath(fileURL: stat.url, repositoryURL: repositoryURL) + changes.append( + .init(uri: stat.url.absoluteString, path: relativePath, baseContent: "", headContent: content) + ) + } + } + + return changes + } + + // TODO: Handle cases of multi-project and referenced file + private static func getRelativePath(fileURL: URL, repositoryURL: URL) -> String { + var relativePath = fileURL.path.replacingOccurrences(of: repositoryURL.path, with: "") + if relativePath.starts(with: "/") { + relativePath = String(relativePath.dropFirst()) + } + + return relativePath + } +} diff --git a/Tool/Sources/GitHelper/GitDiff.swift b/Tool/Sources/GitHelper/GitDiff.swift new file mode 100644 index 00000000..b8cf4a00 --- /dev/null +++ b/Tool/Sources/GitHelper/GitDiff.swift @@ -0,0 +1,114 @@ +import Foundation +import SystemUtils + +public enum GitDiffGroup { + case index // Staged + case workingTree // Unstaged +} + +public struct GitDiff { + public static func getDiff(of filePath: String, repositoryURL: URL, group: GitDiffGroup) async -> String { + var arguments = ["diff"] + if group == .index { + arguments.append("--cached") + } + arguments.append(contentsOf: ["--", filePath]) + + let result = try? SystemUtils.executeCommand( + inDirectory: repositoryURL.path, + path: GitPath, + arguments: arguments + ) + + return result ?? "" + } + + public static func getDiffFiles(repositoryURL: URL, group: GitDiffGroup) async -> [GitChange] { + var arguments = ["diff", "--name-status", "-z", "--diff-filter=ADMR"] + if group == .index { + arguments.append("--cached") + } + + let result = try? SystemUtils.executeCommand( + inDirectory: repositoryURL.path, + path: GitPath, + arguments: arguments + ) + + return result == nil + ? [] + : Self.parseDiff(repositoryURL: repositoryURL, raw: result!) + } + + private static func parseDiff(repositoryURL: URL, raw: String) -> [GitChange] { + var index = 0 + var result: [GitChange] = [] + let segments = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "\0") + .map(String.init) + .filter { !$0.isEmpty } + + segmentsLoop: while index < segments.count - 1 { + let change = segments[index] + index += 1 + + let resourcePath = segments[index] + index += 1 + + if change.isEmpty || resourcePath.isEmpty { + break + } + + let originalURL: URL + if resourcePath.hasPrefix("/") { + originalURL = URL(fileURLWithPath: resourcePath) + } else { + originalURL = repositoryURL.appendingPathComponent(resourcePath) + } + + var url = originalURL + var status = GitFileStatus.untracked + + // Copy or Rename status comes with a number (ex: 'R100'). + // We don't need the number, we use only first character of the status. + switch change.first { + case "A": + status = .indexAdded + + case "M": + status = .modified + + case "D": + status = .deleted + + // Rename contains two paths, the second one is what the file is renamed/copied to. + case "R": + if index >= segments.count { + break + } + + let newPath = segments[index] + index += 1 + + if newPath.isEmpty { + break + } + + status = .indexRenamed + if newPath.hasPrefix("/") { + url = URL(fileURLWithPath: newPath) + } else { + url = repositoryURL.appendingPathComponent(newPath) + } + + default: + // Unknown status + break segmentsLoop + } + + result.append(.init(url: url, originalURL: originalURL, status: status)) + } + + return result + } +} diff --git a/Tool/Sources/GitHelper/GitHunk.swift b/Tool/Sources/GitHelper/GitHunk.swift new file mode 100644 index 00000000..2939dd99 --- /dev/null +++ b/Tool/Sources/GitHelper/GitHunk.swift @@ -0,0 +1,105 @@ +import Foundation + +public struct GitHunk { + public let startDeletedLine: Int // 1-based + public let deletedLines: Int + public let startAddedLine: Int // 1-based + public let addedLines: Int + public let additions: [(start: Int, length: Int)] + public let diffText: String + + public init( + startDeletedLine: Int, + deletedLines: Int, + startAddedLine: Int, + addedLines: Int, + additions: [(start: Int, length: Int)], + diffText: String + ) { + self.startDeletedLine = startDeletedLine + self.deletedLines = deletedLines + self.startAddedLine = startAddedLine + self.addedLines = addedLines + self.additions = additions + self.diffText = diffText + } +} + +public extension GitHunk { + static func parseDiff(_ diff: String) -> [GitHunk] { + var hunkTexts = diff.components(separatedBy: "\n@@") + + if !hunkTexts.isEmpty, hunkTexts.last?.hasSuffix("\n") == true { + hunkTexts[hunkTexts.count - 1] = String(hunkTexts.last!.dropLast()) + } + + let hunks: [GitHunk] = hunkTexts.compactMap { chunk -> GitHunk? in + let rangePattern = #"-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?"# + let regex = try! NSRegularExpression(pattern: rangePattern) + let nsString = chunk as NSString + + guard let match = regex.firstMatch( + in: chunk, + options: [], + range: NSRange(location: 0, length: nsString.length) + ) + else { return nil } + + var startDeletedLine = Int(nsString.substring(with: match.range(at: 1))) ?? 0 + let deletedLines = match.range(at: 2).location != NSNotFound + ? Int(nsString.substring(with: match.range(at: 2))) ?? 1 + : 1 + var startAddedLine = Int(nsString.substring(with: match.range(at: 3))) ?? 0 + let addedLines = match.range(at: 4).location != NSNotFound + ? Int(nsString.substring(with: match.range(at: 4))) ?? 1 + : 1 + + var additions: [(start: Int, length: Int)] = [] + let lines = Array(chunk.components(separatedBy: "\n").dropFirst()) + var d = 0 + var addStart: Int? + + for line in lines { + let ch = line.first ?? Character(" ") + + if ch == "+" { + if addStart == nil { + addStart = startAddedLine + d + } + d += 1 + } else { + if let start = addStart { + additions.append((start: start, length: startAddedLine + d - start)) + addStart = nil + } + if ch == " " { + d += 1 + } + } + } + + if let start = addStart { + additions.append((start: start, length: startAddedLine + d - start)) + } + + if startDeletedLine == 0 { + startDeletedLine = 1 + } + + if startAddedLine == 0 { + startAddedLine = 1 + } + + return GitHunk( + startDeletedLine: startDeletedLine, + deletedLines: deletedLines, + startAddedLine: startAddedLine, + addedLines: addedLines, + additions: additions, + diffText: lines.joined(separator: "\n") + ) + } + + return hunks + } +} diff --git a/Tool/Sources/GitHelper/GitShow.swift b/Tool/Sources/GitHelper/GitShow.swift new file mode 100644 index 00000000..6eaf858f --- /dev/null +++ b/Tool/Sources/GitHelper/GitShow.swift @@ -0,0 +1,24 @@ +import Foundation +import SystemUtils + +public struct GitShow { + public static func showHeadContent(of filePath: String, repositoryURL: URL) -> String? { + let escapedFilePath = Self.escapePath(filePath) + let arguments = ["show", "HEAD:\(escapedFilePath)"] + + let result = try? SystemUtils.executeCommand( + inDirectory: repositoryURL.path, + path: GitPath, + arguments: arguments + ) + + return result + } + + private static func escapePath(_ string: String) -> String { + let charactersToEscape = CharacterSet(charactersIn: " '\"&()[]{}$`\\|;<>*?~") + return string.unicodeScalars.map { scalar in + charactersToEscape.contains(scalar) ? "\\\(Character(scalar))" : String(Character(scalar)) + }.joined() + } +} diff --git a/Tool/Sources/GitHelper/GitStatus.swift b/Tool/Sources/GitHelper/GitStatus.swift new file mode 100644 index 00000000..eb769403 --- /dev/null +++ b/Tool/Sources/GitHelper/GitStatus.swift @@ -0,0 +1,47 @@ +import Foundation +import SystemUtils + +public enum UntrackedFilesOption: String { + case all, no, normal +} + +public struct GitStatus { + static let unTrackedFilePrefix = "?? " + + public static func getStatus(repositoryURL: URL, untrackedFilesOption: UntrackedFilesOption = .all) -> [GitChange] { + let arguments = ["status", "--porcelain", "--untracked-files=\(untrackedFilesOption.rawValue)"] + + let result = try? SystemUtils.executeCommand( + inDirectory: repositoryURL.path, + path: GitPath, + arguments: arguments + ) + + if let result = result { + return Self.parseStatus(statusOutput: result, repositoryURL: repositoryURL) + } else { + return [] + } + } + + private static func parseStatus(statusOutput: String, repositoryURL: URL) -> [GitChange] { + var changes: [GitChange] = [] + let fileManager = FileManager.default + + let lines = statusOutput.components(separatedBy: .newlines) + for line in lines { + if line.hasPrefix(unTrackedFilePrefix) { + let fileRelativePath = String(line.dropFirst(unTrackedFilePrefix.count)) + let fileURL = repositoryURL.appendingPathComponent(fileRelativePath) + + guard fileManager.fileExists(atPath: fileURL.path) else { continue } + + changes.append( + .init(url: fileURL, originalURL: fileURL, status: .untracked) + ) + } + } + + return changes + } +} diff --git a/Tool/Sources/GitHelper/types.swift b/Tool/Sources/GitHelper/types.swift new file mode 100644 index 00000000..26adcec7 --- /dev/null +++ b/Tool/Sources/GitHelper/types.swift @@ -0,0 +1,23 @@ +import Foundation + +let GitPath = "/usr/bin/git" + +public enum GitFileStatus { + case untracked + case indexAdded + case modified + case deleted + case indexRenamed +} + +public struct GitChange { + public let url: URL + public let originalURL: URL + public let status: GitFileStatus + + public init(url: URL, originalURL: URL, status: GitFileStatus) { + self.url = url + self.originalURL = originalURL + self.status = status + } +} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/MCPOAuthRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/MCPOAuthRequestHandler.swift new file mode 100644 index 00000000..47ea5017 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Conversation/MCPOAuthRequestHandler.swift @@ -0,0 +1,67 @@ +import JSONRPC +import Foundation +import Combine +import Logger +import AppKit + +public protocol MCPOAuthRequestHandler { + func handleShowOAuthMessage( + _ request: MCPOAuthRequest, + completion: @escaping ( + AnyJSONRPCResponse + ) -> Void + ) +} + +public final class MCPOAuthRequestHandlerImpl: MCPOAuthRequestHandler { + public static let shared = MCPOAuthRequestHandlerImpl() + + public func handleShowOAuthMessage(_ request: MCPOAuthRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) { + guard let params = request.params else { return } + Logger.gitHubCopilot.debug("Received MCP OAuth Request: \(params)") + Task { @MainActor in + let confirmResult = showMCPOAuthAlert(params) + let jsonResult = try? JSONEncoder().encode(MCPOAuthResponse(confirm: confirmResult)) + let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null + completion(AnyJSONRPCResponse(id: request.id, result: jsonValue)) + } + } + + @MainActor + func showMCPOAuthAlert(_ params: MCPOAuthRequestParams) -> Bool { + let alert = NSAlert() + let mcpConfigString = UserDefaults.shared.value(for: \.gitHubCopilotMCPConfig) + + var serverName = params.mcpServer // Default fallback + + if let mcpConfigData = mcpConfigString.data(using: .utf8), + let mcpConfig = try? JSONDecoder().decode(JSONValue.self, from: mcpConfigData) { + // Iterate through the servers to find a match for the mcpServer URL + if case .hash(let serversDict) = mcpConfig { + for (userDefinedName, serverConfig) in serversDict { + if let url = serverConfig["url"]?.stringValue { + // Check if the mcpServer URL matches the configured URL + if params.mcpServer.contains(url) || url.contains(params.mcpServer) { + serverName = userDefinedName + break + } + } + } + } + } + + alert.messageText = "GitHub Copilot" + alert.informativeText = "The MCP Server Definition '\(serverName)' wants to authenticate to \(params.authLabel)." + alert.alertStyle = .informational + + alert.addButton(withTitle: "Continue") + alert.addButton(withTitle: "Cancel") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + return true + } else { + return false + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift index 664100cc..f450935e 100644 --- a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift +++ b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift @@ -3,17 +3,16 @@ import Combine import Workspace import XcodeInspector import Foundation +import ConversationServiceProvider +import LanguageServerProtocol public protocol WatchedFilesHandler { - var onWatchedFiles: PassthroughSubject<(WatchedFilesRequest, (AnyJSONRPCResponse) -> Void), Never> { get } func handleWatchedFiles(_ request: WatchedFilesRequest, workspaceURL: URL, completion: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?) } public final class WatchedFilesHandlerImpl: WatchedFilesHandler { public static let shared = WatchedFilesHandlerImpl() - - public let onWatchedFiles: PassthroughSubject<(WatchedFilesRequest, (AnyJSONRPCResponse) -> Void), Never> = .init() - + public func handleWatchedFiles(_ request: WatchedFilesRequest, workspaceURL: URL, completion: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?) { guard let params = request.params, params.workspaceFolder.uri != "/" else { return } @@ -25,19 +24,22 @@ public final class WatchedFilesHandlerImpl: WatchedFilesHandler { excludeGitIgnoredFiles: params.excludeGitignoredFiles, excludeIDEIgnoredFiles: params.excludeIDEIgnoredFiles ) + WorkspaceFileIndex.shared.setFiles(files, for: workspaceURL) + + let fileUris = files.prefix(10000).map { $0.url.absoluteString } // Set max number of indexing file to 10000 let batchSize = BatchingFileChangeWatcher.maxEventPublishSize /// only `batchSize`(100) files to complete this event for setup watching workspace in CLS side - let jsonResult: JSONValue = .array(files.prefix(batchSize).map { .hash(["uri": .string($0)]) }) + let jsonResult: JSONValue = .array(fileUris.prefix(batchSize).map { .hash(["uri": .string($0)]) }) let jsonValue: JSONValue = .hash(["files": jsonResult]) completion(AnyJSONRPCResponse(id: request.id, result: jsonValue)) Task { - if files.count > batchSize { - for startIndex in stride(from: batchSize, to: files.count, by: batchSize) { - let endIndex = min(startIndex + batchSize, files.count) - let batch = Array(files[startIndex.. batchSize { + for startIndex in stride(from: batchSize, to: fileUris.count, by: batchSize) { + let endIndex = min(startIndex + batchSize, fileUris.count) + let batch = Array(fileUris[startIndex.. 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 ec0d5add..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, @@ -93,14 +93,38 @@ func registerClientTools(server: GitHubCopilotConversationServiceType) async { required: ["filePath", "code", "explanation"] ) ) + + let fetchWebPageTool: LanguageModelToolInformation = .init( + name: ToolName.fetchWebPage.rawValue, + description: "Fetches the main content from a web page. This tool is useful for summarizing or analyzing the content of a webpage.", + inputSchema: .init( + type: "object", + properties: [ + "urls": .init( + type: "array", + description: "An array of web page URLs to fetch content from.", + items: .init(type: "string") + ), + ], + required: ["urls"] + ), + confirmationMessages: LanguageModelToolConfirmationMessages( + title: "Fetch Web Page", + message: "Web content may contain malicious code or attempt prompt injection attacks." + ) + ) tools.append(runInTerminalTool) tools.append(getTerminalOutputTool) tools.append(getErrorsTool) tools.append(insertEditIntoFileTool) tools.append(createFileTool) + tools.append(fetchWebPageTool) if !tools.isEmpty { - try? await server.registerTools(tools: tools) + let response = try? await server.registerTools(tools: tools) + return response ?? [] } + + return [] } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift new file mode 100644 index 00000000..554a4bfe --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift @@ -0,0 +1,106 @@ +import ConversationServiceProvider +import Foundation +import Logger + +public extension Notification.Name { + static let gitHubCopilotToolsDidChange = Notification + .Name("com.github.CopilotForXcode.CopilotToolsDidChange") +} + +public class CopilotLanguageModelToolManager { + private static var availableLanguageModelTools: [LanguageModelTool]? + + public static func updateToolsStatus(_ tools: [LanguageModelTool]) { + // If we have no previous snapshot, just adopt what we received. + guard let previous = availableLanguageModelTools, !previous.isEmpty else { + let sorted = sortTools(tools) + guard sorted != availableLanguageModelTools else { return } + availableLanguageModelTools = sorted + DispatchQueue.main.async { + Logger.client.info("Notify about language model tools change: \(getLanguageModelToolsSummary())") + DistributedNotificationCenter.default().post(name: .gitHubCopilotToolsDidChange, object: nil) + } + return + } + + // Map previous and new by name for merging. + let previousByName = Dictionary(uniqueKeysWithValues: previous.map { ($0.name, $0) }) + let incomingByName = Dictionary(uniqueKeysWithValues: tools.map { ($0.name, $0) }) + + var merged: [LanguageModelTool] = [] + + for (name, oldTool) in previousByName { + if let updated = incomingByName[name] { + merged.append(updated) + } else { + if oldTool.status == .disabled { + merged.append(oldTool) // already disabled, keep as-is + } else { + // Synthesize a disabled copy (all fields same except status). + let disabledCopy = LanguageModelTool( + id: oldTool.id, + type: oldTool.type, + toolProvider: oldTool.toolProvider, + nameForModel: oldTool.nameForModel, + name: oldTool.name, + displayName: oldTool.displayName, + description: oldTool.description, + displayDescription: oldTool.displayDescription, + inputSchema: oldTool.inputSchema, + annotations: oldTool.annotations, + status: .disabled + ) + merged.append(disabledCopy) + } + } + } + + for (name, newTool) in incomingByName { + if previousByName[name] == nil { + merged.append(newTool) + } + } + + let sorted = sortTools(merged) + guard sorted != availableLanguageModelTools else { return } + availableLanguageModelTools = sorted + + DispatchQueue.main.async { + Logger.client.info("Notify about language model tools change (merged): \(getLanguageModelToolsSummary())") + DistributedNotificationCenter.default().post(name: .gitHubCopilotToolsDidChange, object: nil) + } + } + + // Extracted sorting logic to keep behavior identical. + private static func sortTools(_ tools: [LanguageModelTool]) -> [LanguageModelTool] { + tools.sorted { lhs, rhs in + let lKey = lhs.displayName ?? lhs.name + let rKey = rhs.displayName ?? rhs.name + let primary = lKey.localizedCaseInsensitiveCompare(rKey) + if primary == .orderedSame { + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return primary == .orderedAscending + } + } + + private static func getLanguageModelToolsSummary() -> String { + guard let tools = availableLanguageModelTools else { return "" } + return "\(tools.filter { $0.status == .enabled }.count) enabled, \(tools.filter { $0.status == .disabled }.count) disabled." + } + + public static func getAvailableLanguageModelTools() -> [LanguageModelTool]? { + return availableLanguageModelTools + } + + public static func hasLanguageModelTools() -> Bool { + return availableLanguageModelTools != nil && !availableLanguageModelTools!.isEmpty + } + + public static func clearLanguageModelTools() { + availableLanguageModelTools = [] + DispatchQueue.main.async { + DistributedNotificationCenter.default().post(name: .gitHubCopilotToolsDidChange, object: nil) + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index 8182833f..29e33d35 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -7,18 +7,51 @@ import Logger import ProcessEnv import Status +public enum ServerError: LocalizedError { + case handlerUnavailable(String) + case unhandledMethod(String) + case notificationDispatchFailed(Error) + case requestDispatchFailed(Error) + case clientDataUnavailable(Error) + case serverUnavailable + case missingExpectedParameter + case missingExpectedResult + case unableToDecodeRequest(Error) + case unableToSendRequest(Error) + case unableToSendNotification(Error) + case serverError(code: Int, message: String, data: Codable?) + case invalidRequest(Error?) + case timeout + case unknownError(Error) + + static func responseError(_ error: AnyJSONRPCResponseError) -> ServerError { + return ServerError.serverError(code: error.code, + message: error.message, + data: error.data) + } + + static func convertToServerError(error: any Error) -> ServerError { + if let serverError = error as? ServerError { + return serverError + } else if let jsonRPCError = error as? AnyJSONRPCResponseError { + return responseError(jsonRPCError) + } + + return .unknownError(error) + } +} + +public typealias LSPResponse = Decodable & Sendable + /// A clone of the `LocalProcessServer`. /// We need it because the original one does not allow us to handle custom notifications. class CopilotLocalProcessServer { public var notificationPublisher: PassthroughSubject = PassthroughSubject() - public var serverRequestPublisher: PassthroughSubject<(AnyJSONRPCRequest, (AnyJSONRPCResponse) -> Void), Never> = PassthroughSubject<(AnyJSONRPCRequest, (AnyJSONRPCResponse) -> Void), Never>() - private let transport: StdioDataTransport - private let customTransport: CustomDataTransport - private let process: Process - private var wrappedServer: CustomJSONRPCLanguageServer? + private var process: Process? + private var wrappedServer: CustomJSONRPCServerConnection? + private var cancellables = Set() - var terminationHandler: (() -> Void)? @MainActor var ongoingCompletionRequestIDs: [JSONId] = [] @MainActor var ongoingConversationRequestIDs = [String: JSONId]() @@ -37,238 +70,91 @@ class CopilotLocalProcessServer { } init(executionParameters parameters: Process.ExecutionParameters) { - transport = StdioDataTransport() - let framing = SeperatedHTTPHeaderMessageFraming() - let messageTransport = MessageTransport( - dataTransport: transport, - messageProtocol: framing - ) - customTransport = CustomDataTransport(nextTransport: messageTransport) - wrappedServer = CustomJSONRPCLanguageServer(dataTransport: customTransport) - - process = Process() - - // Because the implementation of LanguageClient is so closed, - // we need to get the request IDs from a custom transport before the data - // is written to the language server. - customTransport.onWriteRequest = { [weak self] request in - if request.method == "getCompletionsCycling" { - Task { @MainActor [weak self] in - self?.ongoingCompletionRequestIDs.append(request.id) - } - } else if request.method == "conversation/create" { - Task { @MainActor [weak self] in - if let paramsData = try? JSONEncoder().encode(request.params) { - do { - let params = try JSONDecoder().decode(ConversationCreateParams.self, from: paramsData) - self?.ongoingConversationRequestIDs[params.workDoneToken] = request.id - } catch { - // Handle decoding error - print("Error decoding ConversationCreateParams: \(error)") - Logger.gitHubCopilot.error("Error decoding ConversationCreateParams: \(error)") - } - } - } - } else if request.method == "conversation/turn" { - Task { @MainActor [weak self] in - if let paramsData = try? JSONEncoder().encode(request.params) { - do { - let params = try JSONDecoder().decode(TurnCreateParams.self, from: paramsData) - self?.ongoingConversationRequestIDs[params.workDoneToken] = request.id - } catch { - // Handle decoding error - print("Error decoding TurnCreateParams: \(error)") - Logger.gitHubCopilot.error("Error decoding TurnCreateParams: \(error)") - } - } - } - } + do { + let channel: DataChannel = try startLocalProcess(parameters: parameters, terminationHandler: processTerminated) + let noop: @Sendable (Data) async -> Void = { _ in } + let newChannel = DataChannel.tap(channel: channel.withMessageFraming(), onRead: noop, onWrite: onWriteRequest) + + self.wrappedServer = CustomJSONRPCServerConnection(dataChannel: newChannel, notificationHandler: handleNotification) + } catch { + Logger.gitHubCopilot.error("Failed to start local CLS process: \(error)") } - - wrappedServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in - self?.notificationPublisher.send(notification) - }).store(in: &cancellables) - - wrappedServer?.serverRequestPublisher.sink(receiveValue: { [weak self] (request, callback) in - self?.serverRequestPublisher.send((request, callback)) - }).store(in: &cancellables) - - process.standardInput = transport.stdinPipe - process.standardOutput = transport.stdoutPipe - process.standardError = transport.stderrPipe - - process.parameters = parameters - - process.terminationHandler = { [unowned self] task in - self.processTerminated(task) - } - - process.launch() } - + deinit { - process.terminationHandler = nil - process.terminate() - transport.close() - } - - private func processTerminated(_: Process) { - transport.close() - - // releasing the server here will short-circuit any pending requests, - // which might otherwise take a while to time out, if ever. - wrappedServer = nil - terminationHandler?() - } - - var logMessages: Bool { - get { return wrappedServer?.logMessages ?? false } - set { wrappedServer?.logMessages = newValue } - } -} - -extension CopilotLocalProcessServer: LanguageServerProtocol.Server { - public var requestHandler: RequestHandler? { - get { return wrappedServer?.requestHandler } - set { wrappedServer?.requestHandler = newValue } - } - - public var notificationHandler: NotificationHandler? { - get { wrappedServer?.notificationHandler } - set { wrappedServer?.notificationHandler = newValue } + self.process?.terminate() } - public func sendNotification( - _ notif: ClientNotification, - completionHandler: @escaping (ServerError?) -> Void - ) { - guard let server = wrappedServer, process.isRunning else { - completionHandler(.serverUnavailable) - return - } - - server.sendNotification(notif, completionHandler: completionHandler) - } - - /// send copilot specific notification - public func sendCopilotNotification( - _ notif: CopilotClientNotification, - completionHandler: @escaping (ServerError?) -> Void - ) { - guard let server = wrappedServer, process.isRunning else { - completionHandler(.serverUnavailable) - return + private func startLocalProcess(parameters: Process.ExecutionParameters, + terminationHandler: @escaping @Sendable () -> Void) throws -> DataChannel { + let (channel, process) = try DataChannel.localProcessChannel(parameters: parameters, terminationHandler: terminationHandler) + + // Create a serial queue to synchronize writes + let writeQueue = DispatchQueue(label: "DataChannel.writeQueue") + let stdinPipe: Pipe = process.standardInput as! Pipe + self.process = process + let handler: DataChannel.WriteHandler = { data in + try writeQueue.sync { + // write is not thread-safe, so we need to use queue to ensure it thread-safe + try stdinPipe.fileHandleForWriting.write(contentsOf: data) + } } - server.sendCopilotNotification(notif, completionHandler: completionHandler) - } + let wrappedChannel = DataChannel( + writeHandler: handler, + dataSequence: channel.dataSequence + ) - /// Cancel ongoing completion requests. - public func cancelOngoingTasks() async { - let task = Task { @MainActor in - for id in ongoingCompletionRequestIDs { - await cancelTask(id) - } - self.ongoingCompletionRequestIDs = [] - } - await task.value + return wrappedChannel } - public func cancelOngoingTask(_ workDoneToken: String) async { - let task = Task { @MainActor in - guard let id = ongoingConversationRequestIDs[workDoneToken] else { return } - await cancelTask(id) - } - await task.value - } - - public func cancelTask(_ id: JSONId) async { - guard let server = wrappedServer, process.isRunning else { - return - } - - switch id { - case let .numericId(id): - try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) - case let .stringId(id): - try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) - } - } - - public func sendRequest( - _ request: ClientRequest, - completionHandler: @escaping (ServerResult) -> Void - ) { - guard let server = wrappedServer, process.isRunning else { - completionHandler(.failure(.serverUnavailable)) + @Sendable + private func onWriteRequest(data: Data) { + guard let request = try? JSONDecoder().decode(JSONRPCRequest.self, from: data) else { return } - server.sendRequest(request, completionHandler: completionHandler) - } -} - -protocol CopilotNotificationJSONRPCLanguageServer { - func sendCopilotNotification(_ notif: CopilotClientNotification, completionHandler: @escaping (ServerError?) -> Void) -} - -final class CustomJSONRPCLanguageServer: Server { - let internalServer: JSONRPCLanguageServer - - typealias ProtocolResponse = ProtocolTransport.ResponseResult - - private let protocolTransport: ProtocolTransport - - public var requestHandler: RequestHandler? - public var notificationHandler: NotificationHandler? - public var notificationPublisher: PassthroughSubject = PassthroughSubject() - public var serverRequestPublisher: PassthroughSubject<(AnyJSONRPCRequest, (AnyJSONRPCResponse) -> Void), Never> = PassthroughSubject<(AnyJSONRPCRequest, (AnyJSONRPCResponse) -> Void), Never>() - - private var outOfBandError: Error? - - init(protocolTransport: ProtocolTransport) { - self.protocolTransport = protocolTransport - internalServer = JSONRPCLanguageServer(protocolTransport: protocolTransport) - - let previouseRequestHandler = protocolTransport.requestHandler - let previouseNotificationHandler = protocolTransport.notificationHandler - - protocolTransport - .requestHandler = { [weak self] in - guard let self else { return } - if !self.handleRequest($0, data: $1, callback: $2) { - previouseRequestHandler?($0, $1, $2) + if request.method == "getCompletionsCycling" { + Task { @MainActor [weak self] in + self?.ongoingCompletionRequestIDs.append(request.id) + } + } else if request.method == "conversation/create" { + Task { @MainActor [weak self] in + if let paramsData = try? JSONEncoder().encode(request.params) { + do { + let params = try JSONDecoder().decode(ConversationCreateParams.self, from: paramsData) + self?.ongoingConversationRequestIDs[params.workDoneToken] = request.id + } catch { + // Handle decoding error + Logger.gitHubCopilot.error("Error decoding ConversationCreateParams: \(error)") + } } } - protocolTransport - .notificationHandler = { [weak self] in - guard let self else { return } - if !self.handleNotification($0, data: $1, block: $2) { - previouseNotificationHandler?($0, $1, $2) + } else if request.method == "conversation/turn" { + Task { @MainActor [weak self] in + if let paramsData = try? JSONEncoder().encode(request.params) { + do { + let params = try JSONDecoder().decode(TurnCreateParams.self, from: paramsData) + self?.ongoingConversationRequestIDs[params.workDoneToken] = request.id + } catch { + // Handle decoding error + Logger.gitHubCopilot.error("Error decoding TurnCreateParams: \(error)") + } } } + } } - convenience init(dataTransport: DataTransport) { - self.init(protocolTransport: ProtocolTransport(dataTransport: dataTransport)) - } - - deinit { - protocolTransport.requestHandler = nil - protocolTransport.notificationHandler = nil - } - - var logMessages: Bool { - get { return internalServer.logMessages } - set { internalServer.logMessages = newValue } + @Sendable + private func processTerminated() { + // releasing the server here will short-circuit any pending requests, + // which might otherwise take a while to time out, if ever. + wrappedServer = nil } -} -extension CustomJSONRPCLanguageServer { private func handleNotification( _ anyNotification: AnyJSONRPCNotification, - data: Data, - block: @escaping (Error?) -> Void + data: Data ) -> Bool { let methodName = anyNotification.method let debugDescription = encodeJSONParams(params: anyNotification.params) @@ -276,11 +162,9 @@ extension CustomJSONRPCLanguageServer { switch method { case .windowLogMessage: Logger.gitHubCopilot.info("\(anyNotification.method): \(debugDescription)") - block(nil) return true case .protocolProgress: notificationPublisher.send(anyNotification) - block(nil) return true default: return false @@ -289,7 +173,6 @@ extension CustomJSONRPCLanguageServer { switch methodName { case "LogMessage": Logger.gitHubCopilot.info("\(anyNotification.method): \(debugDescription)") - block(nil) return true case "didChangeStatus": Logger.gitHubCopilot.info("\(anyNotification.method): \(debugDescription)") @@ -303,63 +186,110 @@ extension CustomJSONRPCLanguageServer { ) } } - block(nil) return true - case "featureFlagsNotification": + case "copilot/didChangeFeatureFlags": notificationPublisher.send(anyNotification) - block(nil) return true case "copilot/mcpTools": - if let payload = GetAllToolsParams.decode( - fromParams: anyNotification.params - ) { - Logger.gitHubCopilot.info("MCPTools: \(payload)") - CopilotMCPToolManager.updateMCPTools(payload.servers) - } - block(nil) + notificationPublisher.send(anyNotification) + return true + case "copilot/mcpRuntimeLogs": + notificationPublisher.send(anyNotification) return true case "conversation/preconditionsNotification", "statusNotification": // Ignore - block(nil) return true default: return false } } } +} - public func sendNotification( - _ notif: ClientNotification, - completionHandler: @escaping (ServerError?) -> Void - ) { - internalServer.sendNotification(notif, completionHandler: completionHandler) +extension CopilotLocalProcessServer: ServerConnection { + var eventSequence: EventSequence { + guard let server = wrappedServer else { + let result = EventSequence.makeStream() + result.continuation.finish() + return result.stream + } + + return server.eventSequence } -} -extension CustomJSONRPCLanguageServer { - private func handleRequest( - _ request: AnyJSONRPCRequest, - data: Data, - callback: @escaping (AnyJSONRPCResponse) -> Void - ) -> Bool { - let methodName = request.method - let debugDescription = encodeJSONParams(params: request.params) - serverRequestPublisher.send((request: request, callback: callback)) + public func sendNotification(_ notif: ClientNotification) async throws { + guard let server = wrappedServer, let process = process, process.isRunning else { + throw ServerError.serverUnavailable + } + + do { + try await server.sendNotification(notif) + } catch { + throw ServerError.unableToSendNotification(error) + } + } + + /// send copilot specific notification + public func sendCopilotNotification(_ notif: CopilotClientNotification) async throws -> Void { + guard let server = wrappedServer, let process = process, process.isRunning else { + throw ServerError.serverUnavailable + } + + let method = notif.method.rawValue + + switch notif { + case .copilotDidChangeWatchedFiles(let params): + do { + try await server.sendNotification(params, method: method) + } catch { + throw ServerError.unableToSendNotification(error) + } + } + } - switch methodName { - case "conversation/invokeClientTool": - return true - case "conversation/invokeClientToolConfirmation": - return true - case "conversation/context": - return true - case "copilot/watchedFiles": - return true - case "window/showMessageRequest": - Logger.gitHubCopilot.info("\(methodName): \(debugDescription)") - return true - default: - return false // delegate the default handling to the server + /// Cancel ongoing completion requests. + public func cancelOngoingTasks() async { + let task = Task { @MainActor in + for id in ongoingCompletionRequestIDs { + await cancelTask(id) + } + self.ongoingCompletionRequestIDs = [] + } + await task.value + } + + public func cancelOngoingTask(_ workDoneToken: String) async { + let task = Task { @MainActor in + guard let id = ongoingConversationRequestIDs[workDoneToken] else { return } + await cancelTask(id) + } + await task.value + } + + public func cancelTask(_ id: JSONId) async { + guard let server = wrappedServer, let process = process, process.isRunning else { + return + } + + switch id { + case let .numericId(id): + try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) + case let .stringId(id): + try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) + } + } + + public func sendRequest( + _ request: ClientRequest + ) async throws -> Response { + guard let server = wrappedServer, let process = process, process.isRunning else { + throw ServerError.serverUnavailable + } + + do { + return try await server.sendRequest(request) + } catch { + throw ServerError.convertToServerError(error: error) } } } @@ -375,19 +305,10 @@ func encodeJSONParams(params: JSONValue?) -> String { return "N/A" } -extension CustomJSONRPCLanguageServer { - public func sendRequest( - _ request: ClientRequest, - completionHandler: @escaping (ServerResult) -> Void - ) { - internalServer.sendRequest(request, completionHandler: completionHandler) - } -} - // MARK: - Copilot custom notification public struct CopilotDidChangeWatchedFilesParams: Codable, Hashable { - /// The CLS need an additional paramter `workspaceUri` for "workspace/didChangeWatchedFiles" event + /// The CLS need an additional parameter `workspaceUri` for "workspace/didChangeWatchedFiles" event public var workspaceUri: String public var changes: [FileEvent] @@ -411,17 +332,3 @@ public enum CopilotClientNotification { } } } - -extension CustomJSONRPCLanguageServer: CopilotNotificationJSONRPCLanguageServer { - public func sendCopilotNotification(_ notif: CopilotClientNotification, completionHandler: @escaping (ServerError?) -> Void) { - let method = notif.method.rawValue - - switch notif { - case .copilotDidChangeWatchedFiles(let params): - // the protocolTransport is not exposed by LSP Server, need to use it directly - protocolTransport.sendNotification(params, method: method) { error in - completionHandler(error.map({ .unableToSendNotification($0) })) - } - } - } -} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift index 3718f849..a2baecbc 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift @@ -1,4 +1,5 @@ import Foundation +import Logger public extension Notification.Name { static let gitHubCopilotMCPToolsDidChange = Notification @@ -6,50 +7,45 @@ public extension Notification.Name { } public class CopilotMCPToolManager { - private static var availableMCPServerTools: [MCPServerToolsCollection] = [] - private static var updatedMCPToolsStatusParams: UpdateMCPToolsStatusParams = .init(servers: []) + private static var availableMCPServerTools: [MCPServerToolsCollection]? public static func updateMCPTools(_ serverToolsCollections: [MCPServerToolsCollection]) { let sortedMCPServerTools = serverToolsCollections.sorted(by: { $0.name.lowercased() < $1.name.lowercased() }) guard sortedMCPServerTools != availableMCPServerTools else { return } availableMCPServerTools = sortedMCPServerTools DispatchQueue.main.async { - NotificationCenter.default.post(name: .gitHubCopilotMCPToolsDidChange, object: nil) + Logger.client.info("Notify about MCP tools change: \(getToolsSummary())") + DistributedNotificationCenter.default().post(name: .gitHubCopilotMCPToolsDidChange, object: nil) } } - public static func getAvailableMCPTools() -> [MCPTool] { + private static func getToolsSummary() -> String { + var summary = "" + guard let tools = availableMCPServerTools else { return summary } + for server in tools { + summary += "Server: \(server.name) with \(server.tools.count) tools (\(server.tools.filter { $0._status == .enabled }.count) enabled, \(server.tools.filter { $0._status == .disabled }.count) disabled). " + } + + return summary + } + + public static func getAvailableMCPTools() -> [MCPTool]? { // Flatten all tools from all servers into a single array - return availableMCPServerTools.flatMap { $0.tools } + return availableMCPServerTools?.flatMap { $0.tools } } - public static func getAvailableMCPServerToolsCollections() -> [MCPServerToolsCollection] { + public static func getAvailableMCPServerToolsCollections() -> [MCPServerToolsCollection]? { return availableMCPServerTools } public static func hasMCPTools() -> Bool { - return !availableMCPServerTools.isEmpty - } - - public static func updateMCPToolsStatus(_ servers: [UpdateMCPToolsStatusServerCollection]) { - updatedMCPToolsStatusParams = .init(servers: servers) - DispatchQueue.main.async { - NotificationCenter.default - .post( - name: .gitHubCopilotShouldUpdateMCPToolsStatus, - object: nil - ) - } - } - - public static func getUpdatedMCPToolsStatusParams() -> UpdateMCPToolsStatusParams { - return updatedMCPToolsStatusParams + return availableMCPServerTools != nil && !availableMCPServerTools!.isEmpty } public static func clearMCPTools() { availableMCPServerTools = [] DispatchQueue.main.async { - NotificationCenter.default.post(name: .gitHubCopilotMCPToolsDidChange, object: nil) + DistributedNotificationCenter.default().post(name: .gitHubCopilotMCPToolsDidChange, object: nil) } } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotModelManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotModelManager.swift index ea4b9e23..898dd5b0 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotModelManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotModelManager.swift @@ -4,15 +4,19 @@ import Foundation public extension Notification.Name { static let gitHubCopilotModelsDidChange = Notification .Name("com.github.CopilotForXcode.CopilotModelsDidChange") + static let gitHubCopilotShouldSwitchFallbackModel = Notification + .Name("com.github.CopilotForXcode.CopilotShouldSwitchFallbackModel") } public class CopilotModelManager { private static var availableLLMs: [CopilotModel] = [] + private static var fallbackLLMs: [CopilotModel] = [] public static func updateLLMs(_ models: [CopilotModel]) { let sortedModels = models.sorted(by: { $0.modelName.lowercased() < $1.modelName.lowercased() }) guard sortedModels != availableLLMs else { return } availableLLMs = sortedModels + fallbackLLMs = models.filter({ $0.isChatFallback}) NotificationCenter.default.post(name: .gitHubCopilotModelsDidChange, object: nil) } @@ -23,6 +27,14 @@ public class CopilotModelManager { public static func hasLLMs() -> Bool { return !availableLLMs.isEmpty } + + public static func getFallbackLLM(scope: PromptTemplateScope) -> CopilotModel? { + return fallbackLLMs.first(where: { $0.scopes.contains(scope) && $0.billing?.isPremium == false}) + } + + public static func switchToFallbackModel() { + NotificationCenter.default.post(name: .gitHubCopilotShouldSwitchFallbackModel, object: nil) + } public static func clearLLMs() { availableLLMs = [] diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CustomJSONRPCServerConnection.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CustomJSONRPCServerConnection.swift new file mode 100644 index 00000000..d65e9c4c --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CustomJSONRPCServerConnection.swift @@ -0,0 +1,378 @@ +import Foundation +import LanguageClient +import JSONRPC +import LanguageServerProtocol + +/// A clone of the `JSONRPCServerConnection`. +/// We need it because the original one does not allow us to handle custom notifications. +public actor CustomJSONRPCServerConnection: ServerConnection { + public let eventSequence: EventSequence + private let eventContinuation: EventSequence.Continuation + + private let session: JSONRPCSession + + /// NOTE: The channel will wrapped with message framing + public init(dataChannel: DataChannel, notificationHandler: ((AnyJSONRPCNotification, Data) -> Bool)? = nil) { + self.notificationHandler = notificationHandler + self.session = JSONRPCSession(channel: dataChannel) + + (self.eventSequence, self.eventContinuation) = EventSequence.makeStream() + + Task { + await startMonitoringSession() + } + } + + deinit { + eventContinuation.finish() + } + + private func startMonitoringSession() async { + let seq = await session.eventSequence + + for await event in seq { + + switch event { + case let .notification(notification, data): + self.handleNotification(notification, data: data) + case let .request(request, handler, data): + self.handleRequest(request, data: data, handler: handler) + case .error: + break // TODO? + } + + } + + eventContinuation.finish() + } + + public func sendNotification(_ notif: ClientNotification) async throws { + let method = notif.method.rawValue + + switch notif { + case .initialized(let params): + try await session.sendNotification(params, method: method) + case .exit: + try await session.sendNotification(method: method) + case .textDocumentDidChange(let params): + try await session.sendNotification(params, method: method) + case .textDocumentDidOpen(let params): + try await session.sendNotification(params, method: method) + case .textDocumentDidClose(let params): + try await session.sendNotification(params, method: method) + case .textDocumentWillSave(let params): + try await session.sendNotification(params, method: method) + case .textDocumentDidSave(let params): + try await session.sendNotification(params, method: method) + case .workspaceDidChangeWatchedFiles(let params): + try await session.sendNotification(params, method: method) + case .protocolCancelRequest(let params): + try await session.sendNotification(params, method: method) + case .protocolSetTrace(let params): + try await session.sendNotification(params, method: method) + case .workspaceDidChangeWorkspaceFolders(let params): + try await session.sendNotification(params, method: method) + case .workspaceDidChangeConfiguration(let params): + try await session.sendNotification(params, method: method) + case .workspaceDidCreateFiles(let params): + try await session.sendNotification(params, method: method) + case .workspaceDidRenameFiles(let params): + try await session.sendNotification(params, method: method) + case .workspaceDidDeleteFiles(let params): + try await session.sendNotification(params, method: method) + case .windowWorkDoneProgressCancel(let params): + try await session.sendNotification(params, method: method) + } + } + + public func sendRequest(_ request: ClientRequest) async throws -> Response + where Response: Decodable & Sendable { + let method = request.method.rawValue + + switch request { + case .initialize(let params, _): + return try await session.response(to: method, params: params) + case .shutdown: + return try await session.response(to: method) + case .workspaceExecuteCommand(let params, _): + return try await session.response(to: method, params: params) + case .workspaceInlayHintRefresh: + return try await session.response(to: method) + case .workspaceWillCreateFiles(let params, _): + return try await session.response(to: method, params: params) + case .workspaceWillRenameFiles(let params, _): + return try await session.response(to: method, params: params) + case .workspaceWillDeleteFiles(let params, _): + return try await session.response(to: method, params: params) + case .workspaceSymbol(let params, _): + return try await session.response(to: method, params: params) + case .workspaceSymbolResolve(let params, _): + return try await session.response(to: method, params: params) + case .textDocumentWillSaveWaitUntil(let params, _): + return try await session.response(to: method, params: params) + case .completion(let params, _): + return try await session.response(to: method, params: params) + case .completionItemResolve(let params, _): + return try await session.response(to: method, params: params) + case .hover(let params, _): + return try await session.response(to: method, params: params) + case .signatureHelp(let params, _): + return try await session.response(to: method, params: params) + case .declaration(let params, _): + return try await session.response(to: method, params: params) + case .definition(let params, _): + return try await session.response(to: method, params: params) + case .typeDefinition(let params, _): + return try await session.response(to: method, params: params) + case .implementation(let params, _): + return try await session.response(to: method, params: params) + case .documentHighlight(let params, _): + return try await session.response(to: method, params: params) + case .documentSymbol(let params, _): + return try await session.response(to: method, params: params) + case .codeAction(let params, _): + return try await session.response(to: method, params: params) + case .codeActionResolve(let params, _): + return try await session.response(to: method, params: params) + case .codeLens(let params, _): + return try await session.response(to: method, params: params) + case .codeLensResolve(let params, _): + return try await session.response(to: method, params: params) + case .selectionRange(let params, _): + return try await session.response(to: method, params: params) + case .linkedEditingRange(let params, _): + return try await session.response(to: method, params: params) + case .prepareCallHierarchy(let params, _): + return try await session.response(to: method, params: params) + case .prepareRename(let params, _): + return try await session.response(to: method, params: params) + case .prepareTypeHierarchy(let params, _): + return try await session.response(to: method, params: params) + case .rename(let params, _): + return try await session.response(to: method, params: params) + case .inlayHint(let params, _): + return try await session.response(to: method, params: params) + case .inlayHintResolve(let params, _): + return try await session.response(to: method, params: params) + case .diagnostics(let params, _): + return try await session.response(to: method, params: params) + case .documentLink(let params, _): + return try await session.response(to: method, params: params) + case .documentLinkResolve(let params, _): + return try await session.response(to: method, params: params) + case .documentColor(let params, _): + return try await session.response(to: method, params: params) + case .colorPresentation(let params, _): + return try await session.response(to: method, params: params) + case .formatting(let params, _): + return try await session.response(to: method, params: params) + case .rangeFormatting(let params, _): + return try await session.response(to: method, params: params) + case .onTypeFormatting(let params, _): + return try await session.response(to: method, params: params) + case .references(let params, _): + return try await session.response(to: method, params: params) + case .foldingRange(let params, _): + return try await session.response(to: method, params: params) + case .moniker(let params, _): + return try await session.response(to: method, params: params) + case .semanticTokensFull(let params, _): + return try await session.response(to: method, params: params) + case .semanticTokensFullDelta(let params, _): + return try await session.response(to: method, params: params) + case .semanticTokensRange(let params, _): + return try await session.response(to: method, params: params) + case .callHierarchyIncomingCalls(let params, _): + return try await session.response(to: method, params: params) + case .callHierarchyOutgoingCalls(let params, _): + return try await session.response(to: method, params: params) + case let .custom(method, params, _): + return try await session.response(to: method, params: params) + } + } + + private func decodeNotificationParams(_ type: Params.Type, from data: Data) throws + -> Params where Params: Decodable + { + let note = try JSONDecoder().decode(JSONRPCNotification.self, from: data) + + guard let params = note.params else { + throw ProtocolError.missingParams + } + + return params + } + + private func yield(_ notification: ServerNotification) { + eventContinuation.yield(.notification(notification)) + } + + private func yield(id: JSONId, request: ServerRequest) { + eventContinuation.yield(.request(id: id, request: request)) + } + + private func handleNotification(_ anyNotification: AnyJSONRPCNotification, data: Data) { + // MARK: Handle custom notifications here. + if let handler = notificationHandler, handler(anyNotification, data) { + return + } + // MARK: End of custom notification handling. + + let methodName = anyNotification.method + + do { + guard let method = ServerNotification.Method(rawValue: methodName) else { + throw ProtocolError.unrecognizedMethod(methodName) + } + + switch method { + case .windowLogMessage: + let params = try decodeNotificationParams(LogMessageParams.self, from: data) + + yield(.windowLogMessage(params)) + case .windowShowMessage: + let params = try decodeNotificationParams(ShowMessageParams.self, from: data) + + yield(.windowShowMessage(params)) + case .textDocumentPublishDiagnostics: + let params = try decodeNotificationParams(PublishDiagnosticsParams.self, from: data) + + yield(.textDocumentPublishDiagnostics(params)) + case .telemetryEvent: + let params = anyNotification.params ?? .null + + yield(.telemetryEvent(params)) + case .protocolCancelRequest: + let params = try decodeNotificationParams(CancelParams.self, from: data) + + yield(.protocolCancelRequest(params)) + case .protocolProgress: + let params = try decodeNotificationParams(ProgressParams.self, from: data) + + yield(.protocolProgress(params)) + case .protocolLogTrace: + let params = try decodeNotificationParams(LogTraceParams.self, from: data) + + yield(.protocolLogTrace(params)) + } + } catch { + // should we backchannel this to the client somehow? + print("failed to relay notification: \(error)") + } + } + + private func decodeRequestParams(_ type: Params.Type, from data: Data) throws -> Params + where Params: Decodable { + let req = try JSONDecoder().decode(JSONRPCRequest.self, from: data) + + guard let params = req.params else { + throw ProtocolError.missingParams + } + + return params + } + + private nonisolated func makeErrorOnlyHandler(_ handler: @escaping JSONRPCEvent.RequestHandler) + -> ServerRequest.ErrorOnlyHandler + { + return { + if let error = $0 { + await handler(.failure(error)) + } else { + await handler(.success(JSONValue.null)) + } + } + } + + private nonisolated func makeHandler(_ handler: @escaping JSONRPCEvent.RequestHandler) + -> ServerRequest.Handler + { + return { + let loweredResult = $0.map({ $0 as Encodable & Sendable }) + + await handler(loweredResult) + } + } + + private func handleRequest( + _ anyRequest: AnyJSONRPCRequest, data: Data, handler: @escaping JSONRPCEvent.RequestHandler + ) { + let methodName = anyRequest.method + let id = anyRequest.id + + do { + + let method = ServerRequest.Method(rawValue: methodName) ?? .custom + switch method { + case .workspaceConfiguration: + let params = try decodeRequestParams(ConfigurationParams.self, from: data) + let reqHandler: ServerRequest.Handler<[LSPAny]> = makeHandler(handler) + + yield(id: id, request: ServerRequest.workspaceConfiguration(params, reqHandler)) + case .workspaceFolders: + let reqHandler: ServerRequest.Handler = makeHandler( + handler) + + yield(id: id, request: ServerRequest.workspaceFolders(reqHandler)) + case .workspaceApplyEdit: + let params = try decodeRequestParams(ApplyWorkspaceEditParams.self, from: data) + let reqHandler: ServerRequest.Handler = makeHandler( + handler) + + yield(id: id, request: ServerRequest.workspaceApplyEdit(params, reqHandler)) + case .clientRegisterCapability: + let params = try decodeRequestParams(RegistrationParams.self, from: data) + let reqHandler = makeErrorOnlyHandler(handler) + + yield(id: id, request: ServerRequest.clientRegisterCapability(params, reqHandler)) + case .clientUnregisterCapability: + let params = try decodeRequestParams(UnregistrationParams.self, from: data) + let reqHandler = makeErrorOnlyHandler(handler) + + yield(id: id, request: ServerRequest.clientUnregisterCapability(params, reqHandler)) + case .workspaceCodeLensRefresh: + let reqHandler = makeErrorOnlyHandler(handler) + + yield(id: id, request: ServerRequest.workspaceCodeLensRefresh(reqHandler)) + case .workspaceSemanticTokenRefresh: + let reqHandler = makeErrorOnlyHandler(handler) + + yield(id: id, request: ServerRequest.workspaceSemanticTokenRefresh(reqHandler)) + case .windowShowMessageRequest: + let params = try decodeRequestParams(ShowMessageRequestParams.self, from: data) + let reqHandler: ServerRequest.Handler = makeHandler( + handler) + + yield(id: id, request: ServerRequest.windowShowMessageRequest(params, reqHandler)) + case .windowShowDocument: + let params = try decodeRequestParams(ShowDocumentParams.self, from: data) + let reqHandler: ServerRequest.Handler = makeHandler(handler) + + yield(id: id, request: ServerRequest.windowShowDocument(params, reqHandler)) + case .windowWorkDoneProgressCreate: + let params = try decodeRequestParams(WorkDoneProgressCreateParams.self, from: data) + let reqHandler = makeErrorOnlyHandler(handler) + + yield( + id: id, request: ServerRequest.windowWorkDoneProgressCreate(params, reqHandler)) + case .custom: + let params = try decodeRequestParams(LSPAny.self, from: data) + let reqHandler: ServerRequest.Handler = makeHandler(handler) + + yield(id: id, request: ServerRequest.custom(methodName, params, reqHandler)) + + } + + } catch { + // should we backchannel this to the client somehow? + print("failed to relay request: \(error)") + } + } + + // MARK: New properties/methods to handle custom copilot notifications + private var notificationHandler: ((AnyJSONRPCNotification, Data) -> Bool)? + + public func sendNotification(_ params: Note, method: String) async throws where Note: Encodable { + try await self.session.sendNotification(params, method: method) + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift deleted file mode 100644 index 82e98544..00000000 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import JSONRPC -import os.log - -public class CustomDataTransport: DataTransport { - let nextTransport: DataTransport - - var onWriteRequest: (JSONRPCRequest) -> Void = { _ in } - - init(nextTransport: DataTransport) { - self.nextTransport = nextTransport - } - - public func write(_ data: Data) { - if let request = try? JSONDecoder().decode(JSONRPCRequest.self, from: data) { - onWriteRequest(request) - } - - nextTransport.write(data) - } - - public func setReaderHandler(_ handler: @escaping ReadHandler) { - nextTransport.setReaderHandler(handler) - } - - public func close() { - nextTransport.close() - } -} - diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 086769cb..0d89df9c 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -51,7 +51,7 @@ public struct GitHubCopilotCodeSuggestion: Codable, Equatable { public var displayText: String } -public func editorConfiguration() -> JSONValue { +public func editorConfiguration(includeMCP: Bool) -> JSONValue { var proxyAuthorization: String? { let username = UserDefaults.shared.value(for: \.gitHubCopilotProxyUsername) if username.isEmpty { return nil } @@ -87,14 +87,22 @@ public func editorConfiguration() -> JSONValue { let mcpConfig = UserDefaults.shared.value(for: \.gitHubCopilotMCPConfig) return JSONValue.string(mcpConfig) } - + + var customInstructions: JSONValue? { + let instructions = UserDefaults.shared.value(for: \.globalCopilotInstructions) + return .string(instructions) + } + var d: [String: JSONValue] = [:] if let http { d["http"] = http } if let authProvider { d["github-enterprise"] = authProvider } - if let mcp { + if (includeMCP && mcp != nil) || customInstructions != nil { var github: [String: JSONValue] = [:] var copilot: [String: JSONValue] = [:] - copilot["mcp"] = mcp + if includeMCP { + copilot["mcp"] = mcp + } + copilot["globalCopilotInstructions"] = customInstructions github["copilot"] = .hash(copilot) d["github"] = .hash(github) } @@ -113,7 +121,7 @@ enum GitHubCopilotRequest { } var request: ClientRequest { - .custom("getVersion", .hash([:])) + .custom("getVersion", .hash([:]), ClientRequest.NullHandler) } } @@ -124,7 +132,15 @@ enum GitHubCopilotRequest { } var request: ClientRequest { - .custom("checkStatus", .hash([:])) + .custom("checkStatus", .hash([:]), ClientRequest.NullHandler) + } + } + + struct CheckQuota: GitHubCopilotRequestType { + typealias Response = GitHubCopilotQuotaInfo + + var request: ClientRequest { + .custom("checkQuota", .hash([:]), ClientRequest.NullHandler) } } @@ -139,7 +155,7 @@ enum GitHubCopilotRequest { } var request: ClientRequest { - .custom("signInInitiate", .hash([:])) + .custom("signInInitiate", .hash([:]), ClientRequest.NullHandler) } } @@ -154,7 +170,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { .custom("signInConfirm", .hash([ "userCode": .string(userCode), - ])) + ]), ClientRequest.NullHandler) } } @@ -164,7 +180,7 @@ enum GitHubCopilotRequest { } var request: ClientRequest { - .custom("signOut", .hash([:])) + .custom("signOut", .hash([:]), ClientRequest.NullHandler) } } @@ -180,7 +196,7 @@ enum GitHubCopilotRequest { let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) return .custom("getCompletions", .hash([ "doc": dict, - ])) + ]), ClientRequest.NullHandler) } } @@ -196,7 +212,7 @@ enum GitHubCopilotRequest { let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) return .custom("getCompletionsCycling", .hash([ "doc": dict, - ])) + ]), ClientRequest.NullHandler) } } @@ -246,7 +262,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(doc)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("textDocument/inlineCompletion", dict) + return .custom("textDocument/inlineCompletion", dict, ClientRequest.NullHandler) } } @@ -262,7 +278,7 @@ enum GitHubCopilotRequest { let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) return .custom("getPanelCompletions", .hash([ "doc": dict, - ])) + ]), ClientRequest.NullHandler) } } @@ -274,7 +290,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { .custom("notifyShown", .hash([ "uuid": .string(completionUUID), - ])) + ]), ClientRequest.NullHandler) } } @@ -293,7 +309,7 @@ enum GitHubCopilotRequest { dict["acceptedLength"] = .number(Double(acceptedLength)) } - return .custom("notifyAccepted", .hash(dict)) + return .custom("notifyAccepted", .hash(dict), ClientRequest.NullHandler) } } @@ -305,7 +321,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { .custom("notifyRejected", .hash([ "uuids": .array(completionUUIDs.map(JSONValue.string)), - ])) + ]), ClientRequest.NullHandler) } } @@ -319,7 +335,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("conversation/create", dict) + return .custom("conversation/create", dict, ClientRequest.NullHandler) } } @@ -333,7 +349,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("conversation/turn", dict) + return .custom("conversation/turn", dict, ClientRequest.NullHandler) } } @@ -347,7 +363,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("conversation/rating", dict) + return .custom("conversation/rating", dict, ClientRequest.NullHandler) } } @@ -355,9 +371,13 @@ enum GitHubCopilotRequest { struct GetTemplates: GitHubCopilotRequestType { typealias Response = Array + + var params: ConversationTemplatesParams var request: ClientRequest { - .custom("conversation/templates", .hash([:])) + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/templates", dict, ClientRequest.NullHandler) } } @@ -365,7 +385,7 @@ enum GitHubCopilotRequest { typealias Response = Array var request: ClientRequest { - .custom("copilot/models", .hash([:])) + .custom("copilot/models", .hash([:]), ClientRequest.NullHandler) } } @@ -379,7 +399,33 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("mcp/updateToolsStatus", dict) + return .custom("mcp/updateToolsStatus", dict, ClientRequest.NullHandler) + } + } + + // 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) } } @@ -389,19 +435,45 @@ enum GitHubCopilotRequest { typealias Response = Array var request: ClientRequest { - .custom("conversation/agents", .hash([:])) + .custom("conversation/agents", .hash([:]), ClientRequest.NullHandler) + } + } + + // MARK: - Code Review + + struct ReviewChanges: GitHubCopilotRequestType { + typealias Response = CodeReviewResult + + var params: ReviewChangesParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("copilot/codeReview/reviewChanges", dict, ClientRequest.NullHandler) } } struct RegisterTools: GitHubCopilotRequestType { - struct Response: Codable {} + typealias Response = Array var params: RegisterToolsParams var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("conversation/registerTools", dict) + return .custom("conversation/registerTools", dict, ClientRequest.NullHandler) + } + } + + 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) } } @@ -415,7 +487,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("conversation/copyCode", dict) + return .custom("conversation/copyCode", dict, ClientRequest.NullHandler) } } @@ -429,7 +501,80 @@ enum GitHubCopilotRequest { var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) - return .custom("telemetry/exception", dict) + return .custom("telemetry/exception", dict, ClientRequest.NullHandler) + } + } + + // 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) } } } @@ -468,4 +613,23 @@ public enum GitHubCopilotNotification { } } + + public struct MCPRuntimeNotification: Codable { + public enum MCPRuntimeLogLevel: String, Codable { + case Info = "info" + case Warning = "warning" + case Error = "error" + } + + public var level: MCPRuntimeLogLevel + public var message: String + public var server: String + public var tool: String? + public var time: Double + + public static func decode(fromParams params: JSONValue?) -> MCPRuntimeNotification? { + try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data()) + } + } + } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/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 66% rename from Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift index ed0b0c02..355e04ee 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift @@ -10,7 +10,7 @@ enum ConversationSource: String, Codable { case panel, inline } -public struct Reference: Codable, Equatable, Hashable { +public struct FileReference: Codable, Equatable, Hashable { public var type: String = "file" public let uri: String public let position: Position? @@ -20,9 +20,71 @@ public struct Reference: Codable, Equatable, Hashable { public let activeAt: String? } +public struct DirectoryReference: Codable, Equatable, Hashable { + public var type: String = "directory" + public let uri: String +} + +public enum Reference: Codable, Equatable, Hashable { + case file(FileReference) + case directory(DirectoryReference) + + public func encode(to encoder: Encoder) throws { + switch self { + case .file(let fileRef): + try fileRef.encode(to: encoder) + case .directory(let directoryRef): + try directoryRef.encode(to: encoder) + } + } + + 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)) + } + } +} + struct ConversationCreateParams: Codable { var workDoneToken: String - var turns: [ConversationTurn] + var turns: [TurnSchema] var capabilities: Capabilities var textDocument: Doc? var references: [Reference]? @@ -32,8 +94,10 @@ struct ConversationCreateParams: Codable { var workspaceFolders: [WorkspaceFolder]? var ignoredSkills: [String]? var model: String? + var modelProviderName: String? var chatMode: String? var needToolCallConfirmation: Bool? + var userLanguage: String? struct Capabilities: Codable { var skills: [String] @@ -65,7 +129,7 @@ public struct ConversationProgressReport: BaseConversationProgress { public let conversationId: String public let turnId: String public let reply: String? - public let references: [Reference]? + public let references: [FileReference]? public let steps: [ConversationProgressStep]? public let editAgentRounds: [AgentRound]? } @@ -120,22 +184,22 @@ struct ConversationRatingParams: Codable { var source: ConversationSource? } -// MARK: Conversation turn - -struct ConversationTurn: Codable { - var request: String - var response: String? - var turnId: String? +// MARK: Conversation templates +struct ConversationTemplatesParams: Codable { + var workspaceFolders: [WorkspaceFolder]? } +// MARK: Conversation turn struct TurnCreateParams: Codable { var workDoneToken: String var conversationId: String - var message: String + var turnId: String? + var message: MessageContent var textDocument: Doc? var ignoredSkills: [String]? var references: [Reference]? var model: String? + var modelProviderName: String? var workspaceFolder: String? var workspaceFolders: [WorkspaceFolder]? var chatMode: String? diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift similarity index 90% rename from Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift index 431ca5ed..e7d37610 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift @@ -1,6 +1,7 @@ import Foundation import JSONRPC import LanguageServerProtocol +import ConversationServiceProvider public enum MCPServerStatus: String, Codable, Equatable, Hashable { case running = "running" @@ -8,11 +9,6 @@ public enum MCPServerStatus: String, Codable, Equatable, Hashable { case error = "error" } -public enum MCPToolStatus: String, Codable, Equatable, Hashable { - case enabled = "enabled" - case disabled = "disabled" -} - public struct InputSchema: Codable, Equatable, Hashable { public var type: String = "object" public var properties: [String: JSONValue]? @@ -81,14 +77,14 @@ public struct ToolAnnotations: Codable, Equatable, Hashable { public struct MCPTool: Codable, Equatable, Hashable { public let name: String public let description: String? - public let _status: MCPToolStatus + public let _status: ToolStatus public let inputSchema: InputSchema public var annotations: ToolAnnotations? public init( name: String, description: String? = nil, - _status: MCPToolStatus, + _status: ToolStatus, inputSchema: InputSchema, annotations: ToolAnnotations? = nil ) { @@ -132,9 +128,9 @@ public struct GetAllToolsParams: Codable, Hashable { public struct UpdatedMCPToolsStatus: Codable, Hashable { public var name: String - public var status: MCPToolStatus + public var status: ToolStatus - public init(name: String, status: MCPToolStatus) { + public init(name: String, status: ToolStatus) { self.name = name self.status = status } @@ -159,3 +155,14 @@ public struct UpdateMCPToolsStatusParams: Codable, Hashable { } public typealias CopilotMCPToolsRequest = JSONRPCRequest + +public struct MCPOAuthRequestParams: Codable, Hashable { + public var mcpServer: String + public var authLabel: String +} + +public struct MCPOAuthResponse: Codable, Hashable { + public var confirm: Bool +} + +public typealias MCPOAuthRequest = JSONRPCRequest diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift new file mode 100644 index 00000000..dc891647 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift @@ -0,0 +1,459 @@ +import Foundation +import JSONRPC +import ConversationServiceProvider + +/// Schema definitions for MCP Registry API based on the OpenAPI spec: +/// https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/openapi.yaml + +// MARK: - Repository + +public struct Repository: Codable { + public let url: String + public let source: String + public let id: String? + public let subfolder: String? + + enum CodingKeys: String, CodingKey { + case url, source, id, subfolder + } +} + +// MARK: - Server Status + +public enum ServerStatus: String, Codable { + case active + case deprecated +} + +// MARK: - Base Input Protocol + +public protocol InputProtocol: Codable { + var description: String? { get } + var isRequired: Bool? { get } + var format: ArgumentFormat? { get } + var value: String? { get } + var isSecret: Bool? { get } + var defaultValue: String? { get } + var choices: [String]? { get } +} + +// MARK: - Input (base type) + +public struct Input: InputProtocol { + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let choices: [String]? + + enum CodingKeys: String, CodingKey { + case description + case isRequired = "is_required" + case format + case value + case isSecret = "is_secret" + case defaultValue = "default" + case choices + } +} + +// MARK: - Input with Variables + +public struct InputWithVariables: InputProtocol { + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let choices: [String]? + public let variables: [String: Input]? + + enum CodingKeys: String, CodingKey { + case description + case isRequired = "is_required" + case format + case value + case isSecret = "is_secret" + case defaultValue = "default" + case choices + case variables + } +} + +// MARK: - Argument Format + +public enum ArgumentFormat: String, Codable { + case string + case number + case boolean + case filepath +} + +// MARK: - Argument Type + +public enum ArgumentType: String, Codable { + case positional + case named +} + +// MARK: - Base Argument Protocol + +public protocol ArgumentProtocol: InputProtocol { + var type: ArgumentType { get } + var variables: [String: Input]? { get } +} + +// MARK: - Positional Argument + +public struct PositionalArgument: ArgumentProtocol { + public let type: ArgumentType = .positional + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let choices: [String]? + public let variables: [String: Input]? + public let valueHint: String? + public let isRepeated: Bool? + + enum CodingKeys: String, CodingKey { + case type, description, format, value, choices, variables + case isRequired = "is_required" + case isSecret = "is_secret" + case defaultValue = "default" + case valueHint = "value_hint" + case isRepeated = "is_repeated" + } +} + +// MARK: - Named Argument + +public struct NamedArgument: ArgumentProtocol { + public let type: ArgumentType = .named + public let name: String + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let choices: [String]? + public let variables: [String: Input]? + public let isRepeated: Bool? + + enum CodingKeys: String, CodingKey { + case type, name, description, format, value, choices, variables + case isRequired = "is_required" + case isSecret = "is_secret" + case defaultValue = "default" + case isRepeated = "is_repeated" + } +} + +// MARK: - Argument Enum + +public enum Argument: Codable { + case positional(PositionalArgument) + case named(NamedArgument) + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Discriminator.self) + let type = try container.decode(ArgumentType.self, forKey: .type) + switch type { + case .positional: + self = .positional(try PositionalArgument(from: decoder)) + case .named: + self = .named(try NamedArgument(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .positional(let arg): + try arg.encode(to: encoder) + case .named(let arg): + try arg.encode(to: encoder) + } + } + + private enum Discriminator: String, CodingKey { + case type + } +} + +// MARK: - KeyValueInput + +public struct KeyValueInput: InputProtocol { + public let name: String + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let choices: [String]? + public let variables: [String: Input]? + + enum CodingKeys: String, CodingKey { + case name, description, format, value, choices, variables + case isRequired = "is_required" + case isSecret = "is_secret" + case defaultValue = "default" + } +} + +// MARK: - Package + +public struct Package: Codable { + public let registryType: String? + public let registryBaseURL: String? + public let identifier: String? + public let version: String? + public let fileSHA256: String? + public let runtimeHint: String? + public let runtimeArguments: [Argument]? + public let packageArguments: [Argument]? + public let environmentVariables: [KeyValueInput]? + + enum CodingKeys: String, CodingKey { + case version, identifier + case registryType = "registry_type" + case registryBaseURL = "registry_base_url" + case fileSHA256 = "file_sha256" + case runtimeHint = "runtime_hint" + case runtimeArguments = "runtime_arguments" + case packageArguments = "package_arguments" + case environmentVariables = "environment_variables" + } +} + +// MARK: - Transport Type + +public enum TransportType: String, Codable { + case streamable = "streamable" + case streamableHttp = "streamable-http" + case sse = "sse" +} + +// MARK: - Remote + +public struct Remote: Codable { + public let transportType: TransportType + public let url: String + public let headers: [KeyValueInput]? + + enum CodingKeys: String, CodingKey { + case url, headers + case transportType = "type" + } +} + +// MARK: - Publisher Provided Meta + +public struct PublisherProvidedMeta: Codable { + public let tool: String? + public let version: String? + public let buildInfo: BuildInfo? + private let additionalProperties: [String: AnyCodable]? + + public struct BuildInfo: Codable { + public let commit: String? + public let timestamp: String? + public let pipelineID: String? + + enum CodingKeys: String, CodingKey { + case commit, timestamp + case pipelineID = "pipeline_id" + } + } + + enum CodingKeys: String, CodingKey { + case tool, version + case buildInfo = "build_info" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + tool = try container.decodeIfPresent(String.self, forKey: .tool) + version = try container.decodeIfPresent(String.self, forKey: .version) + buildInfo = try container.decodeIfPresent(BuildInfo.self, forKey: .buildInfo) + + // Capture additional properties + let allKeys = try decoder.container(keyedBy: AnyCodingKey.self) + var extras: [String: AnyCodable] = [:] + + for key in allKeys.allKeys { + if !["tool", "version", "build_info"].contains(key.stringValue) { + extras[key.stringValue] = try allKeys.decode(AnyCodable.self, forKey: key) + } + } + additionalProperties = extras.isEmpty ? nil : extras + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(tool, forKey: .tool) + try container.encodeIfPresent(version, forKey: .version) + try container.encodeIfPresent(buildInfo, forKey: .buildInfo) + + if let additionalProperties = additionalProperties { + var dynamicContainer = encoder.container(keyedBy: AnyCodingKey.self) + for (key, value) in additionalProperties { + try dynamicContainer.encode(value, forKey: AnyCodingKey(stringValue: key)!) + } + } + } +} + +// MARK: - Official Meta + +public struct OfficialMeta: Codable { + public let id: String + public let publishedAt: String + public let updatedAt: String + public let isLatest: Bool + + enum CodingKeys: String, CodingKey { + case id + case publishedAt = "published_at" + case updatedAt = "updated_at" + case isLatest = "is_latest" + } +} + +// MARK: - Server Meta + +public struct ServerMeta: Codable { + public let publisherProvided: PublisherProvidedMeta? + public let official: OfficialMeta? + private let additionalProperties: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case publisherProvided = "io.modelcontextprotocol.registry/publisher-provided" + case official = "io.modelcontextprotocol.registry/official" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + publisherProvided = try container.decodeIfPresent(PublisherProvidedMeta.self, forKey: .publisherProvided) + official = try container.decodeIfPresent(OfficialMeta.self, forKey: .official) + + // Capture additional properties + let allKeys = try decoder.container(keyedBy: AnyCodingKey.self) + var extras: [String: AnyCodable] = [:] + + let knownKeys = ["io.modelcontextprotocol.registry/publisher-provided", "io.modelcontextprotocol.registry/official"] + for key in allKeys.allKeys { + if !knownKeys.contains(key.stringValue) { + extras[key.stringValue] = try allKeys.decode(AnyCodable.self, forKey: key) + } + } + additionalProperties = extras.isEmpty ? nil : extras + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(publisherProvided, forKey: .publisherProvided) + try container.encodeIfPresent(official, forKey: .official) + + if let additionalProperties = additionalProperties { + var dynamicContainer = encoder.container(keyedBy: AnyCodingKey.self) + for (key, value) in additionalProperties { + try dynamicContainer.encode(value, forKey: AnyCodingKey(stringValue: key)!) + } + } + } +} + +// MARK: - Dynamic Coding Key Helper + +private struct AnyCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } +} + +// MARK: - Server Detail + +public struct MCPRegistryServerDetail: Codable { + public let name: String + public let description: String + public let status: ServerStatus? + public let repository: Repository? + public let version: String + public let websiteURL: String? + public let createdAt: String? + public let updatedAt: String? + public let schemaURL: String? + public let packages: [Package]? + public let remotes: [Remote]? + public let meta: ServerMeta? + + enum CodingKeys: String, CodingKey { + case name, description, status, repository, version, packages, remotes + case websiteURL = "website_url" + case createdAt = "created_at" + case updatedAt = "updated_at" + case schemaURL = "$schema" + case meta = "_meta" + } +} + +// MARK: - Server List Metadata + +public struct MCPRegistryServerListMetadata: Codable { + public let nextCursor: String? + public let count: Int? + + enum CodingKeys: String, CodingKey { + case nextCursor = "next_cursor" + case count + } +} + +// MARK: - Server List + +public struct MCPRegistryServerList: Codable { + public let servers: [MCPRegistryServerDetail] + public let metadata: MCPRegistryServerListMetadata? +} + +// MARK: - Request Parameters + +public struct MCPRegistryListServersParams: Codable { + public let baseUrl: String + public let cursor: String? + public let limit: Int? + + public init(baseUrl: String, cursor: String? = nil, limit: Int? = nil) { + self.baseUrl = baseUrl + self.cursor = cursor + self.limit = limit + } +} + +public struct MCPRegistryGetServerParams: Codable { + public let baseUrl: String + public let id: String + public let version: String? + + public init(baseUrl: String, id: String, version: String?) { + self.baseUrl = baseUrl + self.id = id + self.version = version + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Message.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Message.swift new file mode 100644 index 00000000..b43ec840 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Message.swift @@ -0,0 +1,4 @@ +import JSONRPC +import LanguageServerProtocol + +public typealias ShowMessageRequest = JSONRPCRequest 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 6992d42f..5a2acb3c 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -11,9 +11,11 @@ import Preferences import Status import SuggestionBasic import SystemUtils +import Persist public protocol GitHubCopilotAuthServiceType { func checkStatus() async throws -> GitHubCopilotAccountStatus + func checkQuota() async throws -> GitHubCopilotQuotaInfo func signInInitiate() async throws -> (status: SignInInitiateStatus, verificationUri: String?, userCode: String?, user: String?) func signInConfirm(userCode: String) async throws -> (username: String, status: GitHubCopilotAccountStatus) @@ -51,38 +53,43 @@ public protocol GitHubCopilotTelemetryServiceType { } public protocol GitHubCopilotConversationServiceType { - func createConversation(_ message: String, + func createConversation(_ message: MessageContent, workDoneToken: String, workspaceFolder: String, workspaceFolders: [WorkspaceFolder]?, activeDoc: Doc?, skills: [String], ignoredSkills: [String]?, - references: [FileReference], + references: [ConversationAttachedReference], model: String?, + modelProviderName: String?, turns: [TurnSchema], - agentMode: Bool) async throws - func createTurn(_ message: String, + agentMode: Bool, + userLanguage: String?) async throws + 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 func rateConversation(turnId: String, rating: ConversationRating) async throws func copyCode(turnId: String, codeBlockIndex: Int, copyType: CopyKind, copiedCharacters: Int, totalCharacters: Int, copiedText: String) async throws func cancelProgress(token: String) async - func templates() async throws -> [ChatTemplate] + func templates(workspaceFolders: [WorkspaceFolder]?) async throws -> [ChatTemplate] func models() async throws -> [CopilotModel] - func registerTools(tools: [LanguageModelToolInformation]) async throws + func registerTools(tools: [LanguageModelToolInformation]) async throws -> [LanguageModelTool] + func updateToolsStatus(params: UpdateToolsStatusParams) async throws -> [LanguageModelTool] } protocol GitHubCopilotLSP { + var eventSequence: ServerConnection.EventSequence { get } func sendRequest(_ endpoint: E) async throws -> E.Response - func sendRequest(_ endpoint: E, timeout: TimeInterval) async throws -> E.Response func sendNotification(_ notif: ClientNotification) async throws } @@ -131,6 +138,8 @@ public enum GitHubCopilotError: Error, LocalizedError { return "Language server error: Invalid request" case .timeout: return "Language server error: Timeout, please try again later" + case .unknownError: + return "Language server error: An unknown error occurred: \(error)" } } } @@ -139,8 +148,6 @@ public enum GitHubCopilotError: Error, LocalizedError { public extension Notification.Name { static let gitHubCopilotShouldRefreshEditorInformation = Notification .Name("com.github.CopilotForXcode.GitHubCopilotShouldRefreshEditorInformation") - static let gitHubCopilotShouldUpdateMCPToolsStatus = Notification - .Name("com.github.CopilotForXcode.gitHubCopilotShouldUpdateMCPToolsStatus") } public class GitHubCopilotBaseService { @@ -175,6 +182,8 @@ public class GitHubCopilotBaseService { } } + environment["PATH"] = SystemUtils.shared.appendCommonBinPaths(path: environment["PATH"] ?? "") + let versionNumber = JSONValue( stringLiteral: SystemUtils.editorPluginVersion ?? "" ) @@ -223,13 +232,8 @@ public class GitHubCopilotBaseService { Logger.gitHubCopilot.info("Running on Xcode \(xcodeVersion), extension version \(versionNumber)") let localServer = CopilotLocalProcessServer(executionParameters: executionParams) - localServer.notificationHandler = { _, respond in - respond(.timeout) - } - let server = InitializingServer(server: localServer) - // TODO: set proper timeout against different request. - server.defaultTimeout = 90 - server.initializeParamsProvider = { + + let initializeParamsProvider = { @Sendable () -> InitializeParams in let capabilities = ClientCapabilities( workspace: .init( applyEdit: false, @@ -249,6 +253,7 @@ public class GitHubCopilotBaseService { experimental: nil ) + let authAppId = Bundle.main.infoDictionary?["GITHUB_APP_ID"] as? String return InitializeParams( processId: Int(ProcessInfo.processInfo.processIdentifier), locale: nil, @@ -266,7 +271,9 @@ public class GitHubCopilotBaseService { "copilotCapabilities": [ /// The editor has support for watching files over LSP "watchedFiles": watchedFiles, - ] + "didChangeFeatureFlags": true + ], + "githubAppId": authAppId.map(JSONValue.string) ?? .null, ], capabilities: capabilities, trace: .off, @@ -276,40 +283,17 @@ public class GitHubCopilotBaseService { )] ) } + + let server = SafeInitializingServer(InitializingServer(server: localServer, initializeParamsProvider: initializeParamsProvider)) return (server, localServer) }() self.server = server localProcessServer = localServer - - let notifications = NotificationCenter.default - .notifications(named: .gitHubCopilotShouldRefreshEditorInformation) - Task { [weak self] in - if projectRootURL.path != "/" { - try? await server.sendNotification( - .workspaceDidChangeWorkspaceFolders( - .init(event: .init(added: [.init(uri: projectRootURL.absoluteString, name: projectRootURL.lastPathComponent)], removed: [])) - ) - ) - } - // Send workspace/didChangeConfiguration once after initalize - _ = try? await server.sendNotification( - .workspaceDidChangeConfiguration( - .init(settings: editorConfiguration()) - ) - ) - for await _ in notifications { - guard self != nil else { return } - _ = try? await server.sendNotification( - .workspaceDidChangeConfiguration( - .init(settings: editorConfiguration()) - ) - ) - } - } } - + + public static func createFoldersIfNeeded() throws -> ( applicationSupportURL: URL, @@ -383,41 +367,16 @@ func getTerminalEnvironmentVariables(_ variableNames: [String]) -> [String: Stri guard let shell = userShell else { return results } - - let process = Process() - let pipe = Pipe() - - process.executableURL = URL(fileURLWithPath: shell) - process.arguments = ["-l", "-c", "env"] - process.standardOutput = pipe - - do { - try process.run() - process.waitUntilExit() - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let outputString = String(data: data, encoding: .utf8) else { - Logger.gitHubCopilot.info("Failed to decode shell output for variables: \(variableNames.joined(separator: ", "))") - return results - } - // Process each line of env output - for line in outputString.split(separator: "\n") { - // Each env line is in the format NAME=VALUE - if let idx = line.firstIndex(of: "=") { - let key = String(line[.. [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, @@ -438,6 +421,11 @@ public final class GitHubCopilotService: private var cancellables = Set() private var statusWatcher: CopilotAuthStatusWatcher? private static var services: [GitHubCopilotService] = [] // cache all alive copilot service instances + private var isMCPInitialized = false + private var unrestoredMcpServers: [String] = [] + private var mcpRuntimeLogFileName: String = "" + private static let toolInitializationActor = ToolInitializationActor() + private var lastSentConfiguration: JSONValue? override init(designatedServer: any GitHubCopilotLSP) { super.init(designatedServer: designatedServer) @@ -446,25 +434,58 @@ 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 != "/" { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + Task { @MainActor in + await self.handleMCPToolsNotification(notification) + } + } + } + + if notification.method == "copilot/mcpRuntimeLogs" && projectRootURL.path != "/" { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + Task { @MainActor in + await self.handleMCPRuntimeLogsNotification(notification) + } + } + } + self?.serverNotificationHandler.handleNotification(notification) }).store(in: &cancellables) - localProcessServer?.serverRequestPublisher.sink(receiveValue: { [weak self] (request, callback) in - self?.serverRequestHandler.handleRequest(request, workspaceURL: workspaceURL, callback: callback, service: self) - }).store(in: &cancellables) + + Task { + for await event in server.eventSequence { + switch event { + case let .request(id, request): + switch request { + case let .custom(method, params, callback): + if method == "copilot/mcpOAuth" && projectRootURL.path == "/" { + continue + } + self.serverRequestHandler.handleRequest(.init(id: id, method: method, params: params), workspaceURL: workspaceURL, callback: callback, service: self) + default: + break + } + default: + break + } + } + } + updateStatusInBackground() GitHubCopilotService.services.append(self) - - setupMCPInformationObserver() - - NotificationCenter.default.post( - name: .gitHubCopilotShouldUpdateMCPToolsStatus, - object: nil - ) Task { - await registerClientTools(server: self) + let tools = await registerClientTools(server: self) + CopilotLanguageModelToolManager.updateToolsStatus(tools) + await restoreRegisteredToolsStatus() } } catch { Logger.gitHubCopilot.error(error) @@ -476,30 +497,7 @@ public final class GitHubCopilotService: deinit { GitHubCopilotService.services.removeAll { $0 === self } } - - // Setup notification observer for refreshing MCP information - private func setupMCPInformationObserver() { - NotificationCenter.default.addObserver( - forName: .gitHubCopilotShouldUpdateMCPToolsStatus, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @GitHubCopilotSuggestionActor [weak self] in - guard let self = self else { return } - do { - let servers = try await self.updateMCPToolsStatus( - params: CopilotMCPToolManager - .getUpdatedMCPToolsStatusParams() - ) - CopilotMCPToolManager.updateMCPTools(servers) - } catch { - Logger.gitHubCopilot.error("Failed to send notification: \(error)") - } - - } - } - } - + @GitHubCopilotSuggestionActor public func getCompletions( fileURL: URL, @@ -605,51 +603,53 @@ public final class GitHubCopilotService: } @GitHubCopilotSuggestionActor - public func createConversation(_ message: String, + public func createConversation(_ message: MessageContent, workDoneToken: String, workspaceFolder: String, workspaceFolders: [WorkspaceFolder]? = nil, activeDoc: Doc?, skills: [String], ignoredSkills: [String]?, - references: [FileReference], + references: [ConversationAttachedReference], model: String?, + modelProviderName: String?, turns: [TurnSchema], - agentMode: Bool) async throws { - var conversationCreateTurns: [ConversationTurn] = [] + agentMode: Bool, + userLanguage: String?) async throws { + var conversationCreateTurns: [TurnSchema] = [] // invoke conversation history if turns.count > 0 { conversationCreateTurns.append( contentsOf: turns.map { - ConversationTurn(request: $0.request, response: $0.response, turnId: $0.turnId) + TurnSchema( + request: $0.request, + response: $0.response, + agentSlug: $0.agentSlug, + turnId: $0.turnId + ) } ) } - conversationCreateTurns.append(ConversationTurn(request: message)) + conversationCreateTurns.append(TurnSchema(request: message)) let params = ConversationCreateParams(workDoneToken: workDoneToken, turns: conversationCreateTurns, capabilities: ConversationCreateParams.Capabilities( skills: skills, allSkills: false), textDocument: activeDoc, - references: references.map { - Reference(uri: $0.url.absoluteString, - position: nil, - visibleRange: nil, - selection: nil, - openedAt: nil, - activeAt: nil) - }, + references: references.map { Reference.from($0) }, source: .panel, workspaceFolder: workspaceFolder, workspaceFolders: workspaceFolders, ignoredSkills: ignoredSkills, model: model, + modelProviderName: modelProviderName, chatMode: agentMode ? "Agent" : nil, - needToolCallConfirmation: true) + needToolCallConfirmation: true, + userLanguage: userLanguage) do { _ = try await sendRequest( - GitHubCopilotRequest.CreateConversation(params: params), timeout: conversationRequestTimeout(agentMode)) + GitHubCopilotRequest.CreateConversation(params: params)) } catch { print("Failed to create conversation. Error: \(error)") throw error @@ -657,43 +657,46 @@ public final class GitHubCopilotService: } @GitHubCopilotSuggestionActor - public func createTurn(_ message: String, workDoneToken: String, conversationId: String, activeDoc: Doc?, ignoredSkills: [String]?, references: [FileReference], model: String?, workspaceFolder: String, workspaceFolders: [WorkspaceFolder]? = nil, agentMode: Bool) async throws { + public func createTurn(_ message: MessageContent, + workDoneToken: String, + conversationId: String, + turnId: String?, + activeDoc: Doc?, + ignoredSkills: [String]?, + references: [ConversationAttachedReference], + model: String?, + modelProviderName: String?, + workspaceFolder: String, + workspaceFolders: [WorkspaceFolder]? = nil, + agentMode: Bool) async throws { do { let params = TurnCreateParams(workDoneToken: workDoneToken, conversationId: conversationId, + turnId: turnId, message: message, textDocument: activeDoc, ignoredSkills: ignoredSkills, - 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, needToolCallConfirmation: true) _ = try await sendRequest( - GitHubCopilotRequest.CreateTurn(params: params), timeout: conversationRequestTimeout(agentMode)) + GitHubCopilotRequest.CreateTurn(params: params)) } catch { print("Failed to create turn. Error: \(error)") throw error } } - private func conversationRequestTimeout(_ agentMode: Bool) -> TimeInterval { - return agentMode ? 86400 /* 24h for agent mode timeout */ : 90 - } - @GitHubCopilotSuggestionActor - public func templates() async throws -> [ChatTemplate] { + public func templates(workspaceFolders: [WorkspaceFolder]? = nil) async throws -> [ChatTemplate] { do { + let params = ConversationTemplatesParams(workspaceFolders: workspaceFolders) let response = try await sendRequest( - GitHubCopilotRequest.GetTemplates() + GitHubCopilotRequest.GetTemplates(params: params) ) return response } catch { @@ -724,13 +727,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 } @@ -748,6 +776,29 @@ public final class GitHubCopilotService: } } + @GitHubCopilotSuggestionActor + public func listMCPRegistryServers(_ params: MCPRegistryListServersParams) async throws -> MCPRegistryServerList { + do { + let response = try await sendRequest( + GitHubCopilotRequest.MCPRegistryListServers(params: params) + ) + return response + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func getMCPRegistryServer(_ params: MCPRegistryGetServerParams) async throws -> MCPRegistryServerDetail { + do { + let response = try await sendRequest( + GitHubCopilotRequest.MCPRegistryGetServer(params: params) + ) + return response + } catch { + throw error + } + } @GitHubCopilotSuggestionActor public func rateConversation(turnId: String, rating: ConversationRating) async throws { @@ -816,7 +867,7 @@ public final class GitHubCopilotService: let uri = "file://\(fileURL.path)" // Logger.service.debug("Open \(uri), \(content.count)") try await server.sendNotification( - .didOpenTextDocument( + .textDocumentDidOpen( DidOpenTextDocumentParams( textDocument: .init( uri: uri, @@ -838,7 +889,7 @@ public final class GitHubCopilotService: let uri = "file://\(fileURL.path)" // Logger.service.debug("Change \(uri), \(content.count)") try await server.sendNotification( - .didChangeTextDocument( + .textDocumentDidChange( DidChangeTextDocumentParams( uri: uri, version: version, @@ -856,14 +907,14 @@ public final class GitHubCopilotService: public func notifySaveTextDocument(fileURL: URL) async throws { let uri = "file://\(fileURL.path)" // Logger.service.debug("Save \(uri)") - try await server.sendNotification(.didSaveTextDocument(.init(uri: uri))) + try await server.sendNotification(.textDocumentDidSave(.init(uri: uri))) } @GitHubCopilotSuggestionActor public func notifyCloseTextDocument(fileURL: URL) async throws { let uri = "file://\(fileURL.path)" // Logger.service.debug("Close \(uri)") - try await server.sendNotification(.didCloseTextDocument(.init(uri: uri))) + try await server.sendNotification(.textDocumentDidClose(.init(uri: uri))) } @GitHubCopilotSuggestionActor @@ -889,6 +940,19 @@ public final class GitHubCopilotService: throw error } } + + @GitHubCopilotSuggestionActor + public func checkQuota() async throws -> GitHubCopilotQuotaInfo { + do { + let response = try await sendRequest(GitHubCopilotRequest.CheckQuota()) + await Status.shared.updateQuotaInfo(response) + return response + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } public func updateStatusInBackground() { Task { @GitHubCopilotSuggestionActor in @@ -907,6 +971,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 @@ -1001,34 +1087,20 @@ public final class GitHubCopilotService: @GitHubCopilotSuggestionActor public func shutdown() async throws { GitHubCopilotService.services.removeAll { $0 === self } - let stream = AsyncThrowingStream { continuation in - if let localProcessServer { - localProcessServer.shutdown() { err in - continuation.finish(throwing: err) - } - } else { - continuation.finish(throwing: GitHubCopilotError.languageServerError(ServerError.serverUnavailable)) - } - } - for try await _ in stream { - return + if let localProcessServer { + try await localProcessServer.shutdown() + } else { + throw GitHubCopilotError.languageServerError(ServerError.serverUnavailable) } } @GitHubCopilotSuggestionActor public func exit() async throws { GitHubCopilotService.services.removeAll { $0 === self } - let stream = AsyncThrowingStream { continuation in - if let localProcessServer { - localProcessServer.exit() { err in - continuation.finish(throwing: err) - } - } else { - continuation.finish(throwing: GitHubCopilotError.languageServerError(ServerError.serverUnavailable)) - } - } - for try await _ in stream { - return + if let localProcessServer { + try await localProcessServer.exit() + } else { + throw GitHubCopilotError.languageServerError(ServerError.serverUnavailable) } } @@ -1059,12 +1131,9 @@ public final class GitHubCopilotService: private func sendRequest(_ endpoint: E, timeout: TimeInterval? = nil) async throws -> E.Response { do { - if let timeout = timeout { - return try await server.sendRequest(endpoint, timeout: timeout) - } else { - return try await server.sendRequest(endpoint) - } - } catch let error as ServerError { + return try await server.sendRequest(endpoint) + } catch { + let error = ServerError.convertToServerError(error: error) if let info = CLSErrorInfo(for: error) { // update the auth status if the error indicates it may have changed, and then rethrow if info.affectsAuthStatus && !(endpoint is GitHubCopilotRequest.CheckStatus) { @@ -1073,7 +1142,7 @@ public final class GitHubCopilotService: } let methodName: String switch endpoint.request { - case .custom(let method, _): + case .custom(let method, _, _): methodName = method default: methodName = endpoint.request.method.rawValue @@ -1091,7 +1160,7 @@ public final class GitHubCopilotService: var signoutError: Error? = nil for service in services { do { - try await service.signOut() + let _ = try await service.signOut() } catch let error as ServerError { signoutError = GitHubCopilotError.languageServerError(error) } catch { @@ -1105,32 +1174,313 @@ public final class GitHubCopilotService: CopilotModelManager.clearLLMs() } } -} + + public static func updateAllClsMCP(collections: [UpdateMCPToolsStatusServerCollection]) async { + var updateError: Error? = nil + var servers: [MCPServerToolsCollection] = [] -extension InitializingServer: GitHubCopilotLSP { - func sendRequest(_ endpoint: E) async throws -> E.Response { - try await sendRequest(endpoint.request) + for service in services { + if service.projectRootURL.path == "/" { + continue // Skip services with root project URL + } + + do { + servers = try await service.updateMCPToolsStatus( + params: .init(servers: collections) + ) + } catch let error as ServerError { + updateError = GitHubCopilotError.languageServerError(error) + } catch { + updateError = error + } + } + + CopilotMCPToolManager.updateMCPTools(servers) + Logger.gitHubCopilot.info("Updated All MCPTools: \(servers.count) servers") + + if let updateError { + Logger.gitHubCopilot.error("Failed to update MCP Tools status: \(updateError)") + } } + + public static func updateAllCLSTools(tools: [ToolStatusUpdate]) async -> [LanguageModelTool] { + var updateError: Error? = nil + var updatedTools: [LanguageModelTool] = [] - func sendRequest(_ endpoint: E, timeout: TimeInterval) async throws -> E.Response { - return try await withCheckedThrowingContinuation { continuation in - self.sendRequest(endpoint.request, timeout: timeout) { result in - continuation.resume(with: result) + for service in services { + if service.projectRootURL.path == "/" { + continue // Skip services with root project URL } + + do { + updatedTools = try await service.updateToolsStatus( + params: .init(tools: tools) + ) + } catch let error as ServerError { + updateError = GitHubCopilotError.languageServerError(error) + } catch { + updateError = error + } + } + + CopilotLanguageModelToolManager.updateToolsStatus(updatedTools) + Logger.gitHubCopilot.info("Updated All Built-In Tools: \(tools.count) tools") + + if let updateError { + Logger.gitHubCopilot.error("Failed to update Built-In Tools status: \(updateError)") } + + return updatedTools } -} + + private func loadUnrestoredLanguageModelTools() -> [ToolStatusUpdate] { + if let savedJSON = AppState.shared.get(key: "languageModelToolsStatus"), + let data = try? JSONEncoder().encode(savedJSON), + let savedTools = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data) { + return savedTools + } + return [] + } + + private func restoreRegisteredToolsStatus() async { + // Get unrestored tools from the shared coordinator + let toolsToRestore = await GitHubCopilotService.toolInitializationActor.loadUnrestoredToolsIfNeeded() -extension GitHubCopilotService { - func sendCopilotNotification(_ notif: CopilotClientNotification) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - localProcessServer?.sendCopilotNotification(notif) { error in - if let error = error { - continuation.resume(throwing: error) - } else { - continuation.resume() + 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"), + let data = try? JSONEncoder().encode(savedJSON), + let savedStatus = try? JSONDecoder().decode([UpdateMCPToolsStatusServerCollection].self, from: data) { + return savedStatus + .filter { !$0.tools.isEmpty } + .map { $0.name } + } + + return [] + } + + private func restoreMCPToolsStatus(_ mcpServers: [String]) async -> [MCPServerToolsCollection]? { + guard let savedJSON = AppState.shared.get(key: "mcpToolsStatus"), + let data = try? JSONEncoder().encode(savedJSON), + let savedStatus = try? JSONDecoder().decode([UpdateMCPToolsStatusServerCollection].self, from: data) else { + Logger.gitHubCopilot.info("Failed to get MCP Tools status") + return nil + } + + do { + let savedServers = savedStatus.filter { mcpServers.contains($0.name) } + if savedServers.isEmpty { + return nil + } else { + return try await updateMCPToolsStatus( + params: .init(servers: savedServers) + ) + } + } catch let error as ServerError { + Logger.gitHubCopilot.error("Failed to update MCP Tools status: \(GitHubCopilotError.languageServerError(error))") + } catch { + Logger.gitHubCopilot.error("Failed to update MCP Tools status: \(error)") + } + + return nil + } + + public func handleMCPToolsNotification(_ notification: AnyJSONRPCNotification) async { + defer { + self.isMCPInitialized = true + } + + if !self.isMCPInitialized { + self.unrestoredMcpServers = self.loadUnrestoredMCPServers() + } + + if let payload = GetAllToolsParams.decode(fromParams: notification.params) { + if !self.unrestoredMcpServers.isEmpty { + // Find servers that need to be restored + let toRestore = payload.servers.filter { !$0.tools.isEmpty } + .filter { self.unrestoredMcpServers.contains($0.name) } + .map { $0.name } + self.unrestoredMcpServers.removeAll { toRestore.contains($0) } + + if let tools = await self.restoreMCPToolsStatus(toRestore) { + Logger.gitHubCopilot.info("Restore MCP tools status for servers: \(toRestore)") + CopilotMCPToolManager.updateMCPTools(tools) + return } } + + CopilotMCPToolManager.updateMCPTools(payload.servers) + } + } + + public func handleMCPRuntimeLogsNotification(_ notification: AnyJSONRPCNotification) async { + let debugDescription = encodeJSONParams(params: notification.params) + Logger.mcp.info("[\(self.projectRootURL.path)] copilot/mcpRuntimeLogs: \(debugDescription)") + + if let payload = GitHubCopilotNotification.MCPRuntimeNotification.decode( + fromParams: notification.params + ) { + if mcpRuntimeLogFileName.isEmpty { + mcpRuntimeLogFileName = mcpLogFileNameFromURL(projectRootURL) + } + Logger + .logMCPRuntime( + logFileName: mcpRuntimeLogFileName, + level: payload.level.rawValue, + message: payload.message, + server: payload.server, + tool: payload.tool, + time: payload.time + ) + } + } + + private func mcpLogFileNameFromURL(_ projectRootURL: URL) -> String { + // Create a unique key from workspace URL that's safe for filesystem + let workspaceName = projectRootURL.lastPathComponent + .replacingOccurrences(of: ".xcworkspace", with: "") + .replacingOccurrences(of: ".xcodeproj", with: "") + .replacingOccurrences(of: ".playground", with: "") + let workspacePath = projectRootURL.path + + // Use a combination of name and hash of path for uniqueness + let pathHash = String(workspacePath.hash.magnitude, radix: 36).prefix(6) + return "\(workspaceName)-\(pathHash)" + } + + public func handleSendWorkspaceDidChangeNotifications() { + Task { + if projectRootURL.path != "/" { + try? await self.server.sendNotification( + .workspaceDidChangeWorkspaceFolders( + .init(event: .init(added: [.init(uri: projectRootURL.absoluteString, name: projectRootURL.lastPathComponent)], removed: [])) + ) + ) + } + + // Send initial configuration after initialize + await sendConfigurationUpdate() + + // Combine both notification streams + let combinedNotifications = Publishers.Merge( + NotificationCenter.default.publisher(for: .gitHubCopilotShouldRefreshEditorInformation).map { _ in "editorInfo" }, + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.map { _ in "featureFlags" } + ) + + for await _ in combinedNotifications.values { + await sendConfigurationUpdate() + } + } + } + + private func sendConfigurationUpdate() async { + let includeMCP = projectRootURL.path != "/" && + FeatureFlagNotifierImpl.shared.featureFlags.agentMode && + FeatureFlagNotifierImpl.shared.featureFlags.mcp + + let newConfiguration = editorConfiguration(includeMCP: includeMCP) + + // Only send the notification if the configuration has actually changed + guard self.lastSentConfiguration != newConfiguration else { return } + + _ = try? await self.server.sendNotification( + .workspaceDidChangeConfiguration( + .init(settings: newConfiguration) + ) + ) + + // Cache the sent configuration + self.lastSentConfiguration = newConfiguration + } + + 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 { + func sendRequest(_ endpoint: E) async throws -> E.Response { + try await sendRequest(endpoint.request) + } +} + +extension GitHubCopilotService { + func sendCopilotNotification(_ notif: CopilotClientNotification) async throws { + try await localProcessServer?.sendCopilotNotification(notif) + } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GithubCopilotRequest+Message.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GithubCopilotRequest+Message.swift deleted file mode 100644 index 3ed0fa85..00000000 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GithubCopilotRequest+Message.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import JSONRPC -import LanguageServerProtocol - -public struct MessageActionItem: Codable, Hashable { - public var title: String -} - -public struct ShowMessageRequestParams: Codable, Hashable { - public var type: MessageType - public var message: String - public var actions: [MessageActionItem]? -} - -extension ShowMessageRequestParams: CustomStringConvertible { - public var description: String { - return "\(type): \(message)" - } -} - -public typealias ShowMessageRequestResponse = MessageActionItem? - -public typealias ShowMessageRequest = JSONRPCRequest diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/SafeInitializingServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/SafeInitializingServer.swift new file mode 100644 index 00000000..49cbbeb8 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/SafeInitializingServer.swift @@ -0,0 +1,62 @@ +import LanguageClient +import LanguageServerProtocol + +public actor SafeInitializingServer { + private let underlying: InitializingServer + private var initTask: Task? = nil + + public init(_ server: InitializingServer) { + self.underlying = server + } + + // Ensure initialize request is sent by once + public func initializeIfNeeded() async throws -> InitializationResponse { + if let task = initTask { + return try await task.value + } + + let task = Task { + try await underlying.initializeIfNeeded() + } + initTask = task + + do { + let result = try await task.value + return result + } catch { + // Retryable failure + initTask = nil + throw error + } + } + + public func shutdownAndExit() async throws { + try await underlying.shutdownAndExit() + } + + public func sendNotification(_ notif: ClientNotification) async throws { + _ = try await initializeIfNeeded() + try await underlying.sendNotification(notif) + } + + public func sendRequest(_ request: ClientRequest) async throws -> Response { + _ = try await initializeIfNeeded() + return try await underlying.sendRequest(request) + } + + public var capabilities: ServerCapabilities? { + get async { + await underlying.capabilities + } + } + + public var serverInfo: ServerInfo? { + get async { + await underlying.serverInfo + } + } + + public nonisolated var eventSequence: ServerConnection.EventSequence { + underlying.eventSequence + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift index 1381747b..39c2c4a5 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift @@ -35,10 +35,13 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { } } else { switch methodName { - case "featureFlagsNotification": + case "copilot/didChangeFeatureFlags": if let data = try? JSONEncoder().encode(notification.params), - let featureFlags = try? JSONDecoder().decode(FeatureFlags.self, from: data) { - featureFlagNotifier.handleFeatureFlagNotification(featureFlags) + let didChangeFeatureFlagsParams = try? JSONDecoder().decode( + DidChangeFeatureFlagsParams.self, + from: data + ) { + featureFlagNotifier.handleFeatureFlagNotification(didChangeFeatureFlagsParams) } break default: diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift index f76031fe..7b28b73b 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift @@ -6,8 +6,11 @@ import LanguageClient import LanguageServerProtocol import Logger +public typealias ResponseHandler = ServerRequest.Handler +public typealias LegacyResponseHandler = (AnyJSONRPCResponse) -> Void + protocol ServerRequestHandler { - func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?) + func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping ResponseHandler, service: GitHubCopilotService?) } class ServerRequestHandlerImpl : ServerRequestHandler { @@ -15,9 +18,11 @@ class ServerRequestHandlerImpl : ServerRequestHandler { private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared private let showMessageRequestHandler: ShowMessageRequestHandler = ShowMessageRequestHandlerImpl.shared - - func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?) { + private let mcpOAuthRequestHandler: MCPOAuthRequestHandler = MCPOAuthRequestHandlerImpl.shared + + func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping ResponseHandler, service: GitHubCopilotService?) { let methodName = request.method + let legacyResponseHandler = toLegacyResponseHandler(callback) do { switch methodName { case "conversation/context": @@ -25,12 +30,12 @@ class ServerRequestHandlerImpl : ServerRequestHandler { let contextParams = try JSONDecoder().decode(ConversationContextParams.self, from: params) conversationContextHandler.handleConversationContext( ConversationContextRequest(id: request.id, method: request.method, params: contextParams), - completion: callback) + completion: legacyResponseHandler) case "copilot/watchedFiles": let params = try JSONEncoder().encode(request.params) let watchedFilesParams = try JSONDecoder().decode(WatchedFilesParams.self, from: params) - watchedFilesHandler.handleWatchedFiles(WatchedFilesRequest(id: request.id, method: request.method, params: watchedFilesParams), workspaceURL: workspaceURL, completion: callback, service: service) + watchedFilesHandler.handleWatchedFiles(WatchedFilesRequest(id: request.id, method: request.method, params: watchedFilesParams), workspaceURL: workspaceURL, completion: legacyResponseHandler, service: service) case "window/showMessageRequest": let params = try JSONEncoder().encode(request.params) @@ -42,24 +47,36 @@ class ServerRequestHandlerImpl : ServerRequestHandler { method: request.method, params: showMessageRequestParams ), - completion: callback + completion: legacyResponseHandler ) case "conversation/invokeClientTool": let params = try JSONEncoder().encode(request.params) let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: params) - ClientToolHandlerImpl.shared.invokeClientTool(InvokeClientToolRequest(id: request.id, method: request.method, params: invokeParams), completion: callback) + ClientToolHandlerImpl.shared.invokeClientTool(InvokeClientToolRequest(id: request.id, method: request.method, params: invokeParams), completion: legacyResponseHandler) case "conversation/invokeClientToolConfirmation": let params = try JSONEncoder().encode(request.params) let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: params) - ClientToolHandlerImpl.shared.invokeClientToolConfirmation(InvokeClientToolConfirmationRequest(id: request.id, method: request.method, params: invokeParams), completion: callback) + ClientToolHandlerImpl.shared.invokeClientToolConfirmation(InvokeClientToolConfirmationRequest(id: request.id, method: request.method, params: invokeParams), completion: legacyResponseHandler) + + case "copilot/mcpOAuth": + let params = try JSONEncoder().encode(request.params) + let mcpOAuthRequestParams = try JSONDecoder().decode(MCPOAuthRequestParams.self, from: params) + mcpOAuthRequestHandler.handleShowOAuthMessage( + MCPOAuthRequest( + id: request.id, + method: request.method, + params: mcpOAuthRequestParams + ), + completion: legacyResponseHandler + ) default: break } } catch { - handleError(request, error: error, callback: callback) + handleError(request, error: error, callback: legacyResponseHandler) } } @@ -77,4 +94,19 @@ class ServerRequestHandlerImpl : ServerRequestHandler { ) Logger.gitHubCopilot.error(error) } + + /// Converts a new Handler to work with old code that expects LegacyResponseHandler + private func toLegacyResponseHandler( + _ newHandler: @escaping ResponseHandler + ) -> LegacyResponseHandler { + return { response in + Task { + if let error = response.error { + await newHandler(.failure(error)) + } else if let result = response.result { + await newHandler(.success(result)) + } + } + } + } } diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift index 2f0949c1..8b343d61 100644 --- a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift +++ b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift @@ -1,36 +1,117 @@ import Combine import SwiftUI +import JSONRPC + +public extension Notification.Name { + static let gitHubCopilotFeatureFlagsDidChange = Notification + .Name("com.github.CopilotForXcode.CopilotFeatureFlagsDidChange") +} + +public enum ExperimentValue: Hashable, Codable { + case string(String) + case number(Double) + case boolean(Bool) + case stringArray([String]) +} + +public typealias ActiveExperimentForFeatureFlags = [String: ExperimentValue] + +public struct DidChangeFeatureFlagsParams: Hashable, Codable { + let envelope: [String: JSONValue] + let token: [String: String] + let activeExps: ActiveExperimentForFeatureFlags + let byok: Bool? +} public struct FeatureFlags: Hashable, Codable { - public var rt: Bool - public var sn: Bool + public var restrictedTelemetry: Bool + public var snippy: Bool public var chat: Bool - public var xc: Bool? + public var inlineChat: Bool + 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( + restrictedTelemetry: Bool = true, + snippy: Bool = true, + chat: Bool = true, + inlineChat: Bool = true, + projectContext: Bool = true, + agentMode: Bool = true, + mcp: Bool = true, + ccr: Bool = true, + byok: Bool = true, + editorPreviewFeatures: Bool = true, + activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags = [:] + ) { + self.restrictedTelemetry = restrictedTelemetry + self.snippy = snippy + self.chat = chat + self.inlineChat = inlineChat + self.projectContext = projectContext + self.agentMode = agentMode + self.mcp = mcp + self.ccr = ccr + self.byok = byok + self.editorPreviewFeatures = editorPreviewFeatures + self.activeExperimentForFeatureFlags = activeExperimentForFeatureFlags + } } public protocol FeatureFlagNotifier { - var featureFlags: FeatureFlags { get } + var didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams { get } var featureFlagsDidChange: PassthroughSubject { get } - func handleFeatureFlagNotification(_ featureFlags: FeatureFlags) + func handleFeatureFlagNotification(_ didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams) } public class FeatureFlagNotifierImpl: FeatureFlagNotifier { + public var didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams public var featureFlags: FeatureFlags public static let shared = FeatureFlagNotifierImpl() public var featureFlagsDidChange: PassthroughSubject - init(featureFlags: FeatureFlags = FeatureFlags(rt: false, sn: false, chat: true), - featureFlagsDidChange: PassthroughSubject = PassthroughSubject()) { + init( + didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams = .init( + envelope: [:], + token: [:], + activeExps: [:], + byok: nil + ), + featureFlags: FeatureFlags = FeatureFlags(), + featureFlagsDidChange: PassthroughSubject = PassthroughSubject() + ) { + self.didChangeFeatureFlagsParams = didChangeFeatureFlagsParams self.featureFlags = featureFlags self.featureFlagsDidChange = featureFlagsDidChange } + + private func updateFeatureFlags() { + let xcodeChat = self.didChangeFeatureFlagsParams.envelope["xcode_chat"]?.boolValue != false + let chatEnabled = self.didChangeFeatureFlagsParams.envelope["chat_enabled"]?.boolValue != false + self.featureFlags.restrictedTelemetry = self.didChangeFeatureFlagsParams.token["rt"] != "0" + self.featureFlags.snippy = self.didChangeFeatureFlagsParams.token["sn"] != "0" + self.featureFlags.chat = xcodeChat && chatEnabled + self.featureFlags.inlineChat = chatEnabled + self.featureFlags.agentMode = self.didChangeFeatureFlagsParams.token["agent_mode"] != "0" + self.featureFlags.mcp = self.didChangeFeatureFlagsParams.token["mcp"] != "0" + self.featureFlags.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 + } - public func handleFeatureFlagNotification(_ featureFlags: FeatureFlags) { - self.featureFlags = featureFlags - self.featureFlags.chat = featureFlags.chat == true && featureFlags.xc == true + public func handleFeatureFlagNotification(_ didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams) { + self.didChangeFeatureFlagsParams = didChangeFeatureFlagsParams + updateFeatureFlags() DispatchQueue.main.async { [weak self] in guard let self else { return } self.featureFlagsDidChange.send(self.featureFlags) + DistributedNotificationCenter.default().post(name: .gitHubCopilotFeatureFlagsDidChange, object: nil) } } } diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift index 0958470e..2557d0ee 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -6,6 +6,10 @@ import Workspace import LanguageServerProtocol public final class GitHubCopilotConversationService: ConversationServiceType { + public func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspace: WorkspaceInfo) async throws { + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyChangeTextDocument(fileURL: fileURL, content: content, version: version) + } private let serviceLocator: ServiceLocator @@ -19,11 +23,29 @@ public final class GitHubCopilotConversationService: ConversationServiceType { WorkspaceFolder(uri: project.uri, name: project.name) } } + + private func getMessageContent(_ request: ConversationRequest) -> MessageContent { + let contentImages = request.contentImages + let message: MessageContent + if contentImages.count > 0 { + var chatCompletionContentParts: [ChatCompletionContentPart] = contentImages.map { + .imageUrl($0) + } + chatCompletionContentParts.append(.text(ChatCompletionContentPartText(text: request.content))) + message = .messageContentArray(chatCompletionContentParts) + } else { + message = .string(request.content) + } + + return message + } public func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws { guard let service = await serviceLocator.getService(from: workspace) else { return } - return try await service.createConversation(request.content, + let message = getMessageContent(request) + + return try await service.createConversation(message, workDoneToken: request.workDoneToken, workspaceFolder: workspace.projectURL.absoluteString, workspaceFolders: getWorkspaceFolders(workspace: workspace), @@ -32,20 +54,26 @@ public final class GitHubCopilotConversationService: ConversationServiceType { ignoredSkills: request.ignoredSkills, references: request.references ?? [], model: request.model, + modelProviderName: request.modelProviderName, turns: request.turns, - agentMode: request.agentMode) + agentMode: request.agentMode, + 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 } - return try await service.createTurn(request.content, + let message = getMessageContent(request) + + return try await service.createTurn(message, workDoneToken: request.workDoneToken, conversationId: conversationId, + turnId: request.turnId, activeDoc: request.activeDoc, ignoredSkills: request.ignoredSkills, references: request.references ?? [], model: request.model, + modelProviderName: request.modelProviderName, workspaceFolder: workspace.projectURL.absoluteString, workspaceFolders: getWorkspaceFolders(workspace: workspace), agentMode: request.agentMode) @@ -69,7 +97,9 @@ public final class GitHubCopilotConversationService: ConversationServiceType { public func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]? { guard let service = await serviceLocator.getService(from: workspace) else { return nil } - return try await service.templates() + let isPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures + let workspaceFolders = isPreviewEnabled ? getWorkspaceFolders(workspace: workspace) : nil + return try await service.templates(workspaceFolders: workspaceFolders) } public func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]? { @@ -89,5 +119,11 @@ public final class GitHubCopilotConversationService: ConversationServiceType { guard let service = await serviceLocator.getService(from: workspace) else { return nil } return try await service.agents() } + + public func reviewChanges(workspace: WorkspaceInfo, params: ReviewChangesParams) async throws -> CodeReviewResult? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } + + return try await service.reviewChanges(params: params) + } } diff --git a/Tool/Sources/HostAppActivator/HostAppActivator.swift b/Tool/Sources/HostAppActivator/HostAppActivator.swift index 81658337..28172e69 100644 --- a/Tool/Sources/HostAppActivator/HostAppActivator.swift +++ b/Tool/Sources/HostAppActivator/HostAppActivator.swift @@ -7,8 +7,10 @@ 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") } public enum GitHubCopilotForXcodeSettingsLaunchError: Error, LocalizedError { @@ -54,7 +56,7 @@ public func launchHostAppSettings() throws { } } -public func launchHostAppMCPSettings() throws { +public func launchHostAppToolsSettings() throws { // Try the AppleScript approach first, but only if app is already running if let hostApp = getRunningHostApp() { let activated = hostApp.activate(options: [.activateIgnoringOtherApps]) @@ -63,14 +65,34 @@ public func launchHostAppMCPSettings() throws { _ = tryLaunchWithAppleScript() DistributedNotificationCenter.default().postNotificationName( - .openMCPSettingsWindowRequest, + .openToolsSettingsWindowRequest, object: nil ) Logger.ui.info("\(hostAppName()) MCP settings notification sent after activation") return } else { // If app is not running, launch it with the settings flag - try launchHostAppWithArgs(args: ["--mcp"]) + try launchHostAppWithArgs(args: ["--tools"]) + } +} + +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"]) } } diff --git a/Tool/Sources/Logger/FileLogger.swift b/Tool/Sources/Logger/FileLogger.swift index 14d9ff8d..92d51161 100644 --- a/Tool/Sources/Logger/FileLogger.swift +++ b/Tool/Sources/Logger/FileLogger.swift @@ -8,6 +8,8 @@ public final class FileLoggingLocation { .appending("Logs") .appending("GitHubCopilot") }() + + public static let mcpRuntimeLogsPath = path.appending("MCPRuntimeLogs") } final class FileLogger { @@ -33,30 +35,56 @@ final class FileLogger { } actor FileLoggerImplementation { + private let baseLogger: BaseFileLoggerImplementation + + public init() { + baseLogger = BaseFileLoggerImplementation( + logDir: FileLoggingLocation.path + ) + } + + public func logToFile(_ log: String) async { + await baseLogger.logToFile(log) + } +} + +// MARK: - Shared Base File Logger +actor BaseFileLoggerImplementation { #if DEBUG private let logBaseName = "github-copilot-for-xcode-dev" #else private let logBaseName = "github-copilot-for-xcode" #endif private let logExtension = "log" - private let maxLogSize = 5_000_000 - private let logOverflowLimit = 5_000_000 * 2 - private let maxLogs = 10 - private let maxLockTime = 3_600 // 1 hour - + private let maxLogSize: Int + private let logOverflowLimit: Int + private let maxLogs: Int + private let maxLockTime: Int + private let logDir: FilePath private let logName: String private let lockFilePath: FilePath private var logStream: OutputStream? private var logHandle: FileHandle? - - public init() { - logDir = FileLoggingLocation.path - logName = "\(logBaseName).\(logExtension)" - lockFilePath = logDir.appending(logName + ".lock") + + init( + logDir: FilePath, + logFileName: String? = nil, + maxLogSize: Int = 5_000_000, + logOverflowLimit: Int? = nil, + maxLogs: Int = 10, + maxLockTime: Int = 3_600 + ) { + self.logDir = logDir + self.logName = (logFileName ?? logBaseName) + "." + logExtension + self.lockFilePath = logDir.appending(logName + ".lock") + self.maxLogSize = maxLogSize + self.logOverflowLimit = logOverflowLimit ?? maxLogSize * 2 + self.maxLogs = maxLogs + self.maxLockTime = maxLockTime } - public func logToFile(_ log: String) { + func logToFile(_ log: String) async { if let stream = logAppender() { let data = [UInt8](log.utf8) stream.write(data, maxLength: data.count) diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index ae52c32b..a23f33b2 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -12,6 +12,7 @@ public final class Logger { private let category: String private let osLog: OSLog private let fileLogger = FileLogger() + private static let mcpRuntimeFileLogger = MCPRuntimeFileLogger() public static let service = Logger(category: "Service") public static let ui = Logger(category: "UI") @@ -24,6 +25,7 @@ public final class Logger { public static let `extension` = Logger(category: "Extension") public static let communicationBridge = Logger(category: "CommunicationBridge") public static let workspacePool = Logger(category: "WorkspacePool") + public static let mcp = Logger(category: "MCP") public static let debug = Logger(category: "Debug") public static var telemetryLogger: TelemetryLoggerProvider? = nil #if DEBUG @@ -57,7 +59,9 @@ public final class Logger { } os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg) - fileLogger.log(level: level, category: category, message: message) + if category != "MCP" { + fileLogger.log(level: level, category: category, message: message) + } if osLogType == .error { if let error = error { @@ -140,6 +144,25 @@ public final class Logger { ) } + public static func logMCPRuntime( + logFileName: String, + level: String, + message: String, + server: String, + tool: String? = nil, + time: Double + ) { + mcpRuntimeFileLogger + .log( + logFileName: logFileName, + level: level, + message: message, + server: server, + tool: tool, + time: time + ) + } + public func signpostBegin( name: StaticString, file: StaticString = #file, diff --git a/Tool/Sources/Logger/MCPRuntimeLogger.swift b/Tool/Sources/Logger/MCPRuntimeLogger.swift new file mode 100644 index 00000000..36527e43 --- /dev/null +++ b/Tool/Sources/Logger/MCPRuntimeLogger.swift @@ -0,0 +1,53 @@ +import Foundation +import System + +public final class MCPRuntimeFileLogger { + private let timestampFormat = Date.ISO8601FormatStyle.iso8601 + .year() + .month() + .day() + .timeZone(separator: .omitted).time(includingFractionalSeconds: true) + private static let implementation = MCPRuntimeFileLoggerImplementation() + + /// Converts a timestamp in milliseconds since the Unix epoch to a formatted date string. + private func timestamp(timeStamp: Double) -> String { + return Date(timeIntervalSince1970: timeStamp/1000).formatted(timestampFormat) + } + + public func log( + logFileName: String, + level: String, + message: String, + server: String, + tool: String? = nil, + time: Double + ) { + let log = "[\(timestamp(timeStamp: time))] [\(level)] [\(server)\(tool == nil ? "" : "-\(tool!))")] \(message)\(message.hasSuffix("\n") ? "" : "\n")" + + Task { + await MCPRuntimeFileLogger.implementation.logToFile(logFileName: logFileName, log: log) + } + } +} + +actor MCPRuntimeFileLoggerImplementation { + private let logDir: FilePath + private var workspaceLoggers: [String: BaseFileLoggerImplementation] = [:] + + public init() { + logDir = FileLoggingLocation.mcpRuntimeLogsPath + } + + public func logToFile(logFileName: String, log: String) async { + if workspaceLoggers[logFileName] == nil { + workspaceLoggers[logFileName] = BaseFileLoggerImplementation( + logDir: logDir, + logFileName: logFileName + ) + } + + if let logger = workspaceLoggers[logFileName] { + await logger.logToFile(log) + } + } +} diff --git a/Tool/Sources/Persist/AppState.swift b/Tool/Sources/Persist/AppState.swift index 8decd65c..e9e8424a 100644 --- a/Tool/Sources/Persist/AppState.swift +++ b/Tool/Sources/Persist/AppState.swift @@ -18,6 +18,20 @@ public extension JSONValue { } return nil } + + var boolValue: Bool? { + if case .bool(let value) = self { + return value + } + return nil + } + + var numberValue: Double? { + if case .number(let value) = self { + return value + } + return nil + } static func convertToJSONValue(_ object: T) -> JSONValue? { do { @@ -54,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) @@ -63,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] } @@ -74,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] ?? [:]) @@ -86,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/Persist/ConfigPathUtils.swift b/Tool/Sources/Persist/ConfigPathUtils.swift index ec7614ac..603581ba 100644 --- a/Tool/Sources/Persist/ConfigPathUtils.swift +++ b/Tool/Sources/Persist/ConfigPathUtils.swift @@ -62,8 +62,12 @@ struct ConfigPathUtils { if !fileManager.fileExists(atPath: url.path) { do { try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) - } catch { - Logger.client.info("Failed to create directory: \(error)") + } catch let error as NSError { + if error.domain == NSPOSIXErrorDomain && error.code == EACCES { + Logger.client.error("Permission denied when trying to create directory: \(url.path)") + } else { + Logger.client.info("Failed to create directory: \(error)") + } } } } diff --git a/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift b/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift index 720faae0..2ec2f53c 100644 --- a/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift +++ b/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift @@ -167,11 +167,19 @@ public final class ConversationStorage: ConversationStorageProtocol { switch type { case .all: - query = query.order(column.updatedAt.asc) + query = query.order(column.updatedAt.desc) case .selected: query = query .filter(column.isSelected == true) .limit(1) + case .latest: + query = query + .order(column.updatedAt.desc) + .limit(1) + case .id(let id): + query = query + .filter(conversationTable.column.id == id) + .limit(1) } let rowIterator = try db.prepareRowIterator(query) @@ -190,6 +198,30 @@ public final class ConversationStorage: ConversationStorageProtocol { return items } + + public func fetchConversationPreviewItems() throws -> [ConversationPreviewItem] { + var items: [ConversationPreviewItem] = [] + + try withDB { db in + let table = conversationTable.table + let column = conversationTable.column + let query = table + .select(column.id, column.title, column.isSelected, column.updatedAt) + .order(column.updatedAt.desc) + + let rowIterator = try db.prepareRowIterator(query) + items = try rowIterator.map { row in + ConversationPreviewItem( + id: row[column.id], + title: row[column.title], + isSelected: row[column.isSelected], + updatedAt: row[column.updatedAt].toDate() + ) + } + } + + return items + } } diff --git a/Tool/Sources/Persist/Storage/ConversationStorage/Model.swift b/Tool/Sources/Persist/Storage/ConversationStorage/Model.swift index 0684b8cb..6193f4d5 100644 --- a/Tool/Sources/Persist/Storage/ConversationStorage/Model.swift +++ b/Tool/Sources/Persist/Storage/ConversationStorage/Model.swift @@ -40,6 +40,13 @@ public struct ConversationItem: Codable, Equatable { } } +public struct ConversationPreviewItem: Codable, Equatable { + public let id: String + public let title: String? + public let isSelected: Bool + public let updatedAt: Date +} + public enum DeleteType { case conversation(id: String) case turn(id: String) @@ -62,5 +69,5 @@ public struct OperationRequest { } public enum ConversationFetchType { - case all, selected + case all, selected, latest, id(String) } diff --git a/Tool/Sources/Persist/Storage/ConversationStorageService.swift b/Tool/Sources/Persist/Storage/ConversationStorageService.swift index 14102792..113eafa2 100644 --- a/Tool/Sources/Persist/Storage/ConversationStorageService.swift +++ b/Tool/Sources/Persist/Storage/ConversationStorageService.swift @@ -97,6 +97,20 @@ public final class ConversationStorageService: ConversationStorageServiceProtoco return items } + public func fetchConversationPreviewItems(metadata: StorageMetadata) -> [ConversationPreviewItem] { + var items: [ConversationPreviewItem] = [] + + do { + try withStorage(metadata) { conversationStorage in + items = try conversationStorage.fetchConversationPreviewItems() + } + } catch { + Logger.client.error("Failed to fetch conversation preview items: \(error)") + } + + return items + } + public func fetchTurnItems(for conversationID: String, metadata: StorageMetadata) -> [TurnItem] { var items: [TurnItem] = [] diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 16bcbc40..1e063da7 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -116,6 +116,11 @@ public struct UserDefaultPreferenceKeys { defaultValue: false, key: "ExtensionPermissionShown" ) + + public let capturePermissionShown = PreferenceKey( + defaultValue: false, + key: "CapturePermissionShown" + ) } // MARK: - Prompt to Code @@ -238,6 +243,10 @@ public extension UserDefaultPreferenceKeys { // MARK: - Chat public extension UserDefaultPreferenceKeys { + + var fontScale: PreferenceKey { + .init(defaultValue: 1.0, key: "FontScale") + } var chatFontSize: PreferenceKey { .init(defaultValue: 13, key: "ChatFontSize") @@ -295,6 +304,22 @@ public extension UserDefaultPreferenceKeys { var enableCurrentEditorContext: PreferenceKey { .init(defaultValue: true, key: "EnableCurrentEditorContext") } + + var chatResponseLocale: PreferenceKey { + .init(defaultValue: "en", key: "ChatResponseLocale") + } + + var globalCopilotInstructions: PreferenceKey { + .init(defaultValue: "", key: "GlobalCopilotInstructions") + } + + var autoAttachChatToXcode: PreferenceKey { + .init(defaultValue: true, key: "AutoAttachChatToXcode") + } + + var enableFixError: PreferenceKey { + .init(defaultValue: true, key: "EnableFixError") + } } // MARK: - Theme @@ -558,6 +583,10 @@ public extension UserDefaultPreferenceKeys { var gitHubCopilotMCPConfig: PreferenceKey { .init(defaultValue: "", key: "GitHubCopilotMCPConfig") } + + var gitHubCopilotMCPUpdatedStatus: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotMCPUpdatedStatus") + } var gitHubCopilotEnterpriseURI: PreferenceKey { .init(defaultValue: "", key: "GitHubCopilotEnterpriseURI") @@ -566,4 +595,8 @@ public extension UserDefaultPreferenceKeys { var verboseLoggingEnabled: PreferenceKey { .init(defaultValue: false, key: "VerboseLoggingEnabled") } + + var currentUserName: PreferenceKey { + .init(defaultValue: "", key: "CurrentUserName") + } } diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift index 6971134f..56c19b47 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -15,6 +15,8 @@ public extension UserDefaults { shared.setupDefaultValue(for: \.realtimeSuggestionToggle) shared.setupDefaultValue(for: \.realtimeSuggestionDebounce) shared.setupDefaultValue(for: \.suggestionPresentationMode) + shared.setupDefaultValue(for: \.autoAttachChatToXcode) + shared.setupDefaultValue(for: \.enableFixError) shared.setupDefaultValue(for: \.widgetColorScheme) shared.setupDefaultValue(for: \.customCommands) shared.setupDefaultValue( @@ -60,6 +62,10 @@ public extension UserDefaults { weight: .regular ))) ) + shared.setupDefaultValue( + for: \.fontScale, + defaultValue: shared.value(for: \.fontScale) + ) } } diff --git a/Tool/Sources/SharedUIComponents/Base/Colors.swift b/Tool/Sources/SharedUIComponents/Base/Colors.swift new file mode 100644 index 00000000..2015102a --- /dev/null +++ b/Tool/Sources/SharedUIComponents/Base/Colors.swift @@ -0,0 +1,5 @@ +import SwiftUI + +public extension Color { + static var hoverColor: Color { .gray.opacity(0.1) } +} diff --git a/Tool/Sources/SharedUIComponents/Base/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..e58b5b56 100644 --- a/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift +++ b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift @@ -6,7 +6,7 @@ public struct HoverButtonStyle: ButtonStyle { private var padding: CGFloat private var hoverColor: Color - public init(isHovered: Bool = false, padding: CGFloat = 4, hoverColor: Color = Color.gray.opacity(0.1)) { + public init(isHovered: Bool = false, padding: CGFloat = 4, hoverColor: Color = .hoverColor) { self.isHovered = isHovered self.padding = padding self.hoverColor = hoverColor diff --git a/Tool/Sources/SharedUIComponents/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..d9673c17 100644 --- a/Tool/Sources/SharedUIComponents/CopyButton.swift +++ b/Tool/Sources/SharedUIComponents/CopyButton.swift @@ -29,7 +29,7 @@ public struct CopyButton: View { Image(systemName: isCopied ? "checkmark.circle" : "doc.on.doc") .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) + .scaledFrame(width: 14, height: 14) .foregroundColor(foregroundColor ?? .secondary) .conditionalFontWeight(fontWeight) .padding(4) diff --git a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift index 9023b4e7..5b0e967a 100644 --- a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift +++ b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift @@ -1,50 +1,62 @@ 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 public let isEditable: Bool public let maxHeight: Double + public let minHeight: Double public let onSubmit: () -> Void - public var completions: (_ text: String, _ words: [String], _ range: NSRange) -> [String] - + public let onTextEditorStateChanged: ((TextEditorState?) -> Void)? + + @State private var textEditorHeight: CGFloat + public init( text: Binding, font: NSFont, isEditable: Bool, maxHeight: Double, onSubmit: @escaping () -> Void, - completions: @escaping (_ text: String, _ words: [String], _ range: NSRange) - -> [String] = { _, _, _ in [] } + onTextEditorStateChanged: ((TextEditorState?) -> Void)? = nil ) { _text = text self.font = font self.isEditable = isEditable self.maxHeight = maxHeight + self.minHeight = Double(font.ascender + abs(font.descender) + font.leading) // Following the original padding: .top(1), .bottom(2) self.onSubmit = onSubmit - self.completions = completions + self.onTextEditorStateChanged = onTextEditorStateChanged + + // Initialize with font height + 3 as in the original logic + _textEditorHeight = State(initialValue: self.minHeight) } public var body: some View { - ZStack(alignment: .center) { - // a hack to support dynamic height of TextEditor - Text(text.isEmpty ? "Hi" : text).opacity(0) - .font(.init(font)) - .frame(maxWidth: .infinity, maxHeight: maxHeight) - .padding(.top, 1) - .padding(.bottom, 2) - .padding(.horizontal, 4) - - CustomTextEditor( - text: $text, - font: font, - maxHeight: maxHeight, - onSubmit: onSubmit, - completions: completions - ) - .padding(.top, 1) - .padding(.bottom, -1) - } + CustomTextEditor( + text: $text, + font: font, + isEditable: isEditable, + maxHeight: maxHeight, + minHeight: minHeight, + onSubmit: onSubmit, + heightDidChange: { height in + self.textEditorHeight = min(height, maxHeight) + }, + onTextEditorStateChanged: onTextEditorStateChanged + ) + .frame(height: textEditorHeight) + .padding(.top, 1) + .padding(.bottom, -1) } } @@ -56,29 +68,33 @@ public struct CustomTextEditor: NSViewRepresentable { @Binding public var text: String public let font: NSFont public let maxHeight: Double + public let minHeight: Double public let isEditable: Bool public let onSubmit: () -> Void - public var completions: (_ text: String, _ words: [String], _ range: NSRange) -> [String] + public let heightDidChange: (CGFloat) -> Void + public let onTextEditorStateChanged: ((TextEditorState?) -> Void)? public init( text: Binding, font: NSFont, isEditable: Bool = true, maxHeight: Double, + minHeight: Double, onSubmit: @escaping () -> Void, - completions: @escaping (_ text: String, _ words: [String], _ range: NSRange) - -> [String] = { _, _, _ in [] } + heightDidChange: @escaping (CGFloat) -> Void, + onTextEditorStateChanged: ((TextEditorState?) -> Void)? = nil ) { _text = text self.font = font self.isEditable = isEditable self.maxHeight = maxHeight + self.minHeight = minHeight self.onSubmit = onSubmit - self.completions = completions + self.heightDidChange = heightDidChange + self.onTextEditorStateChanged = onTextEditorStateChanged } public func makeNSView(context: Context) -> NSScrollView { -// context.coordinator.completions = completions let textView = (context.coordinator.theTextView.documentView as! NSTextView) textView.delegate = context.coordinator textView.string = text @@ -89,21 +105,42 @@ public struct CustomTextEditor: NSViewRepresentable { textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.setAccessibilityLabel("Chat Input, Ask Copilot. Type to ask questions or type / for topics, press enter to send out the request. Use the Chat Accessibility Help command for more information.") + + // Set up text container for dynamic height + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.textContainer?.containerSize = NSSize(width: textView.frame.width, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true // Configure scroll view let scrollView = context.coordinator.theTextView scrollView.hasHorizontalScroller = false - context.coordinator.observeHeight(scrollView: scrollView, maxHeight: maxHeight) + scrollView.hasVerticalScroller = false // We'll manage the scrolling ourselves + + // Initialize height calculation + context.coordinator.view = self + context.coordinator.calculateAndUpdateHeight(textView: textView) + return scrollView } public func updateNSView(_ nsView: NSScrollView, context: Context) { -// context.coordinator.completions = completions let textView = (context.coordinator.theTextView.documentView as! NSTextView) textView.isEditable = isEditable - guard textView.string != text else { return } - textView.string = text - textView.undoManager?.removeAllActions() + + 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) + } + } } @@ -112,22 +149,108 @@ public extension CustomTextEditor { var view: CustomTextEditor var theTextView = NSTextView.scrollableTextView() var affectedCharRange: NSRange? - var completions: (String, [String], _ range: NSRange) -> [String] = { _, _, _ in [] } - var heightObserver: NSKeyValueObservation? init(_ view: CustomTextEditor) { self.view = view } + + 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 + let textInsets = textView.textContainerInset + let newHeight = max(view.minHeight, usedRect.height + textInsets.height * 2) + + // Update scroll behavior based on height vs maxHeight + theTextView.hasVerticalScroller = newHeight >= view.maxHeight + + // Only report the height that will be used for display + let heightToReport = min(newHeight, view.maxHeight) + + // Inform the SwiftUI view of the height + DispatchQueue.main.async { + self.view.heightDidChange(heightToReport) + } + } public func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } - - view.text = textView.string - textView.complete(nil) + + // Defer updating the binding for large text changes + DispatchQueue.main.async { + self.view.text = textView.string + } + + // Update height after text changes + calculateAndUpdateHeight(textView: textView) } - + + // 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 @@ -152,29 +275,5 @@ public extension CustomTextEditor { ) -> Bool { return true } - - public func textView( - _ textView: NSTextView, - completions words: [String], - forPartialWordRange charRange: NSRange, - indexOfSelectedItem index: UnsafeMutablePointer? - ) -> [String] { - index?.pointee = -1 - return completions(textView.textStorage?.string ?? "", words, charRange) - } - - func observeHeight(scrollView: NSScrollView, maxHeight: Double) { - let textView = scrollView.documentView as! NSTextView - heightObserver = textView.observe(\NSTextView.frame) { [weak scrollView] _, _ in - guard let scrollView = scrollView else { return } - let contentHeight = textView.frame.height - scrollView.hasVerticalScroller = contentHeight >= maxHeight - } - } - - deinit { - heightObserver?.invalidate() - } } } - diff --git a/Tool/Sources/SharedUIComponents/DownvoteButton.swift b/Tool/Sources/SharedUIComponents/DownvoteButton.swift index 952aadbc..703a2f24 100644 --- a/Tool/Sources/SharedUIComponents/DownvoteButton.swift +++ b/Tool/Sources/SharedUIComponents/DownvoteButton.swift @@ -18,13 +18,8 @@ public struct DownvoteButton: View { Image(systemName: isSelected ? "hand.thumbsdown.fill" : "hand.thumbsdown") .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) -// .frame(width: 20, height: 20, alignment: .center) + .scaledFrame(width: 14, height: 14) .foregroundColor(.secondary) -// .background( -// .regularMaterial, -// in: RoundedRectangle(cornerRadius: 4, style: .circular) -// ) .padding(4) .help("Unhelpful") } diff --git a/Tool/Sources/SharedUIComponents/InsertButton.swift b/Tool/Sources/SharedUIComponents/InsertButton.swift index 355d8982..dc465210 100644 --- a/Tool/Sources/SharedUIComponents/InsertButton.swift +++ b/Tool/Sources/SharedUIComponents/InsertButton.swift @@ -20,13 +20,8 @@ public struct InsertButton: View { self.icon .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) -// .frame(width: 20, height: 20, alignment: .center) + .scaledFrame(width: 14, height: 14) .foregroundColor(.secondary) -// .background( -// .regularMaterial, -// in: RoundedRectangle(cornerRadius: 4, style: .circular) -// ) .padding(4) } .buttonStyle(HoverButtonStyle(padding: 0)) diff --git a/Tool/Sources/SharedUIComponents/InstructionView.swift b/Tool/Sources/SharedUIComponents/InstructionView.swift index 774ea7c8..86a45280 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,18 +42,18 @@ public struct Instruction: View { if isAgentMode { Label("to configure MCP server", systemImage: "wrench.and.screwdriver") .foregroundColor(Color("DescriptionForegroundColor")) - .font(.system(size: 14)) + .scaledFont(.system(size: 14)) } Label("to reference context", systemImage: "paperclip") .foregroundColor(Color("DescriptionForegroundColor")) - .font(.system(size: 14)) + .scaledFont(.system(size: 14)) if !isAgentMode { Text("@ to chat with extensions") .foregroundColor(Color("DescriptionForegroundColor")) - .font(.system(size: 14)) + .scaledFont(.system(size: 14)) Text("Type / to use commands") .foregroundColor(Color("DescriptionForegroundColor")) - .font(.system(size: 14)) + .scaledFont(.system(size: 14)) } } } diff --git a/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift b/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift 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/ScaledComponent/FontScaleManager.swift b/Tool/Sources/SharedUIComponents/ScaledComponent/FontScaleManager.swift new file mode 100644 index 00000000..f7b25d55 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/FontScaleManager.swift @@ -0,0 +1,104 @@ +import SwiftUI +import Combine + +extension Notification.Name { + static let fontScaleDidChange = Notification + .Name("com.github.CopilotForXcode.FontScaleDidChange") +} + +@MainActor +public class FontScaleManager: ObservableObject { + @AppStorage(\.fontScale) private var fontScale { + didSet { + // Only post notification if this change originated locally + postNotificationIfNeeded() + } + } + + public static let shared: FontScaleManager = .init() + + public static let maxScale: Double = 2.0 + public static let minScale: Double = 0.8 + public static let scaleStep: Double = 0.1 + public static let defaultScale: Double = 1.0 + + private let processIdentifier = UUID().uuidString + private var lastReceivedNotificationId: String? + + private init() { + // Listen for font scale changes from other processes + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleFontScaleChanged(_:)), + name: .fontScaleDidChange, + object: nil + ) + } + + deinit { + DistributedNotificationCenter.default().removeObserver(self) + } + + private func postNotificationIfNeeded() { + // Don't post notification if we're processing an external notification + guard lastReceivedNotificationId == nil else { return } + + let notificationId = UUID().uuidString + DistributedNotificationCenter.default().postNotificationName( + .fontScaleDidChange, + object: nil, + userInfo: [ + "fontScale": fontScale, + "sourceProcess": processIdentifier, + "notificationId": notificationId + ], + deliverImmediately: true + ) + } + + @objc private func handleFontScaleChanged(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let scale = userInfo["fontScale"] as? Double, + let sourceProcess = userInfo["sourceProcess"] as? String, + let notificationId = userInfo["notificationId"] as? String else { + return + } + + // Ignore notifications from this process + guard sourceProcess != processIdentifier else { return } + + // Ignore duplicate notifications + guard notificationId != lastReceivedNotificationId else { return } + + // Only update if the value actually changed (with epsilon for floating-point) + guard abs(fontScale - scale) > 0.001 else { return } + + lastReceivedNotificationId = notificationId + fontScale = scale + lastReceivedNotificationId = nil + } + + public func increaseFontScale() { + fontScale = min(fontScale + Self.scaleStep, Self.maxScale) + } + + public func decreaseFontScale() { + fontScale = max(fontScale - Self.scaleStep, Self.minScale) + } + + public func setFontScale(_ scale: Double) { + guard scale <= Self.maxScale && scale >= Self.minScale else { + return + } + + fontScale = scale + } + + public func resetFontScale() { + fontScale = Self.defaultScale + } + + public var currentScale: Double { + fontScale + } +} diff --git a/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFont.swift b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFont.swift new file mode 100644 index 00000000..2d01ebcc --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFont.swift @@ -0,0 +1,82 @@ +import SwiftUI +import AppKit + +// MARK: built-in fonts +// Refer to https://developer.apple.com/design/human-interface-guidelines/typography#macOS-built-in-text-styles +extension Font { + + public var builtinSize: CGFloat { + let textStyle = nsTextStyle ?? .body + + return NSFont.preferredFont(forTextStyle: textStyle).pointSize + } + + // Map SwiftUI Font to NSFont.TextStyle + private var nsTextStyle: NSFont.TextStyle? { + switch self { + case .largeTitle: .largeTitle + case .title: .title1 + case .title2: .title2 + case .title3: .title3 + case .headline: .headline + case .subheadline: .subheadline + case .body: .body + case .callout: .callout + case .footnote: .footnote + case .caption: .caption1 + case .caption2: .caption2 + default: nil + } + } + + var builtinWeight: Font.Weight { + switch self { + case .headline: .bold + case .caption2: .medium + default: .regular + } + } +} + +public extension View { + func scaledFont(_ font: Font) -> some View { + ScaledFontView(self, font: font) + } + + func scaledFont(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> some View { + ScaledFontView(self, size: size, weight: weight, design: design) + } +} + + +public struct ScaledFontView: View { + let fontSize: CGFloat + let fontWeight: Font.Weight + var fontDesign: Font.Design + let content: Content + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + init(_ content: Content, font: Font) { + self.fontSize = font.builtinSize + self.fontWeight = font.builtinWeight + self.fontDesign = .default + self.content = content + } + + public init(_ content: Content, size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) { + self.fontSize = size + self.fontWeight = weight + self.fontDesign = design + self.content = content + } + + public var body: some View { + content + .font(.system(size: fontSize * fontScale, weight: fontWeight, design: fontDesign)) + } +} diff --git a/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift new file mode 100644 index 00000000..9693ac8b --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift @@ -0,0 +1,46 @@ +import SwiftUI + +extension View { + public func scaledFrame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View { + ScaledFrameView(self, width: width, height: height, alignment: alignment) + } +} + +struct ScaledFrameView: View { + let content: Content + let width: CGFloat? + let height: CGFloat? + let alignment: Alignment + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + var scaledWidth: CGFloat? { + guard let width else { + return nil + } + return width * fontScale + } + + var scaledHeight: CGFloat? { + guard let height else { + return nil + } + return height * fontScale + } + + init(_ content: Content, width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) { + self.content = content + self.width = width + self.height = height + self.alignment = alignment + } + + var body: some View { + content + .frame(width: scaledWidth, height: scaledHeight, alignment: alignment) + } +} diff --git a/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledModifier.swift b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledModifier.swift new file mode 100644 index 00000000..a5f51378 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledModifier.swift @@ -0,0 +1,66 @@ +import SwiftUI + +// MARK: - padding +public extension View { + func scaledPadding(_ length: CGFloat?) -> some View { + scaledPadding(.all, length) + } + + func scaledPadding(_ edges: Edge.Set = .all, _ length: CGFloat? = nil) -> some View { + ScaledPaddingView(self, edges: edges, length: length) + } +} + +struct ScaledPaddingView: View { + let content: Content + let edges: Edge.Set + let length: CGFloat? + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + init(_ content: Content, edges: Edge.Set, length: CGFloat? = nil) { + self.content = content + self.edges = edges + self.length = length + } + + var body: some View { + content + .padding(edges, length.map { $0 * fontScale }) + } +} + + +// MARK: - scaleEffect +public extension View { + func scaledScaleEffect(_ s: CGFloat, anchor: UnitPoint = .center) -> some View { + ScaledScaleEffectView(self, s, anchor: anchor) + } +} + +struct ScaledScaleEffectView: View { + let content: Content + let s: CGFloat + let anchor: UnitPoint + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + init(_ content: Content, _ s: CGFloat, anchor: UnitPoint = .center) { + self.content = content + self.s = s + self.anchor = anchor + } + + var body: some View { + content + .scaleEffect(s * fontScale, anchor: anchor) + } +} diff --git a/Tool/Sources/SharedUIComponents/UpvoteButton.swift b/Tool/Sources/SharedUIComponents/UpvoteButton.swift index b4e13e2a..7a0b88b6 100644 --- a/Tool/Sources/SharedUIComponents/UpvoteButton.swift +++ b/Tool/Sources/SharedUIComponents/UpvoteButton.swift @@ -18,13 +18,8 @@ public struct UpvoteButton: View { Image(systemName: isSelected ? "hand.thumbsup.fill" : "hand.thumbsup") .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) -// .frame(width: 20, height: 20, alignment: .center) + .scaledFrame(width: 14, height: 14) .foregroundColor(.secondary) -// .background( -// .regularMaterial, -// in: RoundedRectangle(cornerRadius: 4, style: .circular) -// ) .padding(4) .help("Helpful") } diff --git a/Tool/Sources/Status/Status.swift b/Tool/Sources/Status/Status.swift index 7e6baec3..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 { @@ -9,23 +10,6 @@ import Foundation case unknown = -1, granted = 1, notGranted = 0 } -public struct CLSStatus: Equatable { - public enum Status { case unknown, normal, error, warning, inactive } - public let status: Status - public let busy: Bool - public let message: String - - public var isInactiveStatus: Bool { status == .inactive && !message.isEmpty } - public var isErrorStatus: Bool { (status == .warning || status == .error) && !message.isEmpty } -} - -public struct AuthStatus: Equatable { - public enum Status { case unknown, loggedIn, notLoggedIn, notAuthorized } - public let status: Status - public let username: String? - public let message: String? -} - private struct AuthStatusInfo { let authIcon: StatusResponse.Icon? let authStatus: AuthStatus.Status @@ -48,39 +32,9 @@ public extension Notification.Name { static let serviceStatusDidChange = Notification.Name("com.github.CopilotForXcode.serviceStatusDidChange") } -public struct StatusResponse { - public struct Icon { - /// Name of the icon resource - public let name: String - - public init(name: String) { - self.name = name - } - - public var nsImage: NSImage? { - return NSImage(named: name) - } - } - - /// The icon to display in the menu bar - public let icon: Icon - /// Indicates if an operation is in progress - public let inProgress: Bool - /// Message from the CLS (Copilot Language Server) status - public let clsMessage: String - /// Additional message (for accessibility or extension status) - public let message: String? - /// Extension status - public let extensionStatus: ExtensionPermissionStatus - /// URL for system preferences or other actions - public let url: String? - /// Current authentication status - public let authStatus: AuthStatus.Status - /// GitHub username of the authenticated user - public let userName: String? -} - private var currentUserName: String? = nil +private var currentUserCopilotPlan: String? = nil + public final actor Status { public static let shared = Status() @@ -88,9 +42,12 @@ public final actor Status { private var axStatus: ObservedAXStatus = .unknown private var clsStatus = CLSStatus(status: .unknown, busy: false, message: "") private var authStatus = AuthStatus(status: .unknown, username: nil, message: nil) + + private var currentUserQuotaInfo: GitHubCopilotQuotaInfo? = nil private let okIcon = StatusResponse.Icon(name: "MenuBarIcon") - private let errorIcon = StatusResponse.Icon(name: "MenuBarWarningIcon") + private let errorIcon = StatusResponse.Icon(name: "MenuBarErrorIcon") + private let warningIcon = StatusResponse.Icon(name: "MenuBarWarningIcon") private let inactiveIcon = StatusResponse.Icon(name: "MenuBarInactiveIcon") private init() {} @@ -98,6 +55,17 @@ public final actor Status { public static func currentUser() -> String? { return currentUserName } + + public func currentUserPlan() -> String? { + return currentUserCopilotPlan + } + + public func updateQuotaInfo(_ quotaInfo: GitHubCopilotQuotaInfo?) { + guard quotaInfo != currentUserQuotaInfo else { return } + currentUserQuotaInfo = quotaInfo + currentUserCopilotPlan = quotaInfo?.copilotPlan + broadcast() + } public func updateExtensionStatus(_ status: ExtensionPermissionStatus) { guard status != extensionStatus else { return } @@ -120,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 @@ -146,13 +115,17 @@ public final actor Status { ).isEmpty } - public func getAuthStatus() -> AuthStatus.Status { - authStatus.status + public func getAuthStatus() -> AuthStatus { + authStatus } public func getCLSStatus() -> CLSStatus { clsStatus } + + public func getQuotaInfo() -> GitHubCopilotQuotaInfo? { + currentUserQuotaInfo + } public func getStatus() -> StatusResponse { let authStatusInfo: AuthStatusInfo = getAuthStatusInfo() @@ -169,7 +142,8 @@ public final actor Status { extensionStatus: extensionStatus, url: accessibilityStatusInfo.url, authStatus: authStatusInfo.authStatus, - userName: authStatusInfo.userName + userName: authStatusInfo.userName, + quotaInfo: currentUserQuotaInfo ) } @@ -200,6 +174,9 @@ public final actor Status { if clsStatus.isInactiveStatus { return CLSStatusInfo(icon: inactiveIcon, message: clsStatus.message) } + if clsStatus.isWarningStatus { + return CLSStatusInfo(icon: warningIcon, message: clsStatus.message) + } if clsStatus.isErrorStatus { return CLSStatusInfo(icon: errorIcon, message: clsStatus.message) } diff --git a/Tool/Sources/Status/StatusObserver.swift b/Tool/Sources/Status/StatusObserver.swift index 2fce99ac..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,12 +34,21 @@ 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: authStatus.status, username: statusInfo.userName, message: nil ) @@ -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/AuthStatus.swift b/Tool/Sources/Status/Types/AuthStatus.swift new file mode 100644 index 00000000..668b4a11 --- /dev/null +++ b/Tool/Sources/Status/Types/AuthStatus.swift @@ -0,0 +1,17 @@ +public struct AuthStatus: Codable, Equatable, Hashable { + public enum Status: Codable, Equatable, Hashable { + case unknown + case loggedIn + case notLoggedIn + case notAuthorized + } + public let status: Status + public let username: String? + public let message: String? + + public init(status: Status, username: String? = nil, message: String? = nil) { + self.status = status + self.username = username + self.message = message + } +} diff --git a/Tool/Sources/Status/Types/CLSStatus.swift b/Tool/Sources/Status/Types/CLSStatus.swift new file mode 100644 index 00000000..07b5d765 --- /dev/null +++ b/Tool/Sources/Status/Types/CLSStatus.swift @@ -0,0 +1,10 @@ +public struct CLSStatus: Equatable { + public enum Status { case unknown, normal, error, warning, inactive } + public let status: Status + public let busy: Bool + public let message: String + + public var isInactiveStatus: Bool { status == .inactive && !message.isEmpty } + public var isErrorStatus: Bool { status == .error && !message.isEmpty } + public var isWarningStatus: Bool { status == .warning && !message.isEmpty } +} diff --git a/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift b/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift new file mode 100644 index 00000000..50ffc4f3 --- /dev/null +++ b/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct QuotaSnapshot: Codable, Equatable, Hashable { + public var percentRemaining: Float + public var unlimited: Bool + public var overagePermitted: Bool +} + +public struct GitHubCopilotQuotaInfo: Codable, Equatable, Hashable { + public var chat: QuotaSnapshot + public var completions: QuotaSnapshot + public var premiumInteractions: QuotaSnapshot + public var resetDate: String + public var copilotPlan: String + + public var isFreeUser: Bool { copilotPlan == "free" } +} diff --git a/Tool/Sources/Status/Types/StatusResponse.swift b/Tool/Sources/Status/Types/StatusResponse.swift new file mode 100644 index 00000000..3842c088 --- /dev/null +++ b/Tool/Sources/Status/Types/StatusResponse.swift @@ -0,0 +1,35 @@ +import AppKit + +public struct StatusResponse { + public struct Icon { + /// Name of the icon resource + public let name: String + + public init(name: String) { + self.name = name + } + + public var nsImage: NSImage? { + return NSImage(named: name) + } + } + + /// The icon to display in the menu bar + public let icon: Icon + /// Indicates if an operation is in progress + public let inProgress: Bool + /// Message from the CLS (Copilot Language Server) status + public let clsMessage: String + /// Additional message (for accessibility or extension status) + public let message: String? + /// Extension status + public let extensionStatus: ExtensionPermissionStatus + /// URL for system preferences or other actions + public let url: String? + /// Current authentication status + public let authStatus: AuthStatus.Status + /// GitHub username of the authenticated user + public let userName: String? + /// Quota information for GitHub Copilot + public let quotaInfo: GitHubCopilotQuotaInfo? +} diff --git a/Tool/Sources/StatusBarItemView/AccountItemView.swift b/Tool/Sources/StatusBarItemView/AccountItemView.swift index e545cb82..3eff1406 100644 --- a/Tool/Sources/StatusBarItemView/AccountItemView.swift +++ b/Tool/Sources/StatusBarItemView/AccountItemView.swift @@ -38,7 +38,7 @@ public class AccountItemView: NSView { self.visualEffect.isHidden = true self.visualEffect.wantsLayer = true self.visualEffect.layer?.cornerRadius = 4 - self.visualEffect.layer?.backgroundColor = NSColor.systemBlue.cgColor + self.visualEffect.layer?.backgroundColor = NSColor.controlAccentColor.cgColor self.visualEffect.isEmphasized = true // Initialize with a reasonable starting size diff --git a/Tool/Sources/StatusBarItemView/HoverButton.swift b/Tool/Sources/StatusBarItemView/HoverButton.swift new file mode 100644 index 00000000..66b58bb8 --- /dev/null +++ b/Tool/Sources/StatusBarItemView/HoverButton.swift @@ -0,0 +1,145 @@ +import AppKit + +class HoverButton: NSButton { + private var isLinkMode = false + + override func awakeFromNib() { + super.awakeFromNib() + setupButton() + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupButton() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupButton() + } + + private func setupButton() { + self.wantsLayer = true + self.layer?.backgroundColor = NSColor.clear.cgColor + self.layer?.cornerRadius = 3 + } + + private func resetToDefaultState() { + self.layer?.backgroundColor = NSColor.clear.cgColor + if isLinkMode { + updateLinkAppearance(isHovered: false) + } + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + DispatchQueue.main.async { + self.updateTrackingAreas() + } + } + + override func layout() { + super.layout() + updateTrackingAreas() + } + + func configureLinkMode() { + isLinkMode = true + self.isBordered = false + self.setButtonType(.momentaryChange) + self.layer?.backgroundColor = NSColor.clear.cgColor + } + + func setLinkStyle(title: String, fontSize: CGFloat) { + configureLinkMode() + updateLinkAppearance(title: title, fontSize: fontSize, isHovered: false) + } + + override func mouseEntered(with event: NSEvent) { + if isLinkMode { + updateLinkAppearance(isHovered: true) + } else { + self.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.15).cgColor + super.mouseEntered(with: event) + } + } + + override func mouseExited(with event: NSEvent) { + if isLinkMode { + updateLinkAppearance(isHovered: false) + } else { + super.mouseExited(with: event) + resetToDefaultState() + } + } + + private func updateLinkAppearance(title: String? = nil, fontSize: CGFloat? = nil, isHovered: Bool = false) { + let buttonTitle = title ?? self.title + let font = fontSize != nil ? NSFont.systemFont(ofSize: fontSize!, weight: .regular) : NSFont.systemFont(ofSize: 11) + + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.controlAccentColor, + .font: font, + .underlineStyle: isHovered ? NSUnderlineStyle.single.rawValue : 0 + ] + + let attributedTitle = NSAttributedString(string: buttonTitle, attributes: attributes) + self.attributedTitle = attributedTitle + } + + override func mouseDown(with event: NSEvent) { + super.mouseDown(with: event) + // Reset state immediately after click + DispatchQueue.main.async { + self.resetToDefaultState() + } + } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + // Ensure state is reset + DispatchQueue.main.async { + self.resetToDefaultState() + } + } + + override func viewDidHide() { + super.viewDidHide() + // Reset state when view is hidden (like when menu closes) + resetToDefaultState() + } + + override func viewDidUnhide() { + super.viewDidUnhide() + // Ensure clean state when view reappears + resetToDefaultState() + } + + override func removeFromSuperview() { + super.removeFromSuperview() + // Reset state when removed from superview + resetToDefaultState() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + for trackingArea in self.trackingAreas { + self.removeTrackingArea(trackingArea) + } + + guard self.bounds.width > 0 && self.bounds.height > 0 else { return } + + let trackingArea = NSTrackingArea( + rect: self.bounds, + options: [ + .mouseEnteredAndExited, + .activeAlways, + .inVisibleRect + ], + owner: self, + userInfo: nil + ) + self.addTrackingArea(trackingArea) + } +} diff --git a/Tool/Sources/StatusBarItemView/QuotaView.swift b/Tool/Sources/StatusBarItemView/QuotaView.swift new file mode 100644 index 00000000..f1b2d1d3 --- /dev/null +++ b/Tool/Sources/StatusBarItemView/QuotaView.swift @@ -0,0 +1,617 @@ +import SwiftUI +import Foundation + +// MARK: - QuotaSnapshot Model +public struct QuotaSnapshot { + public var percentRemaining: Float + public var unlimited: Bool + public var overagePermitted: Bool + + public init(percentRemaining: Float, unlimited: Bool, overagePermitted: Bool) { + self.percentRemaining = percentRemaining + self.unlimited = unlimited + self.overagePermitted = overagePermitted + } +} + +// MARK: - QuotaView Main Class +public class QuotaView: NSView { + + // MARK: - Properties + private let chat: QuotaSnapshot + private let completions: QuotaSnapshot + private let premiumInteractions: QuotaSnapshot + private let resetDate: String + private let copilotPlan: String + + private var isFreeUser: Bool { + return copilotPlan == "free" + } + + private var isOrgUser: Bool { + return copilotPlan == "business" || copilotPlan == "enterprise" + } + + private var isFreeQuotaUsedUp: Bool { + return chat.percentRemaining == 0 && completions.percentRemaining == 0 + } + + private var isFreeQuotaRemaining: Bool { + return chat.percentRemaining > 25 && completions.percentRemaining > 25 + } + + // MARK: - Initialization + public init( + chat: QuotaSnapshot, + completions: QuotaSnapshot, + premiumInteractions: QuotaSnapshot, + resetDate: String, + copilotPlan: String + ) { + self.chat = chat + self.completions = completions + self.premiumInteractions = premiumInteractions + self.resetDate = resetDate + self.copilotPlan = copilotPlan + + super.init(frame: NSRect(x: 0, y: 0, width: Layout.viewWidth, height: 0)) + + configureView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Configuration + private func configureView() { + autoresizingMask = [.width] + setupView() + + layoutSubtreeIfNeeded() + let calculatedHeight = fittingSize.height + frame = NSRect(x: 0, y: 0, width: Layout.viewWidth, height: calculatedHeight) + } + + private func setupView() { + let components = createViewComponents() + addSubviewsToHierarchy(components) + setupLayoutConstraints(components) + } + + // MARK: - Component Creation + private func createViewComponents() -> ViewComponents { + return ViewComponents( + titleContainer: createTitleContainer(), + progressViews: createProgressViews(), + statusMessageLabel: createStatusMessageLabel(), + resetTextLabel: createResetTextLabel(), + upsellLabel: createUpsellLabel() + ) + } + + private func addSubviewsToHierarchy(_ components: ViewComponents) { + addSubview(components.titleContainer) + components.progressViews.forEach { addSubview($0) } + if !isFreeUser { + addSubview(components.statusMessageLabel) + } + addSubview(components.resetTextLabel) + if !(isOrgUser || (isFreeUser && isFreeQuotaRemaining)) { + addSubview(components.upsellLabel) + } + } +} + +// MARK: - Title Section +extension QuotaView { + private func createTitleContainer() -> NSView { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = createTitleLabel() + let settingsButton = createSettingsButton() + + container.addSubview(titleLabel) + container.addSubview(settingsButton) + + setupTitleConstraints(container: container, titleLabel: titleLabel, settingsButton: settingsButton) + + return container + } + + private func createTitleLabel() -> NSTextField { + let label = NSTextField(labelWithString: "Copilot Usage") + label.font = NSFont.systemFont(ofSize: Style.titleFontSize, weight: .medium) + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .systemGray + return label + } + + private func createSettingsButton() -> HoverButton { + let button = HoverButton() + + if let image = NSImage(systemSymbolName: "slider.horizontal.3", accessibilityDescription: "Manage Copilot") { + image.isTemplate = true + button.image = image + } + + button.imagePosition = .imageOnly + button.alphaValue = Style.buttonAlphaValue + button.toolTip = "Manage Copilot" + button.setButtonType(.momentaryChange) + button.isBordered = false + button.translatesAutoresizingMaskIntoConstraints = false + button.target = self + button.action = #selector(openCopilotSettings) + + return button + } + + private func setupTitleConstraints(container: NSView, titleLabel: NSTextField, settingsButton: HoverButton) { + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor), + titleLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor), + + settingsButton.trailingAnchor.constraint(equalTo: container.trailingAnchor), + settingsButton.centerYAnchor.constraint(equalTo: container.centerYAnchor), + settingsButton.widthAnchor.constraint(equalToConstant: Layout.settingsButtonSize), + settingsButton.heightAnchor.constraint(equalToConstant: Layout.settingsButtonHoverSize), + + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: settingsButton.leadingAnchor, constant: -Layout.settingsButtonSpacing) + ]) + } +} + +// MARK: - Progress Bars Section +extension QuotaView { + private func createProgressViews() -> [NSView] { + let completionsView = createProgressBarSection( + title: "Code Completions", + snapshot: completions + ) + + let chatView = createProgressBarSection( + title: "Chat Messages", + snapshot: chat + ) + + if isFreeUser { + return [completionsView, chatView] + } + + let premiumView = createProgressBarSection( + title: "Premium Requests", + snapshot: premiumInteractions + ) + + return [completionsView, chatView, premiumView] + } + + private func createProgressBarSection(title: String, snapshot: QuotaSnapshot) -> NSView { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = createProgressTitleLabel(title: title) + let percentageLabel = createPercentageLabel(snapshot: snapshot) + + container.addSubview(titleLabel) + container.addSubview(percentageLabel) + + if !snapshot.unlimited { + addProgressBar(to: container, snapshot: snapshot, titleLabel: titleLabel, percentageLabel: percentageLabel) + } else { + setupUnlimitedLayout(container: container, titleLabel: titleLabel, percentageLabel: percentageLabel) + } + + return container + } + + private func createProgressTitleLabel(title: String) -> NSTextField { + let label = NSTextField(labelWithString: title) + label.font = NSFont.systemFont(ofSize: Style.progressFontSize, weight: .regular) + label.textColor = .labelColor + label.translatesAutoresizingMaskIntoConstraints = false + return label + } + + private func createPercentageLabel(snapshot: QuotaSnapshot) -> NSTextField { + let usedPercentage = (100.0 - snapshot.percentRemaining) + let numberPart = usedPercentage.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", usedPercentage) + : String(format: "%.1f", usedPercentage) + let text = snapshot.unlimited ? "Included" : "\(numberPart)%" + + let label = NSTextField(labelWithString: text) + label.font = NSFont.systemFont(ofSize: Style.percentageFontSize, weight: .regular) + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .secondaryLabelColor + label.alignment = .right + + return label + } + + private func addProgressBar(to container: NSView, snapshot: QuotaSnapshot, titleLabel: NSTextField, percentageLabel: NSTextField) { + let usedPercentage = 100.0 - snapshot.percentRemaining + let color = getProgressBarColor(for: usedPercentage) + + let progressBackground = createProgressBackground(color: color) + let progressFill = createProgressFill(color: color, usedPercentage: usedPercentage) + + progressBackground.addSubview(progressFill) + container.addSubview(progressBackground) + + setupProgressBarConstraints( + container: container, + titleLabel: titleLabel, + percentageLabel: percentageLabel, + progressBackground: progressBackground, + progressFill: progressFill, + usedPercentage: usedPercentage + ) + } + + private func createProgressBackground(color: NSColor) -> NSView { + let background = NSView() + background.wantsLayer = true + background.layer?.backgroundColor = color.cgColor.copy(alpha: Style.progressBarBackgroundAlpha) + background.layer?.cornerRadius = Layout.progressBarCornerRadius + background.translatesAutoresizingMaskIntoConstraints = false + return background + } + + private func createProgressFill(color: NSColor, usedPercentage: Float) -> NSView { + let fill = NSView() + fill.wantsLayer = true + fill.translatesAutoresizingMaskIntoConstraints = false + fill.layer?.backgroundColor = color.cgColor + fill.layer?.cornerRadius = Layout.progressBarCornerRadius + return fill + } + + private func setupProgressBarConstraints( + container: NSView, + titleLabel: NSTextField, + percentageLabel: NSTextField, + progressBackground: NSView, + progressFill: NSView, + usedPercentage: Float + ) { + NSLayoutConstraint.activate([ + // Title and percentage on the same line + titleLabel.topAnchor.constraint(equalTo: container.topAnchor), + titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: percentageLabel.leadingAnchor, constant: -Layout.percentageLabelSpacing), + + percentageLabel.topAnchor.constraint(equalTo: container.topAnchor), + percentageLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor), + percentageLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: Layout.percentageLabelMinWidth), + + // Progress bar background + progressBackground.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: Layout.progressBarVerticalOffset), + progressBackground.leadingAnchor.constraint(equalTo: container.leadingAnchor), + progressBackground.trailingAnchor.constraint(equalTo: container.trailingAnchor), + progressBackground.bottomAnchor.constraint(equalTo: container.bottomAnchor), + progressBackground.heightAnchor.constraint(equalToConstant: Layout.progressBarThickness), + + // Progress bar fill + progressFill.topAnchor.constraint(equalTo: progressBackground.topAnchor), + progressFill.leadingAnchor.constraint(equalTo: progressBackground.leadingAnchor), + progressFill.bottomAnchor.constraint(equalTo: progressBackground.bottomAnchor), + progressFill.widthAnchor.constraint(equalTo: progressBackground.widthAnchor, multiplier: CGFloat(usedPercentage / 100.0)) + ]) + } + + private func setupUnlimitedLayout(container: NSView, titleLabel: NSTextField, percentageLabel: NSTextField) { + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: container.topAnchor), + titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: percentageLabel.leadingAnchor, constant: -Layout.percentageLabelSpacing), + titleLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor), + + percentageLabel.topAnchor.constraint(equalTo: container.topAnchor), + percentageLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor), + percentageLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: Layout.percentageLabelMinWidth), + percentageLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + } + + private func getProgressBarColor(for usedPercentage: Float) -> NSColor { + switch usedPercentage { + case 90...: + return .systemRed + case 75..<90: + return .systemYellow + default: + return .systemBlue + } + } +} + +// MARK: - Footer Section +extension QuotaView { + private func createStatusMessageLabel() -> NSTextField { + let message = premiumInteractions.overagePermitted ? + "Additional paid premium requests enabled." : + "Additional paid premium requests disabled." + + let label = NSTextField(labelWithString: isFreeUser ? "" : message) + label.font = NSFont.systemFont(ofSize: Style.footerFontSize, weight: .regular) + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .secondaryLabelColor + label.alignment = .left + return label + } + + private func createResetTextLabel() -> NSTextField { + + // Format reset date + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd" + + var resetText = "Allowance resets \(resetDate)." + + if let date = formatter.date(from: resetDate) { + let outputFormatter = DateFormatter() + outputFormatter.dateFormat = "MMMM d, yyyy" + let formattedDate = outputFormatter.string(from: date) + resetText = "Allowance resets \(formattedDate)." + } + + let label = NSTextField(labelWithString: resetText) + label.font = NSFont.systemFont(ofSize: Style.footerFontSize, weight: .regular) + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .secondaryLabelColor + label.alignment = .left + return label + } + + private func createUpsellLabel() -> NSButton { + if isFreeUser { + let button = NSButton() + let upgradeTitle = "Upgrade to Copilot Pro" + + button.translatesAutoresizingMaskIntoConstraints = false + button.bezelStyle = .push + if isFreeQuotaUsedUp { + button.attributedTitle = NSAttributedString( + string: upgradeTitle, + attributes: [.foregroundColor: NSColor.white] + ) + button.bezelColor = .controlAccentColor + } else { + button.title = upgradeTitle + } + button.controlSize = .large + button.target = self + button.action = #selector(openCopilotUpgradePlan) + + return button + } else { + let button = HoverButton() + let title = "Manage paid premium requests" + + button.setLinkStyle(title: title, fontSize: Style.footerFontSize) + button.translatesAutoresizingMaskIntoConstraints = false + button.alphaValue = Style.labelAlphaValue + button.alignment = .left + button.target = self + button.action = #selector(openCopilotManageOverage) + + return button + } + } +} + +// MARK: - Layout Constraints +extension QuotaView { + private func setupLayoutConstraints(_ components: ViewComponents) { + let constraints = buildConstraints(components) + NSLayoutConstraint.activate(constraints) + } + + private func buildConstraints(_ components: ViewComponents) -> [NSLayoutConstraint] { + var constraints: [NSLayoutConstraint] = [] + + // Title constraints + constraints.append(contentsOf: buildTitleConstraints(components.titleContainer)) + + // Progress view constraints + constraints.append(contentsOf: buildProgressViewConstraints(components)) + + // Footer constraints + constraints.append(contentsOf: buildFooterConstraints(components)) + + return constraints + } + + private func buildTitleConstraints(_ titleContainer: NSView) -> [NSLayoutConstraint] { + return [ + titleContainer.topAnchor.constraint(equalTo: topAnchor, constant: 0), + titleContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + titleContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + titleContainer.heightAnchor.constraint(equalToConstant: Layout.titleHeight) + ] + } + + private func buildProgressViewConstraints(_ components: ViewComponents) -> [NSLayoutConstraint] { + let completionsView = components.progressViews[0] + let chatView = components.progressViews[1] + + var constraints: [NSLayoutConstraint] = [] + + if !isFreeUser { + let premiumView = components.progressViews[2] + constraints.append(contentsOf: buildPremiumProgressConstraints(premiumView, titleContainer: components.titleContainer)) + constraints.append(contentsOf: buildCompletionsProgressConstraints(completionsView, topView: premiumView, isPremiumUnlimited: premiumInteractions.unlimited)) + } else { + constraints.append(contentsOf: buildCompletionsProgressConstraints(completionsView, topView: components.titleContainer, isPremiumUnlimited: false)) + } + + constraints.append(contentsOf: buildChatProgressConstraints(chatView, topView: completionsView)) + + return constraints + } + + private func buildPremiumProgressConstraints(_ premiumView: NSView, titleContainer: NSView) -> [NSLayoutConstraint] { + return [ + premiumView.topAnchor.constraint(equalTo: titleContainer.bottomAnchor, constant: Layout.verticalSpacing), + premiumView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + premiumView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + premiumView.heightAnchor.constraint( + equalToConstant: premiumInteractions.unlimited ? Layout.unlimitedProgressBarHeight : Layout.progressBarHeight + ) + ] + } + + private func buildCompletionsProgressConstraints(_ completionsView: NSView, topView: NSView, isPremiumUnlimited: Bool) -> [NSLayoutConstraint] { + let topSpacing = isPremiumUnlimited ? Layout.unlimitedVerticalSpacing : Layout.verticalSpacing + + return [ + completionsView.topAnchor.constraint(equalTo: topView.bottomAnchor, constant: topSpacing), + completionsView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + completionsView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + completionsView.heightAnchor.constraint( + equalToConstant: completions.unlimited ? Layout.unlimitedProgressBarHeight : Layout.progressBarHeight + ) + ] + } + + private func buildChatProgressConstraints(_ chatView: NSView, topView: NSView) -> [NSLayoutConstraint] { + let topSpacing = completions.unlimited ? Layout.unlimitedVerticalSpacing : Layout.verticalSpacing + + return [ + chatView.topAnchor.constraint(equalTo: topView.bottomAnchor, constant: topSpacing), + chatView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + chatView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + chatView.heightAnchor.constraint( + equalToConstant: chat.unlimited ? Layout.unlimitedProgressBarHeight : Layout.progressBarHeight + ) + ] + } + + private func buildFooterConstraints(_ components: ViewComponents) -> [NSLayoutConstraint] { + let chatView = components.progressViews[1] + let topSpacing = chat.unlimited ? Layout.unlimitedVerticalSpacing : Layout.verticalSpacing + + var constraints = [NSLayoutConstraint]() + + if !isFreeUser { + // Add status message label constraints + constraints.append(contentsOf: [ + components.statusMessageLabel.topAnchor.constraint(equalTo: chatView.bottomAnchor, constant: topSpacing), + components.statusMessageLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + components.statusMessageLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + components.statusMessageLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight) + ]) + + // Add reset text label constraints with status message label as the top anchor + constraints.append(contentsOf: [ + components.resetTextLabel.topAnchor.constraint(equalTo: components.statusMessageLabel.bottomAnchor), + components.resetTextLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + components.resetTextLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + components.resetTextLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight) + ]) + } else { + // For free users, only show reset text label + constraints.append(contentsOf: [ + components.resetTextLabel.topAnchor.constraint(equalTo: chatView.bottomAnchor, constant: topSpacing), + components.resetTextLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + components.resetTextLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + components.resetTextLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight) + ]) + } + + if isOrgUser || (isFreeUser && isFreeQuotaRemaining) { + // Do not show link label for business or enterprise users + constraints.append(components.resetTextLabel.bottomAnchor.constraint(equalTo: bottomAnchor)) + return constraints + } + + // Add link label constraints + constraints.append(contentsOf: [ + components.upsellLabel.topAnchor.constraint(equalTo: components.resetTextLabel.bottomAnchor), + components.upsellLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + components.upsellLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + components.upsellLabel.heightAnchor.constraint(equalToConstant: isFreeUser ? Layout.upgradeButtonHeight : Layout.linkLabelHeight), + + components.upsellLabel.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + return constraints + } +} + +// MARK: - Actions +extension QuotaView { + @objc private func openCopilotSettings() { + Task { + if let url = URL(string: "https://aka.ms/github-copilot-settings") { + NSWorkspace.shared.open(url) + } + } + } + + @objc private func openCopilotManageOverage() { + Task { + if let url = URL(string: "https://aka.ms/github-copilot-manage-overage") { + NSWorkspace.shared.open(url) + } + } + } + + @objc private func openCopilotUpgradePlan() { + Task { + if let url = URL(string: "https://aka.ms/github-copilot-upgrade-plan") { + NSWorkspace.shared.open(url) + } + } + } +} + +// MARK: - Helper Types +private struct ViewComponents { + let titleContainer: NSView + let progressViews: [NSView] + let statusMessageLabel: NSTextField + let resetTextLabel: NSTextField + let upsellLabel: NSButton +} + +// MARK: - Layout Constants +private struct Layout { + static let viewWidth: CGFloat = 256 + static let horizontalMargin: CGFloat = 14 + static let verticalSpacing: CGFloat = 8 + static let unlimitedVerticalSpacing: CGFloat = 6 + static let smallVerticalSpacing: CGFloat = 4 + + static let titleHeight: CGFloat = 20 + static let progressBarHeight: CGFloat = 22 + static let unlimitedProgressBarHeight: CGFloat = 16 + static let footerTextHeight: CGFloat = 16 + static let linkLabelHeight: CGFloat = 16 + static let upgradeButtonHeight: CGFloat = 40 + + static let settingsButtonSize: CGFloat = 20 + static let settingsButtonHoverSize: CGFloat = 14 + static let settingsButtonSpacing: CGFloat = 8 + + static let progressBarThickness: CGFloat = 3 + static let progressBarCornerRadius: CGFloat = 1.5 + static let progressBarVerticalOffset: CGFloat = -10 + static let percentageLabelMinWidth: CGFloat = 35 + static let percentageLabelSpacing: CGFloat = 8 +} + +// MARK: - Style Constants +private struct Style { + static let labelAlphaValue: CGFloat = 0.85 + static let progressBarBackgroundAlpha: CGFloat = 0.3 + static let buttonAlphaValue: CGFloat = 0.85 + + static let titleFontSize: CGFloat = 11 + static let progressFontSize: CGFloat = 13 + static let percentageFontSize: CGFloat = 11 + static let footerFontSize: CGFloat = 11 +} diff --git a/Tool/Sources/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/SystemUtils/FileUtils.swift b/Tool/Sources/SystemUtils/FileUtils.swift new file mode 100644 index 00000000..0af7e34e --- /dev/null +++ b/Tool/Sources/SystemUtils/FileUtils.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct FileUtils{ + public typealias ReadabilityErrorMessageProvider = (ReadabilityStatus) -> String? + + public enum ReadabilityStatus { + case readable + case notFound + case permissionDenied + + public var isReadable: Bool { + switch self { + case .readable: true + case .notFound, .permissionDenied: false + } + } + + public func errorMessage(using provider: ReadabilityErrorMessageProvider? = nil) -> String? { + if let provider = provider { + return provider(self) + } + + // Default error messages + switch self { + case .readable: + return nil + case .notFound: + return "File may have been removed or is unavailable." + case .permissionDenied: + return "Permission Denied to access file." + } + } + } + + public static func checkFileReadability(at path: String) -> ReadabilityStatus { + let fileManager = FileManager.default + if fileManager.fileExists(atPath: path) { + if fileManager.isReadableFile(atPath: path) { + return .readable + } else { + return .permissionDenied + } + } else { + return .notFound + } + } +} diff --git a/Tool/Sources/SystemUtils/SystemUtils.swift b/Tool/Sources/SystemUtils/SystemUtils.swift index c2db343a..43569b88 100644 --- a/Tool/Sources/SystemUtils/SystemUtils.swift +++ b/Tool/Sources/SystemUtils/SystemUtils.swift @@ -1,4 +1,5 @@ import Foundation +import Logger import IOKit import CryptoKit @@ -172,4 +173,77 @@ public class SystemUtils { return false #endif } + + /// Returns the environment of a login shell (to get correct PATH and other variables) + public func getLoginShellEnvironment(shellPath: String = "/bin/zsh") -> [String: String]? { + do { + 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: "=") { + let key = String(line[.. 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() + let commonPaths = [ + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + homeDirectory + "/.local/bin", + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + ] + + let paths = path.split(separator: ":").map { String($0) } + var newPath = path + for commonPath in commonPaths { + if FileManager.default.fileExists(atPath: commonPath) && !paths.contains(commonPath) { + newPath += (newPath.isEmpty ? "" : ":") + commonPath + } + } + + return newPath + } } diff --git a/Tool/Sources/Terminal/TerminalSession.swift b/Tool/Sources/Terminal/TerminalSession.swift index 237d2a05..6db53ef6 100644 --- a/Tool/Sources/Terminal/TerminalSession.swift +++ b/Tool/Sources/Terminal/TerminalSession.swift @@ -1,4 +1,5 @@ import Foundation +import SystemUtils import Logger import Combine @@ -45,7 +46,7 @@ class ShellProcessManager { // Configure the process process?.executableURL = URL(fileURLWithPath: "/bin/zsh") - process?.arguments = ["-i"] + process?.arguments = ["-i", "-l"] // Create temporary file for shell integration let tempDir = FileManager.default.temporaryDirectory @@ -68,11 +69,13 @@ class ShellProcessManager { var environment = ProcessInfo.processInfo.environment // Fetch login shell environment to get correct PATH - if let shellEnv = ShellProcessManager.getLoginShellEnvironment() { + if let shellEnv = SystemUtils.shared.getLoginShellEnvironment(shellPath: "/bin/zsh") { for (key, value) in shellEnv { environment[key] = value } } + // Append common bin paths to PATH + environment["PATH"] = SystemUtils.shared.appendCommonBinPaths(path: environment["PATH"] ?? "") let userZdotdir = environment["ZDOTDIR"] ?? NSHomeDirectory() environment["ZDOTDIR"] = zshdir.path @@ -108,33 +111,6 @@ class ShellProcessManager { } } - /// Returns the environment of a login shell (to get correct PATH and other variables) - private static func getLoginShellEnvironment() -> [String: String]? { - let task = Process() - let pipe = Pipe() - task.executableURL = URL(fileURLWithPath: "/bin/zsh") - task.arguments = ["-l", "-c", "env"] - task.standardOutput = pipe - do { - try task.run() - task.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let output = String(data: data, encoding: .utf8) else { return nil } - var env: [String: String] = [:] - for line in output.split(separator: "\n") { - if let idx = line.firstIndex(of: "=") { - let key = String(line[.. String { + let doc = try SwiftSoup.parse(html) + let rawMarkdown = try extractCleanContent(from: doc) + return cleanupExcessiveNewlines(rawMarkdown) + } + + // MARK: - Content Extraction + private func extractCleanContent(from doc: Document) throws -> String { + try removeUnwantedElements(from: doc) + + // Try to find main content areas + for selector in Config.mainContentSelectors { + if let mainElement = try findMainContent(in: doc, using: selector) { + return try convertElementToMarkdown(mainElement) + } + } + + // Fallback: clean body content + return try fallbackContentExtraction(from: doc) + } + + private func removeUnwantedElements(from doc: Document) throws { + try doc.select(Config.unwantedSelectors).remove() + } + + private func findMainContent(in doc: Document, using selector: String) throws -> Element? { + let elements = try doc.select(selector) + guard let mainElement = elements.first() else { return nil } + + // Clean nested unwanted elements + try mainElement.select("nav, aside, .related, .comments, .social-share, .advertisement").remove() + return mainElement + } + + private func fallbackContentExtraction(from doc: Document) throws -> String { + guard let body = doc.body() else { return "" } + try body.select(Config.unwantedSelectors).remove() + return try convertElementToMarkdown(body) + } + + // MARK: - Cleanup Method + private func cleanupExcessiveNewlines(_ markdown: String) -> String { + // Replace 3+ consecutive newlines with just 2 newlines + let cleaned = markdown.replacingOccurrences( + of: #"\n{3,}"#, + with: "\n\n", + options: .regularExpression + ) + return cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Element Processing + private func convertElementToMarkdown(_ element: Element) throws -> String { + let markdown = try convertElement(element) + return markdown + } + + func convertElement(_ element: Element) throws -> String { + var result = "" + + for node in element.getChildNodes() { + if let textNode = node as? TextNode { + result += textNode.text() + } else if let childElement = node as? Element { + result += try convertSpecificElement(childElement) + } + } + + return result + } + + private func convertSpecificElement(_ element: Element) throws -> String { + let tagName = element.tagName().lowercased() + let text = try element.text() + + switch tagName { + case "h1": + return "\n# \(text)\n" + case "h2": + return "\n## \(text)\n" + case "h3": + return "\n### \(text)\n" + case "h4": + return "\n#### \(text)\n" + case "h5": + return "\n##### \(text)\n" + case "h6": + return "\n###### \(text)\n" + case "p": + return "\n\(try convertElement(element))\n" + case "br": + return "\n" + case "strong", "b": + return "**\(text)**" + case "em", "i": + return "*\(text)*" + case "code": + return "`\(text)`" + case "pre": + return "\n```\n\(text)\n```\n" + case "a": + let href = try element.attr("href") + let title = try element.attr("title") + if href.isEmpty { + return text + } + + // Skip non-http/https/file schemes + if let url = URL(string: href), + let scheme = url.scheme?.lowercased(), + !["http", "https", "file"].contains(scheme) { + return text + } + + let titlePart = title.isEmpty ? "" : " \"\(title.replacingOccurrences(of: "\"", with: "\\\""))\"" + return "[\(text)](\(href)\(titlePart))" + case "img": + let src = try element.attr("src") + let alt = try element.attr("alt") + let title = try element.attr("title") + + var finalSrc = src + // Remove data URIs + if src.hasPrefix("data:") { + finalSrc = src.components(separatedBy: ",").first ?? "" + "..." + } + + let titlePart = title.isEmpty ? "" : " \"\(title.replacingOccurrences(of: "\"", with: "\\\""))\"" + return "![\(alt)](\(finalSrc)\(titlePart))" + case "ul": + return try convertList(element, ordered: false) + case "ol": + return try convertList(element, ordered: true) + case "li": + return try convertElement(element) + case "table": + return try convertTable(element) + case "blockquote": + let content = try convertElement(element) + return content.components(separatedBy: .newlines) + .map { "> \($0)" } + .joined(separator: "\n") + default: + return try convertElement(element) + } + } + + private func convertList(_ element: Element, ordered: Bool) throws -> String { + var result = "\n" + let items = try element.select("li") + + for (index, item) in items.enumerated() { + let content = try convertElement(item).trimmingCharacters(in: .whitespacesAndNewlines) + if ordered { + result += "\(index + 1). \(content)\n" + } else { + result += "- \(content)\n" + } + } + + return result + } + + private func convertTable(_ element: Element) throws -> String { + var result = "\n" + let rows = try element.select("tr") + + guard !rows.isEmpty() else { return "" } + + var isFirstRow = true + for row in rows { + let cells = try row.select("td, th") + let cellContents = try cells.map { try $0.text() } + + result += "| " + cellContents.joined(separator: " | ") + " |\n" + + if isFirstRow { + let separator = Array(repeating: "---", count: cellContents.count).joined(separator: " | ") + result += "| \(separator) |\n" + isFirstRow = false + } + } + + return result + } +} diff --git a/Tool/Sources/WebContentExtractor/WebContentExtractor.swift b/Tool/Sources/WebContentExtractor/WebContentExtractor.swift new file mode 100644 index 00000000..aee0d889 --- /dev/null +++ b/Tool/Sources/WebContentExtractor/WebContentExtractor.swift @@ -0,0 +1,227 @@ +import WebKit +import Logger +import Preferences + +public class WebContentFetcher: NSObject, WKNavigationDelegate { + private var webView: WKWebView? + private var loadingTimer: Timer? + private static let converter = HTMLToMarkdownConverter() + private var completion: ((Result) -> Void)? + + private struct Config { + static let timeout: TimeInterval = 30.0 + static let contentLoadDelay: TimeInterval = 2.0 + } + + public enum WebContentError: Error, LocalizedError { + case invalidURL(String) + case timeout + case noContent + case navigationFailed(Error) + case javascriptError(Error) + + public var errorDescription: String? { + switch self { + case .invalidURL(let url): "Invalid URL: \(url)" + case .timeout: "Request timed out" + case .noContent: "No content found" + case .navigationFailed(let error): "Navigation failed: \(error.localizedDescription)" + case .javascriptError(let error): "JavaScript execution error: \(error.localizedDescription)" + } + } + } + + // MARK: - Initialization + public override init() { + super.init() + setupWebView() + } + + deinit { + cleanup() + } + + // MARK: - Public Methods + public func fetchContent(from urlString: String, completion: @escaping (Result) -> Void) { + guard let url = URL(string: urlString) else { + completion(.failure(WebContentError.invalidURL(urlString))) + return + } + + DispatchQueue.main.async { [weak self] in + self?.completion = completion + self?.setupTimeout() + self?.loadContent(from: url) + } + } + + public static func fetchContentAsync(from urlString: String) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let fetcher = WebContentFetcher() + fetcher.fetchContent(from: urlString) { result in + withExtendedLifetime(fetcher) { + continuation.resume(with: result) + } + } + } + } + + public static func fetchMultipleContentAsync(from urls: [String]) async -> [String] { + var results: [String] = [] + + for url in urls { + do { + let content = try await fetchContentAsync(from: url) + results.append("Successfully fetched content from \(url): \(content)") + } catch { + Logger.client.error("Failed to fetch content from \(url): \(error.localizedDescription)") + results.append("Failed to fetch content from \(url) with error: \(error.localizedDescription)") + } + } + + return results + } + + // MARK: - Private Methods + private func setupWebView() { + let configuration = WKWebViewConfiguration() + let dataSource = WKWebsiteDataStore.nonPersistent() + + if #available(macOS 14.0, *) { + configureProxy(for: dataSource) + } + + configuration.websiteDataStore = dataSource + webView = WKWebView(frame: .zero, configuration: configuration) + webView?.navigationDelegate = self + } + + @available(macOS 14.0, *) + private func configureProxy(for dataSource: WKWebsiteDataStore) { + let proxyURL = UserDefaults.shared.value(for: \.gitHubCopilotProxyUrl) + guard let url = URL(string: proxyURL), + let host = url.host, + let port = url.port, + let proxyPort = NWEndpoint.Port(port.description) else { return } + + let tlsOptions = NWProtocolTLS.Options() + let useStrictSSL = UserDefaults.shared.value(for: \.gitHubCopilotUseStrictSSL) + + if !useStrictSSL { + let secOptions = tlsOptions.securityProtocolOptions + sec_protocol_options_set_verify_block(secOptions, { _, _, completion in + completion(true) + }, .main) + } + + let httpProxy = ProxyConfiguration( + httpCONNECTProxy: NWEndpoint.hostPort( + host: NWEndpoint.Host(host), + port: proxyPort + ), + tlsOptions: tlsOptions + ) + + httpProxy.applyCredential( + username: UserDefaults.shared.value(for: \.gitHubCopilotProxyUsername), + password: UserDefaults.shared.value(for: \.gitHubCopilotProxyPassword) + ) + + dataSource.proxyConfigurations = [httpProxy] + } + + private func cleanup() { + loadingTimer?.invalidate() + loadingTimer = nil + webView?.navigationDelegate = nil + webView?.stopLoading() + webView = nil + } + + private func setupTimeout() { + loadingTimer?.invalidate() + loadingTimer = Timer.scheduledTimer(withTimeInterval: Config.timeout, repeats: false) { [weak self] _ in + DispatchQueue.main.async { + Logger.client.error("Request timed out") + self?.completeWithError(WebContentError.timeout) + } + } + } + + private func loadContent(from url: URL) { + if webView == nil { + setupWebView() + } + + guard let webView = webView else { + completeWithError(WebContentError.navigationFailed(NSError(domain: "WebView creation failed", code: -1))) + return + } + + let request = URLRequest( + url: url, + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, + timeoutInterval: Config.timeout + ) + webView.load(request) + } + + private func processHTML(_ html: String) { + do { + let cleanedText = try Self.converter.convertToMarkdown(from: html) + completeWithSuccess(cleanedText) + } catch { + Logger.client.error("SwiftSoup parsing error: \(error.localizedDescription)") + completeWithError(error) + } + } + + private func completeWithSuccess(_ content: String) { + completion?(.success(content)) + completion = nil + } + + private func completeWithError(_ error: Error) { + completion?(.failure(error)) + completion = nil + } + + // MARK: - WKNavigationDelegate + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + loadingTimer?.invalidate() + + DispatchQueue.main.asyncAfter(deadline: .now() + Config.contentLoadDelay) { + webView.evaluateJavaScript("document.body.innerHTML") { [weak self] result, error in + DispatchQueue.main.async { + if let error = error { + Logger.client.error("JavaScript execution error: \(error.localizedDescription)") + self?.completeWithError(WebContentError.javascriptError(error)) + return + } + + if let html = result as? String, !html.isEmpty { + self?.processHTML(html) + } else { + self?.completeWithError(WebContentError.noContent) + } + } + } + } + } + + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + handleNavigationFailure(error) + } + + public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + handleNavigationFailure(error) + } + + private func handleNavigationFailure(_ error: Error) { + loadingTimer?.invalidate() + DispatchQueue.main.async { + Logger.client.error("Navigation failed: \(error.localizedDescription)") + self.completeWithError(WebContentError.navigationFailed(error)) + } + } +} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/BatchingFileChangeWatcher.swift b/Tool/Sources/Workspace/FileChangeWatcher/BatchingFileChangeWatcher.swift new file mode 100644 index 00000000..4273bac7 --- /dev/null +++ b/Tool/Sources/Workspace/FileChangeWatcher/BatchingFileChangeWatcher.swift @@ -0,0 +1,320 @@ +import Foundation +import System +import Logger +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 + + // Dependencies injected for testing + private let fsEventProvider: FSEventProvider + + /// TODO: set a proper value for stdio + public static let maxEventPublishSize = 100 + + init( + watchedPaths: [URL], + changePublisher: @escaping PublisherType, + publishInterval: TimeInterval = 3.0, + 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.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() + } + + private func updateWatchedPaths(_ paths: [URL]) { + guard isWatching, paths != watchedPaths else { return } + stopWatching() + watchedPaths = paths + _ = startWatching() + } + + public func addPaths(_ paths: [URL]) { + let newPaths = paths.filter { !watchedPaths.contains($0) } + if !newPaths.isEmpty { + let updatedPaths = watchedPaths + newPaths + updateWatchedPaths(updatedPaths) + } + } + + public func removePaths(_ paths: [URL]) { + let updatedPaths = watchedPaths.filter { !paths.contains($0) } + if updatedPaths.count != watchedPaths.count { + updateWatchedPaths(updatedPaths) + } + } + + public func paths() -> [URL] { + return watchedPaths + } + + internal func start() { + guard !isWatching else { return } + + guard self.startWatching() else { + Logger.client.info("Failed to start watching for: \(watchedPaths)") + return + } + self.startPublishTimer() + isWatching = true + } + + deinit { + stopWatching() + self.timer?.invalidate() + } + + internal func startPublishTimer() { + guard self.timer == nil else { return } + + Task { @MainActor [weak self] in + guard let self else { return } + self.timer = Timer.scheduledTimer(withTimeInterval: self.publishInterval, repeats: true) { [weak self] _ in + self?.publishChanges() + self?.publishDirectoryChanges() + } + } + } + + internal func addEvent(file: URL, type: FileChangeType) { + eventQueue.async { + self.pendingEvents.append(FileEvent(uri: file.absoluteString, type: type)) + } + } + + internal func addDirectoryEvent(directory: URL, type: FileChangeType) { + guard self.directoryChangePublisher != nil else { + return + } + directoryEventQueue.async { + self.pendingDirectoryEvents.append(FileEvent(uri: directory.absoluteString, type: type)) + } + } + + /// 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 } + + let compressedEventArray = self.compressEvents(self.pendingEvents) + + let changes = Array(compressedEventArray.prefix(BatchingFileChangeWatcher.maxEventPublishSize)) + if compressedEventArray.count > BatchingFileChangeWatcher.maxEventPublishSize { + self.pendingEvents = Array(compressedEventArray[BatchingFileChangeWatcher.maxEventPublishSize.. 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 + var isEventStreamStarted = false + + var context = FSEventStreamContext() + context.info = Unmanaged.passUnretained(self).toOpaque() + + let paths = watchedPaths.map { $0.path } as CFArray + let flags = UInt32( + kFSEventStreamCreateFlagFileEvents | + kFSEventStreamCreateFlagNoDefer | + kFSEventStreamCreateFlagWatchRoot + ) + + eventStream = fsEventProvider.createEventStream( + paths: paths, + latency: 1, // 1 second latency, + flags: flags, + callback: { _, clientCallbackInfo, numEvents, eventPaths, eventFlags, _ in + guard let clientCallbackInfo = clientCallbackInfo else { return } + let watcher = Unmanaged.fromOpaque(clientCallbackInfo).takeUnretainedValue() + watcher.processEvent(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags) + }, + context: &context + ) + + if let eventStream = eventStream { + fsEventProvider.setDispatchQueue(eventStream, queue: fsEventQueue) + fsEventProvider.startStream(eventStream) + isEventStreamStarted = true + } + + return isEventStreamStarted + } + + /// Stops watching for file changes + public func stopWatching() { + guard isWatching, let eventStream = eventStream else { return } + + fsEventProvider.stopStream(eventStream) + fsEventProvider.invalidateStream(eventStream) + fsEventProvider.releaseStream(eventStream) + self.eventStream = nil + + isWatching = false + + Logger.client.info("Stoped watching for file changes in \(watchedPaths)") + } + + public func processEvent(numEvents: CFIndex, eventPaths: UnsafeRawPointer, eventFlags: UnsafePointer) { + let pathsPtr = eventPaths.bindMemory(to: UnsafeMutableRawPointer.self, capacity: numEvents) + + for i in 0.. Void)? = nil, onFileDeleted: (() -> Void)? = nil, onFileRenamed: (() -> Void)? = nil) -> FileWatcherProtocol { + return SingleFileWatcher(fileURL: fileURL, + dispatchQueue: dispatchQueue, + onFileModified: onFileModified, + onFileDeleted: onFileDeleted, + onFileRenamed: onFileRenamed + ) + } + + public func createDirectoryWatcher( + watchedPaths: [URL], + changePublisher: @escaping PublisherType, + publishInterval: TimeInterval, + directoryChangePublisher: PublisherType? = nil + ) -> DirectoryWatcherProtocol { + return BatchingFileChangeWatcher( + watchedPaths: watchedPaths, + changePublisher: changePublisher, + publishInterval: publishInterval, + fsEventProvider: FileChangeWatcherFSEventProvider(), + directoryChangePublisher: directoryChangePublisher + ) + } +} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FSEventProvider.swift b/Tool/Sources/Workspace/FileChangeWatcher/FSEventProvider.swift index 8057b106..3a15c016 100644 --- a/Tool/Sources/Workspace/FileChangeWatcher/FSEventProvider.swift +++ b/Tool/Sources/Workspace/FileChangeWatcher/FSEventProvider.swift @@ -1,6 +1,6 @@ import Foundation -protocol FSEventProvider { +public protocol FSEventProvider { func createEventStream( paths: CFArray, latency: CFTimeInterval, diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift b/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift deleted file mode 100644 index f89a90de..00000000 --- a/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift +++ /dev/null @@ -1,401 +0,0 @@ -import Foundation -import System -import Logger -import CoreServices -import LanguageServerProtocol -import XcodeInspector - -public typealias PublisherType = (([FileEvent]) -> Void) - -protocol FileChangeWatcher { - func onFileCreated(file: URL) - func onFileChanged(file: URL) - func onFileDeleted(file: URL) - - func addPaths(_ paths: [URL]) - func removePaths(_ paths: [URL]) -} - -public final class BatchingFileChangeWatcher: FileChangeWatcher { - private var watchedPaths: [URL] - private let changePublisher: PublisherType - private let publishInterval: TimeInterval - - private var pendingEvents: [FileEvent] = [] - private var timer: Timer? - private let eventQueue: DispatchQueue - private let fsEventQueue: DispatchQueue - private var eventStream: FSEventStreamRef? - private(set) public var isWatching = false - - // Dependencies injected for testing - private let fsEventProvider: FSEventProvider - - public var paths: [URL] { watchedPaths } - - /// TODO: set a proper value for stdio - public static let maxEventPublishSize = 100 - - init( - watchedPaths: [URL], - changePublisher: @escaping PublisherType, - publishInterval: TimeInterval = 3.0, - fsEventProvider: FSEventProvider = FileChangeWatcherFSEventProvider() - ) { - self.watchedPaths = watchedPaths - self.changePublisher = changePublisher - self.publishInterval = publishInterval - self.fsEventProvider = fsEventProvider - self.eventQueue = DispatchQueue(label: "com.github.copilot.filechangewatcher") - self.fsEventQueue = DispatchQueue(label: "com.github.copilot.filechangewatcherfseventstream", qos: .utility) - - self.start() - } - - private func updateWatchedPaths(_ paths: [URL]) { - guard isWatching, paths != watchedPaths else { return } - stopWatching() - watchedPaths = paths - _ = startWatching() - } - - public func addPaths(_ paths: [URL]) { - let newPaths = paths.filter { !watchedPaths.contains($0) } - if !newPaths.isEmpty { - let updatedPaths = watchedPaths + newPaths - updateWatchedPaths(updatedPaths) - } - } - - public func removePaths(_ paths: [URL]) { - let updatedPaths = watchedPaths.filter { !paths.contains($0) } - if updatedPaths.count != watchedPaths.count { - updateWatchedPaths(updatedPaths) - } - } - - internal func start() { - guard !isWatching else { return } - - guard self.startWatching() else { - Logger.client.info("Failed to start watching for: \(watchedPaths)") - return - } - self.startPublishTimer() - isWatching = true - } - - deinit { - stopWatching() - self.timer?.invalidate() - } - - internal func startPublishTimer() { - guard self.timer == nil else { return } - - Task { @MainActor [weak self] in - guard let self else { return } - self.timer = Timer.scheduledTimer(withTimeInterval: self.publishInterval, repeats: true) { [weak self] _ in - self?.publishChanges() - } - } - } - - internal func addEvent(file: URL, type: FileChangeType) { - eventQueue.async { - self.pendingEvents.append(FileEvent(uri: file.absoluteString, type: type)) - } - } - - public func onFileCreated(file: URL) { - addEvent(file: file, type: .created) - } - - public func onFileChanged(file: URL) { - addEvent(file: file, type: .changed) - } - - public func onFileDeleted(file: URL) { - addEvent(file: file, type: .deleted) - } - - 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 changes = Array(compressedEventArray.prefix(BatchingFileChangeWatcher.maxEventPublishSize)) - if compressedEventArray.count > BatchingFileChangeWatcher.maxEventPublishSize { - self.pendingEvents = Array(compressedEventArray[BatchingFileChangeWatcher.maxEventPublishSize.. Bool { - var isEventStreamStarted = false - - var context = FSEventStreamContext() - context.info = Unmanaged.passUnretained(self).toOpaque() - - let paths = watchedPaths.map { $0.path } as CFArray - let flags = UInt32( - kFSEventStreamCreateFlagFileEvents | - kFSEventStreamCreateFlagNoDefer | - kFSEventStreamCreateFlagWatchRoot - ) - - eventStream = fsEventProvider.createEventStream( - paths: paths, - latency: 1, // 1 second latency, - flags: flags, - callback: { _, clientCallbackInfo, numEvents, eventPaths, eventFlags, _ in - guard let clientCallbackInfo = clientCallbackInfo else { return } - let watcher = Unmanaged.fromOpaque(clientCallbackInfo).takeUnretainedValue() - watcher.processEvent(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags) - }, - context: &context - ) - - if let eventStream = eventStream { - fsEventProvider.setDispatchQueue(eventStream, queue: fsEventQueue) - fsEventProvider.startStream(eventStream) - isEventStreamStarted = true - } - - return isEventStreamStarted - } - - /// Stops watching for file changes - internal func stopWatching() { - guard isWatching, let eventStream = eventStream else { return } - - fsEventProvider.stopStream(eventStream) - fsEventProvider.invalidateStream(eventStream) - fsEventProvider.releaseStream(eventStream) - self.eventStream = nil - isWatching = false - - Logger.client.info("Stoped watching for file changes in \(watchedPaths)") - } - - public func processEvent(numEvents: CFIndex, eventPaths: UnsafeRawPointer, eventFlags: UnsafePointer) { - let pathsPtr = eventPaths.bindMemory(to: UnsafeMutableRawPointer.self, capacity: numEvents) - - for i in 0.. 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 - } -} - -public class FileChangeWatcherService { - internal var watcher: BatchingFileChangeWatcher? - /// for watching projects added or removed - private var timer: Timer? - private var projectWatchingInterval: TimeInterval = 3.0 - - private(set) public var workspaceURL: URL - private(set) public var publisher: PublisherType - - // Dependencies injected for testing - internal let workspaceFileProvider: WorkspaceFileProvider - internal let watcherFactory: ([URL], @escaping PublisherType) -> BatchingFileChangeWatcher - - public init( - _ workspaceURL: URL, - publisher: @escaping PublisherType, - publishInterval: TimeInterval = 3.0, - projectWatchingInterval: TimeInterval = 3.0, - workspaceFileProvider: WorkspaceFileProvider = FileChangeWatcherWorkspaceFileProvider(), - watcherFactory: (([URL], @escaping PublisherType) -> BatchingFileChangeWatcher)? = nil - ) { - self.workspaceURL = workspaceURL - self.publisher = publisher - self.workspaceFileProvider = workspaceFileProvider - self.watcherFactory = watcherFactory ?? { projectURLs, publisher in - BatchingFileChangeWatcher(watchedPaths: projectURLs, changePublisher: publisher, publishInterval: publishInterval) - } - } - - deinit { - self.watcher = nil - self.timer?.invalidate() - } - - internal func startWatchingProject() { - guard timer == nil else { return } - - Task { @MainActor [weak self] in - guard let self else { return } - - self.timer = Timer.scheduledTimer(withTimeInterval: self.projectWatchingInterval, repeats: true) { [weak self] _ in - guard let self, let watcher = self.watcher else { return } - - let watchingProjects = Set(watcher.paths) - let projects = Set(self.workspaceFileProvider.getSubprojectURLs(in: self.workspaceURL)) - - /// find added projects - let addedProjects = projects.subtracting(watchingProjects) - self.onProjectAdded(Array(addedProjects)) - - /// find removed projects - let removedProjects = watchingProjects.subtracting(projects) - self.onProjectRemoved(Array(removedProjects)) - } - } - } - - public func startWatching() { - guard workspaceURL.path != "/" else { return } - - guard watcher == nil else { return } - - let projects = workspaceFileProvider.getSubprojectURLs(in: workspaceURL) - - watcher = watcherFactory(projects, publisher) - Logger.client.info("Started watching for file changes in \(projects)") - - startWatchingProject() - } - - internal func onProjectAdded(_ projectURLs: [URL]) { - guard let watcher = watcher, projectURLs.count > 0 else { return } - - watcher.addPaths(projectURLs) - - Logger.client.info("Started watching for file changes in \(projectURLs)") - - /// sync all the files as created in the project when added - for projectURL in projectURLs { - let files = workspaceFileProvider.getFilesInActiveWorkspace( - workspaceURL: projectURL, - workspaceRootURL: projectURL - ) - publisher(files.map { .init(uri: $0.url.absoluteString, type: .created) }) - } - } - - internal func onProjectRemoved(_ projectURLs: [URL]) { - guard let watcher = watcher, projectURLs.count > 0 else { return } - - watcher.removePaths(projectURLs) - - Logger.client.info("Stopped watching for file changes in \(projectURLs)") - - /// sync all the files as deleted in the project when removed - for projectURL in projectURLs { - let files = workspaceFileProvider.getFilesInActiveWorkspace(workspaceURL: projectURL, workspaceRootURL: projectURL) - publisher(files.map { .init(uri: $0.url.absoluteString, type: .deleted) }) - } - } -} - -@globalActor -public enum PoolActor: GlobalActor { - public actor Actor {} - public static let shared = Actor() -} - -public class FileChangeWatcherServicePool { - - public static let shared = FileChangeWatcherServicePool() - private var servicePool: [URL: FileChangeWatcherService] = [:] - - private init() {} - - @PoolActor - public func watch(for workspaceURL: URL, publisher: @escaping PublisherType) { - guard workspaceURL.path != "/" else { return } - - var validWorkspaceURL: URL? = nil - if WorkspaceFile.isXCWorkspace(workspaceURL) { - validWorkspaceURL = workspaceURL - } else if WorkspaceFile.isXCProject(workspaceURL) { - validWorkspaceURL = WorkspaceFile.getWorkspaceByProject(workspaceURL) - } - - guard let validWorkspaceURL else { return } - - guard servicePool[workspaceURL] == nil else { return } - - let watcherService = FileChangeWatcherService(validWorkspaceURL, publisher: publisher) - watcherService.startWatching() - - servicePool[workspaceURL] = watcherService - } -} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcherService.swift b/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcherService.swift new file mode 100644 index 00000000..ac6f76dd --- /dev/null +++ b/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcherService.swift @@ -0,0 +1,222 @@ +import Foundation +import System +import Logger +import CoreServices +import LanguageServerProtocol +import XcodeInspector + +public class FileChangeWatcherService { + internal var watcher: DirectoryWatcherProtocol? + + private(set) public var workspaceURL: URL + private(set) public var publisher: PublisherType + private(set) public var publishInterval: TimeInterval + private(set) public var directoryChangePublisher: PublisherType? + + // Dependencies injected for testing + internal let workspaceFileProvider: WorkspaceFileProvider + internal let watcherFactory: FileWatcherFactory + + // Watching workspace metadata file + private var workspaceConfigFileWatcher: FileWatcherProtocol? + private var isMonitoringWorkspaceConfigFile = false + private let monitoringQueue = DispatchQueue(label: "com.github.copilot.workspaceMonitor", qos: .utility) + private let configFileEventQueue = DispatchQueue(label: "com.github.copilot.workspaceEventMonitor", qos: .utility) + + public init( + _ workspaceURL: URL, + publisher: @escaping PublisherType, + publishInterval: TimeInterval = 3.0, + workspaceFileProvider: WorkspaceFileProvider = FileChangeWatcherWorkspaceFileProvider(), + watcherFactory: FileWatcherFactory? = nil, + directoryChangePublisher: PublisherType? + ) { + self.workspaceURL = workspaceURL + self.publisher = publisher + self.publishInterval = publishInterval + self.workspaceFileProvider = workspaceFileProvider + self.watcherFactory = watcherFactory ?? DefaultFileWatcherFactory() + self.directoryChangePublisher = directoryChangePublisher + } + + deinit { + stopWorkspaceConfigFileMonitoring() + self.watcher = nil + } + + public func startWatching() { + guard workspaceURL.path != "/" else { return } + + guard watcher == nil else { return } + + let projects = workspaceFileProvider.getProjects(by: workspaceURL) + guard projects.count > 0 else { return } + + watcher = watcherFactory.createDirectoryWatcher( + watchedPaths: projects, + changePublisher: publisher, + publishInterval: publishInterval, + directoryChangePublisher: directoryChangePublisher + ) + Logger.client.info("Started watching for file changes in \(projects)") + + startWatchingProject() + } + + internal func startWatchingProject() { + if self.workspaceFileProvider.isXCWorkspace(self.workspaceURL) { + guard !isMonitoringWorkspaceConfigFile else { return } + isMonitoringWorkspaceConfigFile = true + recreateConfigFileMonitor() + } + } + + private func recreateConfigFileMonitor() { + let workspaceDataFile = workspaceURL.appendingPathComponent("contents.xcworkspacedata") + + // Clean up existing monitor first + cleanupCurrentMonitor() + + guard self.workspaceFileProvider.fileExists(atPath: workspaceDataFile.path) else { + Logger.client.info("[FileWatcher] contents.xcworkspacedata file not found at \(workspaceDataFile.path).") + return + } + + // Create SingleFileWatcher for the workspace file + workspaceConfigFileWatcher = self.watcherFactory.createFileWatcher( + fileURL: workspaceDataFile, + dispatchQueue: configFileEventQueue, + onFileModified: { [weak self] in + self?.handleWorkspaceConfigFileChange() + self?.scheduleMonitorRecreation(delay: 1.0) + }, + onFileDeleted: { [weak self] in + self?.handleWorkspaceConfigFileChange() + self?.scheduleMonitorRecreation(delay: 1.0) + }, + onFileRenamed: nil + ) + + let _ = workspaceConfigFileWatcher?.startWatching() + } + + private func handleWorkspaceConfigFileChange() { + guard let watcher = self.watcher else { + return + } + + let workspaceDataFile = workspaceURL.appendingPathComponent("contents.xcworkspacedata") + // Check if file still exists + let fileExists = self.workspaceFileProvider.fileExists(atPath: workspaceDataFile.path) + if fileExists { + // File was modified, check for project changes + let watchingProjects = Set(watcher.paths()) + let projects = Set(self.workspaceFileProvider.getProjects(by: self.workspaceURL)) + + /// find added projects + let addedProjects = projects.subtracting(watchingProjects) + if !addedProjects.isEmpty { + self.onProjectAdded(Array(addedProjects)) + } + + /// find removed projects + let removedProjects = watchingProjects.subtracting(projects) + if !removedProjects.isEmpty { + self.onProjectRemoved(Array(removedProjects)) + } + } else { + Logger.client.info("[FileWatcher] contents.xcworkspacedata file was deleted") + } + } + + private func scheduleMonitorRecreation(delay: TimeInterval) { + monitoringQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self, self.isMonitoringWorkspaceConfigFile else { return } + self.recreateConfigFileMonitor() + } + } + + private func cleanupCurrentMonitor() { + workspaceConfigFileWatcher?.stopWatching() + workspaceConfigFileWatcher = nil + } + + private func stopWorkspaceConfigFileMonitoring() { + isMonitoringWorkspaceConfigFile = false + cleanupCurrentMonitor() + } + + internal func onProjectAdded(_ projectURLs: [URL]) { + guard let watcher = watcher, projectURLs.count > 0 else { return } + + watcher.addPaths(projectURLs) + + Logger.client.info("Started watching for file changes in \(projectURLs)") + + /// sync all the files as created in the project when added + for projectURL in projectURLs { + let files = workspaceFileProvider.getFilesInActiveWorkspace( + workspaceURL: projectURL, + workspaceRootURL: projectURL + ) + publisher(files.map { .init(uri: $0.url.absoluteString, type: .created) }) + } + } + + internal func onProjectRemoved(_ projectURLs: [URL]) { + guard let watcher = watcher, projectURLs.count > 0 else { return } + + watcher.removePaths(projectURLs) + + Logger.client.info("Stopped watching for file changes in \(projectURLs)") + + /// sync all the files as deleted in the project when removed + for projectURL in projectURLs { + let files = workspaceFileProvider.getFilesInActiveWorkspace(workspaceURL: projectURL, workspaceRootURL: projectURL) + publisher(files.map { .init(uri: $0.url.absoluteString, type: .deleted) }) + } + } +} + +@globalActor +public enum PoolActor: GlobalActor { + public actor Actor {} + public static let shared = Actor() +} + +public class FileChangeWatcherServicePool { + + public static let shared = FileChangeWatcherServicePool() + private var servicePool: [URL: FileChangeWatcherService] = [:] + + private init() {} + + @PoolActor + public func watch( + for workspaceURL: URL, + publisher: @escaping PublisherType, + directoryChangePublisher: PublisherType? = nil + ) { + guard workspaceURL.path != "/" else { return } + + var validWorkspaceURL: URL? = nil + if WorkspaceFile.isXCWorkspace(workspaceURL) { + validWorkspaceURL = workspaceURL + } else if WorkspaceFile.isXCProject(workspaceURL) { + validWorkspaceURL = WorkspaceFile.getWorkspaceByProject(workspaceURL) + } + + guard let validWorkspaceURL else { return } + + guard servicePool[workspaceURL] == nil else { return } + + let watcherService = FileChangeWatcherService( + validWorkspaceURL, + publisher: publisher, + directoryChangePublisher: directoryChangePublisher + ) + watcherService.startWatching() + + servicePool[workspaceURL] = watcherService + } +} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FileWatcherProtocol.swift b/Tool/Sources/Workspace/FileChangeWatcher/FileWatcherProtocol.swift new file mode 100644 index 00000000..a4d37754 --- /dev/null +++ b/Tool/Sources/Workspace/FileChangeWatcher/FileWatcherProtocol.swift @@ -0,0 +1,32 @@ +import Foundation +import LanguageServerProtocol + +public protocol FileWatcherProtocol { + func startWatching() -> Bool + func stopWatching() +} + +public typealias PublisherType = (([FileEvent]) -> Void) + +public protocol DirectoryWatcherProtocol: FileWatcherProtocol { + func addPaths(_ paths: [URL]) + func removePaths(_ paths: [URL]) + func paths() -> [URL] +} + +public protocol FileWatcherFactory { + func createFileWatcher( + fileURL: URL, + dispatchQueue: DispatchQueue?, + onFileModified: (() -> Void)?, + onFileDeleted: (() -> Void)?, + onFileRenamed: (() -> Void)? + ) -> FileWatcherProtocol + + func createDirectoryWatcher( + watchedPaths: [URL], + changePublisher: @escaping PublisherType, + publishInterval: TimeInterval, + directoryChangePublisher: PublisherType? + ) -> DirectoryWatcherProtocol +} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/SingleFileWatcher.swift b/Tool/Sources/Workspace/FileChangeWatcher/SingleFileWatcher.swift new file mode 100644 index 00000000..612e402d --- /dev/null +++ b/Tool/Sources/Workspace/FileChangeWatcher/SingleFileWatcher.swift @@ -0,0 +1,81 @@ +import Foundation +import Logger + +class SingleFileWatcher: FileWatcherProtocol { + private var fileDescriptor: CInt = -1 + private var dispatchSource: DispatchSourceFileSystemObject? + private let fileURL: URL + private let dispatchQueue: DispatchQueue? + + // Callbacks for file events + private let onFileModified: (() -> Void)? + private let onFileDeleted: (() -> Void)? + private let onFileRenamed: (() -> Void)? + + init( + fileURL: URL, + dispatchQueue: DispatchQueue? = nil, + onFileModified: (() -> Void)? = nil, + onFileDeleted: (() -> Void)? = nil, + onFileRenamed: (() -> Void)? = nil + ) { + self.fileURL = fileURL + self.dispatchQueue = dispatchQueue + self.onFileModified = onFileModified + self.onFileDeleted = onFileDeleted + self.onFileRenamed = onFileRenamed + } + + func startWatching() -> Bool { + // Open the file for event-only monitoring + fileDescriptor = open(fileURL.path, O_EVTONLY) + guard fileDescriptor != -1 else { + Logger.client.info("[FileWatcher] Failed to open file \(fileURL.path).") + return false + } + + // Create DispatchSource to monitor the file descriptor + dispatchSource = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: [.write, .delete, .rename], + queue: self.dispatchQueue ?? DispatchQueue.global() + ) + + dispatchSource?.setEventHandler { [weak self] in + guard let self = self else { return } + + let flags = self.dispatchSource?.data ?? [] + + if flags.contains(.write) { + self.onFileModified?() + } + if flags.contains(.delete) { + self.onFileDeleted?() + self.stopWatching() + } + if flags.contains(.rename) { + self.onFileRenamed?() + self.stopWatching() + } + } + + dispatchSource?.setCancelHandler { [weak self] in + guard let self = self else { return } + close(self.fileDescriptor) + self.fileDescriptor = -1 + } + + dispatchSource?.resume() + Logger.client.info("[FileWatcher] Started watching file: \(fileURL.path)") + return true + } + + func stopWatching() { + dispatchSource?.cancel() + dispatchSource = nil + } + + deinit { + stopWatching() + } +} diff --git a/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift b/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift index 65a3c56b..151effdb 100644 --- a/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift +++ b/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift @@ -1,21 +1,26 @@ -import Foundation import ConversationServiceProvider +import CopilotForXcodeKit +import Foundation public protocol WorkspaceFileProvider { - func getSubprojectURLs(in workspaceURL: URL) -> [URL] - func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference] + func getProjects(by workspaceURL: URL) -> [URL] + func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [ConversationFileReference] func isXCProject(_ url: URL) -> Bool func isXCWorkspace(_ url: URL) -> Bool + func fileExists(atPath: String) -> Bool } public class FileChangeWatcherWorkspaceFileProvider: WorkspaceFileProvider { public init() {} - public func getSubprojectURLs(in workspaceURL: URL) -> [URL] { - return WorkspaceFile.getSubprojectURLs(in: workspaceURL) + public func getProjects(by workspaceURL: URL) -> [URL] { + guard let workspaceInfo = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) + else { return [] } + + return WorkspaceFile.getProjects(workspace: workspaceInfo).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) } @@ -26,4 +31,8 @@ public class FileChangeWatcherWorkspaceFileProvider: WorkspaceFileProvider { public func isXCWorkspace(_ url: URL) -> Bool { return WorkspaceFile.isXCWorkspace(url) } + + public func fileExists(atPath: String) -> Bool { + return FileManager.default.fileExists(atPath: atPath) + } } diff --git a/Tool/Sources/Workspace/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 4909c43b..11c68ce2 100644 --- a/Tool/Sources/Workspace/WorkspaceFile.swift +++ b/Tool/Sources/Workspace/WorkspaceFile.swift @@ -2,6 +2,7 @@ import Foundation import Logger import ConversationServiceProvider import CopilotForXcodeKit +import XcodeInspector public let supportedFileExtensions: Set = ["swift", "m", "mm", "h", "cpp", "c", "js", "ts", "py", "rb", "java", "applescript", "scpt", "plist", "entitlements", "md", "json", "xml", "txt", "yaml", "yml", "html", "css"] public let skipPatterns: [String] = [ @@ -12,7 +13,9 @@ public let skipPatterns: [String] = [ ".DS_Store", "Thumbs.db", "node_modules", - "bower_components" + "bower_components", + "Preview Content", + ".swiftpm" ] public struct ProjectInfo { @@ -20,8 +23,16 @@ public struct ProjectInfo { public let name: String } +extension NSError { + var isPermissionDenied: Bool { + return (domain == NSCocoaErrorDomain && code == 257) || + (domain == NSPOSIXErrorDomain && code == 1) + } +} + public struct WorkspaceFile { - + private static let wellKnownBundleExtensions: Set = ["app", "xcarchive"] + static func isXCWorkspace(_ url: URL) -> Bool { return url.pathExtension == "xcworkspace" && FileManager.default.fileExists(atPath: url.appendingPathComponent("contents.xcworkspacedata").path) } @@ -30,64 +41,114 @@ public struct WorkspaceFile { return url.pathExtension == "xcodeproj" && FileManager.default.fileExists(atPath: url.appendingPathComponent("project.pbxproj").path) } + static func isKnownPackageFolder(_ url: URL) -> Bool { + guard wellKnownBundleExtensions.contains(url.pathExtension) else { + return false + } + + let resourceValues = try? url.resourceValues(forKeys: [.isPackageKey]) + return resourceValues?.isPackage == true + } + static func getWorkspaceByProject(_ url: URL) -> URL? { guard isXCProject(url) else { return nil } let workspaceURL = url.appendingPathComponent("project.xcworkspace") return isXCWorkspace(workspaceURL) ? workspaceURL : nil } - + + static func getSubprojectURLs(in workspaceURL: URL) -> [URL] { + let workspaceFile = workspaceURL.appendingPathComponent("contents.xcworkspacedata") + do { + let data = try Data(contentsOf: workspaceFile) + return getSubprojectURLs(workspaceURL: workspaceURL, data: data) + } catch let error as NSError { + if error.isPermissionDenied { + Logger.client.info("Permission denied for accessing file at \(workspaceFile.path)") + } else { + Logger.client.error("Failed to read workspace file at \(workspaceFile.path): \(error)") + } + return [] + } + } + static func getSubprojectURLs(workspaceURL: URL, data: Data) -> [URL] { - var subprojectURLs: [URL] = [] do { let xml = try XMLDocument(data: data) - let fileRefs = try xml.nodes(forXPath: "//FileRef") - for fileRef in fileRefs { - if let fileRefElement = fileRef as? XMLElement, - let location = fileRefElement.attribute(forName: "location")?.stringValue { - var path = "" - if location.starts(with: "group:") { - path = location.replacingOccurrences(of: "group:", with: "") - } else if location.starts(with: "container:") { - path = location.replacingOccurrences(of: "container:", with: "") - } else if location.starts(with: "self:") { - // Handle "self:" referece - refers to the containing project directory - var workspaceURLCopy = workspaceURL - workspaceURLCopy.deleteLastPathComponent() - path = workspaceURLCopy.path - - } else { - // Skip absolute paths such as absolute:/path/to/project - continue - } + let workspaceBaseURL = workspaceURL.deletingLastPathComponent() + // Process all FileRefs and Groups recursively + return processWorkspaceNodes(xml.rootElement()?.children ?? [], baseURL: workspaceBaseURL) + } catch { + Logger.client.error("Failed to parse workspace file: \(error)") + } - if path.hasSuffix(".xcodeproj") { - path = (path as NSString).deletingLastPathComponent - } - let subprojectURL = path.isEmpty ? workspaceURL.deletingLastPathComponent() : workspaceURL.deletingLastPathComponent().appendingPathComponent(path) - if !subprojectURLs.contains(subprojectURL) { - subprojectURLs.append(subprojectURL) + return [] + } + + /// Recursively processes all nodes in a workspace file, collecting project URLs + private static func processWorkspaceNodes(_ nodes: [XMLNode], baseURL: URL, currentGroupPath: String = "") -> [URL] { + var results: [URL] = [] + + for node in nodes { + guard let element = node as? XMLElement else { continue } + + let location = element.attribute(forName: "location")?.stringValue ?? "" + if element.name == "FileRef" { + if let url = resolveProjectLocation(location: location, baseURL: baseURL, groupPath: currentGroupPath), + !results.contains(url) { + results.append(url) + } + } else if element.name == "Group" { + var groupPath = currentGroupPath + if !location.isEmpty, let path = extractPathFromLocation(location) { + groupPath = (groupPath as NSString).appendingPathComponent(path) + } + + // Process all children of this group, passing the updated group path + let childResults = processWorkspaceNodes(element.children ?? [], baseURL: baseURL, currentGroupPath: groupPath) + + for url in childResults { + if !results.contains(url) { + results.append(url) } } } - } catch { - Logger.client.error("Failed to parse workspace file: \(error)") } - return subprojectURLs + return results } - - static func getSubprojectURLs(in workspaceURL: URL) -> [URL] { - let workspaceFile = workspaceURL.appendingPathComponent("contents.xcworkspacedata") - do { - let data = try Data(contentsOf: workspaceFile) - return getSubprojectURLs(workspaceURL: workspaceURL, data: data) - } catch { - Logger.client.error("Failed to read workspace file at \(workspaceFile.path): \(error)") - return [] + + /// Extracts path component from a location string + private static func extractPathFromLocation(_ location: String) -> String? { + for prefix in ["group:", "container:", "self:"] { + if location.starts(with: prefix) { + return location.replacingOccurrences(of: prefix, with: "") + } } + return nil } - + + static func resolveProjectLocation(location: String, baseURL: URL, groupPath: String = "") -> URL? { + var path = "" + + // Extract the path from the location string + if let extractedPath = extractPathFromLocation(location) { + path = extractedPath + } else { + // Unknown location format + return nil + } + + var url: URL = groupPath.isEmpty ? baseURL : baseURL.appendingPathComponent(groupPath) + url = path.isEmpty ? url : url.appendingPathComponent(path) + url = url.standardized // normalize “..” or “.” in the path + if isXCProject(url) { // return the containing directory of the .xcodeproj file + url.deleteLastPathComponent() + } + + return url + } + static func matchesPatterns(_ url: URL, patterns: [String]) -> Bool { let fileName = url.lastPathComponent for pattern in patterns { @@ -98,6 +159,15 @@ public struct WorkspaceFile { return false } + public static func getWorkspaceInfo(workspaceURL: URL) -> WorkspaceInfo? { + guard let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(workspaceURL: workspaceURL, documentURL: nil) else { + return nil + } + + let workspaceInfo = WorkspaceInfo(workspaceURL: workspaceURL, projectURL: projectURL) + return workspaceInfo + } + public static func getProjects(workspace: WorkspaceInfo) -> [ProjectInfo] { var subprojects: [ProjectInfo] = [] if isXCWorkspace(workspace.workspaceURL) { @@ -121,13 +191,45 @@ public struct WorkspaceFile { } return name } + + // Commom URL skip checking + public static func shouldSkipURL(_ url: URL) -> Bool { + return matchesPatterns(url, patterns: skipPatterns) + || isXCWorkspace(url) + || isXCProject(url) + || isKnownPackageFolder(url) + || url.pathExtension == "xcassets" + } + + public static func isValidFile( + _ url: URL, + shouldExcludeFile: ((URL) -> Bool)? = nil + ) throws -> Bool { + if shouldSkipURL(url) { return false } + + let resourceValues = try url.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) + + // Handle directories if needed + if resourceValues.isDirectory == true { return false } + + guard resourceValues.isRegularFile == true else { return false } + if supportedFileExtensions.contains(url.pathExtension.lowercased()) == false { + return false + } + + // Apply the custom file exclusion check if provided + if let shouldExcludeFile = shouldExcludeFile, + shouldExcludeFile(url) { return false } + + return true + } public static func getFilesInActiveWorkspace( workspaceURL: URL, workspaceRootURL: URL, shouldExcludeFile: ((URL) -> Bool)? = nil - ) -> [FileReference] { - var files: [FileReference] = [] + ) -> [ConversationFileReference] { + var files: [ConversationFileReference] = [] do { let fileManager = FileManager.default var subprojects: [URL] = [] @@ -149,31 +251,17 @@ public struct WorkspaceFile { while let fileURL = enumerator?.nextObject() as? URL { // Skip items matching the specified pattern - if matchesPatterns(fileURL, patterns: skipPatterns) || isXCWorkspace(fileURL) || - isXCProject(fileURL) { + if shouldSkipURL(fileURL) { enumerator?.skipDescendants() continue } - let resourceValues = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) - // Handle directories if needed - if resourceValues.isDirectory == true { - continue - } - - guard resourceValues.isRegularFile == true else { continue } - if supportedFileExtensions.contains(fileURL.pathExtension.lowercased()) == false { - continue - } - - // Apply the custom file exclusion check if provided - if let shouldExcludeFile = shouldExcludeFile, - shouldExcludeFile(fileURL) { continue } + guard try isValidFile(fileURL, shouldExcludeFile: shouldExcludeFile) else { continue } let relativePath = fileURL.path.replacingOccurrences(of: workspaceRootURL.path, with: "") let fileName = fileURL.lastPathComponent - let file = FileReference(url: fileURL, relativePath: relativePath, fileName: fileName) + let file = ConversationFileReference(url: fileURL, relativePath: relativePath, fileName: fileName) files.append(file) } } @@ -192,7 +280,7 @@ public struct WorkspaceFile { projectURL: URL, excludeGitIgnoredFiles: Bool, excludeIDEIgnoredFiles: Bool - ) -> [String] { + ) -> [ConversationFileReference] { // Directly return for invalid workspace guard workspaceURL.path != "/" else { return [] } @@ -205,6 +293,6 @@ public struct WorkspaceFile { shouldExcludeFile: shouldExcludeFile ) - return files.map { $0.url.absoluteString } + return files } } diff --git a/Tool/Sources/Workspace/WorkspaceFileIndex.swift b/Tool/Sources/Workspace/WorkspaceFileIndex.swift new file mode 100644 index 00000000..ca060504 --- /dev/null +++ b/Tool/Sources/Workspace/WorkspaceFileIndex.swift @@ -0,0 +1,60 @@ +import Foundation +import ConversationServiceProvider + +public class WorkspaceFileIndex { + public static let shared = WorkspaceFileIndex() + /// Maximum number of files allowed per workspace + public static let maxFilesPerWorkspace = 1_000_000 + + private var workspaceIndex: [URL: [ConversationFileReference]] = [:] + private let queue = DispatchQueue(label: "com.copilot.workspace-file-index") + + /// Reset files for a specific workspace URL + public func setFiles(_ files: [ConversationFileReference], for workspaceURL: URL) { + queue.sync { + // Enforce the file limit when setting files + if files.count > Self.maxFilesPerWorkspace { + self.workspaceIndex[workspaceURL] = Array(files.prefix(Self.maxFilesPerWorkspace)) + } else { + self.workspaceIndex[workspaceURL] = files + } + } + } + + /// Get all files for a specific workspace URL + public func getFiles(for workspaceURL: URL) -> [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: ConversationFileReference, to workspaceURL: URL) -> Bool { + return queue.sync { + if self.workspaceIndex[workspaceURL] == nil { + self.workspaceIndex[workspaceURL] = [] + } + + // Check if we've reached the maximum file limit + let currentFileCount = self.workspaceIndex[workspaceURL]!.count + if currentFileCount >= Self.maxFilesPerWorkspace { + return false + } + + // Avoid duplicates by checking if file already exists + if !self.workspaceIndex[workspaceURL]!.contains(file) { + self.workspaceIndex[workspaceURL]!.append(file) + return true + } + + return true // File already exists, so we consider this a successful "add" + } + } + + /// Remove a file from the workspace index + public func removeFile(_ file: ConversationFileReference, from workspaceURL: URL) { + queue.sync { + self.workspaceIndex[workspaceURL]?.removeAll { $0 == file } + } + } +} diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index b5309612..c48cb640 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -1,4 +1,6 @@ import Foundation +import GitHubCopilotService +import ConversationServiceProvider import Logger import Status @@ -48,6 +50,15 @@ public class XPCExtensionService { } } } + + public func getXPCCLSVersion() async throws -> String? { + try await withXPCServiceConnected { + service, continuation in + service.getXPCCLSVersion { version in + continuation.resume(version) + } + } + } public func getXPCServiceAccessibilityPermission() async throws -> ObservedAXStatus { try await withXPCServiceConnected { @@ -322,5 +333,375 @@ extension XPCExtensionService { } } } -} + @XPCServiceActor + public func getXcodeInspectorData() async throws -> XcodeInspectorData { + return try await withXPCServiceConnected { + service, continuation in + service.getXcodeInspectorData { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.reject(NoDataError()) + return + } + + do { + let inspectorData = try JSONDecoder().decode(XcodeInspectorData.self, from: data) + continuation.resume(inspectorData) + } catch { + continuation.reject(error) + } + } + } + } + + // MARK: MCP Server Tools + + @XPCServiceActor + public func getAvailableMCPServerToolsCollections() async throws -> [MCPServerToolsCollection]? { + return try await withXPCServiceConnected { + service, continuation in + service.getAvailableMCPServerToolsCollections { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let tools = try JSONDecoder().decode([MCPServerToolsCollection].self, from: data) + continuation.resume(tools) + } catch { + continuation.reject(error) + } + } + } + } + + @XPCServiceActor + public func updateMCPServerToolsStatus(_ update: [UpdateMCPToolsStatusServerCollection]) async throws { + return try await withXPCServiceConnected { + service, continuation in + do { + let data = try JSONEncoder().encode(update) + service.updateMCPServerToolsStatus(tools: data) + continuation.resume(()) + } catch { + continuation.reject(error) + } + } + } + + // 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 getAvailableLanguageModelTools() async throws -> [LanguageModelTool]? { + return try await withXPCServiceConnected { + service, continuation in + service.getAvailableLanguageModelTools { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let tools = try JSONDecoder().decode([LanguageModelTool].self, from: data) + continuation.resume(tools) + } catch { + continuation.reject(error) + } + } + } + } + + @XPCServiceActor + public func updateToolsStatus(_ update: [ToolStatusUpdate]) async throws -> [LanguageModelTool]? { + return try await withXPCServiceConnected { + service, continuation in + do { + let data = try JSONEncoder().encode(update) + service.updateToolsStatus(tools: data) { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode([LanguageModelTool].self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func getCopilotFeatureFlags() async throws -> FeatureFlags? { + return try await withXPCServiceConnected { + service, continuation in + service.getCopilotFeatureFlags { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let featureFlags = try JSONDecoder().decode(FeatureFlags.self, from: data) + continuation.resume(featureFlags) + } catch { + continuation.reject(error) + } + } + } + } + + @XPCServiceActor + public func signOutAllGitHubCopilotService() async throws { + return try await withXPCServiceConnected { + service, _ in service.signOutAllGitHubCopilotService() + } + } + + @XPCServiceActor + public func getXPCServiceAuthStatus() async throws -> AuthStatus? { + return try await withXPCServiceConnected { + service, continuation in + service.getXPCServiceAuthStatus { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let authStatus = try JSONDecoder().decode(AuthStatus.self, from: data) + continuation.resume(authStatus) + } catch { + continuation.reject(error) + } + } + } + } + + // 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 00bf4307..1ffddc10 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -4,57 +4,44 @@ import SuggestionBasic @objc(XPCServiceProtocol) public protocol XPCServiceProtocol { - func getSuggestedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getNextSuggestedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getPreviousSuggestedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getSuggestionAcceptedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getSuggestionRejectedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func getRealtimeSuggestedCode( - editorContent: Data, - withReply reply: @escaping (Data?, Error?) -> Void - ) - func getPromptToCodeAcceptedCode( - editorContent: Data, - withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void - ) - func openChat( - withReply reply: @escaping (Error?) -> Void - ) - func promptToCode( - editorContent: Data, - withReply reply: @escaping (Data?, Error?) -> Void - ) - func customCommand( - id: String, - editorContent: Data, - withReply reply: @escaping (Data?, Error?) -> Void - ) - + func getSuggestedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getNextSuggestedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getPreviousSuggestedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getSuggestionAcceptedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getSuggestionRejectedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getRealtimeSuggestedCode(editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func getPromptToCodeAcceptedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func openChat(withReply reply: @escaping (Error?) -> Void) + func promptToCode(editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func customCommand(id: String, editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void) func toggleRealtimeSuggestion(withReply reply: @escaping (Error?) -> Void) - - func prefetchRealtimeSuggestions( - editorContent: Data, - withReply reply: @escaping () -> Void - ) + func prefetchRealtimeSuggestions(editorContent: Data, withReply reply: @escaping () -> Void) func getXPCServiceVersion(withReply reply: @escaping (String, String) -> Void) + func getXPCCLSVersion(withReply reply: @escaping (String?) -> Void) func getXPCServiceAccessibilityPermission(withReply reply: @escaping (ObservedAXStatus) -> Void) func getXPCServiceExtensionPermission(withReply reply: @escaping (ExtensionPermissionStatus) -> Void) + func getXcodeInspectorData(withReply reply: @escaping (Data?, Error?) -> Void) + + func getAvailableMCPServerToolsCollections(withReply reply: @escaping (Data?) -> Void) + func updateMCPServerToolsStatus(tools: Data) + func listMCPRegistryServers(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func getMCPRegistryServer(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func getAvailableLanguageModelTools(withReply reply: @escaping (Data?) -> Void) + func updateToolsStatus(tools: Data, withReply reply: @escaping (Data?) -> Void) + + func getCopilotFeatureFlags(withReply reply: @escaping (Data?) -> Void) + + func signOutAllGitHubCopilotService() + 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/XPCShared/XcodeInspectorData.swift b/Tool/Sources/XPCShared/XcodeInspectorData.swift new file mode 100644 index 00000000..defe76b4 --- /dev/null +++ b/Tool/Sources/XPCShared/XcodeInspectorData.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct XcodeInspectorData: Codable { + public let activeWorkspaceURL: String? + public let activeProjectRootURL: String? + public let realtimeActiveWorkspaceURL: String? + public let realtimeActiveProjectURL: String? + public let latestNonRootWorkspaceURL: String? + + public init( + activeWorkspaceURL: String?, + activeProjectRootURL: String?, + realtimeActiveWorkspaceURL: String?, + realtimeActiveProjectURL: String?, + latestNonRootWorkspaceURL: String? + ) { + self.activeWorkspaceURL = activeWorkspaceURL + self.activeProjectRootURL = activeProjectRootURL + self.realtimeActiveWorkspaceURL = realtimeActiveWorkspaceURL + self.realtimeActiveProjectURL = realtimeActiveProjectURL + self.latestNonRootWorkspaceURL = latestNonRootWorkspaceURL + } +} diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift index b842c3ba..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 } @@ -40,7 +41,7 @@ public class AppInstanceInspector: ObservableObject { return runningApplication.activate(options: options) } - init(runningApplication: NSRunningApplication) { + public init(runningApplication: NSRunningApplication) { self.runningApplication = runningApplication processIdentifier = runningApplication.processIdentifier bundleURL = runningApplication.bundleURL diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 29964b12..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? { @@ -401,6 +392,32 @@ extension XcodeAppInstanceInspector { } return updated } + + // The screen that Xcode App located at + public var appScreen: NSScreen? { + appElement.focusedWindow?.maxIntersectionScreen + } +} + +// 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 { @@ -447,4 +464,31 @@ public extension AXUIElement { } return tabBars } + + var maxIntersectionScreen: NSScreen? { + guard let rect = rect else { return nil } + + var bestScreen: NSScreen? + var maxIntersectionArea: CGFloat = 0 + + for screen in NSScreen.screens { + // Skip screens that are in full-screen mode + // Full-screen detection: visible frame equals total frame (no menu bar/dock) + if screen.frame == screen.visibleFrame { + continue + } + + // Calculate intersection area between Xcode frame and screen frame + let intersection = rect.intersection(screen.frame) + let intersectionArea = intersection.width * intersection.height + + // Update best screen if this intersection is larger + if intersectionArea > maxIntersectionArea { + maxIntersectionArea = intersectionArea + bestScreen = screen + } + } + + return bestScreen + } } diff --git a/Tool/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 65ad5170..bb5c3cf9 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -55,6 +55,7 @@ public final class XcodeInspector: ObservableObject { @Published public fileprivate(set) var focusedEditor: SourceEditor? @Published public fileprivate(set) var focusedElement: AXUIElement? @Published public fileprivate(set) var completionPanel: AXUIElement? + @Published public fileprivate(set) var latestNonRootWorkspaceURL: URL? = nil /// Get the content of the source editor. /// @@ -136,6 +137,7 @@ public final class XcodeInspector: ObservableObject { focusedEditor = nil focusedElement = nil completionPanel = nil + latestNonRootWorkspaceURL = nil } let runningApplications = NSWorkspace.shared.runningApplications @@ -283,24 +285,13 @@ public final class XcodeInspector: ObservableObject { activeProjectRootURL = xcode.projectRootURL activeWorkspaceURL = xcode.workspaceURL focusedWindow = xcode.focusedWindow + storeLatestNonRootWorkspaceURL(xcode.workspaceURL) // Add this call let setFocusedElement = { @XcodeInspectorActor [weak self] in guard let self else { return } - 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, @@ -316,6 +307,7 @@ public final class XcodeInspector: ObservableObject { } else { focusedEditor = nil } + } setFocusedElement() @@ -360,7 +352,10 @@ public final class XcodeInspector: ObservableObject { }.store(in: &activeXcodeCancellable) xcode.$workspaceURL.sink { [weak self] url in - Task { @XcodeInspectorActor in self?.activeWorkspaceURL = url } + Task { @XcodeInspectorActor in + self?.activeWorkspaceURL = url + self?.storeLatestNonRootWorkspaceURL(url) + } }.store(in: &activeXcodeCancellable) xcode.$projectRootURL.sink { [weak self] url in @@ -415,5 +410,12 @@ public final class XcodeInspector: ObservableObject { activeXcode.observeAXNotifications() } } -} + @XcodeInspectorActor + private func storeLatestNonRootWorkspaceURL(_ newWorkspaceURL: URL?) { + if let url = newWorkspaceURL, url.path != "/" { + self.latestNonRootWorkspaceURL = url + } + // If newWorkspaceURL is nil or its path is "/", latestNonRootWorkspaceURL remains unchanged. + } +} diff --git a/Tool/Tests/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/GitHubCopilotServiceTests/FetchSuggestionsTests.swift b/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift index face1f60..d6cdcbff 100644 --- a/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift +++ b/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift @@ -44,6 +44,11 @@ final class FetchSuggestionTests: XCTestCase { func sendRequest(_: E, timeout: TimeInterval) async throws -> E.Response where E: GitHubCopilotRequestType { return GitHubCopilotRequest.InlineCompletion.Response(items: []) as! E.Response } + var eventSequence: ServerConnection.EventSequence { + let result = ServerConnection.EventSequence.makeStream() + result.continuation.finish() + return result.stream + } } let service = GitHubCopilotSuggestionService(serviceLocator: TestServiceLocator(server: TestServer())) let completions = try await service.getSuggestions( @@ -87,6 +92,11 @@ final class FetchSuggestionTests: XCTestCase { func sendRequest(_ endpoint: E, timeout: TimeInterval) async throws -> E.Response where E : GitHubCopilotRequestType { return GitHubCopilotRequest.InlineCompletion.Response(items: []) as! E.Response } + var eventSequence: ServerConnection.EventSequence { + let result = ServerConnection.EventSequence.makeStream() + result.continuation.finish() + return result.stream + } } let testServer = TestServer() let service = GitHubCopilotSuggestionService(serviceLocator: TestServiceLocator(server: testServer)) diff --git a/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift b/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift index a01a5a31..4dae3722 100644 --- a/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift +++ b/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift @@ -1,8 +1,5 @@ -import CopilotForXcodeKit -import LanguageServerProtocol import XCTest -@testable import Workspace @testable import SystemUtils final class SystemUtilsTests: XCTestCase { @@ -17,4 +14,85 @@ final class SystemUtilsTests: XCTestCase { XCTAssertTrue(versionTest.evaluate(with: version), "The Xcode version should match the expected format.") XCTAssertFalse(version.isEmpty, "The Xcode version should not be an empty string.") } + + func test_getLoginShellEnvironment() throws { + // Test with a valid shell path + let validShellPath = "/bin/zsh" + let env = SystemUtils.shared.getLoginShellEnvironment(shellPath: validShellPath) + + XCTAssertNotNil(env, "Environment should not be nil for valid shell path") + XCTAssertFalse(env?.isEmpty ?? true, "Environment should contain variables") + + // Check for essential environment variables + XCTAssertNotNil(env?["PATH"], "PATH should be present in environment") + XCTAssertNotNil(env?["HOME"], "HOME should be present in environment") + XCTAssertNotNil(env?["USER"], "USER should be present in environment") + + // Test with an invalid shell path + let invalidShellPath = "/nonexistent/shell" + let invalidEnv = SystemUtils.shared.getLoginShellEnvironment(shellPath: invalidShellPath) + XCTAssertNil(invalidEnv, "Environment should be nil for invalid shell path") + } + + func test_appendCommonBinPaths() { + // Test with an empty path + let appendedEmptyPath = SystemUtils.shared.appendCommonBinPaths(path: "") + XCTAssertFalse(appendedEmptyPath.isEmpty, "Result should not be empty when starting with empty path") + XCTAssertTrue(appendedEmptyPath.contains("/usr/bin"), "Common path /usr/bin should be added") + XCTAssertFalse(appendedEmptyPath.hasPrefix(":"), "Result should not start with ':'") + + // Test with a custom path + let customPath = "/custom/bin:/another/custom/bin" + let appendedCustomPath = SystemUtils.shared.appendCommonBinPaths(path: customPath) + + // Verify original paths are preserved + XCTAssertTrue(appendedCustomPath.hasPrefix(customPath), "Original paths should be preserved") + + // Verify common paths are added + XCTAssertTrue(appendedCustomPath.contains(":/usr/local/bin"), "Should contain /usr/local/bin") + XCTAssertTrue(appendedCustomPath.contains(":/usr/bin"), "Should contain /usr/bin") + XCTAssertTrue(appendedCustomPath.contains(":/bin"), "Should contain /bin") + + // Test with a path that already includes some common paths + let existingCommonPath = "/usr/bin:/custom/bin" + let appendedExistingPath = SystemUtils.shared.appendCommonBinPaths(path: existingCommonPath) + + // Check that /usr/bin wasn't added again + let pathComponents = appendedExistingPath.split(separator: ":") + let usrBinCount = pathComponents.filter { $0 == "/usr/bin" }.count + XCTAssertEqual(usrBinCount, 1, "Common path should not be duplicated") + + // Make sure the result is a valid PATH string + // First component should be the initial path components + XCTAssertTrue(appendedExistingPath.hasPrefix(existingCommonPath), "Should preserve original path at the beginning") + } + + 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 f69da9ad..36883d28 100644 --- a/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift +++ b/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift @@ -1,9 +1,9 @@ -import XCTest -import Foundation +import ConversationServiceProvider import CoreServices +import Foundation import LanguageServerProtocol -import ConversationServiceProvider @testable import Workspace +import XCTest // MARK: - Mocks for Testing @@ -55,17 +55,16 @@ class MockFSEventProvider: FSEventProvider { } class MockWorkspaceFileProvider: WorkspaceFileProvider { - var subprojects: [URL] = [] - var filesInWorkspace: [FileReference] = [] + var filesInWorkspace: [ConversationFileReference] = [] var xcProjectPaths: Set = [] var xcWorkspacePaths: Set = [] - func getSubprojectURLs(in workspace: URL) -> [URL] { + func getProjects(by workspaceURL: URL) -> [URL] { return subprojects } - func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference] { + func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [ConversationFileReference] { return filesInWorkspace } @@ -76,6 +75,58 @@ class MockWorkspaceFileProvider: WorkspaceFileProvider { func isXCWorkspace(_ url: URL) -> Bool { return xcWorkspacePaths.contains(url.path) } + + func fileExists(atPath: String) -> Bool { + return true + } +} + +class MockFileWatcher: FileWatcherProtocol { + var fileURL: URL + var dispatchQueue: DispatchQueue? + var onFileModified: (() -> Void)? + var onFileDeleted: (() -> Void)? + var onFileRenamed: (() -> Void)? + + static var watchers = [URL: MockFileWatcher]() + + init(fileURL: URL, dispatchQueue: DispatchQueue? = nil, onFileModified: (() -> Void)? = nil, onFileDeleted: (() -> Void)? = nil, onFileRenamed: (() -> Void)? = nil) { + self.fileURL = fileURL + self.dispatchQueue = dispatchQueue + self.onFileModified = onFileModified + self.onFileDeleted = onFileDeleted + self.onFileRenamed = onFileRenamed + MockFileWatcher.watchers[fileURL] = self + } + + func startWatching() -> Bool { + return true + } + + func stopWatching() { + MockFileWatcher.watchers[fileURL] = nil + } + + static func triggerFileDelete(for fileURL: URL) { + guard let watcher = watchers[fileURL] else { return } + watcher.onFileDeleted?() + } +} + +class MockFileWatcherFactory: FileWatcherFactory { + func createFileWatcher(fileURL: URL, dispatchQueue: DispatchQueue?, onFileModified: (() -> Void)?, onFileDeleted: (() -> Void)?, onFileRenamed: (() -> Void)?) -> FileWatcherProtocol { + return MockFileWatcher(fileURL: fileURL, dispatchQueue: dispatchQueue, onFileModified: onFileModified, onFileDeleted: onFileDeleted, onFileRenamed: onFileRenamed) + } + + func createDirectoryWatcher(watchedPaths: [URL], changePublisher: @escaping PublisherType, publishInterval: TimeInterval, directoryChangePublisher: PublisherType?) -> DirectoryWatcherProtocol { + return BatchingFileChangeWatcher( + watchedPaths: watchedPaths, + changePublisher: changePublisher, + publishInterval: publishInterval, + fsEventProvider: MockFSEventProvider(), + directoryChangePublisher: directoryChangePublisher + ) + } } // MARK: - Tests for BatchingFileChangeWatcher @@ -131,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) @@ -150,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 } @@ -160,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") @@ -170,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 { @@ -194,13 +457,11 @@ extension BatchingFileChangeWatcherTests { final class FileChangeWatcherServiceTests: XCTestCase { var mockWorkspaceFileProvider: MockWorkspaceFileProvider! var publishedEvents: [[FileEvent]] = [] - var createdWatchers: [[URL]: BatchingFileChangeWatcher] = [:] override func setUp() { super.setUp() mockWorkspaceFileProvider = MockWorkspaceFileProvider() publishedEvents = [] - createdWatchers = [:] } func createService(workspaceURL: URL = URL(fileURLWithPath: "/test/workspace")) -> FileChangeWatcherService { @@ -210,17 +471,9 @@ final class FileChangeWatcherServiceTests: XCTestCase { self?.publishedEvents.append(events) }, publishInterval: 0.1, - projectWatchingInterval: 0.1, workspaceFileProvider: mockWorkspaceFileProvider, - watcherFactory: { projectURLs, publisher in - let watcher = BatchingFileChangeWatcher( - watchedPaths: projectURLs, - changePublisher: publisher, - fsEventProvider: MockFSEventProvider() - ) - self.createdWatchers[projectURLs] = watcher - return watcher - } + watcherFactory: MockFileWatcherFactory(), + directoryChangePublisher: nil ) } @@ -232,26 +485,28 @@ final class FileChangeWatcherServiceTests: XCTestCase { let service = createService() service.startWatching() - XCTAssertEqual(createdWatchers.count, 1) - XCTAssertNotNil(createdWatchers[[project1, project2]]) + XCTAssertNotNil(service.watcher) + XCTAssertEqual(service.watcher?.paths().count, 2) + XCTAssertEqual(service.watcher?.paths(), [project1, project2]) } func testStartWatchingDoesNotCreateWatcherForRootDirectory() { let service = createService(workspaceURL: URL(fileURLWithPath: "/")) service.startWatching() - XCTAssertTrue(createdWatchers.isEmpty) + XCTAssertNil(service.watcher) } func testProjectMonitoringDetectsAddedProjects() { let workspace = URL(fileURLWithPath: "/test/workspace") let project1 = URL(fileURLWithPath: "/test/workspace/project1") mockWorkspaceFileProvider.subprojects = [project1] + mockWorkspaceFileProvider.xcWorkspacePaths = [workspace.path] let service = createService(workspaceURL: workspace) service.startWatching() - XCTAssertEqual(createdWatchers.count, 1) + XCTAssertNotNil(service.watcher) // Simulate adding a new project let project2 = URL(fileURLWithPath: "/test/workspace/project2") @@ -259,22 +514,22 @@ 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 ) mockWorkspaceFileProvider.filesInWorkspace = [file1, file2] - XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout") + MockFileWatcher.triggerFileDelete(for: workspace.appendingPathComponent("contents.xcworkspacedata")) - XCTAssertEqual(createdWatchers.count, 1) + XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout") guard !publishedEvents.isEmpty else { return } @@ -291,24 +546,25 @@ final class FileChangeWatcherServiceTests: XCTestCase { let project1 = URL(fileURLWithPath: "/test/workspace/project1") let project2 = URL(fileURLWithPath: "/test/workspace/project2") mockWorkspaceFileProvider.subprojects = [project1, project2] + mockWorkspaceFileProvider.xcWorkspacePaths = [workspace.path] let service = createService(workspaceURL: workspace) service.startWatching() - XCTAssertEqual(createdWatchers.count, 1) + XCTAssertNotNil(service.watcher) // Simulate removing a project mockWorkspaceFileProvider.subprojects = [project1] // 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 @@ -317,14 +573,13 @@ final class FileChangeWatcherServiceTests: XCTestCase { // Clear published events from setup publishedEvents = [] + + MockFileWatcher.triggerFileDelete(for: workspace.appendingPathComponent("contents.xcworkspacedata")) XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout") guard !publishedEvents.isEmpty else { return } - // Verify the watcher was removed - XCTAssertEqual(createdWatchers.count, 1) - // Verify file events were published XCTAssertEqual(publishedEvents[0].count, 2) 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/WorkspaceTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift index 091f26af..87276a06 100644 --- a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift +++ b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift @@ -14,37 +14,38 @@ class WorkspaceFileTests: XCTestCase { func testIsXCWorkspace() throws { let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } do { let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "myWorkspace.xcworkspace") XCTAssertFalse(WorkspaceFile.isXCWorkspace(xcworkspaceURL)) let xcworkspaceDataURL = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "") XCTAssertTrue(WorkspaceFile.isXCWorkspace(xcworkspaceURL)) } catch { - deleteDirectoryIfExists(at: tmpDir) throw error } - deleteDirectoryIfExists(at: tmpDir) } func testIsXCProject() throws { let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } do { let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "myProject.xcodeproj") XCTAssertFalse(WorkspaceFile.isXCProject(xcprojectURL)) let xcprojectDataURL = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") XCTAssertTrue(WorkspaceFile.isXCProject(xcprojectURL)) } catch { - deleteDirectoryIfExists(at: tmpDir) throw error } - deleteDirectoryIfExists(at: tmpDir) } func testGetFilesInActiveProject() throws { let tmpDir = try createTemporaryDirectory() do { - let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "myProject.xcodeproj") - _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + let xcprojectURL = try createXCProjectFolder(in: tmpDir, withName: "myProject.xcodeproj") _ = try createFile(in: tmpDir, withName: "file1.swift", contents: "") _ = try createFile(in: tmpDir, withName: "file2.swift", contents: "") _ = try createSubdirectory(in: tmpDir, withName: ".git") @@ -62,16 +63,17 @@ class WorkspaceFileTests: XCTestCase { func testGetFilesInActiveWorkspace() throws { let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } do { let myWorkspaceRoot = try createSubdirectory(in: tmpDir, withName: "myWorkspace") - let xcWorkspaceURL = try createSubdirectory(in: myWorkspaceRoot, withName: "myWorkspace.xcworkspace") - let xcprojectURL = try createSubdirectory(in: myWorkspaceRoot, withName: "myProject.xcodeproj") - let myDependencyURL = try createSubdirectory(in: tmpDir, withName: "myDependency") - _ = try createFileFor_contents_dot_xcworkspacedata(directory: xcWorkspaceURL, fileRefs: [ + let xcWorkspaceURL = try createXCWorkspaceFolder(in: myWorkspaceRoot, withName: "myWorkspace.xcworkspace", fileRefs: [ "container:myProject.xcodeproj", "group:../notExistedDir/notExistedProject.xcodeproj", "group:../myDependency",]) - _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + let xcprojectURL = try createXCProjectFolder(in: myWorkspaceRoot, withName: "myProject.xcodeproj") + let myDependencyURL = try createSubdirectory(in: tmpDir, withName: "myDependency") // Files under workspace should be included _ = try createFile(in: myWorkspaceRoot, withName: "file1.swift", contents: "") @@ -86,7 +88,7 @@ class WorkspaceFileTests: XCTestCase { _ = try createFile(in: myDependencyURL, withName: "depFile1.swift", contents: "") // Should be excluded _ = try createSubdirectory(in: myDependencyURL, withName: ".git") - + // Files under unrelated directories should be excluded _ = try createFile(in: tmpDir, withName: "unrelatedFile1.swift", contents: "") @@ -96,64 +98,176 @@ class WorkspaceFileTests: XCTestCase { XCTAssertTrue(fileNames.contains("file1.swift")) XCTAssertTrue(fileNames.contains("depFile1.swift")) } catch { - deleteDirectoryIfExists(at: tmpDir) throw error } - deleteDirectoryIfExists(at: tmpDir) } func testGetSubprojectURLsFromXCWorkspace() throws { let tmpDir = try createTemporaryDirectory() - do { - let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "myWorkspace.xcworkspace") - _ = try createFileFor_contents_dot_xcworkspacedata(directory: xcworkspaceURL, fileRefs: [ - "container:myProject.xcodeproj", - "group:myDependency"]) - let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: xcworkspaceURL) - XCTAssertEqual(subprojectURLs.count, 2) - XCTAssertEqual(subprojectURLs[0].path, tmpDir.path) - XCTAssertEqual(subprojectURLs[1].path, tmpDir.appendingPathComponent("myDependency").path) - } catch { + defer { deleteDirectoryIfExists(at: tmpDir) - throw error } - deleteDirectoryIfExists(at: tmpDir) - } - func testGetSubprojectURLs() { - let workspaceURL = URL(fileURLWithPath: "/path/to/workspace.xcworkspace") + let workspaceDir = try createSubdirectory(in: tmpDir, withName: "workspace") + + // Create tryapp directory and project + let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp") + _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj") + + // Create Copilot for Xcode project + _ = try createXCProjectFolder(in: workspaceDir, withName: "Copilot for Xcode.xcodeproj") + + // Create Test1 directory + let test1Dir = try createSubdirectory(in: tmpDir, withName: "Test1") + + // Create Test2 directory and project + let test2Dir = try createSubdirectory(in: tmpDir, withName: "Test2") + _ = try createXCProjectFolder(in: test2Dir, withName: "project2.xcodeproj") + + // Create the workspace data file with our references let xcworkspaceData = """ + location = "container:../tryapp/tryapp.xcodeproj"> + location = "group:../Test1"> + location = "group:../Test2/project2.xcodeproj"> + location = "absolute:/Test3/project3"> + + """ + let workspaceURL = try createXCWorkspaceFolder(in: workspaceDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData) + + let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL) + + XCTAssertEqual(subprojectURLs.count, 4) + let resolvedPaths = subprojectURLs.map { $0.path } + let expectedPaths = [ + tryappDir.path, + workspaceDir.path, // For Copilot for Xcode.xcodeproj + test1Dir.path, + test2Dir.path + ] + XCTAssertEqual(resolvedPaths, expectedPaths) + } + + func testGetSubprojectURLsFromEmbeddedXCWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + // Create the workspace data file with a self reference + let xcworkspaceData = """ + + + + + + """ + + // Create the MyApp directory structure + let myAppDir = try createSubdirectory(in: tmpDir, withName: "MyApp") + let xcodeProjectDir = try createXCProjectFolder(in: myAppDir, withName: "MyApp.xcodeproj") + let embeddedWorkspaceDir = try createXCWorkspaceFolder(in: xcodeProjectDir, withName: "MyApp.xcworkspace", xcworkspacedata: xcworkspaceData) + + let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: embeddedWorkspaceDir) + XCTAssertEqual(subprojectURLs.count, 1) + XCTAssertEqual(subprojectURLs[0].lastPathComponent, "MyApp") + XCTAssertEqual(subprojectURLs[0].path, myAppDir.path) + } + + func testGetSubprojectURLsFromXCWorkspaceOrganizedByGroup() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + // Create directories for the projects and groups + let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp") + _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj") + + let webLibraryDir = try createSubdirectory(in: tmpDir, withName: "WebLibrary") + + // Create the group directories + let group1Dir = try createSubdirectory(in: tmpDir, withName: "group1") + let group2Dir = try createSubdirectory(in: group1Dir, withName: "group2") + _ = try createSubdirectory(in: group2Dir, withName: "group3") + _ = try createSubdirectory(in: group1Dir, withName: "group4") + + // Create the MyProjects directory + let myProjectsDir = try createSubdirectory(in: tmpDir, withName: "MyProjects") + + // Create the copilot-xcode directory and project + let copilotXcodeDir = try createSubdirectory(in: myProjectsDir, withName: "copilot-xcode") + _ = try createXCProjectFolder(in: copilotXcodeDir, withName: "Copilot for Xcode.xcodeproj") + + // Create the SwiftLanguageWeather directory and project + let swiftWeatherDir = try createSubdirectory(in: myProjectsDir, withName: "SwiftLanguageWeather") + _ = try createXCProjectFolder(in: swiftWeatherDir, withName: "SwiftWeather.xcodeproj") + + // Create the workspace data file with a complex group structure + let xcworkspaceData = """ + + + + + + + + + + + + + + + + location = "group:../MyProjects/SwiftLanguageWeather/SwiftWeather.xcodeproj"> - - """.data(using: .utf8)! +
+ + """ - let subprojectURLs = WorkspaceFile.getSubprojectURLs(workspaceURL: workspaceURL, data: xcworkspaceData) - XCTAssertEqual(subprojectURLs.count, 5) - XCTAssertEqual(subprojectURLs[0].path, "/path/to/tryapp") - XCTAssertEqual(subprojectURLs[1].path, "/path/to") - XCTAssertEqual(subprojectURLs[2].path, "/path/to/Test1") - XCTAssertEqual(subprojectURLs[3].path, "/path/to/Test2") - XCTAssertEqual(subprojectURLs[4].path, "/path/to/../Test4") + // Create a test workspace structure + let workspaceURL = try createXCWorkspaceFolder(in: tmpDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData) + + let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL) + XCTAssertEqual(subprojectURLs.count, 4) + let expectedPaths = [ + tryappDir.path, + webLibraryDir.path, + copilotXcodeDir.path, + swiftWeatherDir.path + ] + for expectedPath in expectedPaths { + XCTAssertTrue(subprojectURLs.contains { $0.path == expectedPath }, "Expected path not found: \(expectedPath)") + } } func deleteDirectoryIfExists(at url: URL) { @@ -189,8 +303,30 @@ class WorkspaceFileTests: XCTestCase { FileManager.default.createFile(atPath: fileURL.path, contents: data, attributes: nil) return fileURL } + + func createXCProjectFolder(in baseDirectory: URL, withName projectName: String) throws -> URL { + let projectURL = try createSubdirectory(in: baseDirectory, withName: projectName) + if projectName.hasSuffix(".xcodeproj") { + _ = try createFile(in: projectURL, withName: "project.pbxproj", contents: "// Project file contents") + } + return projectURL + } - func createFileFor_contents_dot_xcworkspacedata(directory: URL, fileRefs: [String]) throws -> URL { + 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) } @@ -211,4 +347,114 @@ class WorkspaceFileTests: XCTestCase { contents += "" return contents } + + func testIsValidFile() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + // Test valid Swift file + let swiftFileURL = try createFile(in: tmpDir, withName: "ValidFile.swift", contents: "// Swift code") + XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL)) + + // Test valid files with different supported extensions + let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript") + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL)) + + let mdFileURL = try createFile(in: tmpDir, withName: "README.md", contents: "# Markdown") + XCTAssertTrue(try WorkspaceFile.isValidFile(mdFileURL)) + + let jsonFileURL = try createFile(in: tmpDir, withName: "config.json", contents: "{}") + XCTAssertTrue(try WorkspaceFile.isValidFile(jsonFileURL)) + + // Test case insensitive extension matching + let swiftUpperURL = try createFile(in: tmpDir, withName: "File.SWIFT", contents: "// Swift") + XCTAssertTrue(try WorkspaceFile.isValidFile(swiftUpperURL)) + + // Test unsupported file extension + let unsupportedFileURL = try createFile(in: tmpDir, withName: "file.xyz", contents: "unsupported") + XCTAssertFalse(try WorkspaceFile.isValidFile(unsupportedFileURL)) + + // Test files matching skip patterns + let gitFileURL = try createFile(in: tmpDir, withName: ".git", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(gitFileURL)) + + let dsStoreURL = try createFile(in: tmpDir, withName: ".DS_Store", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(dsStoreURL)) + + let nodeModulesURL = try createFile(in: tmpDir, withName: "node_modules", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(nodeModulesURL)) + + // Test directory (should return false) + let subdirURL = try createSubdirectory(in: tmpDir, withName: "subdir") + XCTAssertFalse(try WorkspaceFile.isValidFile(subdirURL)) + + // Test Xcode workspace (should return false) + let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "test.xcworkspace") + _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(xcworkspaceURL)) + + // Test Xcode project (should return false) + let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "test.xcodeproj") + _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(xcprojectURL)) + + } catch { + throw error + } + } + + func testIsValidFileWithCustomExclusionFilter() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let swiftFileURL = try createFile(in: tmpDir, withName: "TestFile.swift", contents: "// Swift code") + let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript") + + // Test without custom exclusion filter + XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL)) + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL)) + + // Test with custom exclusion filter that excludes Swift files + let excludeSwiftFilter: (URL) -> Bool = { url in + return url.pathExtension.lowercased() == "swift" + } + + XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeSwiftFilter)) + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeSwiftFilter)) + + // Test with custom exclusion filter that excludes files with "Test" in name + let excludeTestFilter: (URL) -> Bool = { url in + return url.lastPathComponent.contains("Test") + } + + XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeTestFilter)) + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeTestFilter)) + + } catch { + throw error + } + } + + func testIsValidFileWithAllSupportedExtensions() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let supportedExtensions = supportedFileExtensions + + for (index, ext) in supportedExtensions.enumerated() { + let fileName = "testfile\(index).\(ext)" + let fileURL = try createFile(in: tmpDir, withName: fileName, contents: "test content") + XCTAssertTrue(try WorkspaceFile.isValidFile(fileURL), "File with extension .\(ext) should be valid") + } + + } catch { + throw error + } + } }