diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 434de549..8928b689 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Questions - url: https://github.com/orgs/community/discussions/categories/copilot + url: https://github.com/github/CopilotForXcode/discussions about: Please ask and answer questions about GitHub Copilot here diff --git a/.github/actions/set-xcode-version/action.yml b/.github/actions/set-xcode-version/action.yml index 1a6bb6c9..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: '15.3' + default: '26.0' outputs: xcode-path: description: "Path to current Xcode version" diff --git a/.github/workflows/auto-close-pr.yml b/.github/workflows/auto-close-pr.yml index de2ca780..752e32b3 100644 --- a/.github/workflows/auto-close-pr.yml +++ b/.github/workflows/auto-close-pr.yml @@ -14,7 +14,7 @@ jobs: gh pr close ${{ github.event.pull_request.number }} --comment \ "At the moment we are not accepting contributions to the repository. - Feedback for GitHub Copilot for Xcode can be given in the [Copilot community discussions](https://github.com/orgs/community/discussions/categories/copilot)." + Feedback for GitHub Copilot for Xcode can be given in the [Copilot community discussions](https://github.com/github/CopilotForXcode/discussions)." env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 136e2344..2a7f67ef 100644 --- a/.gitignore +++ b/.gitignore @@ -117,8 +117,10 @@ Core/Package.resolved # Copilot language server Server/node_modules/ +Server/dist # Releases /releases/ /release/ /appcast.xml + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..f380bf27 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,231 @@ +# Changelog + +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.47.0 - February 4, 2026 +### Added +- Auto approval for MCP tools, sensitive files, and terminal commands. +- MCP registry and allowlist are now available (requires editor preview feature flag). + +### Changed +- Improved UI for MCP tool call details. +- Improved UI for working set header. + +### Fixed +- Fixed toolcall layout issue. +- Fixed NES display issue. +- Fixed error message for SSL certificate errors. +- Fixed several performance issues. + +## 0.46.0 - December 11, 2025 +### Added +- MCP: Support delete MCP server from list. + +### Changed +- Refine built-in tools layout and displaying error and output details. +- Better support toolCallingLoop continue operation for subagent turn. +- Update feedback forum link. +- Update client-side MCP restore and persist. +- Adopt NES notification. + +### Fixed +- Disable auto focus for fix error window. +- Fixed an issue where no file change was made when insert_edit_into_file tool succeeds. +- Fixed an issue where insert edit was applied to the incorrect file. +- Fixed model picker to use model id instead of model family. +- Fixed read_file, read_directory tool randomly failing. + +## 0.45.0 - November 14, 2025 +### Added +- New models: GPT-5.1, GPT-5.1-Codex, GPT-5.1-Codex-Mini, Claude Haiku 4.5, and Auto (preview). +- Added support for custom agents (preview). +- Introduced the built-in Plan agent (preview). +- Added support for subagent execution (preview). +- Added support for Next Edit Suggestions (preview). + +### Changed +- MCP servers now support dynamic OAuth setup for third-party authentication providers. +- Added a setting to configure the maximum number of tool requests allowed. + +### Fixed +- Fixed an issue that the terminal view in Agent conversation was clipped +- Fixed an issue that the Chat panel failed to recognize newly created workspaces. + +## 0.44.0 - October 15, 2025 +### Added +- Added support for new models in Chat: Grok Code Fast 1, Claude Sonnet 4.5, Claude Opus 4, Claude Opus 4.1 and GPT-5 mini. +- Added support for restoring to a saved checkpoint snapshot. +- Added support for tool selection in agent mode. +- Added the ability to adjust the chat panel font size. +- Added the ability to edit a previous chat message and resend it. +- Introduced a new setting to disable the Copilot “Fix Error” button. +- Added support for custom instructions in the Code Review feature. + +### Changed +- Switched authentication to a new OAuth app "GitHub Copilot IDE Plugin". +- Updated the chat layout to a messenger-style conversation view (user messages on the right, responses on the left). +- Now shows a clearer, more user-friendly message when Copilot finishes responding. +- Added support for skipping a tool call without ending the conversation. + +### Fixed +- Fixed a command injection vulnerability when opening referenced chat files. +- Resolved display issues in the chat view on macOS 26. + +## 0.43.0 - September 4, 2025 +### Fixed +- Cannot type non-Latin characters in the chat input field. + +## 0.42.0 - September 3, 2025 +### Added +- Support for Bring Your Own Keys (BYOK) with model providers including Azure, OpenAI, Anthropic, Gemini, Groq, and OpenRouter. See [BYOK.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/BYOK.md). +- Use the current selection as chat context. +- Add folders as chat context. +- Shortcut to quickly fix errors in Xcode. +- Support for custom instruction files at `.github/instructions/*.instructions.md`. See [CustomInstructions.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/CustomInstructions.md). +- Support for prompt files at `.github/prompts/*.prompt.md`. See [PromptFiles.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/PromptFiles.md). +- Use ↑/↓ keys to reuse previous chat context in the chat view. + +### Changed +- Default chat mode is now set to “Agent”. + +### Fixed +- Cannot copy url from Safari browser to chat view. + +## 0.41.0 - August 14, 2025 +### Added +- Code review feature. +- Chat: Support for new model GPT-5. +- Agent mode: Added support for new tool to read web URL contents. +- Support disabling MCP when it's disabled by policy. +- Support for opening MCP logs directly from the MCP settings page. +- OAuth support for remote GitHub MCP server. + +### Changed +- Performance: Improved instant-apply speed for edit_file tool. + +### Fixed +- Chat Agent repeatedly reverts its own changes when editing the same file. +- Performance: Avoid chat panel being stuck when sending a large text for chat. + +## 0.40.0 - July 24, 2025 +### Added +- Support disabling Agent mode when it's disabled by policy. + +## 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 + +### Changed +- Switched default model to GPT-4.1 for new installations +- Enhanced model selection interface + +### Fixed +- Resolved critical error handling issues + +## 0.33.0 - April 17, 2025 +### Added +- Added support for new models in Chat: Claude 3.7 Sonnet and GPT 4.5 +- Implemented @workspace context feature allowing questions about the entire codebase in Copilot Chat + +### Changed +- Simplified access to Copilot Chat from the Copilot for Xcode app with a single click +- Enhanced instructions for granting background permissions + +### Fixed +- Resolved false alarms for sign-in and free plan limit notifications +- Improved app launch performance +- Fixed workspace and context update issues + +## 0.32.0 - March 11, 2025 (General Availability) +### Added +- Implemented model picker for selecting LLM model in chat +- Introduced new `/releaseNotes` slash command for accessing release information + +### Changed +- Improved focus handling with automatic switching between chat text field and file search bar +- Enhanced keyboard navigation support for file picker in chat context +- Refined instructions for granting accessibility and extension permissions +- Enhanced accessibility compliance for the chat window +- Redesigned notification and status bar menu styles for better usability + +### Fixed +- Resolved compatibility issues with macOS 12/13/14 +- Fixed handling of invalid workspace switch event '/' +- Corrected chat attachment file picker to respect workspace scope +- Improved icon display consistency across different themes +- Added support for previously unsupported file types (.md, .txt) in attachments +- Adjusted incorrect margins in chat window UI + +## 0.31.0 - February 11, 2025 (Public Preview) +### Added +- Added Copilot Chat support +- Added GitHub Freeplan support +- Implemented conversation and chat history management across multiple Xcode instances +- Introduced multi-file context support for comprehensive code understanding +- Added slash commands for specialized operations diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift index e34dee91..4e289e57 100644 --- a/CommunicationBridge/ServiceDelegate.swift +++ b/CommunicationBridge/ServiceDelegate.swift @@ -136,28 +136,100 @@ actor ExtensionServiceLauncher { isLaunching = true Logger.communicationBridge.info("Launching extension service app.") - - NSWorkspace.shared.openApplication( - at: appURL, - configuration: { - let configuration = NSWorkspace.OpenConfiguration() - configuration.createsNewApplicationInstance = false - configuration.addsToRecentItems = false - configuration.activates = false - return configuration - }() - ) { app, error in - if let error = error { - Logger.communicationBridge.error( - "Failed to launch extension service app: \(error)" - ) - } else { - Logger.communicationBridge.info( - "Finished launching extension service app." - ) + + // First check if the app is already running + if let runningApp = NSWorkspace.shared.runningApplications.first(where: { + $0.bundleIdentifier == appIdentifier + }) { + Logger.communicationBridge.info("Extension service app already running with PID: \(runningApp.processIdentifier)") + self.application = runningApp + self.isLaunching = false + return + } + + // Implement a retry mechanism with exponential backoff + Task { + var retryCount = 0 + let maxRetries = 3 + var success = false + + while !success && retryCount < maxRetries { + do { + // Add a delay between retries with exponential backoff + if retryCount > 0 { + let delaySeconds = pow(2.0, Double(retryCount - 1)) + Logger.communicationBridge.info("Retrying launch after \(delaySeconds) seconds (attempt \(retryCount + 1) of \(maxRetries))") + try await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000)) + } + + // Use a task-based approach for launching with timeout + let launchTask = Task { () -> NSRunningApplication? in + return await withCheckedContinuation { continuation in + NSWorkspace.shared.openApplication( + at: appURL, + configuration: { + let configuration = NSWorkspace.OpenConfiguration() + configuration.createsNewApplicationInstance = false + configuration.addsToRecentItems = false + configuration.activates = false + return configuration + }() + ) { app, error in + if let error = error { + continuation.resume(returning: nil) + } else { + continuation.resume(returning: app) + } + } + } + } + + // Set a timeout for the launch operation + let timeoutTask = Task { + try await Task.sleep(nanoseconds: 10_000_000_000) // 10 seconds + return + } + + // Wait for either the launch or the timeout + let app = try await withTaskCancellationHandler { + try await launchTask.value ?? nil + } onCancel: { + launchTask.cancel() + } + + // Cancel the timeout task + timeoutTask.cancel() + + if let app = app { + // Success! + self.application = app + success = true + break + } else { + // App is nil, retry + retryCount += 1 + Logger.communicationBridge.info("Launch attempt \(retryCount) failed, app is nil") + } + } catch { + retryCount += 1 + Logger.communicationBridge.error("Error during launch attempt \(retryCount): \(error.localizedDescription)") + } } - - self.application = app + + // Double-check we have a valid application + if !success && self.application == nil { + // After all retries, check once more if the app is running (it might have launched but we missed the callback) + if let runningApp = NSWorkspace.shared.runningApplications.first(where: { + $0.bundleIdentifier == appIdentifier + }) { + Logger.communicationBridge.info("Found running extension service after retries: \(runningApp.processIdentifier)") + self.application = runningApp + success = true + } else { + Logger.communicationBridge.info("Failed to launch extension service after \(maxRetries) attempts") + } + } + self.isLaunching = false } } diff --git a/Config.debug.xcconfig b/Config.debug.xcconfig index 63fae668..da143524 100644 --- a/Config.debug.xcconfig +++ b/Config.debug.xcconfig @@ -10,7 +10,7 @@ EXTENSION_BUNDLE_NAME = GitHub Copilot Dev EXTENSION_BUNDLE_DISPLAY_NAME = GitHub Copilot Dev EXTENSION_SERVICE_NAME = GitHub Copilot for Xcode Extension COPILOT_DOCS_URL = https:$(SLASH)$(SLASH)docs.github.com/en/copilot -COPILOT_FORUM_URL = https:$(SLASH)$(SLASH)github.com/orgs/community/discussions/categories/copilot +COPILOT_FORUM_URL = https:$(SLASH)$(SLASH)github.com/github/CopilotForXcode/discussions // see also target Configs diff --git a/Config.xcconfig b/Config.xcconfig index 5fba3479..eef78ad4 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -10,6 +10,6 @@ EXTENSION_BUNDLE_NAME = GitHub Copilot EXTENSION_BUNDLE_DISPLAY_NAME = GitHub Copilot EXTENSION_SERVICE_NAME = GitHub Copilot for Xcode Extension COPILOT_DOCS_URL = https:$(SLASH)$(SLASH)docs.github.com/en/copilot -COPILOT_FORUM_URL = https:$(SLASH)$(SLASH)github.com/orgs/community/discussions/categories/copilot +COPILOT_FORUM_URL = https:$(SLASH)$(SLASH)github.com/github/CopilotForXcode/discussions // see also target Configs diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 56e21c33..c762e625 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -11,12 +11,15 @@ 3ABBEA2B2C8BA00300C61D61 /* copilot-language-server-arm64 in Resources */ = {isa = PBXBuildFile; fileRef = 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */; }; 3ABBEA2C2C8BA00800C61D61 /* copilot-language-server-arm64 in Resources */ = {isa = PBXBuildFile; fileRef = 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */; }; 3ABBEA2D2C8BA00B00C61D61 /* copilot-language-server in Resources */ = {isa = PBXBuildFile; fileRef = 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */; }; + 3E5DB7502D6B8FA500418952 /* ReleaseNotes.md in Resources */ = {isa = PBXBuildFile; fileRef = 3E5DB74F2D6B88EE00418952 /* ReleaseNotes.md */; }; 424ACA212CA4697200FA20F2 /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 424ACA202CA4697200FA20F2 /* Credits.rtf */; }; 427C63282C6E868B000E557C /* OpenSettingsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C63272C6E868B000E557C /* OpenSettingsCommand.swift */; }; 5EC511E32C90CE7400632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B1D2938973000C9DCDA /* Assets.xcassets */; }; 5EC511E42C90CE9800632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B1D2938973000C9DCDA /* Assets.xcassets */; }; 5EC511E52C90CFD600632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C861E6142994F6080056CB02 /* Assets.xcassets */; }; 5EC511E62C90CFD700632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C861E6142994F6080056CB02 /* Assets.xcassets */; }; + 7E6CEC912EAB6774005F2076 /* RejectNESSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E6CEC902EAB6774005F2076 /* RejectNESSuggestionCommand.swift */; }; + 7E856FF72E9F6D24005751CB /* AcceptNESSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E856FF62E9F6D1D005751CB /* AcceptNESSuggestionCommand.swift */; }; C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */; }; C8009C032941C576007AA7E8 /* SyncTextSettingsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009C022941C576007AA7E8 /* SyncTextSettingsCommand.swift */; }; C800DBB1294C624D00B04CAC /* PrefetchSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */; }; @@ -187,10 +190,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server"; path = "Server/node_modules/@github/copilot-language-server/native/darwin-x64/copilot-language-server"; sourceTree = SOURCE_ROOT; }; - 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server-arm64"; path = "Server/node_modules/@github/copilot-language-server/native/darwin-arm64/copilot-language-server-arm64"; sourceTree = SOURCE_ROOT; }; + 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server"; path = "Server/node_modules/@github/copilot-language-server-darwin-x64/copilot-language-server"; sourceTree = SOURCE_ROOT; }; + 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server-arm64"; path = "Server/node_modules/@github/copilot-language-server-darwin-arm64/copilot-language-server-arm64"; sourceTree = SOURCE_ROOT; }; + 3E5DB74F2D6B88EE00418952 /* ReleaseNotes.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ReleaseNotes.md; sourceTree = ""; }; 424ACA202CA4697200FA20F2 /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = ""; }; 427C63272C6E868B000E557C /* OpenSettingsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSettingsCommand.swift; sourceTree = ""; }; + 7E6CEC902EAB6774005F2076 /* RejectNESSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejectNESSuggestionCommand.swift; sourceTree = ""; }; + 7E856FF62E9F6D1D005751CB /* AcceptNESSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptNESSuggestionCommand.swift; sourceTree = ""; }; C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRealtimeSuggestionsCommand.swift; sourceTree = ""; }; C8009C022941C576007AA7E8 /* SyncTextSettingsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTextSettingsCommand.swift; sourceTree = ""; }; C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchSuggestionsCommand.swift; sourceTree = ""; }; @@ -253,6 +259,10 @@ C8F103292A7A365000D28F4F /* launchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = launchAgent.plist; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 9E6A029A2DBDF64200AB6BD5 /* Server */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Server; sourceTree = SOURCE_ROOT; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ C81458892939EFDC00135263 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -324,8 +334,10 @@ C8520300293C4D9000460097 /* Helpers.swift */, C81458952939EFDC00135263 /* GetSuggestionsCommand.swift */, C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */, + 7E856FF62E9F6D1D005751CB /* AcceptNESSuggestionCommand.swift */, C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */, C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */, + 7E6CEC902EAB6774005F2076 /* RejectNESSuggestionCommand.swift */, C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */, C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */, C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */, @@ -345,6 +357,7 @@ C8189B0D2938972F00C9DCDA = { isa = PBXGroup; children = ( + 3E5DB74F2D6B88EE00418952 /* ReleaseNotes.md */, C887BC832965D96000931567 /* DEVELOPMENT.md */, C8520308293D805800460097 /* README.md */, C8F103292A7A365000D28F4F /* launchAgent.plist */, @@ -354,6 +367,7 @@ C81458AE293A009800135263 /* Config.debug.xcconfig */, C8CD828229B88006008D044D /* TestPlan.xctestplan */, C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */, + 9E6A029A2DBDF64200AB6BD5 /* Server */, C81D181E2A1B509B006C1B70 /* Tool */, C8189B282938979000C9DCDA /* Core */, C8189B182938972F00C9DCDA /* Copilot for Xcode */, @@ -678,6 +692,7 @@ C861E6152994F6080056CB02 /* Assets.xcassets in Resources */, 3ABBEA2D2C8BA00B00C61D61 /* copilot-language-server in Resources */, C81291D72994FE6900196E12 /* Main.storyboard in Resources */, + 3E5DB7502D6B8FA500418952 /* ReleaseNotes.md in Resources */, 5EC511E42C90CE9800632BAB /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -696,24 +711,21 @@ /* Begin PBXShellScriptBuildPhase section */ 3A60421A2C8955710006B34C /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( - "$(SRCROOT)/Server/package.json", - "$(SRCROOT)/Server/package-lock.json", ); outputFileListPaths = ( ); outputPaths = ( - "$(SRCROOT)/Server/node_modules/@github/copilot-language-server/native/darwin-x64/copilot-language-server", - "$(SRCROOT)/Server/node_modules/@github/copilot-language-server/native/darwin-arm64/copilot-language-server-arm64", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "npm -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"; + 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 */ @@ -728,12 +740,14 @@ C8758E7029F04BFF00D29C1C /* CustomCommand.swift in Sources */, C8758E7229F04CF100D29C1C /* SeparatorCommand.swift in Sources */, C861A6A329E5503F005C41A3 /* PromptToCodeCommand.swift in Sources */, + 7E6CEC912EAB6774005F2076 /* RejectNESSuggestionCommand.swift in Sources */, C8520301293C4D9000460097 /* Helpers.swift in Sources */, C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */, C80FFB962A95F58200704A25 /* AcceptPromptToCodeCommand.swift in Sources */, 427C63282C6E868B000E557C /* OpenSettingsCommand.swift in Sources */, C87B03A5293B261200C77EAE /* AcceptSuggestionCommand.swift in Sources */, C87B03A9293B262600C77EAE /* NextSuggestionCommand.swift in Sources */, + 7E856FF72E9F6D24005751CB /* AcceptNESSuggestionCommand.swift in Sources */, C87B03AB293B262E00C77EAE /* PreviousSuggestionCommand.swift in Sources */, C87B03A7293B261900C77EAE /* RejectSuggestionCommand.swift in Sources */, C8009C032941C576007AA7E8 /* SyncTextSettingsCommand.swift in Sources */, diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme index c0e9b79f..f672cd16 100644 --- a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme @@ -50,6 +50,18 @@ reference = "container:Pro/ProTestPlan.xctestplan"> + + + + + + NSView { return NSVisualEffectView() } @@ -12,7 +14,162 @@ struct VisualEffect: NSViewRepresentable { } class AppDelegate: NSObject, NSApplicationDelegate { - func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { true } + private var permissionAlertShown = false + + // Launch modes supported by the app + enum LaunchMode { + case chat + case settings + case tools + case toolsAutoApprove + case byok + } + + func applicationDidFinishLaunching(_ notification: Notification) { + if #available(macOS 13.0, *) { + checkBackgroundPermissions() + } + + let launchMode = determineLaunchMode() + handleLaunchMode(launchMode) + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if #available(macOS 13.0, *) { + checkBackgroundPermissions() + } + + let launchMode = determineLaunchMode() + handleLaunchMode(launchMode) + return true + } + + // MARK: - Helper Methods + + private func determineLaunchMode() -> LaunchMode { + let launchArgs = CommandLine.arguments + if launchArgs.contains("--settings") { + return .settings + } else if launchArgs.contains("--tools") { + return .tools + } else if launchArgs.contains("--tools-auto-approve") { + return .toolsAutoApprove + } else if launchArgs.contains("--byok") { + return .byok + } else { + return .chat + } + } + + private func handleLaunchMode(_ mode: LaunchMode) { + switch mode { + case .settings: + openSettings() + case .tools: + openToolsSettings() + case .toolsAutoApprove: + openToolsSettingsAutoApprove() + case .byok: + openBYOKSettings() + case .chat: + openChat() + } + } + + private func openSettings() { + DispatchQueue.main.async { + activateAndOpenSettings() + } + } + + private func openChat() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + Task { + let service = try? getService() + try? await service?.openChat() + } + } + } + + private func openToolsSettings() { + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.tools)) + } + } + + private func openToolsSettingsAutoApprove() { + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.tools)) + hostAppStore.send(.setActiveToolsSubTab(.AutoApprove)) + } + } + + private func openBYOKSettings() { + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.byok)) + } + } + + @available(macOS 13.0, *) + private func checkBackgroundPermissions() { + Task { + // Direct check of permission status + let launchAgentManager = LaunchAgentManager() + let isPermissionGranted = await launchAgentManager.isBackgroundPermissionGranted() + + if !isPermissionGranted { + // Only show alert if permission isn't granted + DispatchQueue.main.async { + if !self.permissionAlertShown { + showBackgroundPermissionAlert() + self.permissionAlertShown = true + } + } + } else { + // Permission is granted, reset flag + self.permissionAlertShown = false + } + } + } + + // MARK: - Application Termination + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + // Immediately terminate extension service if it's running + if let extensionService = NSWorkspace.shared.runningApplications.first(where: { + $0.bundleIdentifier == "\(Bundle.main.bundleIdentifier!).ExtensionService" + }) { + extensionService.terminate() + } + + // Start cleanup in background without waiting + Task { + let quitTask = Task { + let service = try? getService() + try? await service?.quitService() + } + + // Wait just a tiny bit to allow cleanup to start + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + + DispatchQueue.main.async { + NSApp.reply(toApplicationShouldTerminate: true) + } + } + + return .terminateLater + } + + func applicationWillTerminate(_ notification: Notification) { + if let extensionService = NSWorkspace.shared.runningApplications.first(where: { + $0.bundleIdentifier == "\(Bundle.main.bundleIdentifier!).ExtensionService" + }) { + extensionService.terminate() + } + } } class AppUpdateCheckerDelegate: UpdateCheckerDelegate { @@ -28,22 +185,98 @@ class AppUpdateCheckerDelegate: UpdateCheckerDelegate { @main struct CopilotForXcodeApp: App { @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate + + init() { + UserDefaults.setupDefaultSettings() + + Task { + await hostAppStore + .send(.general(.setupLaunchAgentIfNeeded)) + .finish() + } + + DistributedNotificationCenter.default().addObserver( + forName: .openSettingsWindowRequest, + object: nil, + queue: .main + ) { _ in + DispatchQueue.main.async { + activateAndOpenSettings() + } + } + + DistributedNotificationCenter.default().addObserver( + forName: .openToolsSettingsWindowRequest, + object: nil, + queue: .main + ) { _ in + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.tools)) + } + } + + DistributedNotificationCenter.default().addObserver( + forName: .openToolsSettingsAutoApproveWindowRequest, + object: nil, + queue: .main + ) { _ in + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.tools)) + hostAppStore.send(.setActiveToolsSubTab(.AutoApprove)) + } + } + + DistributedNotificationCenter.default().addObserver( + forName: .openBYOKSettingsWindowRequest, + object: nil, + queue: .main + ) { _ in + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.byok)) + } + } + + DistributedNotificationCenter.default().addObserver( + forName: .openAdvancedSettingsWindowRequest, + object: nil, + queue: .main + ) { _ in + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.advanced)) + } + } + } var body: some Scene { - WindowGroup { - TabContainer() - .frame(minWidth: 800, minHeight: 600) - .background(VisualEffect().ignoresSafeArea()) - .onAppear { - UserDefaults.setupDefaultSettings() - } - .copilotIntroSheet() - .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() + )) + } } } } +@MainActor +func activateAndOpenSettings() { + NSApp.activate(ignoringOtherApps: true) + if #available(macOS 14.0, *) { + let environment = SettingsEnvironment() + environment.open() + } else if #available(macOS 13.0, *) { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } else { + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + } +} + var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } diff --git a/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/ChatIcon.svg b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/ChatIcon.svg new file mode 100644 index 00000000..74239992 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/ChatIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Contents.json new file mode 100644 index 00000000..329dae48 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ChatIcon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Copilot for Xcode/Assets.xcassets/Color.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/Color.colorset/Contents.json new file mode 100644 index 00000000..22c4bb0a --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/Color.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/Contents.json new file mode 100644 index 00000000..78e08e6e --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "CopilotError.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/CopilotError.svg b/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/CopilotError.svg new file mode 100644 index 00000000..ad107456 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/CopilotError.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json new file mode 100644 index 00000000..9a465b02 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "CopilotIssue.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/CopilotIssue.svg b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/CopilotIssue.svg new file mode 100644 index 00000000..af4e8900 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/CopilotIssue.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Copilot for Xcode/Assets.xcassets/DangerBackgroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/DangerBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..38242f14 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/DangerBackgroundColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xF4", + "green" : "0xF3", + "red" : "0xFD" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/DangerForegroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/DangerForegroundColor.colorset/Contents.json new file mode 100644 index 00000000..db248f82 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/DangerForegroundColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x1C", + "green" : "0x0E", + "red" : "0xB1" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/DangerStrokeColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/DangerStrokeColor.colorset/Contents.json new file mode 100644 index 00000000..5fbecf46 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/DangerStrokeColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xB2", + "green" : "0xAC", + "red" : "0xEE" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/DescriptionForegroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/DescriptionForegroundColor.colorset/Contents.json new file mode 100644 index 00000000..bdcbb88e --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/DescriptionForegroundColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x9D", + "green" : "0x9D", + "red" : "0x9D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/GroupBoxBackgroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/GroupBoxBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..f7add95c --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/GroupBoxBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.920", + "green" : "0.910", + "red" : "0.910" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.250", + "green" : "0.250", + "red" : "0.250" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/GroupBoxStrokeColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/GroupBoxStrokeColor.colorset/Contents.json new file mode 100644 index 00000000..35b93a68 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/GroupBoxStrokeColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.900", + "green" : "0.900", + "red" : "0.900" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.080", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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/SwiftIcon.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/Contents.json new file mode 100644 index 00000000..1c65bf64 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "file_type_swift.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/file_type_swift.svg b/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/file_type_swift.svg new file mode 100644 index 00000000..c232d1f7 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/file_type_swift.svg @@ -0,0 +1 @@ +file_type_swift \ No newline at end of file diff --git a/Copilot for Xcode/Assets.xcassets/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/Assets.xcassets/TextLinkForegroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/TextLinkForegroundColor.colorset/Contents.json new file mode 100644 index 00000000..d892da13 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/TextLinkForegroundColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x94", + "red" : "0x37" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/ToolTitleHighlightBgColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/ToolTitleHighlightBgColor.colorset/Contents.json new file mode 100644 index 00000000..ce478f39 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/ToolTitleHighlightBgColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.250", + "green" : "0.250", + "red" : "0.250" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Credits.rtf b/Copilot for Xcode/Credits.rtf index 5655b195..941fbb70 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\ \ @@ -3216,4 +3216,223 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ SOFTWARE.\ \ \ +Dependency: github.com/globulus/swiftui-flow-layout\ +Version: 1.0.5\ +License Content:\ +MIT License\ +\ +Copyright (c) 2021 Gordan Glavaš\ +\ +Permission is hereby granted, free of charge, to any person obtaining a copy\ +of this software and associated documentation files (the "Software"), to deal\ +in the Software without restriction, including without limitation the rights\ +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ +copies of the Software, and to permit persons to whom the Software is\ +furnished to do so, subject to the following conditions:\ +\ +The above copyright notice and this permission notice shall be included in all\ +copies or substantial portions of the Software.\ +\ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\ +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\ +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\ +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\ +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\ +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ +SOFTWARE.\ +\ +\ +Dependency: https://github.com/stephencelis/SQLite.swift\ +Version: 0.15.3\ +License Content:\ +MIT License\ +\ +Copyright (c) 2014-2015 Stephen Celis ()\ +\ +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.\ +\ +\ +Dependency: https://github.com/microsoft/monaco-editor\ +Version: 0.52.2\ +License Content:\ +The MIT License (MIT)\ +\ +Copyright (c) 2016 - present Microsoft Corporation\ +\ +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.\ +\ +\ +Dependency: https://github.com/xtermjs/xterm.js\ +Version: @xterm/addon-fit@0.10.0, @xterm/xterm@5.5.0\ +License Content:\ +The MIT License (MIT)\ +\ +Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)\ +Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)\ +Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)\ +\ +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.\ +\ +\ +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.\ +\ +\ +Dependency: https://github.com/tree-sitter/tree-sitter\ +Version: 0.25.10\ +License Content:\ +The MIT License\ +\ +Copyright (c) 2018 Max Brunsfeld +\ +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. +\ +\ +Dependency: https://github.com/tree-sitter/swift-tree-sitter\ +Version: 0.25.0\ +License Content:\ +BSD 3-Clause License\ +\ +Copyright (c) 2021, Chime +All rights reserved. +\ +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +\ +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +\ +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +\ +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. +\ +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +\ +\ +Dependency: https://github.com/tree-sitter/tree-sitter-bash\ +Version: 0.25.1\ +License Content:\ +The MIT License\ +\ +Copyright (c) 2017 Max Brunsfeld +\ +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 b45f6d1d..62815b26 100644 --- a/Copilot-for-Xcode-Info.plist +++ b/Copilot-for-Xcode-Info.plist @@ -1,32 +1,36 @@ - - APP_ID_PREFIX - $(AppIdentifierPrefix) - APPLICATION_SUPPORT_FOLDER - $(APPLICATION_SUPPORT_FOLDER) - BUNDLE_IDENTIFIER_BASE - $(BUNDLE_IDENTIFIER_BASE) - EXTENSION_BUNDLE_NAME - $(EXTENSION_BUNDLE_NAME) - HOST_APP_NAME - $(HOST_APP_NAME) - LANGUAGE_SERVER_PATH - $(LANGUAGE_SERVER_PATH) - NODE_PATH - $(NODE_PATH) - SUEnableAutomaticChecks - YES - SUScheduledCheckInterval - 3600 - SUEnableJavaScript - NO - SUFeedURL - $(SPARKLE_FEED_URL) - SUPublicEDKey - $(SPARKLE_PUBLIC_KEY) - TEAM_ID_PREFIX - $(TeamIdentifierPrefix) - - + + APP_ID_PREFIX + $(AppIdentifierPrefix) + APPLICATION_SUPPORT_FOLDER + $(APPLICATION_SUPPORT_FOLDER) + BUNDLE_IDENTIFIER_BASE + $(BUNDLE_IDENTIFIER_BASE) + EXTENSION_BUNDLE_NAME + $(EXTENSION_BUNDLE_NAME) + HOST_APP_NAME + $(HOST_APP_NAME) + LANGUAGE_SERVER_PATH + $(LANGUAGE_SERVER_PATH) + NODE_PATH + $(NODE_PATH) + SUEnableAutomaticChecks + YES + SUScheduledCheckInterval + 3600 + SUEnableJavaScript + NO + SUFeedURL + $(SPARKLE_FEED_URL) + SUPublicEDKey + $(SPARKLE_PUBLIC_KEY) + TEAM_ID_PREFIX + $(TeamIdentifierPrefix) + STANDARD_TELEMETRY_CHANNEL_KEY + $(STANDARD_TELEMETRY_CHANNEL_KEY) + GITHUB_APP_ID + $(GITHUB_APP_ID) + + \ No newline at end of file diff --git a/Core/Package.swift b/Core/Package.swift index 385746cf..8e2e58cc 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -33,6 +33,7 @@ let package = Package( "Client", "LaunchAgentManager", "UpdateChecker", + "GitHubCopilotViewModel", ] ), ], @@ -52,6 +53,9 @@ 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/tree-sitter/swift-tree-sitter.git", from: "0.25.0"), + .package(url: "https://github.com/tree-sitter/tree-sitter-bash", from: "0.25.1") ], targets: [ // MARK: - Main @@ -76,6 +80,7 @@ let package = Package( "ConversationTab", "KeyBindingManager", "XcodeThemeController", + .product(name: "TelemetryService", package: "Tool"), .product(name: "XPCShared", package: "Tool"), .product(name: "SuggestionProvider", package: "Tool"), .product(name: "ConversationServiceProvider", package: "Tool"), @@ -84,10 +89,13 @@ let package = Package( .product(name: "AppMonitoring", package: "Tool"), .product(name: "SuggestionBasic", package: "Tool"), .product(name: "Status", package: "Tool"), + .product(name: "StatusBarItemView", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "ChatAPIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), + .product(name: "AXHelper", package: "Tool"), + .product(name: "WorkspaceSuggestionService", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Dependencies", package: "swift-dependencies"), @@ -114,6 +122,7 @@ let package = Package( dependencies: [ "Client", "LaunchAgentManager", + "GitHubCopilotViewModel", .product(name: "SuggestionProvider", package: "Tool"), .product(name: "Toast", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), @@ -124,6 +133,8 @@ let package = Package( .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), .product(name: "GitHubCopilotService", package: "Tool"), + .product(name: "Persist", package: "Tool"), + .product(name: "UserDefaultsObserver", package: "Tool"), ]), // MARK: - Suggestion Service @@ -163,13 +174,29 @@ let package = Package( .target( name: "ChatService", dependencies: [ + "PersistMiddleware", .product(name: "AppMonitoring", package: "Tool"), .product(name: "Parsing", package: "swift-parsing"), .product(name: "ChatAPIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), + .product(name: "AXHelper", package: "Tool"), .product(name: "ConversationServiceProvider", package: "Tool"), .product(name: "GitHubCopilotService", package: "Tool"), + .product(name: "Workspace", 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"), + .product(name: "SwiftTreeSitter", package: "swift-tree-sitter"), + .product(name: "SwiftTreeSitterLayer", package: "swift-tree-sitter"), + .product(name: "TreeSitterBash", package: "tree-sitter-bash"), ]), + .testTarget( + name: "ChatServiceTests", + dependencies: ["ChatService"] + ), .target( name: "ConversationTab", @@ -180,8 +207,11 @@ let package = Package( .product(name: "Logger", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "Terminal", package: "Tool"), + .product(name: "Cache", package: "Tool"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout"), + .product(name: "Persist", package: "Tool") ] ), @@ -190,8 +220,11 @@ let package = Package( .target( name: "SuggestionWidget", dependencies: [ + "ChatService", "PromptToCodeService", "ConversationTab", + "GitHubCopilotViewModel", + "PersistMiddleware", .product(name: "GitHubCopilotService", package: "Tool"), .product(name: "Toast", package: "Tool"), .product(name: "UserDefaultsObserver", package: "Tool"), @@ -200,6 +233,7 @@ let package = Package( .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "CustomAsyncAlgorithms", package: "Tool"), + .product(name: "HostAppActivator", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), @@ -209,7 +243,7 @@ let package = Package( // MARK: - Helpers - .target(name: "FileChangeChecker"), + .target(name: "FileChangeChecker"), .target( name: "LaunchAgentManager", dependencies: [ @@ -224,12 +258,22 @@ let package = Package( .product(name: "Logger", package: "Tool"), ] ), + .target( + name: "GitHubCopilotViewModel", + dependencies: [ + "Client", + .product(name: "GitHubCopilotService", package: "Tool"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "Status", package: "Tool"), + ] + ), // MARK: Key Binding .target( name: "KeyBindingManager", dependencies: [ + "SuggestionWidget", .product(name: "Workspace", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "Logger", package: "Tool"), @@ -254,7 +298,17 @@ let package = Package( .product(name: "Highlightr", package: "Highlightr"), ] ), - + + // MARK: Persist Middleware + .target( + name: "PersistMiddleware", + dependencies: [ + .product(name: "Persist", package: "Tool"), + .product(name: "ChatTab", package: "Tool"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "ConversationServiceProvider", package: "Tool") + ] + ) ] ) diff --git a/Core/Sources/ChatService/ChatInjector.swift b/Core/Sources/ChatService/ChatInjector.swift new file mode 100644 index 00000000..df3d454a --- /dev/null +++ b/Core/Sources/ChatService/ChatInjector.swift @@ -0,0 +1,147 @@ +import SuggestionBasic +import AppKit +import XcodeInspector +import AXHelper +import ApplicationServices +import AppActivator +import LanguageServerProtocol + +public struct ChatInjector { + public init() {} + + public func insertCodeBlock(codeBlock: String) { + do { + guard let editorContent = XcodeInspector.shared.focusedEditor?.getContent(), + let focusElement = XcodeInspector.shared.focusedElement, + focusElement.description == "Source Editor" + else { return } + + var cursorPosition = editorContent.cursorPosition + guard cursorPosition.line >= 0, cursorPosition.character >= 0 else { return } + + var lines = editorContent.content.splitByNewLine( + omittingEmptySubsequences: false + ).map { String($0) } + + guard cursorPosition.line <= lines.count else { return } + + var modifications: [Modification] = [] + + // Handle selection deletion + if let selection = editorContent.selections.first, + selection.isValid, + selection.start.line < lines.endIndex { + let selectionEndLine = min(selection.end.line, lines.count - 1) + let deletedSelection = CursorRange( + start: selection.start, + end: .init(line: selectionEndLine, character: selection.end.character) + ) + modifications.append(.deletedSelection(deletedSelection)) + lines = lines.applying([.deletedSelection(deletedSelection)]) + cursorPosition = selection.start + } + + let insertionRange = CursorRange( + start: cursorPosition, + end: cursorPosition + ) + + try Self.performInsertion( + content: codeBlock, + range: insertionRange, + lines: &lines, + modifications: &modifications, + focusElement: focusElement + ) + + } catch { + print("Failed to insert code block: \(error)") + } + } + + public static func insertSuggestion(suggestion: String, range: CursorRange, lines: [String]) { + do { + guard let focusElement = XcodeInspector.shared.focusedElement, + focusElement.description == "Source Editor" + else { return } + + guard range.start.line >= 0, + range.start.line < lines.count, + range.end.line >= 0, + range.end.line < lines.count + else { return } + + var lines = lines + var modifications: [Modification] = [] + + if range.isValid { + modifications.append(.deletedSelection(range)) + lines = lines.applying([.deletedSelection(range)]) + } + + try performInsertion( + content: suggestion, + range: range, + lines: &lines, + modifications: &modifications, + focusElement: focusElement + ) + + } catch { + 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 06f8c4d2..ac75d819 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -5,37 +5,114 @@ import GitHubCopilotService import Preferences import ConversationServiceProvider import BuiltinExtension +import JSONRPC +import Status +import Persist +import PersistMiddleware +import ChatTab +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) async throws + func send( + _ id: String, + content: String, + contentImages: [ChatCompletionContentPartImage], + contentImageReferences: [ImageReference], + skillSet: [ConversationSkill], + references: [ConversationAttachedReference], + model: String?, + modelProviderName: String?, + agentMode: Bool, + customChatModeId: String?, + userLanguage: String?, + turnId: String? + ) async throws func stopReceivingMessage() async func upvote(_ id: String, _ rating: ConversationRating) async func downvote(_ id: String, _ rating: ConversationRating) async func copyCode(_ id: String) async } +struct ToolCallRequest { + let requestId: JSONId + let turnId: String + let roundId: Int + let toolCallId: String + let completion: (AnyJSONRPCResponse) -> Void +} + +struct ConversationTurnTrackingState { + var turnParentMap: [String: String] = [:] // Maps subturn ID to parent turn ID + var validConversationIds: Set = [] // Tracks all valid conversation IDs including subagents + + mutating func reset() { + turnParentMap.removeAll() + validConversationIds.removeAll() + } +} + public final class ChatService: ChatServiceType, ObservableObject { public var memory: ContextAwareAutoManagedChatMemory @Published public internal(set) var chatHistory: [ChatMessage] = [] @Published public internal(set) var isReceivingMessage = false - + @Published public internal(set) var fileEditMap: OrderedDictionary = [:] + public internal(set) var requestType: RequestType? = nil + public private(set) var chatTabInfo: ChatTabInfo private let conversationProvider: ConversationServiceProvider? private let conversationProgressHandler: ConversationProgressHandler + private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared + // sync all the files in the workspace to watch for changes. + private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared private var cancellables = Set() private var activeRequestId: String? - private var conversationId: 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] = [:] + // Workaround: toolConfirmation request does not have parent turnId + private var conversationTurnTracking = ConversationTurnTrackingState() init(provider: any ConversationServiceProvider, memory: ContextAwareAutoManagedChatMemory = ContextAwareAutoManagedChatMemory(), - conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared) { + conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared, + chatTabInfo: ChatTabInfo) { self.memory = memory self.conversationProvider = provider self.conversationProgressHandler = conversationProgressHandler + self.chatTabInfo = chatTabInfo memory.chatService = self subscribeToNotifications() + subscribeToConversationContextRequest() + 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() { @@ -59,27 +136,379 @@ public final class ChatService: ChatServiceType, ObservableObject { }.store(in: &cancellables) } - public static func service() -> ChatService { + private func subscribeToConversationContextRequest() { + self.conversationContextHandler.onConversationContext.sink(receiveValue: { [weak self] (request, completion) in + guard let skills = self?.skillSet, !skills.isEmpty, request.params!.conversationId == self?.conversationId else { return } + skills.forEach { skill in + if (skill.applies(params: request.params!)) { + skill.resolveSkill(request: request, completion: completion) + } + } + }).store(in: &cancellables) + } + + private func subscribeToClientToolConfirmationEvent() { + ClientToolHandlerImpl.shared.onClientToolConfirmationEvent.sink(receiveValue: { [weak self] (request, completion) in + self?.handleClientToolConfirmationEvent(request: request, completion: completion) + }).store(in: &cancellables) + } + + private func subscribeToClientToolInvokeEvent() { + ClientToolHandlerImpl.shared.onClientToolInvokeEvent.sink(receiveValue: { [weak self] (request, completion) in + guard let params = request.params else { return } + + // Check if this conversationId is valid (main conversation or subagent conversation) + guard let validIds = self?.conversationTurnTracking.validConversationIds, validIds.contains(params.conversationId) else { + return + } + + guard let copilotTool = CopilotToolRegistry.shared.getTool(name: params.name) else { + completion(AnyJSONRPCResponse(id: request.id, + result: JSONValue.array([ + JSONValue.null, + JSONValue.hash( + [ + "code": .number(-32601), + "message": .string("Tool function not found") + ]) + ]) + ) + ) + return + } + + _ = copilotTool.invokeTool(request, completion: completion, contextProvider: self) + }).store(in: &cancellables) + } + + func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = [], parentTurnId: String? = nil) { + let chatTabId = self.chatTabInfo.id + Task { + let turnStatus: ChatMessage.TurnStatus? = { + guard let round = editAgentRounds.first, let toolCall = round.toolCalls?.first else { + return nil + } + + switch toolCall.status { + case .waitForConfirmation: return .waitForConfirmation + case .accepted, .running, .completed, .error: return .inProgress + case .cancelled: return .cancelled + } + }() + + let message = ChatMessage( + assistantMessageWithId: turnId, + chatTabID: chatTabId, + editAgentRounds: editAgentRounds, + parentTurnId: parentTurnId, + fileEdits: fileEdits, + turnStatus: turnStatus + ) + + await self.memory.appendMessage(message) + } + } + + 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 ) - return ChatService(provider: provider) + return ChatService(provider: provider, chatTabInfo: chatTabInfo) + } + + // this will be triggerred in conversation tab if needed + public func restoreIfNeeded() { + guard self.isRestored == false else { return } + + Task { + let storedChatMessages = fetchAllChatMessagesFromStorage() + await mutateHistory { history in + history.append(contentsOf: storedChatMessages) + } + } + + self.isRestored = true + } + + /// Updates the status of a tool call (accepted, cancelled, etc.) and notifies the server + /// + /// This method handles two key responsibilities: + /// 1. Sends confirmation response back to the server when user accepts/cancels + /// 2. Updates the tool call status in chat history UI (including subagent tool calls) + public func updateToolCallStatus(toolCallId: String, status: AgentToolCall.ToolCallStatus, payload: Any? = nil) { + // Capture the pending request info before removing it from the dictionary + let toolCallRequest = self.pendingToolCallRequests[toolCallId] + + // Step 1: Send confirmation response to server (for accept/cancel actions only) + if let toolCallRequest = toolCallRequest, status == .accepted || status == .cancelled { + self.pendingToolCallRequests.removeValue(forKey: toolCallId) + sendToolConfirmationResponse(toolCallRequest, accepted: status == .accepted) + } + + // Step 2: Update the tool call status in chat history UI + Task { + guard let targetMessage = await ToolCallStatusUpdater.findMessageContainingToolCall( + toolCallRequest, + conversationTurnTracking: conversationTurnTracking, + history: await memory.history + ) else { + return + } + + // Search for the tool call in main rounds or subagent rounds + if let updatedRound = ToolCallStatusUpdater.findAndUpdateToolCall( + toolCallId: toolCallId, + newStatus: status, + in: targetMessage.editAgentRounds + ) { + let message = ToolCallStatusUpdater.createMessageUpdate( + targetMessage: targetMessage, + updatedRound: updatedRound + ) + await memory.appendMessage(message) + } + } + } + + // MARK: - Helper Methods for Tool Call Status Updates + + /// Returns true if the `conversationId` belongs to the active conversation or any subagent conversations. + func isConversationIdValid(_ conversationId: String) -> Bool { + conversationTurnTracking.validConversationIds.contains(conversationId) + } + + /// Workaround: toolConfirmation request does not have parent turnId. + func parentTurnIdForTurnId(_ turnId: String) -> String? { + conversationTurnTracking.turnParentMap[turnId] + } + + func storePendingToolCallRequest(toolCallId: String, request: ToolCallRequest) { + pendingToolCallRequests[toolCallId] = request + } + + /// Sends the confirmation response (accept/dismiss) back to the server + func sendToolConfirmationResponse(_ request: ToolCallRequest, accepted: Bool) { + let toolResult = LanguageModelToolConfirmationResult( + result: accepted ? .Accept : .Dismiss + ) + let jsonResult = try? JSONEncoder().encode(toolResult) + let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null + + request.completion( + AnyJSONRPCResponse( + id: request.requestId, + result: JSONValue.array([jsonValue, JSONValue.null]) + ) + ) } - public func send(_ id: String, content: String) async throws { + public enum ChatServiceError: Error, LocalizedError { + case conflictingImageFormats(String) + + public var errorDescription: String? { + switch self { + case .conflictingImageFormats(let message): + return message + } + } + } + + public func send( + _ id: String, + content: String, + contentImages: Array = [], + contentImageReferences: Array = [], + skillSet: Array, + references: [ConversationAttachedReference], + model: String? = nil, + modelProviderName: String? = nil, + agentMode: Bool = false, + customChatModeId: String? = nil, + userLanguage: String? = nil, + turnId: String? = nil + ) async throws { guard activeRequestId == nil else { return } let workDoneToken = UUID().uuidString activeRequestId = workDoneToken - await memory.appendMessage(ChatMessage(id: id, role: .user, content: content, summary: nil, references: [])) + 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() + ) + + 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() + + // persist + saveChatMessageToStorage(chatMessage) - let request = ConversationRequest(workDoneToken: workDoneToken, - content: content, workspaceFolder: "", skills: []) - try await send(request) + if content.hasPrefix("/releaseNotes") { + if let fileURL = Bundle.main.url(forResource: "ReleaseNotes", withExtension: "md"), + let whatsNewContent = try? String(contentsOf: fileURL) + { + // will be persist in resetOngoingRequest() + // there is no turn id from CLS, just set it as id + let clsTurnID = UUID().uuidString + let progressMessage = ChatMessage( + assistantMessageWithId: clsTurnID, + chatTabID: chatTabInfo.id, + content: whatsNewContent + ) + await memory.appendMessage(progressMessage) + } + resetOngoingRequest() + return + } + + 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, + customChatModeId: customChatModeId, + userLanguage: userLanguage, + turnId: currentTurnId, + skillSet: validSkillSet + ) + + self.lastUserRequest = request + self.skillSet = validSkillSet + + do { + if let response = try await sendConversationRequest(request) { + await handleConversationCreateResponse(response) + } + } catch { + // Check if this is a certificate error and show helpful message + if isCertificateError(error) { + await showCertificateErrorMessage(turnId: currentTurnId) + } + throw error + } + } + + private func createConversationRequest( + workDoneToken: String, + content: String, + contentImages: [ChatCompletionContentPartImage] = [], + activeDoc: Doc?, + references: [ConversationAttachedReference], + model: String? = nil, + modelProviderName: String? = nil, + agentMode: Bool = false, + customChatModeId: String? = nil, + 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) + } + + /// replace the `@workspace` to `@project` + let newContent = replaceFirstWord(in: content, from: "@workspace", to: "@project") + + return ConversationRequest( + workDoneToken: workDoneToken, + content: newContent, + contentImages: contentImages, + workspaceFolder: "", + activeDoc: activeDoc, + skills: skillCapabilities, + ignoredSkills: ignoredSkills, + references: references, + model: model, + modelProviderName: modelProviderName, + agentMode: agentMode, + customChatModeId: customChatModeId, + userLanguage: userLanguage, + turnId: turnId + ) + } + + private func handleConversationCreateResponse(_ response: ConversationCreateResponse) async { + await memory.mutateHistory { history in + if let index = history.firstIndex(where: { $0.id == response.turnId && $0.role.isAssistant }) { + history[index].modelName = response.modelName + history[index].billingMultiplier = response.billingMultiplier + + self.saveChatMessageToStorage(history[index]) + } + } } public func sendAndWait(_ id: String, content: String) async throws -> String { - try await send(id, content: content) + try await send(id, content: content, skillSet: [], references: []) if let reply = await memory.history.last(where: { $0.role == .assistant })?.content { return reply } @@ -89,38 +518,62 @@ 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)") } } - resetOngoingRequest() + resetOngoingRequest(with: .cancelled) } + // Not used public func clearHistory() async { + let messageIds = await memory.history.map { $0.id } + 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)") } } + + deleteAllChatMessagesFromStorage(messageIds) resetOngoingRequest() } - - public func deleteMessage(id: String) async { - await memory.removeMessage(id) + + public func deleteMessages(ids: [String]) async { + let turnIdsFromMessages = await memory.history + .filter { ids.contains($0.id) } + .compactMap { $0.clsTurnID } + .map { String($0) } + let turnIds = Array(Set(turnIdsFromMessages)) + + await memory.removeMessages(ids) + await deleteTurns(turnIds) + deleteAllChatMessagesFromStorage(ids) } - public func resendMessage(id: String) 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) - } 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, + customChatModeId: lastUserRequest.customChatModeId, + userLanguage: lastUserRequest.userLanguage, + turnId: id + ) } } @@ -128,10 +581,14 @@ public final class ChatService: ChatServiceType, ObservableObject { if let message = (await memory.history).first(where: { $0.id == id }) { await mutateHistory { history in - history.append(.init( + let chatMessage: ChatMessage = .init( + chatTabID: self.chatTabInfo.id, role: .assistant, content: message.content - )) + ) + + history.append(chatMessage) + self.saveChatMessageToStorage(chatMessage) } } } @@ -175,32 +632,51 @@ public final class ChatService: ChatServiceType, ObservableObject { if info.specifiedSystemPrompt != nil || info.extraSystemPrompt != nil { await mutateHistory { history in - history.append(.init( + let chatMessage: ChatMessage = .init( + chatTabID: self.chatTabInfo.id, role: .assistant, content: "" - )) + ) + history.append(chatMessage) + self.saveChatMessageToStorage(chatMessage) } } if let sendingMessageImmediately = info.sendingMessageImmediately, !sendingMessageImmediately.isEmpty { - try await send(UUID().uuidString, content: templateProcessor.process(sendingMessageImmediately)) + try await send(UUID().uuidString, content: templateProcessor.process(sendingMessageImmediately), skillSet: [], references: []) } } + + 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 { // TODO: pass copy code info to Copilot server } + // not used public func handleSingleRoundDialogCommand( systemPrompt: String?, overwriteSystemPrompt: Bool, @@ -210,50 +686,734 @@ public final class ChatService: ChatServiceType, ObservableObject { return try await sendAndWait(UUID().uuidString, content: templateProcessor.process(prompt)) } - private func handleProgressBegin(token: String, progress: ConversationProgress) { + private func handleProgressBegin(token: String, progress: ConversationProgressBegin) { guard let workDoneToken = activeRequestId, workDoneToken == token else { return } - conversationId = progress.conversationId + // Only update conversationId for main turns, not subagent turns + // Subagent turns have their own conversation ID which should not replace the parent + if progress.parentTurnId == nil { + conversationId = progress.conversationId + } + + // Track all valid conversation IDs for the current turn (main conversation + its subturns) + conversationTurnTracking.validConversationIds.insert(progress.conversationId) + + let turnId = progress.turnId + let parentTurnId = progress.parentTurnId + + // Track parent-subturn relationship + if let parentTurnId = parentTurnId { + conversationTurnTracking.turnParentMap[turnId] = parentTurnId + } Task { if var lastUserMessage = await memory.history.last(where: { $0.role == .user }) { - lastUserMessage.turnId = progress.turnId + + // 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) + } + + /// 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. + /// Skip creating a new message for subturns - they will be merged into the parent turn + if parentTurnId == nil { + let message = ChatMessage( + assistantMessageWithId: turnId, + chatTabID: chatTabInfo.id, + turnStatus: .inProgress + ) + + // will persist in resetOngoingRequest() + await memory.appendMessage(message) } } } - private func handleProgressReport(token: String, progress: ConversationProgress) { - guard let workDoneToken = activeRequestId, workDoneToken == token, let reply = progress.reply else { return } + private func handleProgressReport(token: String, progress: ConversationProgressReport) { + guard let workDownToken = activeRequestId, workDownToken == token else { + return + } + + let id = progress.turnId + var content = "" + var references: [ConversationReference] = [] + var steps: [ConversationProgressStep] = [] + var editAgentRounds: [AgentRound] = [] + let parentTurnId = progress.parentTurnId + + if let reply = progress.reply { + content = reply + } + + if let progressReferences = progress.references, !progressReferences.isEmpty { + references = progressReferences.toConversationReferences() + } + + if let progressSteps = progress.steps, !progressSteps.isEmpty { + steps = progressSteps + } + if let progressAgentRounds = progress.editAgentRounds, !progressAgentRounds.isEmpty { + editAgentRounds = progressAgentRounds + } + + if content.isEmpty && references.isEmpty && steps.isEmpty && editAgentRounds.isEmpty && parentTurnId == nil { + return + } + + let messageContent = content + let messageReferences = references + let messageSteps = steps + let messageAgentRounds = editAgentRounds + let messageParentTurnId = parentTurnId + Task { - let message = ChatMessage(id: progress.turnId, role: .assistant, content: reply) + let message = ChatMessage( + assistantMessageWithId: id, + chatTabID: chatTabInfo.id, + content: messageContent, + references: messageReferences, + steps: messageSteps, + editAgentRounds: messageAgentRounds, + parentTurnId: messageParentTurnId, + turnStatus: .inProgress + ) + await memory.appendMessage(message) } } - private func handleProgressEnd(token: String, progress: ConversationProgress) { + private func handleProgressEnd(token: String, progress: ConversationProgressEnd) { guard let workDoneToken = activeRequestId, workDoneToken == token else { return } - resetOngoingRequest() + let followUp = progress.followUp + + if let CLSError = progress.error { + // CLS Error Code 402: reached monthly chat messages limit + if CLSError.code == 402 { + Task { + let selectedModel = lastUserRequest?.model + let selectedModelProviderName = lastUserRequest?.modelProviderName + + var errorMessageText: String + if let selectedModel = selectedModel, let selectedModelProviderName = selectedModelProviderName { + errorMessageText = "You've reached your quota limit for your BYOK model \(selectedModel). Please check with \(selectedModelProviderName) for more information." + } else { + errorMessageText = CLSError.message + } + + await Status.shared + .updateCLSStatus(.warning, busy: false, message: errorMessageText) + let errorMessage = ChatMessage( + errorMessageWithId: progress.turnId, + chatTabID: chatTabInfo.id, + panelMessages: [.init(type: .error, title: String(CLSError.code ?? 0), message: errorMessageText, location: .Panel)] + ) + // will persist in resetongoingRequest() + await memory.appendMessage(errorMessage) + + if let lastUserRequest, + let currentUserPlan = await Status.shared.currentUserPlan(), + currentUserPlan != "free" { + guard let fallbackModel = CopilotModelManager.getFallbackLLM( + scope: lastUserRequest.agentMode ? .agentPanel : .chatPanel + ) else { + resetOngoingRequest(with: .error) + return + } + do { + CopilotModelManager.switchToFallbackModel() + try await resendMessage( + id: progress.turnId, + model: fallbackModel.id, + modelProviderName: nil + ) + } catch { + Logger.gitHubCopilot.error(error) + resetOngoingRequest(with: .error) + } + return + } + } + } else if CLSError.code == 400 && CLSError.message.contains("model is not supported") { + Task { + let errorMessage = ChatMessage( + errorMessageWithId: progress.turnId, + chatTabID: chatTabInfo.id, + errorMessages: ["Oops, the model is not supported. Please enable it first in [GitHub Copilot settings](https://github.com/settings/copilot)."] + ) + await memory.appendMessage(errorMessage) + resetOngoingRequest(with: .error) + return + } + } else { + Task { + var clsErrorMessage = CLSError.message + if CLSError.code == ConversationErrorCode.toolRoundExceedError.rawValue { + // TODO: Remove this after `Continue` is supported. + clsErrorMessage = HardCodedToolRoundExceedErrorMessage + } + + let errorMessage = ChatMessage( + errorMessageWithId: progress.turnId, + chatTabID: chatTabInfo.id, + errorMessages: [clsErrorMessage] + ) + // will persist in resetOngoingRequest() + await memory.appendMessage(errorMessage) + resetOngoingRequest(with: .error) + return + } + } + } + + Task { + let message = ChatMessage( + assistantMessageWithId: progress.turnId, + chatTabID: chatTabInfo.id, + followUp: followUp, + suggestedTitle: progress.suggestedTitle, + turnStatus: .success + ) + // will persist in resetOngoingRequest() + await memory.appendMessage(message) + resetOngoingRequest(with: .success) + } } - private func resetOngoingRequest() { + private func resetOngoingRequest(with turnStatus: ChatMessage.TurnStatus = .success) { activeRequestId = nil isReceivingMessage = false + requestType = nil + + // Clear turn tracking data + conversationTurnTracking.reset() + + // cancel all pending tool call requests + for (_, request) in pendingToolCallRequests { + pendingToolCallRequests.removeValue(forKey: request.toolCallId) + let toolResult = LanguageModelToolConfirmationResult(result: .Dismiss) + let jsonResult = try? JSONEncoder().encode(toolResult) + let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null + request.completion( + AnyJSONRPCResponse( + id: request.requestId, + result: JSONValue.array([ + jsonValue, + JSONValue.null + ]) + ) + ) + } + + Task { + // mark running steps to cancelled + await mutateHistory({ history in + guard !history.isEmpty, + let lastIndex = history.indices.last, + history[lastIndex].role == .assistant else { return } + + for i in 0.. ConversationCreateResponse? { guard !isReceivingMessage else { throw CancellationError() } isReceivingMessage = true + requestType = .conversation do { if let conversationId = conversationId { - try await conversationProvider?.createTurn(with: conversationId, request: request) + return try await conversationProvider? + .createTurn( + with: conversationId, + request: request, + workspaceURL: getWorkspaceURL() + ) } else { - try await conversationProvider?.createConversation(request) + var requestWithTurns = request + + var chatHistory = self.chatHistory + // remove the last user message + let _ = chatHistory.popLast() + if chatHistory.count > 0 { + // invoke history turns + let turns = chatHistory.toTurns() + requestWithTurns.turns = turns + } + + return try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL()) } } catch { - resetOngoingRequest() + resetOngoingRequest(with: .error) throw error } } + + private func deleteTurns(_ turnIds: [String]) async { + guard !turnIds.isEmpty, let conversationId = conversationId else { + return + } + + let workspaceURL = getWorkspaceURL() + + for turnId in turnIds { + do { + try await conversationProvider? + .deleteTurn(with: conversationId, turnId: turnId, workspaceURL: workspaceURL) + } catch { + Logger.client.error("Failed to delete turn: \(error)") + } + } + } + + // MARK: - Certificate Error Detection + + /// Checks if an error is related to SSL certificate issues + private func isCertificateError(_ error: Error) -> Bool { + let errorDescription = error.localizedDescription.lowercased() + + // Check for certificate error messages + if errorDescription.contains("unable to get local issuer certificate") || + errorDescription.contains("self-signed certificate in certificate chain") || + errorDescription.contains("unable_to_get_issuer_cert_locally") { + return true + } + + // Check GitHubCopilotError with ServerError + if let serverError = error as? ServerError, + case .serverError(_, let message, _) = serverError { + let serverMessage = message.lowercased() + if serverMessage.contains("unable to get local issuer certificate") || + serverMessage.contains("self-signed certificate in certificate chain") { + return true + } + } + + return false + } + + private func showCertificateErrorMessage(turnId: String?) async { + let messageId = turnId ?? UUID().uuidString + let errorMessage = ChatMessage( + errorMessageWithId: messageId, + chatTabID: chatTabInfo.id, + errorMessages: [ + SSLCertificateErrorMessage + ] + ) + await memory.appendMessage(errorMessage) + } +} + + +public final class SharedChatService { + public var chatTemplates: [ChatTemplate]? = nil + public var chatAgents: [ChatAgent]? = nil + public var conversationModes: [ConversationMode]? = nil + private let conversationProvider: ConversationServiceProvider? + + public static let shared = SharedChatService.service() + + init(provider: any ConversationServiceProvider) { + self.conversationProvider = provider + } + + public static func service() -> SharedChatService { + let provider = BuiltinExtensionConversationServiceProvider( + extension: GitHubCopilotExtension.self + ) + return SharedChatService(provider: provider) + } + + public func loadChatTemplates() async -> [ChatTemplate]? { + do { + if let templates = (try await conversationProvider?.templates()) { + self.chatTemplates = templates + return templates + } + } catch { + // handle error if desired + } + + return nil + } + + public func loadConversationModes() async -> [ConversationMode]? { + do { + if let modes = (try await conversationProvider?.modes()) { + self.conversationModes = modes + return modes + } + } catch { + // handle error if desired + } + + return nil + } + + public func copilotModels() async -> [CopilotModel] { + guard let models = try? await conversationProvider?.models() else { return [] } + return models + } + + public func loadChatAgents() async -> [ChatAgent]? { + guard self.chatAgents == nil else { return self.chatAgents } + + do { + if let chatAgents = (try await conversationProvider?.agents()) { + self.chatAgents = chatAgents + return chatAgents + } + } catch { + // handle error if desired + } + + return nil + } +} + + +extension ChatService { + + // do storage operatoin in the background + private func runInBackground(_ operation: @escaping () -> Void) { + Task.detached(priority: .utility) { + operation() + } + } + + func saveChatMessageToStorage(_ message: ChatMessage) { + runInBackground { + ChatMessageStore.save(message, with: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username)) + } + } + + func deleteChatMessageFromStorage(_ id: String) { + runInBackground { + ChatMessageStore.delete(by: id, with: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username)) + } + } + func deleteAllChatMessagesFromStorage(_ ids: [String]) { + runInBackground { + ChatMessageStore.deleteAll(by: ids, with: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username)) + } + } + + func fetchAllChatMessagesFromStorage() -> [ChatMessage] { + return ChatMessageStore.getAll(by: self.chatTabInfo.id, metadata: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username)) + } +} + +func replaceFirstWord(in content: String, from oldWord: String, to newWord: String) -> String { + let pattern = "^\(oldWord)\\b" + + if let regex = try? NSRegularExpression(pattern: pattern, options: []) { + let range = NSRange(location: 0, length: content.utf16.count) + return regex.stringByReplacingMatches(in: content, options: [], range: range, withTemplate: newWord) + } + + return content +} + +extension Array where Element == FileReference { + func toConversationReferences() -> [ConversationReference] { + return self.map { + .init(uri: $0.uri, status: .included, kind: .reference($0), referenceType: .file) + } + } +} + +extension Array where Element == ConversationAttachedReference { + func toConversationReferences() -> [ConversationReference] { + return self.map { + 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 + func toTurns() -> [TurnSchema] { + var turns: [TurnSchema] = [] + let count = self.count + var index = 0 + + while index < count { + let message = self[index] + if case .user = message.role { + var turn = TurnSchema(request: message.content, turnId: message.clsTurnID) + // has next message + if index + 1 < count { + let nextMessage = self[index + 1] + if nextMessage.role == .assistant { + turn.response = nextMessage.content + extractContentFromEditAgentRounds(nextMessage.editAgentRounds) + index += 1 + } + } + turns.append(turn) + } + index += 1 + } + + return turns + } + + private func extractContentFromEditAgentRounds(_ editAgentRounds: [AgentRound]) -> String { + var content = "" + for round in editAgentRounds { + if !round.reply.isEmpty { + content += round.reply + } + } + return content + } } +// MARK: Copilot Code Review + +extension ChatService { + + public func requestCodeReview(_ group: GitDiffGroup) async throws { + guard activeRequestId == nil else { return } + activeRequestId = UUID().uuidString + + guard !isReceivingMessage else { + activeRequestId = nil + throw CancellationError() + } + isReceivingMessage = true + requestType = .codeReview + let turnId = UUID().uuidString + + do { + await CodeReviewService.shared.resetComments() + + await addCodeReviewUserMessage(id: UUID().uuidString, turnId: turnId, group: group) + + let initialBotMessage = ChatMessage( + assistantMessageWithId: turnId, + chatTabID: chatTabInfo.id, + turnStatus: .inProgress, + requestType: .codeReview + ) + await memory.appendMessage(initialBotMessage) + + guard let projectRootURL = getProjectRootURL() + else { + let round = CodeReviewRound.fromError(turnId: turnId, error: "Invalid git repository.") + await appendCodeReviewRound(round) + resetOngoingRequest(with: .error) + return + } + + let prChanges = await CurrentChangeService.getPRChanges( + projectRootURL, + group: group, + shouldIncludeFile: shouldIncludeFileForReview + ) + guard !prChanges.isEmpty else { + let round = CodeReviewRound.fromError( + turnId: turnId, + error: group == .index ? "No staged changes found to review." : "No unstaged changes found to review." + ) + await appendCodeReviewRound(round) + resetOngoingRequest() + return + } + + let round: CodeReviewRound = .init( + turnId: turnId, + status: .waitForConfirmation, + request: .from(prChanges) + ) + await appendCodeReviewRound(round, turnStatus: .waitForConfirmation) + } catch { + resetOngoingRequest(with: .error) + throw error + } + } + + private func shouldIncludeFileForReview(url: URL) -> Bool { + let codeLanguage = CodeLanguage(fileURL: url) + + if case .builtIn = codeLanguage { + return true + } else { + return false + } + } + + private func appendCodeReviewRound( + _ round: CodeReviewRound, + turnStatus: ChatMessage.TurnStatus? = nil + ) async { + let message = ChatMessage( + assistantMessageWithId: round.turnId, + chatTabID: chatTabInfo.id, + codeReviewRound: round, + turnStatus: turnStatus + ) + + await memory.appendMessage(message) + } + + private func getCurrentCodeReviewRound(_ id: String) async -> CodeReviewRound? { + guard let lastBotMessage = await memory.history.last, + lastBotMessage.role == .assistant, + let codeReviewRound = lastBotMessage.codeReviewRound, + codeReviewRound.id == id + else { + return nil + } + + return codeReviewRound + } + + public func acceptCodeReview(_ id: String, selectedFileUris: [DocumentUri]) async { + guard activeRequestId != nil, isReceivingMessage else { return } + + guard var round = await getCurrentCodeReviewRound(id), + var request = round.request, + round.status.canTransitionTo(.accepted) + else { return } + + guard selectedFileUris.count > 0 else { + round = round.withError("No files are selected to review.") + await appendCodeReviewRound(round) + resetOngoingRequest() + return + } + + round.status = .accepted + request.updateSelectedChanges(by: selectedFileUris) + round.request = request + await appendCodeReviewRound(round, turnStatus: .inProgress) + + round.status = .running + await appendCodeReviewRound(round) + + let (fileComments, errorMessage) = await CodeReviewProvider.invoke( + request, + context: CodeReviewServiceProvider(conversationServiceProvider: conversationProvider) + ) + + if let errorMessage = errorMessage { + round = round.withError(errorMessage) + await appendCodeReviewRound(round) + resetOngoingRequest(with: .error) + return + } + + round = round.withResponse(.init(fileComments: fileComments)) + await CodeReviewService.shared.updateComments(fileComments) + await appendCodeReviewRound(round) + + round.status = .completed + await appendCodeReviewRound(round) + + resetOngoingRequest() + } + + public func cancelCodeReview(_ id: String) async { + guard activeRequestId != nil, isReceivingMessage else { return } + + guard var round = await getCurrentCodeReviewRound(id), + round.status.canTransitionTo(.cancelled) + else { return } + + round.status = .cancelled + await appendCodeReviewRound(round) + + resetOngoingRequest(with: .cancelled) + } + + private func addCodeReviewUserMessage(id: String, turnId: String, group: GitDiffGroup) async { + let content = group == .index + ? "Code review for staged changes." + : "Code review for unstaged changes." + let chatMessage = ChatMessage( + userMessageWithId: id, + chatTabId: chatTabInfo.id, + content: content, + requestType: .codeReview + ) + await memory.appendMessage(chatMessage) + saveChatMessageToStorage(chatMessage) + } +} diff --git a/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift b/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift new file mode 100644 index 00000000..c41eb61b --- /dev/null +++ b/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift @@ -0,0 +1,57 @@ +import ChatAPIService +import ConversationServiceProvider +import Foundation +import Logger +import GitHelper + +public struct CodeReviewServiceProvider { + public var conversationServiceProvider: (any ConversationServiceProvider)? +} + +public struct CodeReviewProvider { + public static func invoke( + _ request: CodeReviewRequest, + context: CodeReviewServiceProvider + ) async -> (fileComments: [CodeReviewResponse.FileComment], errorMessage: String?) { + var fileComments: [CodeReviewResponse.FileComment] = [] + var errorMessage: String? + + do { + if let result = try await requestReviewChanges(request.fileChange.selectedChanges, context: context) { + for comment in result.comments { + guard let change = request.fileChange.selectedChanges.first(where: { $0.uri == comment.uri }) else { + continue + } + + if let index = fileComments.firstIndex(where: { $0.uri == comment.uri }) { + var currentFileComments = fileComments[index] + currentFileComments.comments.append(comment) + fileComments[index] = currentFileComments + + } else { + fileComments.append( + .init(uri: change.uri, originalContent: change.originalContent, comments: [comment]) + ) + } + } + } + } catch { + Logger.gitHubCopilot.error("Failed to review change: \(error)") + errorMessage = "Oops, failed to review changes." + } + + return (fileComments, errorMessage) + } + + private static func requestReviewChanges( + _ changes: [PRChange], + context: CodeReviewServiceProvider + ) async throws -> CodeReviewResult? { + return try await context.conversationServiceProvider? + .reviewChanges( + changes.map { + .init(uri: $0.uri, path: $0.path, baseContent: $0.baseContent, headContent: $0.headContent) + } + ) + } +} diff --git a/Core/Sources/ChatService/CodeReview/CodeReviewService.swift b/Core/Sources/ChatService/CodeReview/CodeReviewService.swift new file mode 100644 index 00000000..4ae308d1 --- /dev/null +++ b/Core/Sources/ChatService/CodeReview/CodeReviewService.swift @@ -0,0 +1,48 @@ +import Collections +import ConversationServiceProvider +import Foundation +import LanguageServerProtocol + +public struct DocumentReview: Equatable { + public var comments: [ReviewComment] + public let originalContent: String +} + +public typealias DocumentReviewsByUri = OrderedDictionary + +@MainActor +public class CodeReviewService: ObservableObject { + @Published public private(set) var documentReviews: DocumentReviewsByUri = [:] + + public static let shared = CodeReviewService() + + private init() {} + + public func updateComments(for uri: DocumentUri, comments: [ReviewComment], originalContent: String) { + if var existing = documentReviews[uri] { + existing.comments.append(contentsOf: comments) + existing.comments = sortedComments(existing.comments) + documentReviews[uri] = existing + } else { + documentReviews[uri] = .init(comments: comments, originalContent: originalContent) + } + } + + public func updateComments(_ fileComments: [CodeReviewResponse.FileComment]) { + for fileComment in fileComments { + updateComments( + for: fileComment.uri, + comments: fileComment.comments, + originalContent: fileComment.originalContent + ) + } + } + + private func sortedComments(_ comments: [ReviewComment]) -> [ReviewComment] { + return comments.sorted { $0.range.end.line < $1.range.end.line } + } + + public func resetComments() { + documentReviews = [:] + } +} diff --git a/Core/Sources/ChatService/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/Extensions/ChatService+FileEdit.swift b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift new file mode 100644 index 00000000..c901a341 --- /dev/null +++ b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift @@ -0,0 +1,55 @@ +import Foundation +import ConversationServiceProvider +import ChatAPIService + +extension ChatService { + // MARK: - File Edit + + public func updateFileEdits(by fileEdit: FileEdit) { + if let existingFileEdit = self.fileEditMap[fileEdit.fileURL] { + self.fileEditMap[fileEdit.fileURL] = .init( + fileURL: fileEdit.fileURL, + originalContent: existingFileEdit.originalContent, + modifiedContent: fileEdit.modifiedContent, + toolName: existingFileEdit.toolName + ) + } else { + self.fileEditMap[fileEdit.fileURL] = fileEdit + } + } + + public func undoFileEdit(for fileURL: URL) throws { + guard var fileEdit = self.fileEditMap[fileURL], + fileEdit.status == .none + else { return } + + switch fileEdit.toolName { + case .insertEditIntoFile: + InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent) + case .createFile: + try CreateFileTool.undo(for: fileURL) + default: + return + } + + fileEdit.status = .undone + self.fileEditMap[fileURL] = fileEdit + } + + public func keepFileEdit(for fileURL: URL) { + guard var fileEdit = self.fileEditMap[fileURL], fileEdit.status == .none + else { return } + + fileEdit.status = .kept + self.fileEditMap[fileURL] = fileEdit + } + + public func resetFileEdits() { + self.fileEditMap = [:] + } + + public func discardFileEdit(for fileURL: URL) throws { + try self.undoFileEdit(for: fileURL) + self.fileEditMap.removeValue(forKey: fileURL) + } +} diff --git a/Core/Sources/ChatService/Skills/ConversationSkill.swift b/Core/Sources/ChatService/Skills/ConversationSkill.swift new file mode 100644 index 00000000..d7883b8e --- /dev/null +++ b/Core/Sources/ChatService/Skills/ConversationSkill.swift @@ -0,0 +1,10 @@ +import JSONRPC +import GitHubCopilotService + +public typealias JSONRPCResponseHandler = (AnyJSONRPCResponse) -> Void + +public protocol ConversationSkill { + var id: String { get } + func applies(params: ConversationContextParams) -> Bool + func resolveSkill(request: ConversationContextRequest, completion: @escaping JSONRPCResponseHandler) +} diff --git a/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift new file mode 100644 index 00000000..19f4aa8d --- /dev/null +++ b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift @@ -0,0 +1,62 @@ +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: ConversationFileReference + public var id: String { + return CurrentEditorSkill.ID + } + public var currentFilePath: String { currentFile.url.path } + + public init( + currentFile: ConversationFileReference + ) { + self.currentFile = currentFile + } + + public func applies(params: ConversationContextParams) -> Bool { + 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([response, JSONValue.null])) + ) + } +} diff --git a/Core/Sources/ChatService/Skills/ProblemsInActiveDocumentSkill.swift b/Core/Sources/ChatService/Skills/ProblemsInActiveDocumentSkill.swift new file mode 100644 index 00000000..203872db --- /dev/null +++ b/Core/Sources/ChatService/Skills/ProblemsInActiveDocumentSkill.swift @@ -0,0 +1,52 @@ +import ConversationServiceProvider +import Foundation +import GitHubCopilotService +import JSONRPC +import XcodeInspector + +public class ProblemsInActiveDocumentSkill: ConversationSkill { + public static let ID = "problems-in-active-document" + public var id: String { + return ProblemsInActiveDocumentSkill.ID + } + + public init() { + } + + public func applies(params: ConversationContextParams) -> Bool { + return params.skillId == self.id + } + + public func resolveSkill(request: ConversationContextRequest, completion: @escaping JSONRPCResponseHandler) { + Task { + let editor = await XcodeInspector.shared.getFocusedEditorContent() + let result: JSONValue = JSONValue.hash([ + "uri": JSONValue.string(editor?.documentURL.absoluteString ?? ""), + "problems": JSONValue.array(editor?.editorContent?.lineAnnotations.map { annotation in + JSONValue.hash([ + "message": JSONValue.string(annotation.message), + "range": JSONValue.hash([ + "start": JSONValue.hash([ + "line": JSONValue.number(Double(annotation.line)), + "character": JSONValue.number(0) + ]), + "end": JSONValue.hash([ + "line": JSONValue.number(Double(annotation.line)), + "character": JSONValue.number(0) + ]) + ]) + ]) + } ?? []) + ]) + + completion( + AnyJSONRPCResponse(id: request.id, + result: JSONValue.array([ + result, + JSONValue.null + ])) + ) + } + } +} + diff --git a/Core/Sources/ChatService/Skills/ProjectContextSkill.swift b/Core/Sources/ChatService/Skills/ProjectContextSkill.swift new file mode 100644 index 00000000..1575db9b --- /dev/null +++ b/Core/Sources/ChatService/Skills/ProjectContextSkill.swift @@ -0,0 +1,64 @@ +import Foundation +import Workspace +import GitHubCopilotService +import JSONRPC +import XcodeInspector + +/* + * project-context is different from others + * 1. The CLS only request this skill once `after initialized` instead of during conversation / turn. + * 2. After resolved skill, a file watcher needs to be start for syncing file modification to CLS + */ +public class ProjectContextSkill { + public static let ID = "project-context" + public static let ProgressID = "collect-project-context" + + public static var resolvedWorkspace: Set = Set() + + public static func isWorkspaceResolved(_ path: String) -> Bool { + return ProjectContextSkill.resolvedWorkspace.contains(path) + } + + public init() { } + + /* + * The request from CLS only contain the projectPath (a initialization paramter for CLS) + * whereas to get files for xcode workspace, the workspacePath is needed. + */ + public static func resolveSkill( + request: WatchedFilesRequest, + workspacePath: String, + completion: JSONRPCResponseHandler + ) { + guard !ProjectContextSkill.isWorkspaceResolved(workspacePath) else {return } + + let params = request.params! + + guard params.workspaceFolder.uri != "/" else { return } + + /// build workspace URL + let workspaceURL = URL(fileURLWithPath: workspacePath) + /// refer to `init` in `Workspace` + let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: workspaceURL, + documentURL: nil + ) ?? workspaceURL + + /// ignore invalid resolve request + guard projectURL.absoluteString == params.workspaceFolder.uri else { return } + + let files = WorkspaceFile.getWatchedFiles( + workspaceURL: workspaceURL, + projectURL: projectURL, + excludeGitIgnoredFiles: params.excludeGitignoredFiles, + excludeIDEIgnoredFiles: params.excludeIDEIgnoredFiles + ) + + let jsonResult = try? JSONEncoder().encode(["files": files]) + let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null + + completion(AnyJSONRPCResponse(id: request.id, result: jsonValue)) + + ProjectContextSkill.resolvedWorkspace.insert(workspacePath) + } +} diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift new file mode 100644 index 00000000..d3a47556 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift @@ -0,0 +1,11 @@ +import Foundation + +public typealias ConversationID = String + +public enum AutoApprovalScope: Hashable { + case session(ConversationID) + /// Applies to all workspaces. Persisted in `UserDefaults.autoApproval`. + case global + // Future scopes: + // case workspace(String) +} diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/MCPApprovalStorage.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/MCPApprovalStorage.swift new file mode 100644 index 00000000..77a6b1e6 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/MCPApprovalStorage.swift @@ -0,0 +1,163 @@ +import Foundation +import Preferences + +struct MCPApprovalStorage { + /// Stored under `UserDefaults.autoApproval` with key `AutoApproval_MCP_GlobalApprovals`. + /// + /// Stored as native property-list types (NSDictionary/NSArray/Bool/String) + /// so users can edit values directly in the `*.prefs.plist`. + /// + /// Sample structure: + /// ``` + /// { + /// "servers": { + /// "github": { + /// "isServerAllowed": false, + /// "allowedTools": ["search_issues", "get_issue"] + /// }, + /// "my-filesystem-server": { + /// "isServerAllowed": true, + /// "allowedTools": [] + /// } + /// } + /// } + /// ``` + + private struct ServerApprovalState { + var isServerAllowed: Bool = false + var allowedTools: Set = [] + } + + private struct ConversationApprovalState { + var serverApprovals: [String: ServerApprovalState] = [:] + } + + + /// Storage for session-scoped approvals. + private var approvals: [ConversationID: ConversationApprovalState] = [:] + + private var workspaceUserDefaults: UserDefaultsType { UserDefaults.autoApproval } + + mutating func allowTool(scope: AutoApprovalScope, serverName: String, toolName: String) { + let server = normalize(serverName) + let tool = normalize(toolName) + guard !server.isEmpty, !tool.isEmpty else { return } + + switch scope { + case .session(let conversationId): + allowToolInSession(conversationId: conversationId, server: server, tool: tool) + case .global: + allowToolInGlobal(server: server, tool: tool) + } + } + + mutating func allowServer(scope: AutoApprovalScope, serverName: String) { + let server = normalize(serverName) + guard !server.isEmpty else { return } + + switch scope { + case .session(let conversationId): + allowServerInSession(conversationId: conversationId, server: server) + case .global: + allowServerInGlobal(server: server) + } + } + + func isAllowed(scope: AutoApprovalScope, serverName: String, toolName: String) -> Bool { + let server = normalize(serverName) + let tool = normalize(toolName) + guard !server.isEmpty, !tool.isEmpty else { return false } + + switch scope { + case .session(let conversationId): + return isAllowedInSession(conversationId: conversationId, server: server, tool: tool) + case .global: + return isAllowedInGlobal(server: server, tool: tool) + } + } + + mutating func clear(scope: AutoApprovalScope) { + switch scope { + case .session(let conversationId): + clearSession(conversationId: conversationId) + case .global: + clearGlobal() + } + } + + // MARK: - Session-scoped operations (in-memory) + + private mutating func allowToolInSession(conversationId: String, server: String, tool: String) { + guard !conversationId.isEmpty else { return } + approvals[conversationId, default: ConversationApprovalState()] + .serverApprovals[server, default: ServerApprovalState()] + .allowedTools + .insert(tool) + } + + private mutating func allowServerInSession(conversationId: String, server: String) { + guard !conversationId.isEmpty else { return } + approvals[conversationId, default: ConversationApprovalState()] + .serverApprovals[server, default: ServerApprovalState()] + .isServerAllowed = true + } + + private func isAllowedInSession(conversationId: String, server: String, tool: String) -> Bool { + guard !conversationId.isEmpty else { return false } + guard let conversationState = approvals[conversationId], + let serverState = conversationState.serverApprovals[server] else { return false } + if serverState.isServerAllowed { return true } + return serverState.allowedTools.contains(tool) + } + + private mutating func clearSession(conversationId: String) { + guard !conversationId.isEmpty else { return } + approvals.removeValue(forKey: conversationId) + } + + // MARK: - Global operations (persisted) + + private mutating func allowToolInGlobal(server: String, tool: String) { + var globalApprovals = workspaceUserDefaults.value(for: \.mcpServersGlobalApprovals) + var serverState = globalApprovals.servers[server] ?? MCPServerApprovalState() + + serverState.allowedTools.insert(tool) + globalApprovals.servers[server] = serverState + workspaceUserDefaults.set(globalApprovals, for: \.mcpServersGlobalApprovals) + + NotificationCenter.default.post( + name: .githubCopilotAgentAutoApprovalDidChange, + object: nil + ) + } + + private mutating func allowServerInGlobal(server: String) { + var globalApprovals = workspaceUserDefaults.value(for: \.mcpServersGlobalApprovals) + var serverState = globalApprovals.servers[server] ?? MCPServerApprovalState() + + serverState.isServerAllowed = true + globalApprovals.servers[server] = serverState + workspaceUserDefaults.set(globalApprovals, for: \.mcpServersGlobalApprovals) + + NotificationCenter.default.post( + name: .githubCopilotAgentAutoApprovalDidChange, + object: nil + ) + } + + private func isAllowedInGlobal(server: String, tool: String) -> Bool { + let globalApprovals = workspaceUserDefaults.value(for: \.mcpServersGlobalApprovals) + guard let serverState = globalApprovals.servers[server] else { return false } + + if serverState.isServerAllowed { return true } + return serverState.allowedTools.contains(tool) + } + + private mutating func clearGlobal() { + workspaceUserDefaults.set(AutoApprovedMCPServers(), for: \.mcpServersGlobalApprovals) + } + + private func normalize(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/SensitiveFileApprovalStorage.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/SensitiveFileApprovalStorage.swift new file mode 100644 index 00000000..0c204b70 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/SensitiveFileApprovalStorage.swift @@ -0,0 +1,141 @@ +import Foundation +import Preferences + +struct SensitiveFileApprovalStorage { + /// Stored under `UserDefaults.autoApproval` with key `AutoApproval_SensitiveFiles_GlobalApprovals`. + /// + /// Stored as native property-list types (NSDictionary/NSArray/String) + /// so users can edit values directly in the `*.prefs.plist`. + /// + /// Sample structure: + /// ``` + /// { + /// "rules": { + /// "**/*.env": { "description": "Secrets", "autoApprove": true } + /// } + /// } + /// ``` + + private struct ToolApprovalState { + var allowedFiles: Set = [] + } + + private struct ConversationApprovalState { + var toolApprovals: [String: ToolApprovalState] = [:] + } + + + /// Storage for session-scoped approvals. + private var approvals: [ConversationID: ConversationApprovalState] = [:] + + private var workspaceUserDefaults: UserDefaultsType { UserDefaults.autoApproval } + + mutating func allowFile( + scope: AutoApprovalScope, + toolName: String, + fileKey: String + ) { + guard case .session(let conversationId) = scope else { return } + + let tool = normalize(toolName) + let key = normalize(fileKey) + guard !tool.isEmpty, !key.isEmpty else { return } + + allowFileInSession(conversationId: conversationId, tool: tool, fileKey: key) + } + + mutating func allowFile( + scope: AutoApprovalScope, + description: String, + pattern: String + ) { + guard case .global = scope else { return } + + let ruleKey = normalize(pattern) + guard !ruleKey.isEmpty else { return } + + storeRuleInGlobal( + ruleKey: ruleKey, + description: normalize(description), + autoApprove: true + ) + } + + func isAllowed(scope: AutoApprovalScope, toolName: String, fileKey: String) -> Bool { + guard case .session(let conversationId) = scope else { return false } + + let tool = normalize(toolName) + let key = normalize(fileKey) + guard !conversationId.isEmpty, !tool.isEmpty, !key.isEmpty else { return false } + + return isAllowedInSession(conversationId: conversationId, tool: tool, fileKey: key) + } + + mutating func clear(scope: AutoApprovalScope) { + switch scope { + case .session(let conversationId): + clearSession(conversationId: conversationId) + case .global: + clearGlobal() + } + } + + // MARK: - Session-scoped operations (in-memory) + + private mutating func allowFileInSession(conversationId: String, tool: String, fileKey: String) { + guard !conversationId.isEmpty else { return } + approvals[conversationId, default: ConversationApprovalState()] + .toolApprovals[tool, default: ToolApprovalState()] + .allowedFiles + .insert(fileKey) + } + + private func isAllowedInSession(conversationId: String, tool: String, fileKey: String) -> Bool { + guard !conversationId.isEmpty else { return false } + return approvals[conversationId]?.toolApprovals[tool]?.allowedFiles.contains(fileKey) == true + } + + private mutating func clearSession(conversationId: String) { + guard !conversationId.isEmpty else { return } + approvals.removeValue(forKey: conversationId) + } + + // MARK: - Global operations (persisted) + + private mutating func storeRuleInGlobal( + ruleKey: String, + description: String, + autoApprove: Bool + ) { + var state = loadGlobalApprovalState() + var rule = state.rules[ruleKey] ?? SensitiveFileRule(description: "", autoApprove: false) + + if !description.isEmpty { + rule.description = description + } + rule.autoApprove = autoApprove + state.rules[ruleKey] = rule + + saveGlobalApprovalState(state) + NotificationCenter.default.post( + name: .githubCopilotAgentAutoApprovalDidChange, + object: nil + ) + } + + private mutating func clearGlobal() { + workspaceUserDefaults.set(SensitiveFilesRules(), for: \.sensitiveFilesGlobalApprovals) + } + + private func loadGlobalApprovalState() -> SensitiveFilesRules { + return workspaceUserDefaults.value(for: \.sensitiveFilesGlobalApprovals) + } + + private func saveGlobalApprovalState(_ state: SensitiveFilesRules) { + workspaceUserDefaults.set(state, for: \.sensitiveFilesGlobalApprovals) + } + + private func normalize(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/TerminalApprovalStorage.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/TerminalApprovalStorage.swift new file mode 100644 index 00000000..f8641499 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/TerminalApprovalStorage.swift @@ -0,0 +1,152 @@ +import Foundation +import Preferences + +struct TerminalApprovalStorage { + /// Stored under `UserDefaults.autoApproval` with key `AutoApproval_Terminal_GlobalApprovals`. + /// + /// Stored as native property-list types (NSDictionary/NSArray/String) + /// so users can edit values directly in the `*.prefs.plist`. + /// + /// Sample structure: + /// ``` + /// { + /// "commands": { + /// "git status": true + /// } + /// } + /// ``` + + private struct ConversationApprovalState { + var isAllCommandsAllowed: Bool = false + /// Stored as normalized command names (e.g. `git`, `brew`) and/or normalized + /// exact command lines (e.g. `git status`). + /// + /// Note: command names are case-sensitive (e.g. `FOO` != `foo`). + var allowedCommands: Set = [] + } + + private var workspaceUserDefaults: UserDefaultsType { UserDefaults.autoApproval } + + /// Storage for session-scoped approvals. + private var approvals: [ConversationID: ConversationApprovalState] = [:] + + mutating func allowAllCommands(scope: AutoApprovalScope) { + guard case .session(let conversationId) = scope else { return } + guard !conversationId.isEmpty else { return } + approvals[conversationId, default: ConversationApprovalState()].isAllCommandsAllowed = true + } + + mutating func allowCommands(scope: AutoApprovalScope, commands: [String]) { + switch scope { + case .global: + allowCommandsGlobally(commands: commands) + case .session(let conversationId): + allowCommandsInSession(conversationId: conversationId, commands: commands) + } + } + + func isAllowed(scope: AutoApprovalScope, commandLine: String) -> Bool { + guard case .session(let conversationId) = scope else { return false } + + let normalizedCommandLine = normalizeCommandLine(commandLine) + guard !normalizedCommandLine.isEmpty else { return false } + + return isAllowedInSession(conversationId: conversationId, commandLine: normalizedCommandLine) + } + + func isAllCommandsAllowedInSession(conversationId: ConversationID) -> Bool { + guard !conversationId.isEmpty else { return false } + return approvals[conversationId]?.isAllCommandsAllowed == true + } + + mutating func clear(scope: AutoApprovalScope) { + switch scope { + case .session(let conversationId): + approvals.removeValue(forKey: conversationId) + case .global: + workspaceUserDefaults.set(TerminalCommandsRules(), for: \.terminalCommandsGlobalApprovals) + } + } + + // MARK: - Global operations (persisted) + + private mutating func storeRuleInGlobal(commandKey: String, autoApprove: Bool) { + var state = loadGlobalApprovalState() + state.commands[commandKey] = autoApprove + + saveGlobalApprovalState(state) + NotificationCenter.default.post( + name: .githubCopilotAgentAutoApprovalDidChange, + object: nil + ) + } + + private mutating func allowCommandsGlobally(commands: [String]) { + let keys = commands + .map { normalizeCommandLine($0) } + .filter { !$0.isEmpty } + + guard !keys.isEmpty else { return } + + for key in keys { + storeRuleInGlobal(commandKey: key, autoApprove: true) + } + } + + private mutating func allowCommandsInSession(conversationId: String, commands: [String]) { + guard !conversationId.isEmpty else { return } + + let trimmed = commands.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + guard !trimmed.isEmpty else { return } + + var state = approvals[conversationId, default: ConversationApprovalState()] + + for item in trimmed { + // Heuristic: + // - entries containing whitespace are treated as exact command lines + // - otherwise treated as command names (matching `cmd ...`) + if item.rangeOfCharacter(from: .whitespacesAndNewlines) != nil { + let exact = normalizeCommandLine(item) + if !exact.isEmpty { + state.allowedCommands.insert(exact) + } + } else { + let name = normalizeCommandLine(item) + if !name.isEmpty { + state.allowedCommands.insert(name) + } + } + } + + approvals[conversationId] = state + } + + private func isAllowedInSession(conversationId: String, commandLine: String) -> Bool { + guard !conversationId.isEmpty else { return false } + guard let state = approvals[conversationId] else { return false } + + if state.isAllCommandsAllowed { return true } + if state.allowedCommands.contains(commandLine) { return true } + + let requiredCommandNames = ToolAutoApprovalManager.extractTerminalCommandNames(from: commandLine) + .map { normalizeCommandLine($0) } + .filter { !$0.isEmpty } + + guard !requiredCommandNames.isEmpty else { return false } + return requiredCommandNames.allSatisfy { state.allowedCommands.contains($0) } + } + + private func loadGlobalApprovalState() -> TerminalCommandsRules { + workspaceUserDefaults.value(for: \.terminalCommandsGlobalApprovals) + } + + private func saveGlobalApprovalState(_ state: TerminalCommandsRules) { + workspaceUserDefaults.set(state, for: \.terminalCommandsGlobalApprovals) + } + + // MARK: - Key normalization + + private func normalizeCommandLine(_ commandLine: String) -> String { + commandLine.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalManager.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalManager.swift new file mode 100644 index 00000000..71757fa8 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalManager.swift @@ -0,0 +1,179 @@ +import Foundation + +public actor ToolAutoApprovalManager { + public static let shared = ToolAutoApprovalManager() + + public enum AutoApproval: Equatable, Sendable { + case mcpTool(scope: AutoApprovalScope, serverName: String, toolName: String) + case mcpServer(scope: AutoApprovalScope, serverName: String) + case sensitiveFile( + scope: AutoApprovalScope, + toolName: String, + description: String, + pattern: String? + ) + case terminal(scope: AutoApprovalScope, commands: [String]) + } + + private var mcpStorage = MCPApprovalStorage() + private var sensitiveFileStorage = SensitiveFileApprovalStorage() + private var terminalStorage = TerminalApprovalStorage() + + public init() {} + + public func approve(_ approval: AutoApproval) { + switch approval { + case let .mcpTool(scope, serverName, toolName): + switch scope { + case .session(let conversationId): + allowMCPTool(conversationId: conversationId, serverName: serverName, toolName: toolName) + case .global: + allowMCPToolGlobally(serverName: serverName, toolName: toolName) + } + + case let .mcpServer(scope, serverName): + switch scope { + case .session(let conversationId): + allowMCPServer(conversationId: conversationId, serverName: serverName) + case .global: + allowMCPServerGlobally(serverName: serverName) + } + + case let .sensitiveFile(scope, toolName, description, pattern): + switch scope { + case .session(let conversationId): + let key = resolveFileKey(description: description, pattern: pattern) + allowSensitiveFile(conversationId: conversationId, toolName: toolName, fileKey: key) + case .global: + guard let pattern, !pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + // Global approvals require an explicit pattern. + return + } + allowSensitiveRuleGlobally(description: description, pattern: pattern) + } + + case let .terminal(scope, commands): + switch scope { + case .global: + allowTerminalCommandGlobally(commands: commands) + case .session(let conversationId): + if commands.isEmpty { + allowTerminalAllCommandsInSession(conversationId: conversationId) + } else { + allowTerminalCommandsInSession(conversationId: conversationId, commands: commands) + } + } + } + } + + // MARK: - MCP approvals + + public func allowMCPTool(conversationId: String, serverName: String, toolName: String) { + mcpStorage.allowTool(scope: .session(conversationId), serverName: serverName, toolName: toolName) + } + + public func allowMCPServer(conversationId: String, serverName: String) { + mcpStorage.allowServer(scope: .session(conversationId), serverName: serverName) + } + + public func isMCPAllowed( + conversationId: String, + serverName: String, + toolName: String + ) -> Bool { + mcpStorage.isAllowed(scope: .session(conversationId), serverName: serverName, toolName: toolName) + } + + // MARK: - Global MCP approvals + + public func allowMCPToolGlobally(serverName: String, toolName: String) { + mcpStorage.allowTool(scope: .global, serverName: serverName, toolName: toolName) + } + + public func allowMCPServerGlobally(serverName: String) { + mcpStorage.allowServer(scope: .global, serverName: serverName) + } + + public func isMCPAllowedGlobally(serverName: String, toolName: String) -> Bool { + mcpStorage.isAllowed(scope: .global, serverName: serverName, toolName: toolName) + } + + // MARK: - Sensitive file approvals + + public func allowSensitiveFile(conversationId: String, toolName: String, fileKey: String) { + sensitiveFileStorage.allowFile(scope: .session(conversationId), toolName: toolName, fileKey: fileKey) + } + + public func isSensitiveFileAllowed( + conversationId: String, + toolName: String, + fileKey: String + ) -> Bool { + sensitiveFileStorage.isAllowed(scope: .session(conversationId), toolName: toolName, fileKey: fileKey) + } + + // MARK: - Global Sensitive file approvals + + public func allowSensitiveRuleGlobally(description: String, pattern: String) { + // toolName is intentionally ignored for global sensitive-file approvals. + sensitiveFileStorage.allowFile( + scope: .global, + description: description, + pattern: pattern + ) + } + + // MARK: - Global terminal approvals + + /// Stores global auto-approvals for one or more terminal command lines. + public func allowTerminalCommandGlobally(commands: [String]) { + terminalStorage.allowCommands(scope: .global, commands: commands) + } + + /// Stores session-scoped auto-approvals. + /// + /// Heuristic: + /// - entries containing whitespace are treated as exact command lines + /// - otherwise treated as command names (matching `cmd ...`) + public func allowTerminalCommandsInSession(conversationId: String, commands: [String]) { + terminalStorage.allowCommands(scope: .session(conversationId), commands: commands) + } + + public func allowTerminalAllCommandsInSession(conversationId: String) { + terminalStorage.allowAllCommands(scope: .session(conversationId)) + } + + public func isTerminalAllowed(conversationId: String, commandLine: String?) -> Bool { + guard let commandLine, !commandLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return terminalStorage.isAllCommandsAllowedInSession(conversationId: conversationId) + } + + return terminalStorage.isAllowed(scope: .session(conversationId), commandLine: commandLine) + } + + private func resolveFileKey(description: String, pattern: String?) -> String { + if let pattern, !pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return pattern + } + return SensitiveFileConfirmationInfo( + description: description, + pattern: pattern + ).sessionKey + } + + // MARK: - Cleanup + + public func clearConversationData(conversationId: String?) { + guard let conversationId else { return } + mcpStorage.clear(scope: .session(conversationId)) + sensitiveFileStorage.clear(scope: .session(conversationId)) + terminalStorage.clear(scope: .session(conversationId)) + } + + public func clearGlobalData() { + mcpStorage.clear(scope: .global) + sensitiveFileStorage.clear(scope: .global) + terminalStorage.clear(scope: .global) + } +} + diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalParsingHelpers.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalParsingHelpers.swift new file mode 100644 index 00000000..3b8f97f5 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalParsingHelpers.swift @@ -0,0 +1,305 @@ +import Foundation +import ConversationServiceProvider +import SwiftTreeSitter +import SwiftTreeSitterLayer +import TreeSitterBash + +extension ToolAutoApprovalManager { + private static let mcpToolCallPattern = try? NSRegularExpression( + pattern: #"Confirm MCP Tool: .+ - (.+)\(MCP Server\)"#, + options: [] + ) + + private static let sensitiveRuleDescriptionRegex = try? NSRegularExpression( + pattern: #"^(.*?)\s*needs confirmation\."#, + options: [.caseInsensitive] + ) + + private static let sensitiveRulePatternRegex = try? NSRegularExpression( + pattern: #"matching pattern\s+`([^`]+)`"#, + options: [.caseInsensitive] + ) + + public struct SensitiveFileConfirmationInfo: Sendable, Equatable { + public let description: String + // Optional pattern for create_file operations only + public let pattern: String? + + public var sessionKey: String { + if let pattern, !pattern.isEmpty { + return pattern + } + if !description.isEmpty { + return description.lowercased() + } + return "sensitive files" + } + } + + public nonisolated static func extractMCPServerName(from message: String) -> String? { + let fullRange = NSRange(message.startIndex ..< message.endIndex, in: message) + + if let regex = mcpToolCallPattern, + let match = regex.firstMatch(in: message, options: [], range: fullRange), + match.numberOfRanges >= 2, + let range = Range(match.range(at: 1), in: message) { + return String(message[range]).trimmingCharacters(in: .whitespacesAndNewlines) + } + + return nil + } + + public nonisolated static func isSensitiveFileOperation(message: String) -> Bool { + message.range(of: "sensitive files", options: [.caseInsensitive, .diacriticInsensitive]) != nil + } + + public nonisolated static func isTerminalOperation(name: String) -> Bool { + name == ToolName.runInTerminal.rawValue + } + + public nonisolated static func extractSensitiveFileConfirmationInfo(from message: String) -> SensitiveFileConfirmationInfo { + let fullRange = NSRange(message.startIndex ..< message.endIndex, in: message) + + var description = "" + if let regex = sensitiveRuleDescriptionRegex, + let match = regex.firstMatch(in: message, options: [], range: fullRange), + match.numberOfRanges >= 2, + let range = Range(match.range(at: 1), in: message) { + description = String(message[range]).trimmingCharacters(in: .whitespacesAndNewlines) + } + + var pattern: String? + if let regex = sensitiveRulePatternRegex, + let match = regex.firstMatch(in: message, options: [], range: fullRange), + match.numberOfRanges >= 2, + let range = Range(match.range(at: 1), in: message) { + let extracted = String(message[range]).trimmingCharacters(in: .whitespacesAndNewlines) + if !extracted.isEmpty { + pattern = extracted + } + } + + return SensitiveFileConfirmationInfo(description: description, pattern: pattern) + } + + public nonisolated static func sensitiveFileKey(from message: String) -> String { + extractSensitiveFileConfirmationInfo(from: message).sessionKey + } + + // MARK: - Terminal command parsing + + /// Best-effort splitter for injection protection. + /// + /// Splits a command line into sub-commands on common shell separators while respecting + /// basic quoting and escaping rules. + public nonisolated static func splitTerminalCommandLineIntoSubCommands(_ commandLine: String) -> [String] { + let input = commandLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !input.isEmpty else { return [] } + + var subCommands: [String] = [] + var current = "" + + var isInSingleQuotes = false + var isInDoubleQuotes = false + var isEscaping = false + + func flushCurrent() { + let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + subCommands.append(trimmed) + } + current = "" + } + + let scalars = Array(input.unicodeScalars) + var i = 0 + + while i < scalars.count { + let scalar = scalars[i] + let ch = Character(scalar) + + if isEscaping { + current.append(ch) + isEscaping = false + i += 1 + continue + } + + if ch == "\\" { + // Honor backslash escaping outside single-quotes, and inside double-quotes. + if !isInSingleQuotes { + isEscaping = true + } + current.append(ch) + i += 1 + continue + } + + if ch == "\"" && !isInSingleQuotes { + isInDoubleQuotes.toggle() + current.append(ch) + i += 1 + continue + } + + if ch == "'" && !isInDoubleQuotes { + isInSingleQuotes.toggle() + current.append(ch) + i += 1 + continue + } + + if !isInSingleQuotes && !isInDoubleQuotes { + // Separators: newline, semicolon, pipe, &&, || + if ch == "\n" || ch == ";" { + flushCurrent() + i += 1 + continue + } + + if ch == "&" { + if i + 1 < scalars.count, Character(scalars[i + 1]) == "&" { + flushCurrent() + i += 2 + continue + } + + // Check for &> (Redirection to stdout+stderr) + if i + 1 < scalars.count, Character(scalars[i + 1]) == ">" { + current.append(ch) + i += 1 + continue + } + + // Check for >& (Redirection, e.g. 2>&1) + if current.last == ">" { + current.append(ch) + i += 1 + continue + } + + flushCurrent() + i += 1 + continue + } + + if ch == "|" { + if i + 1 < scalars.count, Character(scalars[i + 1]) == "|" { + flushCurrent() + i += 2 + continue + } + flushCurrent() + i += 1 + continue + } + + if ch == "(" || ch == ")" { + flushCurrent() + i += 1 + continue + } + } + + current.append(ch) + i += 1 + } + + flushCurrent() + return subCommands + } + + /// Extracts command names (e.g. `git`, `brew`) from a potentially compound command line. + public nonisolated static func extractTerminalCommandNames(from commandLine: String) -> [String] { + extractSubCommandsWithTreeSitter(commandLine) + .compactMap { extractTerminalCommandName(fromSubCommand: $0) } + } + + /// Extracts the best-effort primary command name from a sub-command. + public nonisolated static func extractTerminalCommandName(fromSubCommand subCommand: String) -> String? { + let trimmed = subCommand.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let parts = trimmed.split(whereSeparator: { $0.isWhitespace }) + guard !parts.isEmpty else { return nil } + + func isEnvAssignment(_ token: Substring) -> Bool { + guard let eq = token.firstIndex(of: "=") else { return false } + let key = token[.. Language { + return Language(language: tree_sitter_bash()) + } + + public nonisolated static func extractSubCommandsWithTreeSitter(_ commandLine: String) -> [String] { + // macOS typically uses zsh or bash, both are close enough for basic command extraction using tree-sitter-bash + do { + let treeSitterLanguage = loadBashLanguage() + let parser = Parser() + try parser.setLanguage(treeSitterLanguage) + + guard let tree = parser.parse(commandLine) else { + return [commandLine.trimmingCharacters(in: .whitespacesAndNewlines)] + } + + let queryData = "(simple_command) @command".data(using: .utf8)! + let query = try Query(language: treeSitterLanguage, data: queryData) + + let matches = query.execute(in: tree) + let captures = matches.flatMap(\.captures) + + let subCommands = captures + .filter { query.captureName(for: $0.index) == "command" } + .compactMap { capture -> String? in + let node = capture.node + let startByte = Int(node.byteRange.lowerBound) + let endByte = Int(node.byteRange.upperBound) + + let utf8 = commandLine.utf8 + guard let startIndex = utf8.index(utf8.startIndex, offsetBy: startByte, limitedBy: utf8.endIndex), + let endIndex = utf8.index(utf8.startIndex, offsetBy: endByte, limitedBy: utf8.endIndex), + let cmd = String(utf8[startIndex ..< endIndex]) else { return nil } + + let trimmed = cmd.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + return subCommands + // return subCommands.isEmpty ? splitTerminalCommandLineIntoSubCommands(commandLine) : subCommands + + } catch { + // Fallback + return splitTerminalCommandLineIntoSubCommands(commandLine) + } + } +} diff --git a/Core/Sources/ChatService/ToolCalls/ClientToolConfirmationEventHandler.swift b/Core/Sources/ChatService/ToolCalls/ClientToolConfirmationEventHandler.swift new file mode 100644 index 00000000..262bd3f6 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/ClientToolConfirmationEventHandler.swift @@ -0,0 +1,115 @@ +import Foundation +import ConversationServiceProvider +import JSONRPC + +extension ChatService { + typealias ToolConfirmationCompletion = (AnyJSONRPCResponse) -> Void + + func handleClientToolConfirmationEvent( + request: InvokeClientToolConfirmationRequest, + completion: @escaping ToolConfirmationCompletion + ) { + guard let params = request.params else { return } + guard isConversationIdValid(params.conversationId) else { return } + + Task { [weak self] in + guard let self else { return } + let shouldAutoApprove = await shouldAutoApprove(params: params) + let parentTurnId = parentTurnIdForTurnId(params.turnId) + + let toolCallStatus: AgentToolCall.ToolCallStatus = shouldAutoApprove + ? .accepted + : .waitForConfirmation + + appendToolCallHistory( + turnId: params.turnId, + editAgentRounds: makeEditAgentRounds(params: params, status: toolCallStatus), + parentTurnId: parentTurnId + ) + + let toolCallRequest = ToolCallRequest( + requestId: request.id, + turnId: params.turnId, + roundId: params.roundId, + toolCallId: params.toolCallId, + completion: completion + ) + + if shouldAutoApprove { + sendToolConfirmationResponse(toolCallRequest, accepted: true) + } else { + storePendingToolCallRequest(toolCallId: params.toolCallId, request: toolCallRequest) + } + } + } + + private func shouldAutoApprove(params: InvokeClientToolParams) async -> Bool { + let mcpServerName = ToolAutoApprovalManager.extractMCPServerName(from: params.title ?? "") + let confirmationMessage = params.message ?? "" + + if ToolAutoApprovalManager.isTerminalOperation(name: params.name) { + let commandLine = params.input?["command"]?.value as? String + let allowed = await ToolAutoApprovalManager.shared.isTerminalAllowed( + conversationId: params.conversationId, + commandLine: commandLine + ) + if allowed { + return true + } + } + + if let mcpServerName { + let allowed = await ToolAutoApprovalManager.shared.isMCPAllowed( + conversationId: params.conversationId, + serverName: mcpServerName, + toolName: params.name + ) + + if allowed { + return true + } + + let globalAllowed = await ToolAutoApprovalManager.shared.isMCPAllowedGlobally( + serverName: mcpServerName, + toolName: params.name + ) + if globalAllowed { + return true + } + } + + if ToolAutoApprovalManager.isSensitiveFileOperation(message: confirmationMessage) { + let info = ToolAutoApprovalManager.extractSensitiveFileConfirmationInfo(from: confirmationMessage) + let fileKey = info.sessionKey + let allowed = await ToolAutoApprovalManager.shared.isSensitiveFileAllowed( + conversationId: params.conversationId, + toolName: params.name, + fileKey: fileKey + ) + + if allowed { + return true + } + } + + return false + } + + func makeEditAgentRounds(params: InvokeClientToolParams, status: AgentToolCall.ToolCallStatus) -> [AgentRound] { + [ + AgentRound( + roundId: params.roundId, + reply: "", + toolCalls: [ + AgentToolCall( + id: params.toolCallId, + name: params.name, + status: status, + invokeParams: params, + title: params.title + ) + ] + ) + ] + } +} diff --git a/Core/Sources/ChatService/ToolCalls/CopilotToolRegistry.swift b/Core/Sources/ChatService/ToolCalls/CopilotToolRegistry.swift new file mode 100644 index 00000000..f03d2fe5 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/CopilotToolRegistry.swift @@ -0,0 +1,19 @@ +import ConversationServiceProvider + +public class CopilotToolRegistry { + public static let shared = CopilotToolRegistry() + private var tools: [String: ICopilotTool] = [:] + + private init() { + tools[ToolName.runInTerminal.rawValue] = RunInTerminalTool() + tools[ToolName.getTerminalOutput.rawValue] = GetTerminalOutputTool() + 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? { + return tools[name] + } +} diff --git a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift new file mode 100644 index 00000000..702ade22 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift @@ -0,0 +1,101 @@ +import JSONRPC +import AppKit +import ConversationServiceProvider +import Foundation +import Logger +import ChatAPIService + +public class CreateFileTool: ICopilotTool { + public static let name = ToolName.createFile + + public func invokeTool( + _ request: InvokeClientToolRequest, + completion: @escaping (AnyJSONRPCResponse) -> Void, + contextProvider: (any ToolContextProvider)? + ) -> Bool { + guard let params = request.params, + let input = params.input, + let filePath = input["filePath"]?.value as? String, + let content = input["content"]?.value as? String + else { + completeResponse(request, status: .error, response: "Invalid parameters", completion: completion) + return true + } + + let fileURL = URL(fileURLWithPath: filePath) + + guard !FileManager.default.fileExists(atPath: filePath) + else { + Logger.client.info("CreateFileTool: File already exists at \(filePath)") + completeResponse(request, status: .error, response: "File already exists at \(filePath)", completion: completion) + return true + } + + do { + // Create intermediate directories if they don't exist + let parentDirectory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil) + try content.write(to: fileURL, atomically: true, encoding: .utf8) + } catch { + Logger.client.error("CreateFileTool: Failed to write content to file at \(filePath): \(error)") + completeResponse(request, status: .error, response: "Failed to write content to file: \(error)", completion: completion) + return true + } + + guard FileManager.default.fileExists(atPath: filePath), + let writtenContent = try? String(contentsOf: fileURL, encoding: .utf8) + else { + Logger.client.info("CreateFileTool: Failed to verify file creation at \(filePath)") + completeResponse(request, status: .error, response: "Failed to verify file creation.", completion: completion) + return true + } + + let fileEdit: FileEdit = .init( + fileURL: URL(fileURLWithPath: filePath), + originalContent: "", + modifiedContent: writtenContent, + toolName: CreateFileTool.name + ) + + contextProvider?.updateFileEdits(by: fileEdit) + + NSWorkspace.openFileInXcode(fileURL: URL(fileURLWithPath: filePath)) { _, error in + if let error = error { + Logger.client.info("Failed to open file at \(filePath), \(error)") + } + } + + let editAgentRounds: [AgentRound] = [ + .init( + roundId: params.roundId, + reply: "", + toolCalls: [ + .init( + id: params.toolCallId, + name: params.name, + status: .completed, + invokeParams: params + ) + ] + ) + ] + + contextProvider?.updateChatHistory(params.turnId, editAgentRounds: editAgentRounds, fileEdits: [fileEdit]) + + completeResponse( + request, + response: "File created at \(filePath).", + completion: completion + ) + return true + } + + public static func undo(for fileURL: URL) throws { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory), + !isDirectory.boolValue + else { return } + + try FileManager.default.removeItem(at: fileURL) + } +} diff --git a/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift new file mode 100644 index 00000000..c9f95260 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift @@ -0,0 +1,45 @@ +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, + 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/GetErrorsTool.swift b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift new file mode 100644 index 00000000..3a464016 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift @@ -0,0 +1,75 @@ +import JSONRPC +import Foundation +import ConversationServiceProvider +import XcodeInspector +import AppKit + +public class GetErrorsTool: ICopilotTool { + public func invokeTool( + _ request: InvokeClientToolRequest, + completion: @escaping (AnyJSONRPCResponse) -> Void, + contextProvider: ToolContextProvider? + ) -> Bool { + guard let params = request.params, + let input = params.input, + let filePaths = input["filePaths"]?.value as? [String] + else { + completeResponse(request, completion: completion) + return true + } + + guard let xcodeInstance = XcodeInspector.shared.xcodes.first( + where: { + $0.workspaceURL?.path == contextProvider?.chatTabInfo.workspacePath + }), + let documentURL = xcodeInstance.realtimeDocumentURL, + filePaths.contains(where: { URL(fileURLWithPath: $0) == documentURL }) + else { + completeResponse(request, completion: completion) + return true + } + + /// Not leveraging the `getFocusedEditorContent` in `XcodeInspector`. + /// As the resolving should be sync. Especially when completion the JSONRPCResponse + let focusedElement: AXUIElement? = try? xcodeInstance.appElement.copyValue(key: kAXFocusedUIElementAttribute) + let focusedEditor: SourceEditor? + if let editorElement = focusedElement, editorElement.isNonNavigatorSourceEditor { + focusedEditor = .init(runningApplication: xcodeInstance.runningApplication, element: editorElement) + } else if let element = focusedElement, let editorElement = element.firstParent( + where: \.isNonNavigatorSourceEditor + ) { + focusedEditor = .init(runningApplication: xcodeInstance.runningApplication, element: editorElement) + } else { + focusedEditor = nil + } + + var errors: String = "" + + if let focusedEditor + { + let editorContent = focusedEditor.getContent() + let errorArray: [String] = editorContent.lineAnnotations.map { + """ + \(documentURL.absoluteString) + + \($0.message) + + + \($0.line) + 0 + + + \($0.line) + 0 + + + + """ + } + errors = errorArray.joined(separator: "\n") + } + + completeResponse(request, response: errors, completion: completion) + return true + } +} diff --git a/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift b/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift new file mode 100644 index 00000000..69a76689 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift @@ -0,0 +1,33 @@ +import ConversationServiceProvider +import Foundation +import JSONRPC +import Terminal + +public class GetTerminalOutputTool: ICopilotTool { + public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, contextProvider: (any ToolContextProvider)?) -> Bool { + var result: String = "" + if let input = request.params?.input as? [String: AnyCodable], let terminalId = input["id"]?.value as? String{ + let session = TerminalSessionManager.shared.getSession(for: terminalId) + result = session?.getCommandOutput() ?? "Terminal id \(terminalId) not found" + } else { + result = "Invalid arguments for \(ToolName.getTerminalOutput.rawValue) tool call" + } + + let toolResult = LanguageModelToolResult(content: [ + .init(value: result) + ]) + 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 + ]) + ) + ) + + return true + } +} diff --git a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift new file mode 100644 index 00000000..8e10fbfa --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift @@ -0,0 +1,91 @@ +import ChatTab +import ConversationServiceProvider +import Foundation +import JSONRPC +import ChatAPIService + +public protocol ToolContextProvider { + // MARK: insert_edit_into_file + var chatTabInfo: ChatTabInfo { get } + func updateFileEdits(by fileEdit: FileEdit) -> Void + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws + func updateChatHistory(_ turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit]) +} + + +public protocol ICopilotTool { + /** + * Invokes the Copilot tool with the given request. + * - Parameters: + * - request: The tool invocation request. + * - completion: Closure called with JSON-RPC response when tool execution completes. + * - contextProvider: Optional provider that supplies additional context information + * needed for tool execution, such as chat tab data and file editing capabilities. + * - Returns: Boolean indicating if the tool call has completed. True if the tool call is completed, false otherwise. + */ + func invokeTool( + _ request: InvokeClientToolRequest, + completion: @escaping (AnyJSONRPCResponse) -> Void, + contextProvider: ToolContextProvider? + ) -> Bool +} + +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 + ) { + 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, + ]) + ) + ) + } +} + +extension ChatService: ToolContextProvider { + public func updateChatHistory(_ turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = []) { + appendToolCallHistory(turnId: turnId, editAgentRounds: editAgentRounds, fileEdits: fileEdits) + } +} diff --git a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift new file mode 100644 index 00000000..2eb6b160 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift @@ -0,0 +1,309 @@ +import AppKit +import AXExtension +import AXHelper +import ConversationServiceProvider +import Foundation +import JSONRPC +import Logger +import XcodeInspector +import ChatAPIService +import SystemUtils +import Workspace + +public enum InsertEditError: LocalizedError { + case missingEditorElement(file: URL) + case openingApplicationUnavailable + case fileNotOpenedInXcode + case fileURLMismatch(expected: URL, actual: URL?) + case fileNotAccessible(URL) + case fileHasUnsavedChanges(URL) + + public var errorDescription: String? { + switch self { + case .missingEditorElement(let file): + return "Could not find source editor element for file \(file.lastPathComponent)." + case .openingApplicationUnavailable: + return "Failed to get the application that opened the file." + case .fileNotOpenedInXcode: + return "The file is not currently opened in Xcode." + case .fileURLMismatch(let expected, let actual): + return "The currently focused file URL \(actual?.lastPathComponent ?? "unknown") does not match the expected file URL \(expected.lastPathComponent)." + case .fileNotAccessible(let fileURL): + return "The file \(fileURL.lastPathComponent) is not accessible." + case .fileHasUnsavedChanges(let fileURL): + return "The file \(fileURL.lastPathComponent) seems to have unsaved changes in Xcode. Please save the file and try again." + } + } +} + +public class InsertEditIntoFileTool: ICopilotTool { + public static let name = ToolName.insertEditIntoFile + + public func invokeTool( + _ request: InvokeClientToolRequest, + completion: @escaping (AnyJSONRPCResponse) -> Void, + contextProvider: (any ToolContextProvider)? + ) -> Bool { + guard let params = request.params, + let input = request.params?.input, + let code = input["code"]?.value as? String, + let filePath = input["filePath"]?.value as? String, + let contextProvider + else { + completeResponse(request, status: .error, response: "Invalid parameters", completion: completion) + return true + } + + do { + let fileURL = URL(fileURLWithPath: filePath) + let originalContent = try String(contentsOf: fileURL, encoding: .utf8) + + InsertEditIntoFileTool.applyEdit(for: fileURL, content: code) { 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 + } + + let fileEdit: FileEdit = .init(fileURL: fileURL, originalContent: originalContent, modifiedContent: code, toolName: InsertEditIntoFileTool.name) + contextProvider.updateFileEdits(by: fileEdit) + + let editAgentRounds: [AgentRound] = [ + .init( + roundId: params.roundId, + reply: "", + toolCalls: [ + .init( + id: params.toolCallId, + name: params.name, + status: .completed, + invokeParams: params + ) + ] + ) + ] + + contextProvider + .updateChatHistory(params.turnId, editAgentRounds: editAgentRounds, fileEdits: [fileEdit]) + + self.completeResponse(request, response: newContent, completion: completion) + } + + } catch { + completeResponse( + request, + status: .error, + response: error.localizedDescription, + completion: completion + ) + } + + return true + } + + public static func applyEdit( + for fileURL: URL, + content: String, + xcodeInstance: AppInstanceInspector + ) throws -> String { + guard let editorElement = Self.getEditorElement(by: xcodeInstance, for: fileURL) + else { + throw InsertEditError.missingEditorElement(file: fileURL) + } + + // 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 lines = value.components(separatedBy: .newlines) + + do { + try Self.checkOpenedFileURL(for: fileURL, xcodeInstance: xcodeInstance) + + 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 + ) + } catch { + Logger.client.error("Failed to inject code for insert edit into file: \(error.localizedDescription)") + throw error + } + + // Verify the content was applied by reading it back + return try Self.getCurrentEditorContent(for: fileURL, by: xcodeInstance) + } + + public static func applyEdit( + for fileURL: URL, + content: String, + completion: ((String?, Error?) -> Void)? = nil + ) { + if SystemUtils.isDeveloperMode || SystemUtils.isPrereleaseBuild { + /// Experimental solution: Use file system write for better reliability. Only enable in dev mode or prerelease builds. + Self.applyEditWithFileSystem( + for: fileURL, + content: content, + completion: completion + ) + } else { + Self.applyEditWithAccessibilityAPI( + for: fileURL, + content: content, + completion: completion + ) + } + } + + /// Get the source editor element with retries for specific file URL + private static func getEditorElement( + by xcodeInstance: AppInstanceInspector, + for fileURL: URL, + retryTimes: Int = 6, + delay: TimeInterval = 0.5 + ) -> AXUIElement? { + var remainingAttempts = max(1, retryTimes) + + while remainingAttempts > 0 { + guard let realtimeURL = xcodeInstance.appElement.realtimeDocumentURL, + realtimeURL == fileURL, + let focusedElement = xcodeInstance.appElement.focusedElement, + let editorElement = focusedElement.findSourceEditorElement() + else { + if remainingAttempts > 1 { + Thread.sleep(forTimeInterval: delay) + } + + remainingAttempts -= 1 + continue + } + + return editorElement + } + + Logger.client.error("Editor element not found for \(fileURL.lastPathComponent) after \(retryTimes) attempts.") + return nil + } + + // Check if current opened file is the target URL + private static func checkOpenedFileURL( + for fileURL: URL, + xcodeInstance: AppInstanceInspector + ) throws { + let realtimeDocumentURL = xcodeInstance.realtimeDocumentURL + + if realtimeDocumentURL != fileURL { + throw InsertEditError.fileURLMismatch(expected: fileURL, actual: realtimeDocumentURL) + } + } + + private static func getCurrentEditorContent(for fileURL: URL, by xcodeInstance: AppInstanceInspector) throws -> String { + guard let editorElement = getEditorElement(by: xcodeInstance, for: fileURL, retryTimes: 1) + else { + throw InsertEditError.missingEditorElement(file: fileURL) + } + + return try editorElement.copyValue(key: kAXValueAttribute) + } +} + +private extension AppInstanceInspector { + var realtimeDocumentURL: URL? { + appElement.realtimeDocumentURL + } +} + +extension InsertEditIntoFileTool { + static func applyEditWithFileSystem( + for fileURL: URL, + content: String, + completion: ((String?, Error?) -> Void)? = nil + ) { + do { + guard let diskFileContent = try? String(contentsOf: fileURL) else { + throw InsertEditError.fileNotAccessible(fileURL) + } + + if let focusedElement = XcodeInspector.shared.focusedElement, + focusedElement.isNonNavigatorSourceEditor, + focusedElement.realtimeDocumentURL == fileURL, + focusedElement.value != diskFileContent + { + throw InsertEditError.fileHasUnsavedChanges(fileURL) + } + + // write content to disk + try content.write(to: fileURL, atomically: true, encoding: .utf8) + + Task { @WorkspaceActor in + await WorkspaceInvocationCoordinator().invokeFilespaceUpdate(fileURL: fileURL, content: content) + if let completion = completion { completion(content, nil) } + } + } catch { + if let completion = completion { completion(nil, error) } + Logger.client.info("Failed to apply edit for file at \(fileURL), \(error)") + } + } + + static func applyEditWithAccessibilityAPI( + for fileURL: URL, + content: String, + 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 InsertEditError.openingApplicationUnavailable + } + + let appInstanceInspector = AppInstanceInspector(runningApplication: app) + guard appInstanceInspector.isXcode + else { + throw InsertEditError.fileNotOpenedInXcode + } + + let newContent = try applyEdit( + for: fileURL, + content: content, + xcodeInstance: appInstanceInspector + ) + + Task { + await WorkspaceInvocationCoordinator().invokeFilespaceUpdate(fileURL: fileURL, content: newContent) + if let completion = completion { completion(newContent, nil) } + } + } catch { + if let completion = completion { completion(nil, error) } + Logger.client.info("Failed to apply edit for file at \(fileURL), \(error)") + } + } + } +} diff --git a/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift b/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift new file mode 100644 index 00000000..1fc8306b --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift @@ -0,0 +1,42 @@ +import ConversationServiceProvider +import Terminal +import XcodeInspector +import JSONRPC + +public class RunInTerminalTool: ICopilotTool { + public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, contextProvider: (any ToolContextProvider)?) -> Bool { + let params = request.params! + + Task { + var currentDirectory: String = "" + if let workspacePath = contextProvider?.chatTabInfo.workspacePath, + let xcodeIntance = Utils.getXcode(by: workspacePath) { + currentDirectory = xcodeIntance.realtimeProjectURL?.path ?? xcodeIntance.projectRootURL?.path ?? "" + } else { + currentDirectory = await XcodeInspector.shared.safe.realtimeActiveProjectURL?.path ?? "" + } + if let input = params.input { + let command = input["command"]?.value as? String + let isBackground = input["isBackground"]?.value as? Bool + let toolId = params.toolCallId + let session = TerminalSessionManager.shared.createSession(for: toolId) + if isBackground == true { + session.executeCommand( + currentDirectory: currentDirectory, + command: command!) { result in + // do nothing + } + completeResponse(request, response: "Command is running in terminal with ID=\(toolId)", completion: completion) + } else { + session.executeCommand( + currentDirectory: currentDirectory, + command: command!) { result in + self.completeResponse(request, response: result.output, completion: completion) + } + } + } + } + + return true + } +} diff --git a/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift b/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift new file mode 100644 index 00000000..b8058ccb --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift @@ -0,0 +1,104 @@ +import ChatAPIService +import ConversationServiceProvider +import Foundation + +/// Helper methods for updating tool call status in chat history +/// Handles both main turn tool calls and subagent tool calls +struct ToolCallStatusUpdater { + /// Finds the message containing the tool call, handling both main turns and subturns + static func findMessageContainingToolCall( + _ toolCallRequest: ToolCallRequest?, + conversationTurnTracking: ConversationTurnTrackingState, + history: [ChatMessage] + ) async -> ChatMessage? { + guard let request = toolCallRequest else { return nil } + + // If this is a subturn, find the parent turn; otherwise use the request's turnId + let turnIdToFind = conversationTurnTracking.turnParentMap[request.turnId] ?? request.turnId + + return history.first(where: { $0.id == turnIdToFind && $0.role == .assistant }) + } + + /// Searches for a tool call in agent rounds (including nested subagent rounds) and creates an update + /// + /// Note: Parent turns can have multiple sequential subturns, but they don't appear simultaneously. + /// Subturns are merged into the parent's last round's subAgentRounds array by ChatMemory. + static func findAndUpdateToolCall( + toolCallId: String, + newStatus: AgentToolCall.ToolCallStatus, + in agentRounds: [AgentRound] + ) -> AgentRound? { + // First, search in main rounds (for regular tool calls) + for round in agentRounds { + if let toolCalls = round.toolCalls { + for toolCall in toolCalls where toolCall.id == toolCallId { + return AgentRound( + roundId: round.roundId, + reply: "", + toolCalls: [ + AgentToolCall( + id: toolCallId, + name: toolCall.name, + toolType: toolCall.toolType, + status: newStatus + ), + ] + ) + } + } + } + + // If not found in main rounds, search in subagent rounds (for subturn tool calls) + // Subturns are nested under the parent round's subAgentRounds + for round in agentRounds { + guard let subAgentRounds = round.subAgentRounds else { continue } + + for subRound in subAgentRounds { + guard let toolCalls = subRound.toolCalls else { continue } + + for toolCall in toolCalls where toolCall.id == toolCallId { + // Create an update that will be merged into the parent's subAgentRounds + // ChatMemory.appendMessage will handle the merging logic + let subagentRound = AgentRound( + roundId: subRound.roundId, + reply: "", + toolCalls: [ + AgentToolCall( + id: toolCallId, + name: toolCall.name, + toolType: toolCall.toolType, + status: newStatus + ), + ] + ) + return AgentRound( + roundId: round.roundId, + reply: "", + toolCalls: [], + subAgentRounds: [subagentRound] + ) + } + } + } + + return nil + } + + /// Creates a message update with the new tool call status + static func createMessageUpdate( + targetMessage: ChatMessage, + updatedRound: AgentRound + ) -> ChatMessage { + return ChatMessage( + id: targetMessage.id, + chatTabID: targetMessage.chatTabID, + clsTurnID: targetMessage.clsTurnID, + role: .assistant, + content: "", + references: [], + steps: [], + editAgentRounds: [updatedRound], + turnStatus: .inProgress + ) + } +} diff --git a/Core/Sources/ChatService/ToolCalls/Utils.swift b/Core/Sources/ChatService/ToolCalls/Utils.swift new file mode 100644 index 00000000..507714cf --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/Utils.swift @@ -0,0 +1,14 @@ +import AppKit +import AppKitExtension +import Foundation +import Logger +import XcodeInspector + +class Utils { + public static func getXcode(by workspacePath: String) -> XcodeAppInstanceInspector? { + return XcodeInspector.shared.xcodes.first( + where: { + $0.workspaceURL?.path == workspacePath + }) + } +} diff --git a/Core/Sources/ChatService/WorkspaceInvocationCoordinator.swift b/Core/Sources/ChatService/WorkspaceInvocationCoordinator.swift new file mode 100644 index 00000000..c7b28d16 --- /dev/null +++ b/Core/Sources/ChatService/WorkspaceInvocationCoordinator.swift @@ -0,0 +1,11 @@ +import Foundation +import Workspace +import Dependencies + +struct WorkspaceInvocationCoordinator { + @Dependency(\.workspaceInvoker) private var workspaceInvoker + + func invokeFilespaceUpdate(fileURL: URL, content: String) async { + await workspaceInvoker.invokeFilespaceUpdate(fileURL, content) + } +} diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index c7f26499..e6ca55e0 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -5,49 +5,79 @@ import ChatAPIService import Preferences import Terminal import ConversationServiceProvider +import Persist +import GitHubCopilotService +import Logger +import OrderedCollections +import SwiftUI +import GitHelper +import SuggestionBasic +import HostAppActivator public struct DisplayedChatMessage: Equatable { public enum Role: Equatable { case user case assistant - case tool case ignored } - public struct Reference: Equatable { - public typealias Kind = ChatMessage.Reference.Kind - - public var title: String - public var subtitle: String - public var uri: String - public var startLine: Int? - public var kind: Kind - - public init( - title: String, - subtitle: String, - uri: String, - startLine: Int?, - kind: Kind - ) { - self.title = title - self.subtitle = subtitle - self.uri = uri - self.startLine = startLine - self.kind = kind - } - } - public var id: String public var role: Role public var text: String - public var references: [Reference] = [] + public var imageReferences: [ImageReference] = [] + public var references: [ConversationReference] = [] + public var followUp: ConversationFollowUp? = nil + public var suggestedTitle: String? = nil + public var errorMessages: [String] = [] + public var steps: [ConversationProgressStep] = [] + public var editAgentRounds: [AgentRound] = [] + public var parentTurnId: String? = nil + public var panelMessages: [CopilotShowMessageParams] = [] + public var codeReviewRound: CodeReviewRound? = nil + public var fileEdits: [FileEdit] = [] + public var turnStatus: ChatMessage.TurnStatus? = nil + public let requestType: RequestType + public var modelName: String? = nil + public var billingMultiplier: Float? = nil - public init(id: String, role: Role, text: String, references: [Reference]) { + public init( + id: String, + role: Role, + text: String, + imageReferences: [ImageReference] = [], + references: [ConversationReference] = [], + followUp: ConversationFollowUp? = nil, + suggestedTitle: String? = nil, + errorMessages: [String] = [], + steps: [ConversationProgressStep] = [], + editAgentRounds: [AgentRound] = [], + parentTurnId: String? = nil, + panelMessages: [CopilotShowMessageParams] = [], + codeReviewRound: CodeReviewRound? = nil, + fileEdits: [FileEdit] = [], + turnStatus: ChatMessage.TurnStatus? = nil, + requestType: RequestType, + modelName: String? = nil, + billingMultiplier: Float? = nil + ) { self.id = id self.role = role self.text = text + self.imageReferences = imageReferences self.references = references + self.followUp = followUp + self.suggestedTitle = suggestedTitle + self.errorMessages = errorMessages + self.steps = steps + self.editAgentRounds = editAgentRounds + self.parentTurnId = parentTurnId + self.panelMessages = panelMessages + self.codeReviewRound = codeReviewRound + self.fileEdits = fileEdits + self.turnStatus = turnStatus + self.requestType = requestType + self.modelName = modelName + self.billingMultiplier = billingMultiplier } } @@ -55,21 +85,479 @@ private var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } +struct ChatContext: Equatable { + var typedMessage: String + var attachedReferences: [ConversationAttachedReference] + var attachedImages: [ImageReference] + + init(typedMessage: String, attachedReferences: [ConversationAttachedReference] = [], attachedImages: [ImageReference] = []) { + self.typedMessage = typedMessage + self.attachedReferences = attachedReferences + self.attachedImages = attachedImages + } + + static func empty() -> ChatContext { + .init(typedMessage: "", attachedReferences: [], attachedImages: []) + } + + static func from(_ message: DisplayedChatMessage, projectURL: URL) -> ChatContext { + .init( + typedMessage: message.text, + attachedReferences: message.references.compactMap { + guard let url = $0.url else { return nil } + if $0.isDirectory { + return .directory(.init(url: url)) + } else { + let relativePath = url.path.replacingOccurrences(of: projectURL.path, with: "") + let fileName = url.lastPathComponent + return .file(.init(url: url, relativePath: relativePath, fileName: fileName)) + } + }, + attachedImages: message.imageReferences) + } +} + +struct ChatContextProvider: Equatable { + var contextStack: [ChatContext] + + init(contextStack: [ChatContext] = []) { + self.contextStack = contextStack + } + + mutating func reset() { + contextStack = [] + } + + mutating func getNextContext() -> ChatContext? { + guard !contextStack.isEmpty else { + return nil + } + + return contextStack.removeLast() + } + + func getPreviousContext(from history: [DisplayedChatMessage], projectURL: URL) -> ChatContext? { + let previousUserMessage: DisplayedChatMessage? = { + let userMessages = history.filter { $0.role == .user } + guard !userMessages.isEmpty else { + return nil + } + + let stackCount = contextStack.count + guard userMessages.count > stackCount else { + return nil + } + + let index = userMessages.count - stackCount - 1 + guard index >= 0 else { return nil } + + return userMessages[index] + }() + + var context: ChatContext? + if let previousUserMessage { + context = .from(previousUserMessage, projectURL: projectURL) + } + + return context + } + + mutating func pushContext(_ context: ChatContext) { + contextStack.append(context) + } +} + @Reducer struct Chat { public typealias MessageID = String + public enum EditorMode: Hashable { + case input // Default input mode + case editUserMessage(MessageID) + + var isDefault: Bool { self == .input } + + var isEditingUserMessage: Bool { + switch self { + case .input: false + case .editUserMessage: true + } + } + + var editingUserMessageId: String? { + switch self { + case .input: nil + case .editUserMessage(let messageID): messageID + } + } + } @ObservableState - struct State: Equatable { - var title: String = "Chat" - var typedMessage = "" - var history: [DisplayedChatMessage] = [] - var isReceivingMessage = false - var chatMenu = ChatMenu.State() - var focusedField: Field? - + struct EditorState: Equatable { enum Field: String, Hashable { case textField + case fileSearchBar + } + + var codeReviewState = ConversationCodeReviewFeature.State() + + var mode: EditorMode + var contexts: [EditorMode: ChatContext] + var contextProvider: ChatContextProvider + var focusedField: Field? + var currentEditor: ConversationFileReference? + var handOffClicked: Bool = false + + init( + mode: EditorMode = .input, + contexts: [EditorMode: ChatContext] = [.input: .empty()], + contextProvider: ChatContextProvider = .init(), + focusedField: Field? = nil, + currentEditor: ConversationFileReference? = nil, + handOffClicked: Bool = false + ) { + self.mode = mode + self.contexts = contexts + self.contextProvider = contextProvider + self.focusedField = focusedField + self.currentEditor = currentEditor + self.handOffClicked = handOffClicked + } + + func context(for mode: EditorMode) -> ChatContext { + contexts[mode] ?? .empty() + } + + mutating func setContext(_ context: ChatContext, for mode: EditorMode) { + contexts[mode] = context + } + + mutating func updateCurrentContext(_ update: (inout ChatContext) -> Void) { + var context = self.context(for: mode) + update(&context) + setContext(context, for: mode) + } + + mutating func keepOnlyInputContext() { + let inputContext = context(for: .input) + contexts = [.input: inputContext] + } + + mutating func clearAttachedImages() { + updateCurrentContext { $0.attachedImages.removeAll() } + } + + mutating func addReference(_ reference: ConversationAttachedReference) { + updateCurrentContext { context in + guard !context.attachedReferences.contains(reference) else { return } + context.attachedReferences.append(reference) + } + } + + mutating func removeReference(_ reference: ConversationAttachedReference) { + updateCurrentContext { context in + guard let index = context.attachedReferences.firstIndex(of: reference) else { return } + context.attachedReferences.remove(at: index) + } + } + + mutating func addImage(_ image: ImageReference) { + updateCurrentContext { context in + guard !context.attachedImages.contains(image) else { return } + context.attachedImages.append(image) + } + } + + mutating func removeImage(_ image: ImageReference) { + updateCurrentContext { context in + guard let index = context.attachedImages.firstIndex(of: image) else { return } + context.attachedImages.remove(at: index) + } + } + + mutating func pushContext(_ context: ChatContext) { + contextProvider.pushContext(context) + } + + mutating func resetContextProvider() { + contextProvider.reset() + } + + mutating func popNextContext() -> ChatContext? { + contextProvider.getNextContext() + } + + func previousContext(from history: [DisplayedChatMessage], projectURL: URL) -> ChatContext? { + contextProvider.getPreviousContext(from: history, projectURL: projectURL) + } + } + + @ObservableState + struct ConversationState: Equatable { + var history: [DisplayedChatMessage] + var isReceivingMessage: Bool + var requestType: RequestType? + + init( + history: [DisplayedChatMessage] = [], + isReceivingMessage: Bool = false, + requestType: RequestType? = nil + ) { + self.history = history + self.isReceivingMessage = isReceivingMessage + self.requestType = requestType + } + + func subsequentMessages(after messageId: MessageID) -> [DisplayedChatMessage] { + guard let index = history.firstIndex(where: { $0.id == messageId }) else { + return [] + } + return Array(history[(index + 1)...]) + } + + func editUserMessageEffectedMessages(for mode: EditorMode) -> [DisplayedChatMessage] { + guard case .editUserMessage(let messageId) = mode else { + return [] + } + return subsequentMessages(after: messageId) + } + } + + struct AgentEditingState: Equatable { + var fileEditMap: OrderedDictionary + var diffViewerController: DiffViewWindowController? + + init( + fileEditMap: OrderedDictionary = [:], + diffViewerController: DiffViewWindowController? = nil + ) { + self.fileEditMap = fileEditMap + self.diffViewerController = diffViewerController + } + + static func == (lhs: AgentEditingState, rhs: AgentEditingState) -> Bool { + lhs.fileEditMap == rhs.fileEditMap && lhs.diffViewerController === rhs.diffViewerController + } + } + + struct EnvironmentState: Equatable { + var isAgentMode: Bool + var workspaceURL: URL? + var selectedAgent: ConversationMode + + init( + isAgentMode: Bool = AppState.shared.isAgentModeEnabled(), + workspaceURL: URL? = nil, + selectedAgent: ConversationMode = .defaultAgent + ) { + self.isAgentMode = isAgentMode + self.workspaceURL = workspaceURL + self.selectedAgent = selectedAgent + } + } + + @ObservableState + struct State: Equatable { + typealias Field = EditorState.Field + + // Not use anymore. the title of history tab will get from chat tab info + // Keep this var as `ChatTabItemView` reference this + var title: String + var editor: EditorState + var conversation: ConversationState + var agentEditing: AgentEditingState + var environment: EnvironmentState + var chatMenu: ChatMenu.State + var codeReviewState: ConversationCodeReviewFeature.State + + init( + title: String = "New Chat", + editor: EditorState = .init(), + conversation: ConversationState = .init(), + agentEditing: AgentEditingState = .init(), + environment: EnvironmentState = .init(), + chatMenu: ChatMenu.State = .init(), + codeReviewState: ConversationCodeReviewFeature.State = .init() + ) { + self.title = title + self.editor = editor + self.conversation = conversation + self.agentEditing = agentEditing + self.environment = environment + self.chatMenu = chatMenu + self.codeReviewState = codeReviewState + } + + init( + title: String = "New Chat", + editorMode: EditorMode = .input, + editorModeContexts: [EditorMode: ChatContext] = [.input: .empty()], + focusedField: Field? = nil, + history: [DisplayedChatMessage] = [], + isReceivingMessage: Bool = false, + requestType: RequestType? = nil, + fileEditMap: OrderedDictionary = [:], + diffViewerController: DiffViewWindowController? = nil, + isAgentMode: Bool = AppState.shared.isAgentModeEnabled(), + workspaceURL: URL? = nil, + selectedAgent: ConversationMode = .defaultAgent, + chatMenu: ChatMenu.State = .init(), + codeReviewState: ConversationCodeReviewFeature.State = .init() + ) { + self.init( + title: title, + editor: EditorState( + mode: editorMode, + contexts: editorModeContexts, + focusedField: focusedField + ), + conversation: ConversationState( + history: history, + isReceivingMessage: isReceivingMessage, + requestType: requestType + ), + agentEditing: AgentEditingState( + fileEditMap: fileEditMap, + diffViewerController: diffViewerController + ), + environment: EnvironmentState( + isAgentMode: isAgentMode, + workspaceURL: workspaceURL, + selectedAgent: selectedAgent + ), + chatMenu: chatMenu, + codeReviewState: codeReviewState + ) + } + + var editorMode: EditorMode { + get { editor.mode } + set { + editor.mode = newValue + if editor.contexts[newValue] == nil { + editor.contexts[newValue] = .empty() + } + } + } + + var chatContext: ChatContext { + get { editor.context(for: editor.mode) } + set { editor.setContext(newValue, for: editor.mode) } + } + + var history: [DisplayedChatMessage] { + get { conversation.history } + set { conversation.history = newValue } + } + + var isReceivingMessage: Bool { + get { conversation.isReceivingMessage } + set { conversation.isReceivingMessage = newValue } + } + + var requestType: RequestType? { + get { conversation.requestType } + set { conversation.requestType = newValue } + } + + var handOffClicked: Bool { + get { editor.handOffClicked } + set { editor.handOffClicked = newValue } + } + + var focusedField: Field? { + get { editor.focusedField } + set { editor.focusedField = newValue } + } + + var currentEditor: ConversationFileReference? { + get { editor.currentEditor } + set { editor.currentEditor = newValue } + } + + var attachedReferences: [ConversationAttachedReference] { + chatContext.attachedReferences + } + + var attachedImages: [ImageReference] { + chatContext.attachedImages + } + + var typedMessage: String { + get { chatContext.typedMessage } + set { + editor.updateCurrentContext { $0.typedMessage = newValue } + editor.resetContextProvider() + } + } + + var fileEditMap: OrderedDictionary { + get { agentEditing.fileEditMap } + set { agentEditing.fileEditMap = newValue } + } + + var diffViewerController: DiffViewWindowController? { + get { agentEditing.diffViewerController } + set { agentEditing.diffViewerController = newValue } + } + + var isAgentMode: Bool { + get { environment.isAgentMode } + set { environment.isAgentMode = newValue } + } + + var workspaceURL: URL? { + get { environment.workspaceURL } + set { environment.workspaceURL = newValue } + } + + var selectedAgent: ConversationMode { + get { environment.selectedAgent } + set { environment.selectedAgent = newValue } + } + + /// Not including the one being edited + var editUserMessageEffectedMessages: [DisplayedChatMessage] { + conversation.editUserMessageEffectedMessages(for: editor.mode) + } + + // The following messages after check point message will hide on ChatPanel + var pendingCheckpointMessageId: String? = nil + // The chat context before the first restoring + var pendingCheckpointContext: ChatContext? = nil + var messagesAfterCheckpoint: [DisplayedChatMessage] { + guard let pendingCheckpointMessageId, let index = history.firstIndex(where: { $0.id == pendingCheckpointMessageId }) else { + return [] + } + + let nextIndex = index + 1 + guard nextIndex < history.count else { + return [] + } + + // The order matters for restoring / redoing file edits + return Array(history[nextIndex...]) + } + + func getMessages(after afterMessageId: String, through throughMessageId: String?) -> [DisplayedChatMessage] { + guard let afterMessageIdIndex = history.firstIndex(where: { $0.id == afterMessageId }) else { + return [] + } + + let startIndex = afterMessageIdIndex + 1 + + let endIndex: Int + if let throughMessageId = throughMessageId, + let throughMessageIdIndex = history.firstIndex(where: { $0.id == throughMessageId }) { + endIndex = throughMessageIdIndex + 1 + } else { + endIndex = history.count + } + + guard startIndex < endIndex, startIndex < history.count else { + return [] + } + + return Array(history[startIndex..) + + case agentModeChanged(Bool) + case selectedAgentChanged(ConversationMode) + + // Code Review + case codeReview(ConversationCodeReviewFeature.Action) + + // Chat Context + case reloadNextContext + case reloadPreviousContext + case resetContextProvider + + // External Action + case observeFixErrorNotification + case fixEditorErrorIssue(EditorErrorIssue) + + // Check Point + case restoreCheckPoint(String) + case restoreFileEdits + case undoCheckPoint // Revert the restore + case discardCheckPoint + case reloadWorkingset(DisplayedChatMessage) + + case openAutoApproveSettings } let service: ChatService @@ -108,9 +650,13 @@ struct Chat { case observeHistoryChange(UUID) 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() @@ -118,6 +664,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 { @@ -129,6 +679,25 @@ struct Chat { await send(.isReceivingMessageChanged) await send(.focusOnTextField) await send(.refresh) + await send(.observeFixErrorNotification) + + let selectedAgentSubModeId = AppState.shared.getSelectedAgentSubMode() + if let modes = await SharedChatService.shared.loadConversationModes(), + let currentMode = modes.first(where: { $0.id == selectedAgentSubModeId }) { + await send(.selectedAgentChanged(currentMode)) + } + + let publisher = NotificationCenter.default.publisher(for: .gitHubCopilotChatModeDidChange) + for await _ in publisher.values { + let isAgentMode = AppState.shared.isAgentModeEnabled() + await send(.agentModeChanged(isAgentMode)) + + let selectedAgentSubModeId = AppState.shared.getSelectedAgentSubMode() + if let modes = await SharedChatService.shared.loadConversationModes(), + let currentMode = modes.first(where: { $0.id == selectedAgentSubModeId }) { + await send(.selectedAgentChanged(currentMode)) + } + } } case .refresh: @@ -137,16 +706,178 @@ struct Chat { } case let .sendButtonTapped(id): - guard !state.typedMessage.isEmpty else { return .none } + guard !state.typedMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return .none } let message = state.typedMessage + let skillSet = state.buildSkillSet( + isCurrentEditorContextEnabled: enableCurrentEditorContext + ) state.typedMessage = "" + + let selectedModel = AppState.shared.getSelectedModel() + let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel( + scope: AppState.shared.modelScope() + )?.modelFamily + let agentMode = AppState.shared.isAgentModeEnabled() + let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() + let shouldAttachImages = selectedModel?.supportVision ?? CopilotModelManager.getDefaultChatModel( + scope: AppState.shared.modelScope() + )?.supportVision ?? false + let attachedImages: [ImageReference] = shouldAttachImages ? state.attachedImages : [] + + let references = state.attachedReferences + state.editor.clearAttachedImages() + + let toDeleteMessageIds: [String] = { + var messageIds: [String] = [] + if state.editorMode.isEditingUserMessage { + messageIds.append(contentsOf: state.editUserMessageEffectedMessages.map { $0.id }) + if let editingUserMessageId = state.editorMode.editingUserMessageId { + messageIds.append(editingUserMessageId) + } + } + return messageIds + }() + + return .run { send in + await send(.resetContextProvider) + await send(.discardCheckPoint) + await service.deleteMessages(ids: toDeleteMessageIds) + await send(.setEditorMode(.input)) + + try await service + .send( + id, + content: message, + contentImageReferences: attachedImages, + skillSet: skillSet, + references: references, + model: selectedModelFamily, + modelProviderName: selectedModel?.providerName, + agentMode: agentMode, + customChatModeId: selectedAgentSubMode, + userLanguage: chatResponseLocale + ) + }.cancellable(id: CancelID.sendMessage(self.id)) + + case let .toolCallAccepted(toolCallId): + guard !toolCallId.isEmpty else { return .none } + return .run { _ in + service.updateToolCallStatus(toolCallId: toolCallId, status: .accepted) + }.cancellable(id: CancelID.sendMessage(self.id)) + + case let .toolCallAcceptedWithApproval(toolCallId, approval): + guard !toolCallId.isEmpty else { return .none } + return .run { send in + if let approval { + await ToolAutoApprovalManager.shared.approve(approval) + } + + await send(.toolCallAccepted(toolCallId)) + }.cancellable(id: CancelID.sendMessage(self.id)) + + case let .toolCallCancelled(toolCallId): + guard !toolCallId.isEmpty else { return .none } + return .run { _ in + service.updateToolCallStatus(toolCallId: toolCallId, status: .cancelled) + }.cancellable(id: CancelID.sendMessage(self.id)) + case let .toolCallCompleted(toolCallId, result): + guard !toolCallId.isEmpty else { return .none } return .run { _ in - try await service.send(id, content: message) + service.updateToolCallStatus(toolCallId: toolCallId, status: .completed, payload: result) + }.cancellable(id: CancelID.sendMessage(self.id)) + + case let .followUpButtonClicked(id, message): + guard !message.isEmpty else { return .none } + let skillSet = state.buildSkillSet( + isCurrentEditorContextEnabled: enableCurrentEditorContext + ) + + let selectedModel = AppState.shared.getSelectedModel() + let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel( + scope: AppState.shared.modelScope() + )?.modelFamily + let references = state.attachedReferences + let agentMode = AppState.shared.isAgentModeEnabled() + let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() + + return .run { send in + await send(.resetContextProvider) + await send(.discardCheckPoint) + + try await service + .send( + id, + content: message, + skillSet: skillSet, + references: references, + model: selectedModelFamily, + modelProviderName: selectedModel?.providerName, + agentMode: agentMode, + customChatModeId: selectedAgentSubMode, + userLanguage: chatResponseLocale + ) }.cancellable(id: CancelID.sendMessage(self.id)) + + case let .handOffButtonClicked(handOff): + state.handOffClicked = true + let agent = handOff.agent + let prompt = handOff.prompt + let shouldSend = handOff.send ?? false + + return .run { send in + // Find and switch to the target agent + let modes = await SharedChatService.shared.loadConversationModes() ?? [] + if let targetAgent = modes.first(where: { $0.name.lowercased() == agent.lowercased() }) { + await send(.selectedAgentChanged(targetAgent)) + } + + // If send is true, send the prompt message + if shouldSend && !prompt.isEmpty { + await send(.updateTypedMessage(prompt)) + let id = UUID().uuidString + await send(.sendButtonTapped(id)) + } else if !prompt.isEmpty { + // Just populate the message field + await send(.updateTypedMessage(prompt)) + } + } case .returnButtonTapped: state.typedMessage += "\n" return .none + + case let .updateTypedMessage(message): + state.typedMessage = message + return .none + + case let .setEditorMode(mode): + + switch mode { + case .input: + state.editorMode = mode + // remove all edit contexts except input mode + state.editor.keepOnlyInputContext() + case .editUserMessage(let messageID): + guard let message = state.history.first(where: { $0.id == messageID }), + message.role == .user, + let projectURL = service.getProjectRootURL() + else { + return .none + } + + let chatContext: ChatContext = .from(message, projectURL: projectURL) + state.editor.setContext(chatContext, for: mode) + state.editorMode = mode + let isReceivingMessage = service.isReceivingMessage + + return .run { send in + if isReceivingMessage { + await send(.stopRespondingButtonTapped) + } + } + } + + return .none case .stopRespondingButtonTapped: return .merge( @@ -163,7 +894,7 @@ struct Chat { case let .deleteMessageButtonTapped(id): return .run { _ in - await service.deleteMessage(id: id) + await service.deleteMessages(ids: [id]) } case let .resendMessageButtonTapped(id): @@ -177,7 +908,9 @@ struct Chat { } case let .referenceClicked(reference): - let fileURL = URL(fileURLWithPath: reference.uri) + guard let fileURL = reference.url else { + return .none + } return .run { _ in if FileManager.default.fileExists(atPath: fileURL.path) { let terminal = Terminal() @@ -186,9 +919,11 @@ struct Chat { "/bin/bash", arguments: [ "-c", - "xed -l \(reference.startLine ?? 0) \"\(reference.uri)\"", + "xed -l 0 \"${TARGET_CHAT_FILE}\"", ], - environment: [:] + environment: [ + "TARGET_CHAT_FILE": reference.filePath + ] ) } catch { print(error) @@ -206,6 +941,7 @@ struct Chat { return .run { send in await send(.observeHistoryChange) await send(.observeIsReceivingMessageChange) + await send(.observeFileEditChange) } case .observeHistoryChange: @@ -245,6 +981,25 @@ struct Chat { id: CancelID.observeIsReceivingMessageChange(id), cancelInFlight: true ) + + case .observeFileEditChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = service.$fileEditMap + .sink { _ in + continuation.yield() + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + for await _ in stream { + await send(.fileEditChanged) + } + }.cancellable( + id: CancelID.observeFileEditChange(id), + cancelInFlight: true + ) case .historyChanged: state.history = service.chatHistory.flatMap { message in @@ -253,47 +1008,81 @@ struct Chat { id: message.id, role: { switch message.role { - case .system: return .ignored case .user: return .user case .assistant: return .assistant + case .system: return .ignored } }(), - text: message.summary ?? message.content, + text: message.content, + imageReferences: message.contentImageReferences, references: message.references.map { .init( - title: $0.title, - subtitle: $0.subTitle, uri: $0.uri, - startLine: $0.startLine, - kind: $0.kind + status: $0.status, + kind: $0.kind, + referenceType: $0.referenceType ) - } + }, + followUp: message.followUp, + suggestedTitle: message.suggestedTitle, + errorMessages: message.errorMessages, + steps: message.steps, + editAgentRounds: message.editAgentRounds, + parentTurnId: message.parentTurnId, + panelMessages: message.panelMessages, + codeReviewRound: message.codeReviewRound, + fileEdits: message.fileEdits, + turnStatus: message.turnStatus, + requestType: message.requestType, + modelName: message.modelName, + billingMultiplier: message.billingMultiplier )) return all } - - state.title = { - let defaultTitle = "Chat" - guard let lastMessageText = state.history - .filter({ $0.role == .assistant || $0.role == .user }) - .last? - .text else { return defaultTitle } - if lastMessageText.isEmpty { return defaultTitle } - let trimmed = lastMessageText - .trimmingCharacters(in: .punctuationCharacters) - .trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.starts(with: "```") { - return "Code Block" - } else { - return trimmed - } - }() + return .none case .isReceivingMessageChanged: state.isReceivingMessage = service.isReceivingMessage + state.requestType = service.requestType return .none + + case .fileEditChanged: + state.fileEditMap = service.fileEditMap + let fileEditMap = state.fileEditMap + + let diffViewerController = state.diffViewerController + + return .run { _ in + /// refresh diff view + + guard let diffViewerController, + diffViewerController.diffViewerState == .shown + else { return } + + if fileEditMap.isEmpty { + await diffViewerController.hideWindow() + return + } + + guard let currentFileEdit = diffViewerController.currentFileEdit + else { return } + + if let updatedFileEdit = fileEditMap[currentFileEdit.fileURL] { + if updatedFileEdit != currentFileEdit { + if updatedFileEdit.status == .undone, + updatedFileEdit.toolName == .createFile + { + await diffViewerController.hideWindow() + } else { + await diffViewerController.showDiffWindow(fileEdit: updatedFileEdit) + } + } + } else { + await diffViewerController.hideWindow() + } + } case .binding: return .none @@ -312,6 +1101,340 @@ struct Chat { return .run { _ in await service.copyCode(id) } + + case let .insertCode(code): + ChatInjector().insertCodeBlock(codeBlock: code) + return .none + + // MARK: - Context + case .resetCurrentEditor: + state.currentEditor = nil + return .none + case let .setCurrentEditor(fileReference): + state.currentEditor = fileReference + return .none + case let .addReference(ref): + state.editor.addReference(ref) + return .none + + case let .removeReference(ref): + state.editor.removeReference(ref) + return .none + + // MARK: - Image Context + case let .addSelectedImage(imageReference): + guard !state.attachedImages.contains(imageReference) else { return .none } + state.editor.addImage(imageReference) + return .run { send in await send(.resetContextProvider) } + case let .removeSelectedImage(imageReference): + guard let _ = state.attachedImages.firstIndex(of: imageReference) else { return .none } + state.editor.removeImage(imageReference) + return .run { send in await send(.resetContextProvider) } + + // MARK: - Agent Edits + + case let .undoEdits(fileURLs): + for fileURL in fileURLs { + do { + try service.undoFileEdit(for: fileURL) + } catch { + Logger.service.error("Failed to undo edit, \(error)") + } + } + + return .none + + case let .keepEdits(fileURLs): + for fileURL in fileURLs { + service.keepFileEdit(for: fileURL) + } + + return .none + + case .resetEdits: + service.resetFileEdits() + + return .none + + case let .discardFileEdits(fileURLs): + for fileURL in fileURLs { + try? service.discardFileEdit(for: fileURL) + } + return .none + + case let .openDiffViewWindow(fileURL): + guard let fileEdit = state.fileEditMap[fileURL], + let diffViewerController = state.diffViewerController + else { return .none } + + return .run { _ in + await diffViewerController.showDiffWindow(fileEdit: fileEdit) + } + + case let .setDiffViewerController(chat): + state.diffViewerController = .init(chat: chat) + return .none + + case let .agentModeChanged(isAgentMode): + state.isAgentMode = isAgentMode + return .none + + case let .selectedAgentChanged(mode): + state.selectedAgent = mode + state.handOffClicked = false + return .none + + // MARK: - Code Review + case let .codeReview(.request(group)): + return .run { send in + await send(.discardCheckPoint) + } + + case .codeReview: + return .none + + // MARK: Chat Context + case .reloadNextContext: + guard let context = state.editor.popNextContext() else { + return .none + } + + state.chatContext = context + + return .run { send in + await send(.focusOnTextField) + } + + case .reloadPreviousContext: + guard let projectURL = service.getProjectRootURL(), + let context = state.editor.previousContext( + from: state.history, + projectURL: projectURL) + else { + return .none + } + + let currentContext = state.chatContext + state.chatContext = context + state.editor.pushContext(currentContext) + + return .run { send in + await send(.focusOnTextField) + } + + case .resetContextProvider: + state.editor.resetContextProvider() + return .none + + // MARK: - External action + + case .observeFixErrorNotification: + return .run { send in + let publisher = NotificationCenter.default.publisher(for: .fixEditorErrorIssue) + + for await notification in publisher.values { + guard service.chatTabInfo.isSelected, + let issue = notification.userInfo?["editorErrorIssue"] as? EditorErrorIssue + else { + continue + } + + await send(.fixEditorErrorIssue(issue)) + } + }.cancellable( + id: CancelID.observeFixErrorNotification(id), + cancelInFlight: true) + + case .fixEditorErrorIssue(let issue): + guard issue.workspaceURL == service.getWorkspaceURL(), + !issue.lineAnnotations.isEmpty + else { + return .none + } + + guard !state.isReceivingMessage else { + return .run { _ in + await MainActor.run { + NotificationCenter.default.post( + name: .fixEditorErrorIssueError, + object: nil, + userInfo: ["error": FixEditorErrorIssueFailure.isReceivingMessage(id: issue.id)] + ) + } + } + } + + let errorAnnotationMessage: String = issue.lineAnnotations + .map { "❗\($0.originalAnnotation)" } + .joined(separator: "\n\n") + let message = "Analyze and fix the following error(s): \n\n\(errorAnnotationMessage)" + + let skillSet = state.buildSkillSet(isCurrentEditorContextEnabled: enableCurrentEditorContext) + let references: [ConversationAttachedReference] = [.file(.init(url: issue.fileURL))] + let selectedModel = AppState.shared.getSelectedModel() + let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel( + scope: AppState.shared.modelScope() + )?.modelFamily + let agentMode = AppState.shared.isAgentModeEnabled() + // TODO: if we need to switch to agent mode or keep the current mode + let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() + + return .run { _ in + try await service.send( + UUID().uuidString, + content: message, + skillSet: skillSet, + references: references, + model: selectedModelFamily, + modelProviderName: selectedModel?.providerName, + agentMode: agentMode, + customChatModeId: selectedAgentSubMode, + userLanguage: chatResponseLocale + ) + }.cancellable(id: CancelID.sendMessage(self.id)) + + // MARK: - Check Point + + case let .restoreCheckPoint(messageId): + guard let message = state.history.first(where: { $0.id == messageId }) else { + return .none + } + + if state.pendingCheckpointContext == nil { + state.pendingCheckpointContext = state.chatContext + } + state.pendingCheckpointMessageId = messageId + + // Reload the chat context + let messagesAfterCheckpoint = state.messagesAfterCheckpoint + if !messagesAfterCheckpoint.isEmpty, + let userMessage = messagesAfterCheckpoint.first, + userMessage.role == .user, + let projectURL = service.getProjectRootURL() + { + state.chatContext = .from(userMessage, projectURL: projectURL) + } + + let isReceivingMessage = state.isReceivingMessage + return .run { send in + await send(.restoreFileEdits) + await send(.reloadWorkingset(message)) + if isReceivingMessage { + await send(.stopRespondingButtonTapped) + } + } + + case .restoreFileEdits: + // Revert file edits in messages after checkpoint + let messagesAfterCheckpoint = state.messagesAfterCheckpoint + guard !messagesAfterCheckpoint.isEmpty else { + return .none + } + + return .run { _ in + var restoredURLs = Set() + let fileManager = FileManager.default + + // Revert the file edit. From the oldest to newest + for message in messagesAfterCheckpoint { + let fileEdits = message.fileEdits + guard !fileEdits.isEmpty else { + continue + } + + for fileEdit in fileEdits { + guard !restoredURLs.contains(fileEdit.fileURL) else { + continue + } + restoredURLs.insert(fileEdit.fileURL) + + do { + switch fileEdit.toolName { + case .createFile: + try fileManager.removeItem(at: fileEdit.fileURL) + case .insertEditIntoFile: + try fileEdit.originalContent.write(to: fileEdit.fileURL, atomically: true, encoding: .utf8) + default: + break + } + } catch { + Logger.client.error(">>> Failed to restore file Edit: \(error)") + } + } + } + } + + case .undoCheckPoint: + if let context = state.pendingCheckpointContext { + state.chatContext = context + state.pendingCheckpointContext = nil + } + let reversedMessagesAfterCheckpoint = Array(state.messagesAfterCheckpoint.reversed()) + + state.pendingCheckpointMessageId = nil + + // Redo file edits in messages after checkpoint + guard !reversedMessagesAfterCheckpoint.isEmpty else { + return .none + } + + return .run { send in + var redoedURL = Set() + let lastMessage = reversedMessagesAfterCheckpoint.first + + for message in reversedMessagesAfterCheckpoint { + let fileEdits = message.fileEdits + guard !fileEdits.isEmpty else { + continue + } + + for fileEdit in fileEdits { + guard !redoedURL.contains(fileEdit.fileURL) else { + continue + } + redoedURL.insert(fileEdit.fileURL) + + do { + switch fileEdit.toolName { + case .createFile, .insertEditIntoFile: + try fileEdit.modifiedContent.write(to: fileEdit.fileURL, atomically: true, encoding: .utf8) + default: + break + } + } catch { + Logger.client.error(">>> failed to undo fileEdit: \(error)") + } + } + } + + // Recover fileEdits working set + if let lastMessage { + await send(.reloadWorkingset(lastMessage)) + } + } + + case .discardCheckPoint: + let messagesAfterCheckpoint = state.messagesAfterCheckpoint + state.pendingCheckpointMessageId = nil + state.pendingCheckpointContext = nil + return .run { _ in + if !messagesAfterCheckpoint.isEmpty { + await service.deleteMessages(ids: messagesAfterCheckpoint.map { $0.id }) + } + } + + case let .reloadWorkingset(message): + return .run { _ in + service.resetFileEdits() + for fileEdit in message.fileEdits { + service.updateFileEdits(by: fileEdit) + } + } + + case .openAutoApproveSettings: + return .run { _ in + try launchHostAppToolsSettingsAutoApprove() + } } } } @@ -385,3 +1508,31 @@ private actor TimedDebounceFunction { await block() } } + +public struct EditorErrorIssue: Equatable { + public let lineAnnotations: [EditorInformation.LineAnnotation] + public let fileURL: URL + public let workspaceURL: URL + public let id: String + + public init( + lineAnnotations: [EditorInformation.LineAnnotation], + fileURL: URL, + workspaceURL: URL, + id: String + ) { + self.lineAnnotations = lineAnnotations + self.fileURL = fileURL + self.workspaceURL = workspaceURL + self.id = id + } +} + +public enum FixEditorErrorIssueFailure: Equatable { + case isReceivingMessage(id: String) +} + +public extension Notification.Name { + static let fixEditorErrorIssue = Notification.Name("com.github.CopilotForXcode.fixEditorErrorIssue") + static let fixEditorErrorIssueError = Notification.Name("com.github.CopilotForXcode.fixEditorErrorIssueError") +} diff --git a/Core/Sources/ConversationTab/ChatContextMenu.swift b/Core/Sources/ConversationTab/ChatContextMenu.swift index c47f3c4f..cf1e5f76 100644 --- a/Core/Sources/ConversationTab/ChatContextMenu.swift +++ b/Core/Sources/ConversationTab/ChatContextMenu.swift @@ -14,6 +14,17 @@ struct ChatTabItemView: View { } } +struct ChatConversationItemView: View { + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + Text(chat.title) + .frame(alignment: .leading) + } + } +} + struct ChatContextMenu: View { let store: StoreOf @AppStorage(\.customCommands) var customCommands @@ -68,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 new file mode 100644 index 00000000..bdd12f50 --- /dev/null +++ b/Core/Sources/ConversationTab/ChatDropdownView.swift @@ -0,0 +1,129 @@ +import ConversationServiceProvider +import AppKit +import SwiftUI +import ComposableArchitecture + +protocol DropDownItem: Equatable { + var id: String { get } + var displayName: String { get } + var displayDescription: String { get } +} + +extension ChatTemplate: DropDownItem { + var displayName: String { id } + var displayDescription: String { description } +} + +extension ChatAgent: DropDownItem { + var id: String { slug } + var displayName: String { slug } + var displayDescription: String { description } +} + +struct ChatDropdownView: View { + @Binding var items: [T] + let prefixSymbol: String + let onSelect: (T) -> Void + @State private var selectedIndex = 0 + @State private var frameHeight: CGFloat = 0 + @State private var localMonitor: Any? = nil + + public var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + HStack { + Text(prefixSymbol + item.displayName) + .hoverPrimaryForeground(isHovered: selectedIndex == index) + Spacer() + Text(item.displayDescription) + .hoverSecondaryForeground(isHovered: selectedIndex == index) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .onTapGesture { + onSelect(item) + } + .hoverBackground(isHovered: selectedIndex == index) + .onHover { isHovered in + if isHovered { + selectedIndex = index + } + } + } + } + .background( + GeometryReader { geometry in + Color.clear + .onAppear { frameHeight = geometry.size.height } + .onChange(of: geometry.size.height) { newHeight in + frameHeight = newHeight + } + } + ) + .background(.ultraThickMaterial) + .cornerRadius(6) + .shadow(radius: 2) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .frame(maxWidth: .infinity) + .offset(y: -1 * frameHeight) + .onChange(of: items) { _ in + selectedIndex = 0 + } + .onAppear { + selectedIndex = 0 + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + switch event.keyCode { + case 126: // Up arrow + moveSelection(up: true) + return nil + case 125: // Down arrow + moveSelection(up: false) + return nil + case 36: // Return key + handleEnter() + return nil + case 48: // Tab key + handleTab() + return nil // not forwarding the Tab Event which will replace the typed message to "\t" + default: + break + } + return event + } + } + .onDisappear { + if let monitor = localMonitor { + NSEvent.removeMonitor(monitor) + localMonitor = nil + } + } + } + } + + private func moveSelection(up: Bool) { + guard !items.isEmpty else { return } + let lowerBound = 0 + let upperBound = items.count - 1 + let newIndex = selectedIndex + (up ? -1 : 1) + selectedIndex = newIndex < lowerBound ? upperBound : (newIndex > upperBound ? lowerBound : newIndex) + } + + private func handleEnter() { + handleTemplateSelection() + } + + private func handleTab() { + handleTemplateSelection() + } + + private func handleTemplateSelection() { + if items.count > 0 && selectedIndex < items.count { + onSelect(items[selectedIndex]) + } + } +} diff --git a/Core/Sources/ConversationTab/ChatExtension.swift b/Core/Sources/ConversationTab/ChatExtension.swift new file mode 100644 index 00000000..f5e2573f --- /dev/null +++ b/Core/Sources/ConversationTab/ChatExtension.swift @@ -0,0 +1,26 @@ +import ChatService +import ConversationServiceProvider + +extension Chat.State { + func buildSkillSet(isCurrentEditorContextEnabled: Bool) -> [ConversationSkill] { + guard let currentFile = self.currentEditor, isCurrentEditorContextEnabled else { + return [] + } + let fileReference = ConversationFileReference( + url: currentFile.url, + relativePath: currentFile.relativePath, + fileName: currentFile.fileName, + isCurrentEditor: currentFile.isCurrentEditor, + selection: currentFile.selection + ) + return [CurrentEditorSkill(currentFile: fileReference), ProblemsInActiveDocumentSkill()] + } + + func getChatContext(of mode: Chat.EditorMode) -> ChatContext { + return editor.context(for: mode) + } + + func getSubsequentMessages(after messageId: String) -> [DisplayedChatMessage] { + conversation.subsequentMessages(after: messageId) + } +} diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index e53730e8..f4794206 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -1,29 +1,124 @@ import AppKit import Combine import ComposableArchitecture +import ConversationServiceProvider import MarkdownUI import ChatAPIService import SharedUIComponents import SwiftUI import ChatService - -private let r: Double = 8 +import SwiftUIFlowLayout +import XcodeInspector +import ChatTab +import Workspace +import Persist +import UniformTypeIdentifiers +import Status +import GitHubCopilotService +import GitHubCopilotViewModel +import LanguageServerProtocol + +private let r: Double = 4 public struct ChatPanel: View { - let chat: StoreOf + @Perception.Bindable var chat: StoreOf @Namespace var inputAreaNamespace public var body: some View { - VStack(spacing: 0) { - ChatPanelMessages(chat: chat) - Divider() - ChatPanelInputArea(chat: chat) + WithPerceptionTracking { + VStack(spacing: 0) { + + if chat.history.isEmpty { + VStack { + Spacer() + Instruction(isAgentMode: $chat.isAgentMode) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + ChatPanelMessages(chat: chat) + .accessibilityElement(children: .combine) + .accessibilityLabel("Chat Messages Group") + + if chat.isAgentMode, let handOffs = chat.selectedAgent.handOffs, !handOffs.isEmpty, + chat.history.contains(where: { $0.role == .assistant && $0.turnStatus != .inProgress }), + !chat.handOffClicked { + ChatHandOffs(chat: chat) + .scaledPadding(.vertical, 8) + .scaledPadding(.horizontal, 16) + .dimWithExitEditMode(chat) + } else if let _ = chat.history.last?.followUp { + ChatFollowUp(chat: chat) + .scaledPadding(.vertical, 8) + .scaledPadding(.horizontal, 16) + .dimWithExitEditMode(chat) + } + } + + if chat.fileEditMap.count > 0 { + WorkingSetView(chat: chat) + .dimWithExitEditMode(chat) + .scaledPadding(.horizontal, 24) + } + + ChatPanelInputArea(chat: chat, r: r, editorMode: .input) + .dimWithExitEditMode(chat) + .scaledPadding(.horizontal, 16) + } + .scaledPadding(.vertical, 12) + .background(Color.chatWindowBackgroundColor) + .onAppear { + chat.send(.appear) + } + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + onFileDrop(providers) + } } - .background(Color(nsColor: .windowBackgroundColor)) - .onAppear { chat.send(.appear) } + } + + 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 @@ -53,6 +148,7 @@ struct ChatPanelMessages: View { @State var didScrollToBottomOnAppearOnce = false @State var isBottomHidden = true @Environment(\.isEnabled) var isEnabled + @AppStorage(\.fontScale) private var fontScale: Double var body: some View { WithPerceptionTracking { @@ -60,18 +156,15 @@ struct ChatPanelMessages: View { GeometryReader { listGeo in List { Group { - Spacer(minLength: 12) - .id(topID) - - Instruction(chat: chat) ChatHistory(chat: chat) - .listItemTint(.clear) + .fixedSize(horizontal: false, vertical: true) ExtraSpacingInResponding(chat: chat) Spacer(minLength: 12) .id(bottomID) + .listRowInsets(EdgeInsets()) .onAppear { isBottomHidden = false if !didScrollToBottomOnAppearOnce { @@ -94,13 +187,13 @@ struct ChatPanelMessages: View { if #available(macOS 13.0, *) { view .listRowSeparator(.hidden) - .listSectionSeparator(.hidden) } else { view } } } .listStyle(.plain) + .scaledPadding(.leading, 8) .listRowBackground(EmptyView()) .modify { view in if #available(macOS 13.0, *) { @@ -122,11 +215,9 @@ struct ChatPanelMessages: View { scrollOffset = value updatePinningState() } - .overlay(alignment: .bottom) { - StopRespondingButton(chat: chat) - } .overlay(alignment: .bottomTrailing) { scrollToBottomButton(proxy: proxy) + .scaledPadding(4) } .background { PinToBottomHandler( @@ -173,12 +264,21 @@ struct ChatPanelMessages: View { .store(in: &cancellable) } + private let listRowSpacing: CGFloat = 32 + private let scrollButtonBuffer: CGFloat = 32 + @MainActor func updatePinningState() { // where does the 32 come from? withAnimation(.linear(duration: 0.1)) { - isScrollToBottomButtonDisplayed = scrollOffset > listHeight + 32 + 20 - || scrollOffset <= 0 + // Ensure listHeight is greater than 0 to avoid invalid calculations or division by zero. + // This guard clause prevents unnecessary updates when the list height is not yet determined. + guard listHeight > 0 else { + isScrollToBottomButtonDisplayed = false + return + } + + isScrollToBottomButtonDisplayed = scrollOffset > listHeight + (listRowSpacing + scrollButtonBuffer) * fontScale } } @@ -190,31 +290,33 @@ struct ChatPanelMessages: View { proxy.scrollTo(bottomID, anchor: .bottom) } }) { - Image(systemName: "arrow.down") - .padding(4) + Image(systemName: "chevron.down") + .scaledFrame(width: 12, height: 12) + .scaledPadding(4) .background { Circle() - .fill(.thickMaterial) - .shadow(color: .black.opacity(0.2), radius: 2) + .fill(Color.chatWindowBackgroundColor) } .overlay { Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1) } .foregroundStyle(.secondary) - .padding(4) } + .buttonStyle(.plain) .keyboardShortcut(.downArrow, modifiers: [.command]) .opacity(isScrollToBottomButtonDisplayed ? 1 : 0) - .buttonStyle(.plain) + .help("Scroll Down") } struct ExtraSpacingInResponding: View { let chat: StoreOf + + @AppStorage(\.fontScale) private var fontScale: Double var body: some View { WithPerceptionTracking { if chat.isReceivingMessage { - Spacer(minLength: 12) + Spacer(minLength: 12 * fontScale) } } } @@ -240,6 +342,17 @@ struct ChatPanelMessages: View { scrollToBottom() } } + } else { + Task { + // Scoll to bottom when `isReceiving` changes to `false` + if pinnedToBottom { + await Task.yield() + withAnimation(.easeInOut(duration: 0.1)) { + scrollToBottom() + } + } + pinnedToBottom = false + } } } .onChange(of: chat.history.last) { _ in @@ -249,8 +362,10 @@ struct ChatPanelMessages: View { } Task { await Task.yield() - withAnimation(.easeInOut(duration: 0.1)) { - scrollToBottom() + if !chat.editorMode.isEditingUserMessage { + withAnimation(.easeInOut(duration: 0.1)) { + scrollToBottom() + } } } } @@ -268,12 +383,63 @@ struct ChatPanelMessages: View { struct ChatHistory: View { let chat: StoreOf + + var filteredHistory: [DisplayedChatMessage] { + guard let pendingCheckpointMessageId = chat.pendingCheckpointMessageId else { + return chat.history + } + + if let checkPointMessageIndex = chat.history.firstIndex(where: { $0.id == pendingCheckpointMessageId }) { + return Array(chat.history.prefix(checkPointMessageIndex + 1)) + } + + return chat.history + } + + var editUserMessageEffectedMessageIds: Set { + Set(chat.editUserMessageEffectedMessages.map { $0.id }) + } var body: some View { WithPerceptionTracking { - ForEach(chat.history, id: \.id) { message in - WithPerceptionTracking { - ChatHistoryItem(chat: chat, message: message).id(message.id) + let currentFilteredHistory = filteredHistory + let pendingCheckpointMessageId = chat.pendingCheckpointMessageId + + VStack(spacing: 16) { + ForEach(Array(currentFilteredHistory.enumerated()), id: \.element.id) { index, message in + VStack(spacing: 8) { + WithPerceptionTracking { + ChatHistoryItem(chat: chat, message: message) + .id(message.id) + } + + if message.role != .ignored && index < currentFilteredHistory.count - 1 { + if message.role == .assistant && message.parentTurnId == nil { + let nextMessage = currentFilteredHistory[index + 1] + let hasContent = !message.text.isEmpty || !message.editAgentRounds.isEmpty + let nextIsNotSubturn = nextMessage.parentTurnId != message.id + + if hasContent && nextIsNotSubturn { + CheckPoint(chat: chat, messageId: message.id) + .padding(.vertical, 8) + .padding(.trailing, 8) + } + } + } + + // Show up check point for redo + if message.id == pendingCheckpointMessageId { + CheckPoint(chat: chat, messageId: message.id) + .padding(.vertical, 8) + .padding(.trailing, 8) + } + } + .dimWithExitEditMode( + chat, + applyTo: message.id, + isDimmed: editUserMessageEffectedMessageIds.contains(message.id), + allowTapToExit: chat.editorMode.isEditingUserMessage && chat.editorMode.editingUserMessageId != message.id + ) } } } @@ -289,30 +455,22 @@ struct ChatHistoryItem: View { let text = message.text switch message.role { case .user: - UserMessage(id: message.id, text: text, chat: chat) - .listRowInsets(EdgeInsets( - top: 0, - leading: -8, - bottom: 0, - trailing: -8 - )) - .padding(.vertical, 4) - case .assistant: - BotMessage( + UserMessage( id: message.id, text: text, - references: message.references, + imageReferences: message.imageReferences, + chat: chat, + editorCornerRadius: r, + requestType: message.requestType + ) + .scaledPadding(.leading, chat.editorMode.isEditingUserMessage && chat.editorMode.editingUserMessageId == message.id ? 0 : 20) + .scaledPadding(.trailing, 8) + case .assistant: + BotMessage( + message: message, chat: chat ) - .listRowInsets(EdgeInsets( - top: 0, - leading: -8, - bottom: 0, - trailing: -8 - )) - .padding(.vertical, 4) - case .tool: - FunctionMessage(id: message.id, text: text) + .scaledPadding(.trailing, 20) case .ignored: EmptyView() } @@ -320,164 +478,127 @@ struct ChatHistoryItem: View { } } -private struct StopRespondingButton: View { +struct ChatFollowUp: View { let chat: StoreOf - + @AppStorage(\.chatFontSize) var chatFontSize + var body: some View { WithPerceptionTracking { - if chat.isReceivingMessage { - Button(action: { - chat.send(.stopRespondingButtonTapped) - }) { - HStack(spacing: 4) { - Image(systemName: "stop.fill") - Text("Stop Responding") + HStack { + if let followUp = chat.history.last?.followUp { + Button(action: { + chat.send(.followUpButtonClicked(UUID().uuidString, followUp.message)) + }) { + HStack(spacing: 4) { + Image(systemName: "sparkles") + .scaledFont(.body) + .foregroundColor(.blue) + + Text(followUp.message) + .scaledFont(size: chatFontSize) + .foregroundColor(.blue) + } } - .padding(8) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: r, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: r, style: .continuous) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + .buttonStyle(.plain) + .onHover { isHovered in + DispatchQueue.main.async { + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .onDisappear { + NSCursor.pop() } } - .buttonStyle(.borderless) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.bottom, 8) - .opacity(chat.isReceivingMessage ? 1 : 0) - .disabled(!chat.isReceivingMessage) - .transformEffect(.init( - translationX: 0, - y: chat.isReceivingMessage ? 0 : 20 - )) } + .frame(maxWidth: .infinity, alignment: .leading) } } } -struct ChatPanelInputArea: View { +struct ChatHandOffs: View { let chat: StoreOf - @FocusState var focusedField: Chat.State.Field? + @AppStorage(\.chatFontSize) var chatFontSize var body: some View { - HStack { - clearButton - InputAreaTextEditor(chat: chat, focusedField: $focusedField) - } - .padding(8) - .background(.ultraThickMaterial) - } - - @MainActor - var clearButton: some View { - Button(action: { - chat.send(.clearButtonTap) - }) { - Group { - if #available(macOS 13.0, *) { - Image(systemName: "eraser.line.dashed.fill") - } else { - Image(systemName: "trash.fill") - } - } - .padding(6) - .background { - Circle().fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - } - .buttonStyle(.plain) - } - - struct InputAreaTextEditor: View { - @Perception.Bindable var chat: StoreOf - var focusedField: FocusState.Binding - - var body: some View { - WithPerceptionTracking { - HStack(spacing: 0) { - AutoresizingCustomTextEditor( - text: $chat.typedMessage, - font: .systemFont(ofSize: 14), - isEditable: true, - maxHeight: 400, - onSubmit: { - chat.send(.sendButtonTapped(UUID().uuidString)) - }, - completions: chatAutoCompletion - ) - .focused(focusedField, equals: .textField) - .bind($chat.focusedField, to: focusedField) - .padding(8) - .fixedSize(horizontal: false, vertical: true) + WithPerceptionTracking { + VStack(alignment: .leading) { + Text("PROCEED FROM \(chat.selectedAgent.name.uppercased())") + .foregroundStyle(.secondary) + .scaledPadding(.horizontal, 4) + .scaledPadding(.bottom, -4) + FlowLayout(mode: .vstack, items: chat.selectedAgent.handOffs ?? [], itemSpacing: 4) { item in Button(action: { - chat.send(.sendButtonTapped(UUID().uuidString)) + chat.send(.handOffButtonClicked(item)) }) { - Image(systemName: "paperplane.fill") - .padding(8) + Text(item.label) } - .buttonStyle(.plain) - .disabled(chat.isReceivingMessage) - .keyboardShortcut(KeyEquivalent.return, modifiers: []) - } - .frame(maxWidth: .infinity) - .background { - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - .background { - Button(action: { - chat.send(.returnButtonTapped) - }) { - EmptyView() + .buttonStyle(.bordered) + .onHover { isHovered in + DispatchQueue.main.async { + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } } - .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) - - Button(action: { - focusedField.wrappedValue = .textField - }) { - EmptyView() + .onDisappear { + NSCursor.pop() } - .keyboardShortcut("l", modifiers: [.command]) } } + .frame(maxWidth: .infinity, alignment: .leading) } + } +} - 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 +struct ChatCLSError: View { + let chat: StoreOf + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + WithPerceptionTracking { + HStack(alignment: .top) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.blue) + .padding(.leading, 8) + + Text("Monthly chat limit reached. [Upgrade now](https://github.com/github-copilot/signup/copilot_individual) or wait until your usage resets.") + .font(.system(size: chatFontSize)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + .background( + RoundedCorners(tl: r, tr: r, bl: 0, br: 0) + .fill(.ultraThickMaterial) + ) + .overlay( + RoundedCorners(tl: r, tr: r, bl: 0, br: 0) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .padding(.top, 4) } } } +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 { @@ -486,7 +607,8 @@ struct ChatPanel_Preview: PreviewProvider { id: "1", role: .user, text: "**Hello**", - references: [] + references: [], + requestType: .conversation ), .init( id: "2", @@ -499,43 +621,34 @@ struct ChatPanel_Preview: PreviewProvider { """, references: [ .init( - title: "Hello Hello Hello Hello", - subtitle: "Hi Hi Hi Hi", - uri: "https://google.com", - startLine: nil, - kind: .class + uri: "Hi Hi Hi Hi", + status: .included, + kind: .class, + referenceType: .file ), - ] + ], + requestType: .conversation ), .init( id: "7", role: .ignored, text: "Ignored", - references: [] - ), - .init( - id: "6", - role: .tool, - text: """ - Searching for something... - - abc - - [def](https://1.com) - > hello - > hi - """, - references: [] + references: [], + requestType: .conversation ), .init( id: "5", role: .assistant, text: "Yooo", - references: [] + references: [], + requestType: .conversation ), .init( id: "4", role: .user, text: "Yeeeehh", - references: [] + references: [], + requestType: .conversation ), .init( id: "3", @@ -554,14 +667,18 @@ struct ChatPanel_Preview: PreviewProvider { - (void)bar {} ``` """#, - references: [] + references: [], + followUp: .init(message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.", id: "3", type: "type"), + requestType: .conversation ), ] + + static let chatTabInfo = ChatTabInfo(id: "", workspacePath: "path", username: "name") static var previews: some View { ChatPanel(chat: .init( initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true), - reducer: { Chat(service: ChatService.service()) } + reducer: { Chat(service: ChatService.service(for: chatTabInfo)) } )) .frame(width: 450, height: 1200) .colorScheme(.dark) @@ -572,7 +689,7 @@ struct ChatPanel_EmptyChat_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( initialState: .init(history: [DisplayedChatMessage](), isReceivingMessage: false), - reducer: { Chat(service: ChatService.service()) } + reducer: { Chat(service: ChatService.service(for: ChatPanel_Preview.chatTabInfo)) } )) .padding() .frame(width: 450, height: 600) @@ -584,7 +701,7 @@ struct ChatPanel_InputText_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: false), - reducer: { Chat(service: ChatService.service()) } + reducer: { Chat(service: ChatService.service(for: ChatPanel_Preview.chatTabInfo)) } )) .padding() .frame(width: 450, height: 600) @@ -597,12 +714,12 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider { ChatPanel( chat: .init( initialState: .init( - typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.", - + editorModeContexts: [Chat.EditorMode.input: ChatContext( + typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.")], history: ChatPanel_Preview.history, isReceivingMessage: false ), - reducer: { Chat(service: ChatService.service()) } + reducer: { Chat(service: ChatService.service(for: ChatPanel_Preview.chatTabInfo)) } ) ) .padding() @@ -615,7 +732,7 @@ struct ChatPanel_Light_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true), - reducer: { Chat(service: ChatService.service()) } + reducer: { Chat(service: ChatService.service(for: ChatPanel_Preview.chatTabInfo)) } )) .padding() .frame(width: 450, height: 600) diff --git a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift index cfbde1c2..3cecf903 100644 --- a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift +++ b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift @@ -90,6 +90,9 @@ struct AsyncCodeBlockView: View { Text(content).font(.init(font)) } } + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) .onAppear { storage.highlight(debounce: false, for: self) } diff --git a/Core/Sources/ConversationTab/ContextUtils.swift b/Core/Sources/ConversationTab/ContextUtils.swift new file mode 100644 index 00000000..6a646248 --- /dev/null +++ b/Core/Sources/ConversationTab/ContextUtils.swift @@ -0,0 +1,55 @@ +import ConversationServiceProvider +import XcodeInspector +import Foundation +import Logger +import Workspace +import SystemUtils + +public struct ContextUtils { + + 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 [] + } + + let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: workspaceURL, workspaceRootURL: workspaceRootURL) + + 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 new file mode 100644 index 00000000..4a52af45 --- /dev/null +++ b/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift @@ -0,0 +1,160 @@ +import SwiftUI +import ChatService +import ComposableArchitecture +import WebKit +import ChatAPIService + +enum Style { + /// default diff view frame. Same as the `ChatPanel` + static let diffViewHeight: Double = 560 + static let diffViewWidth: Double = 504 +} + +class DiffViewWindowController: NSObject, NSWindowDelegate { + enum DiffViewerState { + case shown, closed + } + + private var diffWindow: NSWindow? + private var hostingView: NSHostingView? + 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) + + if let window = diffWindow, let _ = hostingView { + window.title = "Diff View" + + let newHostingView = NSHostingView(rootView: newDiffView) + // Ensure the hosting view fills the window + newHostingView.translatesAutoresizingMaskIntoConstraints = false + + self.hostingView = newHostingView + window.contentView = newHostingView + + // Set constraints to fill the window + if let contentView = window.contentView { + newHostingView.frame = contentView.bounds + newHostingView.autoresizingMask = [.width, .height] + } + + window.makeKeyAndOrderFront(nil) + } else { + let newHostingView = NSHostingView(rootView: newDiffView) + newHostingView.translatesAutoresizingMaskIntoConstraints = false + self.hostingView = newHostingView + + let window = NSWindow( + contentRect: getDiffViewFrame(), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + + window.title = "Diff View" + window.contentView = newHostingView + + // Set constraints to fill the window + if let contentView = window.contentView { + newHostingView.frame = contentView.bounds + newHostingView.autoresizingMask = [.width, .height] + } + + window.center() + window.delegate = self + window.isReleasedWhenClosed = false + + self.diffWindow = window + } + + NSApp.activate(ignoringOtherApps: true) + diffWindow?.makeKeyAndOrderFront(nil) + + diffViewerState = .shown + } + + func windowWillClose(_ notification: Notification) { + if let window = notification.object as? NSWindow, window == diffWindow { + DispatchQueue.main.async { + self.diffWindow?.orderOut(nil) + } + } + } + + @MainActor + func hideWindow() { + guard diffViewerState != .closed else { return } + diffWindow?.orderOut(nil) + diffViewerState = .closed + } + + func getDiffViewFrame() -> NSRect { + guard let mainScreen = NSScreen.screens.first(where: { $0.frame.origin == .zero }) + else { + /// default value + return .init(x: 0, y:0, width: Style.diffViewWidth, height: Style.diffViewHeight) + } + + let visibleScreenFrame = mainScreen.visibleFrame + // avoid too wide + let width = min(Style.diffViewWidth, visibleScreenFrame.width * 0.3) + let height = visibleScreenFrame.height + + return CGRect(x: 0, y: 0, width: width, height: height) + } + + func windowDidResize(_ notification: Notification) { + if let window = notification.object as? NSWindow, window == diffWindow { + if let hostingView = self.hostingView, + let webView = findWebView(in: hostingView) { + let script = """ + if (window.DiffViewer && window.DiffViewer.handleResize) { + window.DiffViewer.handleResize(); + } + """ + webView.evaluateJavaScript(script) + } + } + } + + private func findWebView(in view: NSView) -> WKWebView? { + if let webView = view as? WKWebView { + return webView + } + + for subview in view.subviews { + if let webView = findWebView(in: subview) { + return webView + } + } + + return nil + } +} diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift index 440c91f7..2884f332 100644 --- a/Core/Sources/ConversationTab/ConversationTab.swift +++ b/Core/Sources/ConversationTab/ConversationTab.swift @@ -8,9 +8,13 @@ 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 { + public static var name: String { "Chat" } public let service: ChatService @@ -18,6 +22,14 @@ public class ConversationTab: ChatTab { private var cancellable = Set() private var observer = NSObject() private let updateContentDebounce = DebounceRunner(duration: 0.5) + private var isRestored: Bool = false + + // Get chat tab title. As the tab title is always "Chat" and won't be modified. + // Use the chat title as the tab title. + // TODO: modify tab title dynamicly + public func getChatTabTitle() -> String { + return chat.title + } struct RestorableState: Codable { var history: [ChatAPIService.ChatMessage] @@ -45,6 +57,10 @@ public class ConversationTab: ChatTab { public func buildTabItem() -> any View { ChatTabItemView(chat: chat) } + + public func buildChatConversationItem() -> any View { + ChatConversationItemView(chat: chat) + } public func buildIcon() -> any View { WithPerceptionTracking { @@ -93,56 +109,179 @@ public class ConversationTab: ChatTab { return [Builder(title: "New Chat", customCommand: nil)] + customCommands } + // store.state is type of ChatTabInfo + // add the with parameters to avoiding must override the init @MainActor - public init(service: ChatService = ChatService.service(), store: StoreOf) { + public init(store: StoreOf, with chatTabInfo: ChatTabInfo? = nil) { + let info = chatTabInfo ?? store.state + + 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 + self.start() + + // new created tab do not need restore + self.isRestored = true + } + + // for restore + @MainActor + public init(service: ChatService, store: StoreOf, with chatTabInfo: ChatTabInfo) { + self.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) + let tab = ConversationTab(service: service, store: store, with: chatTabInfo) + + // lazy restore converstaion tab for not selected + if chatTabInfo.isSelected { + tab.restoreIfNeeded() + } + + return tab + } + + @MainActor + public func restoreIfNeeded() { + guard self.isRestored == false else { return } + // restore chat history + self.service.restoreIfNeeded() + // start observer + self.start() + + self.isRestored = true } public func start() { observer = .init() cancellable = [] + + chat.send(.setDiffViewerController(chat: chat)) - chatTabStore.send(.updateTitle("Chat")) - - do { - var lastTrigger = -1 - observer.observe { [weak self] in - guard let self else { return } - let trigger = chatTabStore.focusTrigger - guard lastTrigger != trigger else { return } - lastTrigger = trigger - Task { @MainActor [weak self] in - self?.chat.send(.focusOnTextField) - } - } - } +// chatTabStore.send(.updateTitle("Chat")) - do { - var lastTitle = "" - observer.observe { [weak self] in - guard let self else { return } - let title = self.chatTabStore.state.title - guard lastTitle != title else { return } - lastTitle = title - Task { @MainActor [weak self] in - self?.chatTabStore.send(.updateTitle(title)) +// do { +// var lastTrigger = -1 +// observer.observe { [weak self] in +// guard let self else { return } +// let trigger = chatTabStore.focusTrigger +// guard lastTrigger != trigger else { return } +// lastTrigger = trigger +// Task { @MainActor [weak self] in +// self?.chat.send(.focusOnTextField) +// } +// } +// } + +// do { +// var lastTitle = "" +// observer.observe { [weak self] in +// guard let self else { return } +// let title = self.chatTabStore.state.title +// guard lastTitle != title else { return } +// lastTitle = title +// Task { @MainActor [weak self] in +// self?.chatTabStore.send(.updateTitle(title)) +// } +// } +// } + + var lastIsReceivingMessage = false + + observer.observe { [weak self] in + guard let self else { return } +// let history = chat.history +// _ = chat.title +// _ = chat.isReceivingMessage + + // As the observer won't check the state if changed, we need to check it manually. + // Currently, only receciving message is used. If more states are needed, we can add them here. + let currentIsReceivingMessage = chat.isReceivingMessage + + // Only trigger when isReceivingMessage changes + if lastIsReceivingMessage != currentIsReceivingMessage { + lastIsReceivingMessage = currentIsReceivingMessage + Task { + await self.updateContentDebounce.debounce { @MainActor [weak self] in + guard let self else { return } + self.chatTabStore.send(.tabContentUpdated) + + if let suggestedTitle = chat.history.last?.suggestedTitle { + self.chatTabStore.send(.updateTitle(suggestedTitle)) + } + + if let CLSConversationID = self.service.conversationId, + self.chatTabStore.CLSConversationID != CLSConversationID + { + self.chatTabStore.send(.setCLSConversationID(CLSConversationID)) + } + } } } } + } + + 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 + } - observer.observe { [weak self] in - guard let self else { return } - _ = chat.history - _ = chat.title - _ = chat.isReceivingMessage - Task { - await self.updateContentDebounce.debounce { @MainActor [weak self] in - self?.chatTabStore.send(.tabContentUpdated) + 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/DiffViews/DiffView.swift b/Core/Sources/ConversationTab/DiffViews/DiffView.swift new file mode 100644 index 00000000..ee66ec8b --- /dev/null +++ b/Core/Sources/ConversationTab/DiffViews/DiffView.swift @@ -0,0 +1,97 @@ +import SwiftUI +import WebKit +import ComposableArchitecture +import Logger +import ConversationServiceProvider +import ChatService +import ChatTab +import ChatAPIService + +extension FileEdit { + var originalContentByStatus: String { + return status == .kept ? modifiedContent : originalContent + } + + var modifiedContentByStatus: String { + return status == .undone ? originalContent : modifiedContent + } +} + +struct DiffView: View { + @Perception.Bindable var chat: StoreOf + @State public var fileEdit: FileEdit + + var body: some View { + WithPerceptionTracking { + DiffWebView( + chat: chat, + fileEdit: fileEdit + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .edgesIgnoringSafeArea(.all) + } + } +} + +// preview +struct DiffView_Previews: PreviewProvider { + static var oldText = """ + import Foundation + + func calculateTotal(items: [Double]) -> Double { + var sum = 0.0 + for item in items { + sum += item + } + return sum + } + + func main() { + let prices = [10.5, 20.0, 15.75] + let total = calculateTotal(items: prices) + print("Total: \\(total)") + } + + main() + """ + + static var newText = """ + import Foundation + + func calculateTotal(items: [Double], applyDiscount: Bool = false) -> Double { + var sum = 0.0 + for item in items { + sum += item + } + + // Apply 10% discount if requested + if applyDiscount { + sum *= 0.9 + } + + return sum + } + + func main() { + let prices = [10.5, 20.0, 15.75, 5.0] + let total = calculateTotal(items: prices) + let discountedTotal = calculateTotal(items: prices, applyDiscount: true) + + print("Total: \\(total)") + print("With discount: \\(discountedTotal)") + } + + main() + """ + static let chatTabInfo = ChatTabInfo(id: "", workspacePath: "path", username: "name") + static var previews: some View { + DiffView( + chat: .init( + initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true), + reducer: { Chat(service: ChatService.service(for: chatTabInfo)) } + ), + fileEdit: .init(fileURL: URL(fileURLWithPath: "file:///f1.swift"), originalContent: "test", modifiedContent: "abc", toolName: ToolName.insertEditIntoFile) + ) + .frame(width: 800, height: 600) + } +} diff --git a/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift b/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift new file mode 100644 index 00000000..36c952a5 --- /dev/null +++ b/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift @@ -0,0 +1,185 @@ +import ComposableArchitecture +import ChatService +import SwiftUI +import WebKit +import Logger +import ChatAPIService + +struct DiffWebView: NSViewRepresentable { + @Perception.Bindable var chat: StoreOf + var fileEdit: FileEdit + + init(chat: StoreOf, fileEdit: FileEdit) { + self.chat = chat + self.fileEdit = fileEdit + } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + let userContentController = WKUserContentController() + + #if DEBUG + let scriptSource = """ + function captureLog(msg) { window.webkit.messageHandlers.logging.postMessage(Array.prototype.slice.call(arguments)); } + console.log = captureLog; + console.error = captureLog; + console.warn = captureLog; + console.info = captureLog; + """ + let script = WKUserScript(source: scriptSource, injectionTime: .atDocumentStart, forMainFrameOnly: true) + userContentController.addUserScript(script) + userContentController.add(context.coordinator, name: "logging") + #endif + + userContentController.add(context.coordinator, name: "swiftHandler") + configuration.userContentController = userContentController + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + #if DEBUG + webView.configuration.preferences.setValue(true, forKey: "developerExtrasEnabled") + #endif + + // Configure WebView + webView.wantsLayer = true + webView.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + webView.layer?.borderWidth = 1 + + // Make the webview auto-resize with its container + webView.autoresizingMask = [.width, .height] + webView.translatesAutoresizingMaskIntoConstraints = true + + // Notify the webview of resize events explicitly + let resizeNotificationScript = WKUserScript( + source: """ + window.addEventListener('resize', function() { + if (window.DiffViewer && window.DiffViewer.handleResize) { + window.DiffViewer.handleResize(); + } + }); + """, + injectionTime: .atDocumentEnd, + forMainFrameOnly: true + ) + webView.configuration.userContentController.addUserScript(resizeNotificationScript) + + /// Load web asset resources + let bundleBaseURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/webViewDist/diffView") + let htmlFileURL = bundleBaseURL.appendingPathComponent("diffView.html") + webView.loadFileURL(htmlFileURL, allowingReadAccessTo: bundleBaseURL) + + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + if context.coordinator.shouldUpdate(fileEdit) { + // Update content via JavaScript API + let script = """ + if (typeof window.DiffViewer !== 'undefined') { + window.DiffViewer.update( + `\(escapeJSString(fileEdit.originalContentByStatus))`, + `\(escapeJSString(fileEdit.modifiedContentByStatus))`, + `\(escapeJSString(fileEdit.fileURL.absoluteString))`, + `\(fileEdit.status.rawValue)` + ); + } else { + console.error("DiffViewer is not defined in update"); + } + """ + webView.evaluateJavaScript(script) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + var parent: DiffWebView + private var fileEdit: FileEdit + + init(_ parent: DiffWebView) { + self.parent = parent + self.fileEdit = parent.fileEdit + } + + func shouldUpdate(_ fileEdit: FileEdit) -> Bool { + let shouldUpdate = self.fileEdit != fileEdit + + if shouldUpdate { + self.fileEdit = fileEdit + } + + return shouldUpdate + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + #if DEBUG + if message.name == "logging" { + if let logs = message.body as? [Any] { + let logString = logs.map { "\($0)" }.joined(separator: " ") + Logger.client.info("WebView console: \(logString)") + } + return + } + #endif + + guard message.name == "swiftHandler", + let body = message.body as? [String: Any], + let event = body["event"] as? String, + let data = body["data"] as? [String: String], + let filePath = data["filePath"], + let fileURL = URL(string: filePath) + else { return } + + switch event { + case "undoButtonClicked": + self.parent.chat.send(.undoEdits(fileURLs: [fileURL])) + case "keepButtonClicked": + self.parent.chat.send(.keepEdits(fileURLs: [fileURL])) + default: + break + } + } + + // Initialize content when the page has finished loading + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + let script = """ + if (typeof window.DiffViewer !== 'undefined') { + window.DiffViewer.init( + `\(escapeJSString(fileEdit.originalContentByStatus))`, + `\(escapeJSString(fileEdit.modifiedContentByStatus))`, + `\(escapeJSString(fileEdit.fileURL.absoluteString))`, + `\(fileEdit.status.rawValue)` + ); + } else { + console.error("DiffViewer is not defined on page load"); + } + """ + webView.evaluateJavaScript(script) { result, error in + if let error = error { + Logger.client.error("Error evaluating JavaScript: \(error)") + } + } + } + + // Handle navigation errors + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + Logger.client.error("WebView navigation failed: \(error)") + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + Logger.client.error("WebView provisional navigation failed: \(error)") + } + } +} + +func escapeJSString(_ string: String) -> String { + return string + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "`", with: "\\`") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "$", with: "\\$") +} diff --git a/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift new file mode 100644 index 00000000..d55acc6d --- /dev/null +++ b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift @@ -0,0 +1,92 @@ +import ComposableArchitecture +import ChatService +import Foundation +import ConversationServiceProvider +import GitHelper +import LanguageServerProtocol +import Terminal +import Combine + +@MainActor +public class CodeReviewStateService: ObservableObject { + public static let shared = CodeReviewStateService() + + public let fileClickedEvent = PassthroughSubject() + + private init() { } + + func notifyFileClicked() { + fileClickedEvent.send() + } +} + +@Reducer +public struct ConversationCodeReviewFeature { + @ObservableState + public struct State: Equatable { + + public init() { } + } + + public enum Action: Equatable { + case request(GitDiffGroup) + case accept(id: String, selectedFiles: [DocumentUri]) + case cancel(id: String) + + case onFileClicked(URL, Int) + } + + public let service: ChatService + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .request(let group): + + return .run { _ in + try await service.requestCodeReview(group) + } + + case let .accept(id, selectedFileUris): + + return .run { _ in + await service.acceptCodeReview(id, selectedFileUris: selectedFileUris) + } + + case .cancel(let id): + + return .run { _ in + await service.cancelCodeReview(id) + } + + // lineNumber: 0-based + case .onFileClicked(let fileURL, let lineNumber): + + return .run { _ in + if FileManager.default.fileExists(atPath: fileURL.path) { + let terminal = Terminal() + do { + _ = try await terminal.runCommand( + "/bin/bash", + arguments: [ + "-c", + "xed -l \(lineNumber+1) \"${TARGET_REVIEW_FILE}\"" + ], + environment: [ + "TARGET_REVIEW_FILE": fileURL.path + ] + ) + } catch { + print(error) + } + } + + Task { @MainActor in + CodeReviewStateService.shared.notifyFileClicked() + } + } + + } + } + } +} diff --git a/Core/Sources/ConversationTab/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift new file mode 100644 index 00000000..284c52ec --- /dev/null +++ b/Core/Sources/ConversationTab/FilePicker.swift @@ -0,0 +1,242 @@ +import ComposableArchitecture +import ConversationServiceProvider +import SharedUIComponents +import SwiftUI +import SystemUtils + +public struct FilePicker: View { + @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 + @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 defaultReferencesForDisplay + } + + 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) + } + } + } + + public var body: some View { + WithPerceptionTracking { + VStack(spacing: 8) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField("Search files...", text: $searchText) + .scaledFont(.body) + .textFieldStyle(PlainTextFieldStyle()) + .foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor)) + .focused($isSearchBarFocused) + .onChange(of: searchText) { newValue in + selectedId = 0 + } + .onAppear() { + isSearchBarFocused = true + } + + Button(action: { + withAnimation { + onExit() + } + }) { + Image(systemName: "xmark.circle.fill") + .scaledFont(.body) + .foregroundColor(.secondary) + } + .buttonStyle(HoverButtonStyle()) + .help("Close") + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.1)) + ) + .cornerRadius(6) + .padding(.horizontal, 4) + .padding(.top, 4) + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + 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(filteredReferences?.hashValue) + } + .frame(maxHeight: 200) + .padding(.horizontal, 4) + .padding(.bottom, 4) + .onAppear { + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + if !isSearchBarFocused { // if file search bar is not focused, ignore the event + return event + } + + switch event.keyCode { + case 126: // Up arrow + moveSelection(up: true, proxy: proxy) + return nil + case 125: // Down arrow + moveSelection(up: false, proxy: proxy) + return nil + case 36: // Return key + handleEnter() + return nil + case 53: // Esc key + withAnimation { + onExit() + } + return nil + default: + break + } + return event + } + } + .onDisappear { + if let monitor = localMonitor { + NSEvent.removeMonitor(monitor) + localMonitor = nil + } + } + } + } + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + } + + private func moveSelection(up: Bool, proxy: ScrollViewProxy) { + guard let refs = filteredReferences, !refs.isEmpty else { return } + let nextId = selectedId + (up ? -1 : 1) + selectedId = max(0, min(nextId, refs.count - 1)) + proxy.scrollTo(selectedId, anchor: .bottom) + } + + private func handleEnter() { + guard let refs = filteredReferences, !refs.isEmpty && selectedId < refs.count else { + return + } + + onSubmit(refs[selectedId]) + } +} + +struct FileRowView: View { + @State private var isHovered = false + let ref: ConversationAttachedReference + let id: Int + @Binding var selectedId: Int + + var body: some View { + WithPerceptionTracking { + HStack(alignment: .center) { + drawFileIcon(ref.url, isDirectory: ref.isDirectory) + .scaledToFit() + .scaledFrame(width: 16, height: 16) + .hoverSecondaryForeground(isHovered: selectedId == id) + .padding(.leading, 4) + + HStack(spacing: 4) { + Text(ref.displayName) + .scaledFont(.body) + .hoverPrimaryForeground(isHovered: selectedId == id) + .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() + } + .padding(.vertical, 4) + .hoverRadiusBackground(isHovered: isHovered || selectedId == id, + hoverColor: (selectedId == id ? nil : Color.gray.opacity(0.1)), + cornerRadius: 6) + .onHover(perform: { hovering in + isHovered = hovering + }) + .help(ref.url.path) + } + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift new file mode 100644 index 00000000..ec1abafb --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift @@ -0,0 +1,491 @@ +import SwiftUI +import ChatService +import Persist +import ComposableArchitecture +import GitHubCopilotService +import Combine +import HostAppActivator +import SharedUIComponents +import ConversationServiceProvider + +struct ModeAndModelPicker: View { + let projectRootURL: URL? + @Binding var selectedAgent: ConversationMode + + @State private var selectedModel: LLMModel? + @State private var isHovered = false + @State private var isPressed = false + @ObservedObject private var modelManager = CopilotModelManagerObservable.shared + static var lastRefreshModelsTime: Date = .init(timeIntervalSince1970: 0) + + @State private var chatMode = "Ask" + @State private var isAgentPickerHovered = false + + // Separate caches for both scopes + @State private var askScopeCache: ScopeCache = ScopeCache() + @State private var agentScopeCache: ScopeCache = ScopeCache() + + @State var isMCPFFEnabled: Bool + @State var isBYOKFFEnabled: Bool + @State var isEditorPreviewEnabled: Bool + @State private var cancellables = Set() + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + let attributes: [NSAttributedString.Key: NSFont] = ModelMenuItemFormatter.attributes + + init(projectRootURL: URL?, selectedAgent: Binding) { + self.projectRootURL = projectRootURL + self._selectedAgent = selectedAgent + let initialModel = AppState.shared.getSelectedModel() ?? + CopilotModelManager.getDefaultChatModel() + self._selectedModel = State(initialValue: initialModel) + self.isMCPFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.mcp + self.isBYOKFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.byok + self.isEditorPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures + updateAgentPicker() + } + + private func subscribeToFeatureFlagsDidChangeEvent() { + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in + isMCPFFEnabled = featureFlags.mcp + isBYOKFFEnabled = featureFlags.byok + isEditorPreviewEnabled = featureFlags.editorPreviewFeatures + }) + .store(in: &cancellables) + } + + var copilotModels: [LLMModel] { + AppState.shared.isAgentModeEnabled() ? + modelManager.availableAgentModels : modelManager.availableChatModels + } + + var byokModels: [LLMModel] { + AppState.shared.isAgentModeEnabled() ? + modelManager.availableAgentBYOKModels : modelManager.availableChatBYOKModels + } + + var defaultModel: LLMModel? { + AppState.shared.isAgentModeEnabled() ? modelManager.defaultAgentModel : modelManager.defaultChatModel + } + + // Get the current cache based on scope + var currentCache: ScopeCache { + AppState.shared.isAgentModeEnabled() ? agentScopeCache : askScopeCache + } + + // Helper method to format multiplier text + func formatMultiplierText(for billing: CopilotModelBilling?) -> String { + guard let billingInfo = billing else { return "" } + + let multiplier = billingInfo.multiplier + if multiplier == 0 { + return "Included" + } else { + let numberPart = multiplier.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", multiplier) + : String(format: "%.2f", multiplier) + return "\(numberPart)x" + } + } + + // Update cache for specific scope only if models changed + func updateModelCacheIfNeeded(for scope: PromptTemplateScope) { + let currentModels = scope == .agentPanel ? + modelManager.availableAgentModels + modelManager.availableAgentBYOKModels : + modelManager.availableChatModels + modelManager.availableChatBYOKModels + let modelsHash = currentModels.hashValue + + if scope == .agentPanel { + guard agentScopeCache.lastModelsHash != modelsHash else { return } + agentScopeCache = buildCache(for: currentModels, currentHash: modelsHash) + } else { + guard askScopeCache.lastModelsHash != modelsHash else { return } + askScopeCache = buildCache(for: currentModels, currentHash: modelsHash) + } + } + + // Build cache for given models + private func buildCache(for models: [LLMModel], currentHash: Int) -> ScopeCache { + var newCache: [String: String] = [:] + var maxWidth: CGFloat = 0 + + for model in models { + let multiplierText = ModelMenuItemFormatter.getMultiplierText(for: model) + newCache[model.id.appending(model.providerName ?? "")] = multiplierText + + let displayName = "✓ \(model.displayName ?? model.modelName)" + let displayNameWidth = displayName.size(withAttributes: attributes).width + let multiplierWidth = multiplierText.isEmpty ? 0 : multiplierText.size(withAttributes: attributes).width + let totalWidth = displayNameWidth + ModelMenuItemFormatter.minimumPaddingWidth + multiplierWidth + maxWidth = max(maxWidth, totalWidth) + } + + if maxWidth == 0, let selectedModel = selectedModel { + maxWidth = (selectedModel.displayName ?? selectedModel.modelName).size(withAttributes: attributes).width + } + + return ScopeCache( + modelMultiplierCache: newCache, + cachedMaxWidth: maxWidth, + lastModelsHash: currentHash + ) + } + + func updateCurrentModel() { + let currentModel = AppState.shared.getSelectedModel() + var allAvailableModels = copilotModels + if isBYOKFFEnabled { + allAvailableModels += byokModels + } + + // If editor preview is disabled and current model is auto, switch away from it + if !isEditorPreviewEnabled && currentModel?.isAutoModel == true { + // Try default model first + if let defaultModel = defaultModel, !defaultModel.isAutoModel { + AppState.shared.setSelectedModel(defaultModel) + selectedModel = defaultModel + return + } + // If default is also auto, use first non-auto available model + if let firstNonAuto = allAvailableModels.first(where: { !$0.isAutoModel }) { + AppState.shared.setSelectedModel(firstNonAuto) + selectedModel = firstNonAuto + return + } + } + + // Check if current model exists in available models for current scope using model comparison + let modelExists = allAvailableModels.contains { model in + model == currentModel + } + + if !modelExists && currentModel != nil { + // Switch to default model if current model is not available + if let fallbackModel = defaultModel { + AppState.shared.setSelectedModel(fallbackModel) + selectedModel = fallbackModel + } else if let firstAvailable = allAvailableModels.first { + // If no default model, use first available + AppState.shared.setSelectedModel(firstAvailable) + selectedModel = firstAvailable + } else { + selectedModel = nil + } + } else { + selectedModel = currentModel ?? defaultModel + } + } + + func updateAgentPicker() { + self.chatMode = AppState.shared.getSelectedChatMode() + } + + func switchModelsForScope(_ scope: PromptTemplateScope, model: String?) { + let newModeModels = CopilotModelManager.getAvailableChatLLMs( + scope: scope + ) + BYOKModelManager.getAvailableChatLLMs(scope: scope) + + // If a model string is provided, try to parse and find it + if let modelString = model { + if let parsedModel = parseModelString(modelString, from: newModeModels) { + // Model exists in the scope, set it + AppState.shared.setSelectedModel(parsedModel) + self.updateCurrentModel() + updateModelCacheIfNeeded(for: scope) + return + } + // If model doesn't exist in scope, fall through to default behavior + } + + if let currentModel = AppState.shared.getSelectedModel() { + if !newModeModels.isEmpty && !newModeModels.contains(where: { $0 == currentModel }) { + let defaultModel = CopilotModelManager.getDefaultChatModel(scope: scope) + if let defaultModel = defaultModel { + AppState.shared.setSelectedModel(defaultModel) + } else { + AppState.shared.setSelectedModel(newModeModels[0]) + } + } + } + + self.updateCurrentModel() + updateModelCacheIfNeeded(for: scope) + } + + // Parse model string in format "{Model DisplayName} ({providerName or copilot})" + // If no parentheses, defaults to Copilot model + private func parseModelString(_ modelString: String, from availableModels: [LLMModel]) -> LLMModel? { + var displayName: String + var isCopilotModel: Bool + var provider: String = "" + + // Extract display name and provider from the format: "DisplayName (provider)" + if let openParenIndex = modelString.lastIndex(of: "("), + let closeParenIndex = modelString.lastIndex(of: ")"), + openParenIndex < closeParenIndex { + + let displayNameEndIndex = modelString.index(before: openParenIndex) + displayName = String(modelString[.. some View { + if !models.isEmpty { + Section(title) { + ForEach(models, id: \.self) { model in + modelButton(for: model) + } + } + } + } + + // Helper function to create a model selection button + private func modelButton(for model: LLMModel) -> some View { + Button { + AppState.shared.setSelectedModel(model) + } label: { + Text(createModelMenuItemAttributedString( + modelName: model.displayName ?? model.modelName, + isSelected: selectedModel == model, + cachedMultiplierText: currentCache.modelMultiplierCache[model.id.appending(model.providerName ?? "")] ?? "" + )) + } + .help( + model.isAutoModel + ? "Auto selects the best model for your request based on capacity and performance." + : model.displayName ?? model.modelName) + } + + private var mcpButton: some View { + Group { + if isMCPFFEnabled { + Button(action: { + let currentSubMode = AppState.shared.getSelectedAgentSubMode() + try? launchHostAppToolsSettings(currentAgentSubMode: currentSubMode) + }) { + mcpIcon.foregroundColor(.primary.opacity(0.85)) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .help("Configure your MCP server") + } else { + // Non-interactive view that looks like a button but only shows tooltip + mcpIcon.foregroundColor(Color(nsColor: .tertiaryLabelColor)) + .padding(0) + .help("MCP servers are disabled by org policy. Contact your admin.") + } + } + .cornerRadius(6) + } + + private var mcpIcon: some View { + Image(systemName: "wrench.and.screwdriver") + .resizable() + .scaledToFit() + .scaledFrame(width: 16, height: 16) + .padding(4) + .font(Font.system(size: 11, weight: .semibold)) + } + + // Main view body + var body: some View { + WithPerceptionTracking { + HStack(spacing: 0) { + // Custom segmented control with color change + ChatModePicker( + projectRootURL: projectRootURL, + chatMode: $chatMode, + selectedAgent: $selectedAgent, + onScopeChange: switchModelsForScope + ) + .onAppear { + updateAgentPicker() + } + .onReceive( + NotificationCenter.default.publisher(for: .gitHubCopilotChatModeDidChange)) { _ in + updateAgentPicker() + } + + if chatMode == "Agent" { + mcpButton + } + + // Model Picker + Group { + if !copilotModels.isEmpty && selectedModel != nil { + modelPickerMenu + } else { + EmptyView() + } + } + } + .onAppear() { + updateCurrentModel() + // Initialize both caches + updateModelCacheIfNeeded(for: .chatPanel) + updateModelCacheIfNeeded(for: .agentPanel) + Task { + await refreshModels() + } + } + .onChange(of: defaultModel) { _ in + updateCurrentModel() + } + .onChange(of: modelManager.availableChatModels) { _ in + updateCurrentModel() + updateModelCacheIfNeeded(for: .chatPanel) + } + .onChange(of: modelManager.availableAgentModels) { _ in + updateCurrentModel() + updateModelCacheIfNeeded(for: .agentPanel) + } + .onChange(of: modelManager.availableChatBYOKModels) { _ in + updateCurrentModel() + updateModelCacheIfNeeded(for: .chatPanel) + } + .onChange(of: modelManager.availableAgentBYOKModels) { _ in + updateCurrentModel() + updateModelCacheIfNeeded(for: .agentPanel) + } + .onChange(of: chatMode) { _ in + updateCurrentModel() + } + .onChange(of: isBYOKFFEnabled) { _ in + updateCurrentModel() + } + .onChange(of: isEditorPreviewEnabled) { _ in + updateCurrentModel() + } + .onReceive(NotificationCenter.default.publisher(for: .gitHubCopilotSelectedModelDidChange)) { _ in + updateCurrentModel() + } + .task { + subscribeToFeatureFlagsDidChangeEvent() + } + } + } + + func labelWidth() -> CGFloat { + guard let selectedModel = selectedModel else { return 100 } + let displayName = selectedModel.displayName ?? selectedModel.modelName + let width = displayName.size( + withAttributes: attributes + ).width + return CGFloat(width * fontScale + 20) + } + + @MainActor + func refreshModels() async { + let now = Date() + if now.timeIntervalSince(Self.lastRefreshModelsTime) < 60 { + return + } + + Self.lastRefreshModelsTime = now + let copilotModels = await SharedChatService.shared.copilotModels() + if !copilotModels.isEmpty { + CopilotModelManager.updateLLMs(copilotModels) + } + } + + private func createModelMenuItemAttributedString( + modelName: String, + isSelected: Bool, + cachedMultiplierText: String + ) -> AttributedString { + return ModelMenuItemFormatter.createModelMenuItemAttributedString( + modelName: modelName, + isSelected: isSelected, + multiplierText: cachedMultiplierText, + targetWidth: currentCache.cachedMaxWidth + ) + } +} + +struct ModelPicker_Previews: PreviewProvider { + @State static var agent: ConversationMode = .defaultAgent + + static var previews: some View { + ModeAndModelPicker(projectRootURL: nil, selectedAgent: $agent) + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButton.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButton.swift new file mode 100644 index 00000000..683f8091 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButton.swift @@ -0,0 +1,372 @@ +import AppKit +import ConversationServiceProvider +import Persist +import SharedUIComponents +import SwiftUI + +// MARK: - Custom NSButton that accepts clicks anywhere within its bounds +class ClickThroughButton: NSButton { + override func hitTest(_ point: NSPoint) -> NSView? { + // If the point is within our bounds, return self (the button) + // This ensures clicks on subviews are handled by the button + if self.bounds.contains(point) { + return self + } + return super.hitTest(point) + } +} + +// MARK: - Agent Mode Button + +struct AgentModeButton: NSViewRepresentable { + @StateObject private var fontScaleManager = FontScaleManager.shared + + private var fontScale: Double { + fontScaleManager.currentScale + } + + let title: String + let isSelected: Bool + let activeBackground: Color + let activeTextColor: Color + let inactiveTextColor: Color + let chatMode: String + let builtInAgentModes: [ConversationMode] + let customAgents: [ConversationMode] + let selectedAgent: ConversationMode + let selectedIconName: String? + let isCustomAgentEnabled: Bool + let onSelectAgent: (ConversationMode) -> Void + let onEditAgent: (ConversationMode) -> Void + let onDeleteAgent: (ConversationMode) -> Void + let onCreateAgent: () -> Void + + func makeNSView(context: Context) -> NSView { + let containerView = NSView() + containerView.translatesAutoresizingMaskIntoConstraints = false + + let button = ClickThroughButton() + button.title = "" + button.bezelStyle = .inline + button.setButtonType(.momentaryPushIn) + button.isBordered = false + button.target = context.coordinator + button.action = #selector(Coordinator.buttonClicked(_:)) + button.translatesAutoresizingMaskIntoConstraints = false + + // Create icon for agent mode + let iconImageView = NSImageView() + iconImageView.translatesAutoresizingMaskIntoConstraints = false + iconImageView.imageScaling = .scaleProportionallyDown + + // Create chevron icon + let chevronView = NSImageView() + let chevronImage = NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil) + let symbolConfig = NSImage.SymbolConfiguration(pointSize: 9 * fontScale, weight: .bold) + chevronView.image = chevronImage?.withSymbolConfiguration(symbolConfig) + chevronView.translatesAutoresizingMaskIntoConstraints = false + chevronView.isHidden = !isCustomAgentEnabled + + // Create title label + let titleLabel = NSTextField(labelWithString: title) + titleLabel.font = NSFont.systemFont(ofSize: 12 * fontScale) + titleLabel.isEditable = false + titleLabel.isBordered = false + titleLabel.backgroundColor = .clear + titleLabel.drawsBackground = false + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLabel.alignment = .center + titleLabel.usesSingleLineMode = true + titleLabel.lineBreakMode = .byClipping + + // Create horizontal stack with icon, title, and chevron + let stackView = NSStackView(views: [iconImageView, titleLabel, chevronView]) + stackView.orientation = .horizontal + stackView.spacing = 0 + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.alignment = .centerY + stackView.setHuggingPriority(.required, for: .horizontal) + stackView.setContentCompressionResistancePriority(.required, for: .horizontal) + + // Set custom spacing between title and chevron + stackView.setCustomSpacing(3 * fontScale, after: titleLabel) + + button.addSubview(stackView) + containerView.addSubview(button) + + let stackLeadingConstraint = stackView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 6 * fontScale) + let stackTrailingConstraint = stackView.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -6 * fontScale) + let stackTopConstraint = stackView.topAnchor.constraint(equalTo: button.topAnchor, constant: 2 * fontScale) + let stackBottomConstraint = stackView.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -2 * fontScale) + let iconWidthConstraint = iconImageView.widthAnchor.constraint(equalToConstant: 16 * fontScale) + let iconHeightConstraint = iconImageView.heightAnchor.constraint(equalToConstant: 16 * fontScale) + let chevronWidthConstraint = chevronView.widthAnchor.constraint(equalToConstant: 9 * fontScale) + let chevronHeightConstraint = chevronView.heightAnchor.constraint(equalToConstant: 9 * fontScale) + + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + button.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + button.topAnchor.constraint(equalTo: containerView.topAnchor), + button.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + + stackLeadingConstraint, + stackTrailingConstraint, + stackTopConstraint, + stackBottomConstraint, + + iconWidthConstraint, + iconHeightConstraint, + + chevronWidthConstraint, + chevronHeightConstraint, + ]) + + context.coordinator.button = button + context.coordinator.titleLabel = titleLabel + context.coordinator.iconImageView = iconImageView + context.coordinator.chevronView = chevronView + context.coordinator.stackView = stackView + context.coordinator.stackLeadingConstraint = stackLeadingConstraint + context.coordinator.stackTrailingConstraint = stackTrailingConstraint + context.coordinator.stackTopConstraint = stackTopConstraint + context.coordinator.stackBottomConstraint = stackBottomConstraint + context.coordinator.iconWidthConstraint = iconWidthConstraint + context.coordinator.iconHeightConstraint = iconHeightConstraint + context.coordinator.chevronWidthConstraint = chevronWidthConstraint + context.coordinator.chevronHeightConstraint = chevronHeightConstraint + + return containerView + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let button = context.coordinator.button, + let titleLabel = context.coordinator.titleLabel, + let iconImageView = context.coordinator.iconImageView, + let chevronView = context.coordinator.chevronView, + let stackView = context.coordinator.stackView else { return } + + titleLabel.stringValue = title + titleLabel.font = NSFont.systemFont(ofSize: 12 * fontScale) + context.coordinator.chatMode = chatMode + context.coordinator.builtInAgentModes = builtInAgentModes + context.coordinator.customAgents = customAgents + context.coordinator.selectedAgent = selectedAgent + context.coordinator.isSelected = isSelected + context.coordinator.isCustomAgentEnabled = isCustomAgentEnabled + context.coordinator.fontScale = fontScale + + // Update constraints for scaling + context.coordinator.stackLeadingConstraint?.constant = 6 * fontScale + context.coordinator.stackTrailingConstraint?.constant = -6 * fontScale + context.coordinator.stackTopConstraint?.constant = 2 * fontScale + context.coordinator.stackBottomConstraint?.constant = -2 * fontScale + context.coordinator.iconWidthConstraint?.constant = 16 * fontScale + context.coordinator.iconHeightConstraint?.constant = 16 * fontScale + context.coordinator.chevronWidthConstraint?.constant = 9 * fontScale + context.coordinator.chevronHeightConstraint?.constant = 9 * fontScale + stackView.spacing = 0 + + // Update custom spacing between title and chevron + stackView.setCustomSpacing(3 * fontScale, after: titleLabel) + + // Update chevron visibility based on feature flag and policy + chevronView.isHidden = !isCustomAgentEnabled + + // Update icon based on selected agent mode + if let iconName = selectedIconName { + iconImageView.isHidden = false + iconImageView.image = createIconImage(named: iconName, pointSize: 16 * fontScale) + } else { + // No icon for custom agents + iconImageView.isHidden = true + iconImageView.image = nil + } + + // Update chevron icon with scaled size + chevronView.image = createSFSymbolImage(named: "chevron.down", pointSize: 9 * fontScale, weight: .bold) + + // Update button appearance based on selection + if isSelected { + button.layer?.backgroundColor = NSColor(activeBackground).cgColor + titleLabel.textColor = NSColor(activeTextColor) + iconImageView.contentTintColor = NSColor(activeTextColor) + chevronView.contentTintColor = NSColor(activeTextColor) + + // Remove existing shadows before adding new ones + button.layer?.shadowOpacity = 0 + + // Add shadows + button.shadow = { + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.05) + shadow.shadowOffset = NSSize(width: 0, height: -1) + shadow.shadowBlurRadius = 0.375 + return shadow + }() + + // For the second shadow, we can add a sublayer or just use one. + // For simplicity, we will just use one for now. A second shadow can be added with a sublayer if needed. + + // Add overlay + button.layer?.borderColor = NSColor.black.withAlphaComponent(0.02).cgColor + button.layer?.borderWidth = 0.5 + + } else { + button.layer?.backgroundColor = NSColor.clear.cgColor + titleLabel.textColor = NSColor(inactiveTextColor) + iconImageView.contentTintColor = NSColor(inactiveTextColor) + chevronView.contentTintColor = NSColor(inactiveTextColor) + button.shadow = nil + button.layer?.borderColor = NSColor.clear.cgColor + button.layer?.borderWidth = 0 + } + button.wantsLayer = true + button.layer?.cornerRadius = 10 * fontScale + button.layer?.cornerCurve = .continuous + } + + func makeCoordinator() -> Coordinator { + Coordinator( + chatMode: chatMode, + builtInAgentModes: builtInAgentModes, + customAgents: customAgents, + selectedAgent: selectedAgent, + isSelected: isSelected, + isCustomAgentEnabled: isCustomAgentEnabled, + fontScale: fontScale, + onSelectAgent: onSelectAgent, + onEditAgent: onEditAgent, + onDeleteAgent: onDeleteAgent, + onCreateAgent: onCreateAgent + ) + } + + // MARK: - Helper Methods for Image Creation + + /// Creates an icon image - either a custom asset or SF Symbol + private func createIconImage(named iconName: String, pointSize: CGFloat) -> NSImage? { + if iconName == AgentModeIcon.agent { + return createResizedCustomImage(named: iconName, targetSize: pointSize) + } else { + return createSFSymbolImage(named: iconName, pointSize: pointSize, weight: .bold) + } + } + + /// Creates a resized custom image (non-SF Symbol) with template rendering + private func createResizedCustomImage(named imageName: String, targetSize: CGFloat) -> NSImage? { + guard let image = NSImage(named: imageName) else { return nil } + + let size = NSSize(width: targetSize, height: targetSize) + let resizedImage = NSImage(size: size) + resizedImage.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + image.draw( + in: NSRect(origin: .zero, size: size), + from: NSRect(origin: .zero, size: image.size), + operation: .sourceOver, + fraction: 1.0 + ) + resizedImage.unlockFocus() + resizedImage.isTemplate = true + return resizedImage + } + + /// Creates an SF Symbol image with the specified configuration + private func createSFSymbolImage(named symbolName: String, pointSize: CGFloat, weight: NSFont.Weight) -> NSImage? { + let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: weight) + return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? + .withSymbolConfiguration(config) + } + + class Coordinator: NSObject { + var chatMode: String + var builtInAgentModes: [ConversationMode] + var customAgents: [ConversationMode] + var selectedAgent: ConversationMode + var isSelected: Bool + var isCustomAgentEnabled: Bool + var fontScale: Double + var button: NSButton? + var titleLabel: NSTextField? + var iconImageView: NSImageView? + var chevronView: NSImageView? + var stackView: NSStackView? + var stackLeadingConstraint: NSLayoutConstraint? + var stackTrailingConstraint: NSLayoutConstraint? + var stackTopConstraint: NSLayoutConstraint? + var stackBottomConstraint: NSLayoutConstraint? + var iconWidthConstraint: NSLayoutConstraint? + var iconHeightConstraint: NSLayoutConstraint? + var chevronWidthConstraint: NSLayoutConstraint? + var chevronHeightConstraint: NSLayoutConstraint? + let onSelectAgent: (ConversationMode) -> Void + let onEditAgent: (ConversationMode) -> Void + let onDeleteAgent: (ConversationMode) -> Void + let onCreateAgent: () -> Void + + init( + chatMode: String, + builtInAgentModes: [ConversationMode], + customAgents: [ConversationMode], + selectedAgent: ConversationMode, + isSelected: Bool, + isCustomAgentEnabled: Bool, + fontScale: Double, + onSelectAgent: @escaping (ConversationMode) -> Void, + onEditAgent: @escaping (ConversationMode) -> Void, + onDeleteAgent: @escaping (ConversationMode) -> Void, + onCreateAgent: @escaping () -> Void + ) { + self.chatMode = chatMode + self.builtInAgentModes = builtInAgentModes + self.customAgents = customAgents + self.selectedAgent = selectedAgent + self.isSelected = isSelected + self.isCustomAgentEnabled = isCustomAgentEnabled + self.fontScale = fontScale + self.onSelectAgent = onSelectAgent + self.onEditAgent = onEditAgent + self.onDeleteAgent = onDeleteAgent + self.onCreateAgent = onCreateAgent + } + + @objc func buttonClicked(_ sender: NSButton) { + // If in Ask mode, switch to agent mode + if chatMode == ChatMode.Ask.rawValue { + // Restore the previously selected agent from AppState + let savedSubMode = AppState.shared.getSelectedAgentSubMode() + + // Try to find the saved agent + let agent = builtInAgentModes.first(where: { $0.id == savedSubMode }) + ?? customAgents.first(where: { $0.id == savedSubMode }) + ?? builtInAgentModes.first + + if let agent = agent { + onSelectAgent(agent) + } + } else { + // If in Agent mode and custom agent is enabled, show the menu + // If custom agent is disabled, do nothing + if isCustomAgentEnabled { + showMenu(sender) + } + } + } + + @objc func showMenu(_ sender: NSButton) { + let menuBuilder = AgentModeMenu( + builtInAgentModes: builtInAgentModes, + customAgents: customAgents, + selectedAgent: selectedAgent, + fontScale: fontScale, + onSelectAgent: onSelectAgent, + onEditAgent: onEditAgent, + onDeleteAgent: onDeleteAgent, + onCreateAgent: onCreateAgent + ) + menuBuilder.showMenu(relativeTo: sender) + } + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift new file mode 100644 index 00000000..322bac6d --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift @@ -0,0 +1,522 @@ +import AppKit +import ConversationServiceProvider +import SwiftUI + +// MARK: - Agent Menu Item View + +class AgentModeButtonMenuItem: NSView { + // Layout constants + private let fontScale: Double + + private lazy var scaledConstants = ScaledLayoutConstants(fontScale: fontScale) + + private struct ScaledLayoutConstants { + let fontScale: Double + + var menuHeight: CGFloat { 22 * fontScale } + var checkmarkLeftEdge: CGFloat { 9 * fontScale } + var checkmarkSize: CGFloat { 13 * fontScale } + var iconSize: CGFloat { 16 * fontScale } + var iconTextSpacing: CGFloat { 5 * fontScale } + var checkmarkIconSpacing: CGFloat { 5 * fontScale } + var hoverEdgeInset: CGFloat { 5 * fontScale } + var buttonSpacing: CGFloat { -4 * fontScale } + var deleteButtonRightEdge: CGFloat { 12 * fontScale } + var buttonSize: CGFloat { 24 * fontScale } + var buttonIconSize: CGFloat { 10 * fontScale } + var buttonBackgroundSize: CGFloat { 17 * fontScale } + var buttonBackgroundEdgeInset: CGFloat { 3 * fontScale } + var minWidth: CGFloat { 180 * fontScale } + var maxWidth: CGFloat { 320 * fontScale } + var fontSize: CGFloat { 13 * fontScale } + var fontWeight: NSFont.Weight { .regular } + + // MARK: - Computed Properties for Repeated Calculations + + /// Starting X position for checkmark and icons without selection + var checkmarkStartX: CGFloat { checkmarkLeftEdge } + + /// Starting X position for icons when menu has selection + var iconStartXWithSelection: CGFloat { + checkmarkLeftEdge + checkmarkSize + checkmarkIconSpacing + } + + /// Icon X position based on selection state + func iconX(isSelected: Bool, menuHasSelection: Bool) -> CGFloat { + isSelected || menuHasSelection ? iconStartXWithSelection : checkmarkLeftEdge + } + + /// Helper to vertically center an element within the menu height + func centeredY(for elementSize: CGFloat) -> CGFloat { + (menuHeight - elementSize) / 2 + } + + /// Starting X position for label text based on icon presence + func labelStartX(hasIcon: Bool, iconName: String?, isSelected: Bool, menuHasSelection: Bool) -> CGFloat { + if hasIcon { + let iconX: CGFloat + let iconWidth: CGFloat + if iconName == AgentModeIcon.plus { + iconX = checkmarkLeftEdge + iconWidth = checkmarkSize + } else { + iconX = isSelected ? iconStartXWithSelection : (menuHasSelection ? iconStartXWithSelection : checkmarkLeftEdge) + iconWidth = iconSize + } + return iconX + iconWidth + iconTextSpacing + } else { + return menuHasSelection ? iconStartXWithSelection : checkmarkLeftEdge + } + } + } + + private let name: String + private let iconName: String? + private let isSelected: Bool + private let menuHasSelection: Bool + private let onSelect: () -> Void + private let onEdit: (() -> Void)? + private let onDelete: (() -> Void)? + + private var isHovered = false + private var isEditButtonHovered = false + private var isDeleteButtonHovered = false + private var trackingArea: NSTrackingArea? + + private var hasEditDeleteButtons: Bool { + onEdit != nil && onDelete != nil + } + + private let nameLabel = NSTextField(labelWithString: "") + private let iconImageView = NSImageView() + private let checkmarkImageView = NSImageView() + private let editButton = NSButton() + private let deleteButton = NSButton() + private let editButtonBackground = NSView() + private let deleteButtonBackground = NSView() + + init( + name: String, + iconName: String?, + isSelected: Bool, + menuHasSelection: Bool, + fontScale: Double = 1.0, + fixedWidth: CGFloat? = nil, + onSelect: @escaping () -> Void, + onEdit: (() -> Void)? = nil, + onDelete: (() -> Void)? = nil + ) { + self.name = name + self.iconName = iconName + self.isSelected = isSelected + self.menuHasSelection = menuHasSelection + self.fontScale = fontScale + self.onSelect = onSelect + self.onEdit = onEdit + self.onDelete = onDelete + + // Use fixed width if provided, otherwise calculate dynamically + let calculatedWidth = fixedWidth ?? Self.calculateMenuItemWidth( + name: name, + hasIcon: iconName != nil, + isSelected: isSelected, + menuHasSelection: menuHasSelection, + hasEditDelete: onEdit != nil && onDelete != nil, + fontScale: fontScale + ) + + let constants = ScaledLayoutConstants(fontScale: fontScale) + super.init(frame: NSRect(x: 0, y: 0, width: calculatedWidth, height: constants.menuHeight)) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + static func calculateMenuItemWidth( + name: String, + hasIcon: Bool, + isSelected: Bool, + menuHasSelection: Bool, + hasEditDelete: Bool, + fontScale: Double = 1.0 + ) -> CGFloat { + // Create scaled constants + let constants = ScaledLayoutConstants(fontScale: fontScale) + + // Calculate text width + let font = NSFont.systemFont(ofSize: constants.fontSize, weight: constants.fontWeight) + let textAttributes = [NSAttributedString.Key.font: font] + let textSize = (name as NSString).size(withAttributes: textAttributes) + + // Calculate label X position using computed property + let iconName = hasIcon ? (name == "Create an agent" ? AgentModeIcon.plus : nil) : nil + let labelX = constants.labelStartX(hasIcon: hasIcon, iconName: iconName, isSelected: isSelected, menuHasSelection: menuHasSelection) + + // Calculate required width + var width = labelX + textSize.width + 10 * fontScale // 10pt padding after text + + if hasEditDelete { + // Add space for edit and delete buttons + width = max(width, labelX + textSize.width + 20 * fontScale) // Ensure some space before buttons + width += (constants.buttonSize * 2) + constants.buttonSpacing + constants.deleteButtonRightEdge + } else { + width += 10 * fontScale // Extra padding for items without buttons + } + + // Clamp to min/max width + return min(max(width, constants.minWidth), constants.maxWidth) + } + + private func setupView() { + wantsLayer = true + layer?.masksToBounds = true + + setupCheckmark() + setupIcon() + setupNameLabel() + + let showEditDeleteButtons = onEdit != nil && onDelete != nil + if showEditDeleteButtons { + setupEditDeleteButtons() + } + + setupTrackingArea() + } + + // MARK: - View Setup Helpers + + private func setupCheckmark() { + let checkmarkConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.checkmarkSize, weight: .medium) + if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil)? + .withSymbolConfiguration(checkmarkConfig) { + checkmarkImageView.image = image + } + checkmarkImageView.contentTintColor = .labelColor + let checkmarkY = scaledConstants.centeredY(for: scaledConstants.checkmarkSize) + checkmarkImageView.frame = NSRect( + x: scaledConstants.checkmarkStartX, + y: checkmarkY, + width: scaledConstants.checkmarkSize, + height: scaledConstants.checkmarkSize + ) + checkmarkImageView.isHidden = !isSelected + addSubview(checkmarkImageView) + } + + private func setupIcon() { + guard let iconName = iconName else { return } + + if iconName == AgentModeIcon.agent { + setupCustomAgentIcon() + } else if iconName == AgentModeIcon.plus { + setupPlusIcon() + } else { + setupSFSymbolIcon(iconName) + } + + iconImageView.contentTintColor = .labelColor + iconImageView.isHidden = false + + // Calculate and set icon position + let (iconX, iconSize, iconY) = calculateIconPosition(for: iconName) + iconImageView.frame = NSRect(x: iconX, y: iconY, width: iconSize, height: iconSize) + addSubview(iconImageView) + } + + private func setupCustomAgentIcon() { + guard let image = NSImage(named: AgentModeIcon.agent) else { return } + + let targetSize = NSSize(width: scaledConstants.iconSize, height: scaledConstants.iconSize) + let resizedImage = NSImage(size: targetSize) + resizedImage.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + image.draw( + in: NSRect(origin: .zero, size: targetSize), + from: NSRect(origin: .zero, size: image.size), + operation: .sourceOver, + fraction: 1.0 + ) + resizedImage.unlockFocus() + resizedImage.isTemplate = true + iconImageView.image = resizedImage + } + + private func setupPlusIcon() { + let plusConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.checkmarkSize, weight: .medium) + if let image = NSImage(systemSymbolName: AgentModeIcon.plus, accessibilityDescription: nil) { + iconImageView.image = image.withSymbolConfiguration(plusConfig) + } + } + + private func setupSFSymbolIcon(_ iconName: String) { + let symbolConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.iconSize, weight: .medium) + if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) { + iconImageView.image = image.withSymbolConfiguration(symbolConfig) + } + } + + private func calculateIconPosition(for iconName: String) -> (x: CGFloat, size: CGFloat, y: CGFloat) { + if iconName == AgentModeIcon.plus { + let size = scaledConstants.checkmarkSize + return ( + scaledConstants.checkmarkStartX, + size, + scaledConstants.centeredY(for: size) + ) + } else { + let size = scaledConstants.iconSize + return ( + scaledConstants.iconX(isSelected: isSelected, menuHasSelection: menuHasSelection), + size, + scaledConstants.centeredY(for: size) + ) + } + } + + private func setupNameLabel() { + let labelX = scaledConstants.labelStartX( + hasIcon: iconName != nil, + iconName: iconName, + isSelected: isSelected, + menuHasSelection: menuHasSelection + ) + + nameLabel.stringValue = name + nameLabel.font = NSFont.systemFont(ofSize: scaledConstants.fontSize, weight: scaledConstants.fontWeight) + nameLabel.textColor = .labelColor + nameLabel.frame = NSRect(x: labelX, y: 3 * fontScale, width: 160 * fontScale, height: 16 * fontScale) + nameLabel.isEditable = false + nameLabel.isBordered = false + nameLabel.backgroundColor = .clear + nameLabel.drawsBackground = false + addSubview(nameLabel) + } + + private func setupEditDeleteButtons() { + let viewWidth = frame.width + let buttonIconConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.buttonIconSize, weight: .medium) + + // Calculate button positions from the right edge + let deleteButtonX = viewWidth - scaledConstants.deleteButtonRightEdge - scaledConstants.buttonSize + let editButtonX = deleteButtonX - scaledConstants.buttonSpacing - scaledConstants.buttonSize + let backgroundY = (frame.height - scaledConstants.buttonBackgroundSize) / 2 + + // Setup edit button and background + setupEditButton(at: editButtonX, backgroundY: backgroundY, config: buttonIconConfig) + + // Setup delete button and background + setupDeleteButton(at: deleteButtonX, backgroundY: backgroundY, config: buttonIconConfig) + } + + private func setupButtonWithBackground( + button: NSButton, + background: NSView, + at x: CGFloat, + backgroundY: CGFloat, + iconName: String, + accessibilityDescription: String, + action: Selector, + config: NSImage.SymbolConfiguration + ) { + // Setup background + let backgroundX = x + scaledConstants.buttonBackgroundEdgeInset + background.wantsLayer = true + background.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.15).cgColor + background.layer?.cornerRadius = scaledConstants.buttonBackgroundSize / 2 + background.frame = NSRect( + x: backgroundX, + y: backgroundY, + width: scaledConstants.buttonBackgroundSize, + height: scaledConstants.buttonBackgroundSize + ) + background.isHidden = true + addSubview(background) + + // Setup button + button.image = NSImage(systemSymbolName: iconName, accessibilityDescription: accessibilityDescription)?.withSymbolConfiguration(config) + button.bezelStyle = .roundRect + button.isBordered = false + button.frame = NSRect( + x: x, + y: scaledConstants.centeredY(for: scaledConstants.buttonSize), + width: scaledConstants.buttonSize, + height: scaledConstants.buttonSize + ) + button.target = self + button.action = action + button.isHidden = true + button.alphaValue = 1.0 + addSubview(button) + } + + private func setupEditButton(at x: CGFloat, backgroundY: CGFloat, config: NSImage.SymbolConfiguration) { + setupButtonWithBackground( + button: editButton, + background: editButtonBackground, + at: x, + backgroundY: backgroundY, + iconName: "pencil", + accessibilityDescription: "Edit", + action: #selector(editTapped), + config: config + ) + } + + private func setupDeleteButton(at x: CGFloat, backgroundY: CGFloat, config: NSImage.SymbolConfiguration) { + setupButtonWithBackground( + button: deleteButton, + background: deleteButtonBackground, + at: x, + backgroundY: backgroundY, + iconName: "trash", + accessibilityDescription: "Delete", + action: #selector(deleteTapped), + config: config + ) + } + + private func setupTrackingArea() { + // Use .zero rect with .inVisibleRect to automatically track the visible bounds + // This avoids accessing bounds during layout cycles + trackingArea = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .mouseMoved, .activeInActiveApp, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea!) + } + + override func mouseEntered(with event: NSEvent) { + isHovered = true + updateButtonVisibility() + updateColors() + needsDisplay = true + } + + override func mouseExited(with event: NSEvent) { + isHovered = false + isEditButtonHovered = false + isDeleteButtonHovered = false + updateButtonVisibility() + editButtonBackground.isHidden = true + deleteButtonBackground.isHidden = true + updateColors() + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + let location = convert(event.locationInWindow, from: nil) + + if hasEditDeleteButtons { + if editButton.frame.contains(location) || deleteButton.frame.contains(location) { + return + } + } + + onSelect() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea = trackingArea { + removeTrackingArea(trackingArea) + } + setupTrackingArea() + } + + private func updateButtonVisibility() { + if hasEditDeleteButtons { + editButton.isHidden = !isHovered + deleteButton.isHidden = !isHovered + } + } + + private func updateColors() { + if isHovered { + nameLabel.textColor = .white + iconImageView.contentTintColor = .white + checkmarkImageView.contentTintColor = .white + if hasEditDeleteButtons { + editButton.contentTintColor = .white + deleteButton.contentTintColor = .white + } + } else { + nameLabel.textColor = .labelColor + iconImageView.contentTintColor = .labelColor + checkmarkImageView.contentTintColor = .labelColor + if hasEditDeleteButtons { + editButton.contentTintColor = nil + deleteButton.contentTintColor = nil + } + } + } + + @objc private func editTapped() { + onEdit?() + } + + @objc private func deleteTapped() { + onDelete?() + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + if isHovered { + NSGraphicsContext.saveGraphicsState() + + let hoverColor = NSColor(.accentColor) + hoverColor.setFill() + + let cornerRadius: CGFloat + if #available(macOS 26.0, *) { + cornerRadius = 8.0 * fontScale + } else { + cornerRadius = 4.0 * fontScale + } + + // Use frame dimensions instead of bounds to avoid layout recursion + let viewWidth = frame.width + let viewHeight = frame.height + let hoverWidth = viewWidth - (scaledConstants.hoverEdgeInset * 2) + let insetRect = NSRect(x: scaledConstants.hoverEdgeInset, y: 0, width: hoverWidth, height: viewHeight) + let path = NSBezierPath(roundedRect: insetRect, xRadius: cornerRadius, yRadius: cornerRadius) + path.fill() + + NSGraphicsContext.restoreGraphicsState() + } + } + + override func mouseMoved(with event: NSEvent) { + guard hasEditDeleteButtons else { return } + + let location = convert(event.locationInWindow, from: nil) + + if editButton.frame.contains(location) && !editButton.isHidden { + updateButtonHoverState(editHovered: true, deleteHovered: false, trashFilled: false) + } else if deleteButton.frame.contains(location) && !deleteButton.isHidden { + updateButtonHoverState(editHovered: false, deleteHovered: true, trashFilled: true) + } else { + updateButtonHoverState(editHovered: false, deleteHovered: false, trashFilled: false) + } + + if isHovered { + editButton.contentTintColor = .white + deleteButton.contentTintColor = .white + } + } + + private func updateButtonHoverState(editHovered: Bool, deleteHovered: Bool, trashFilled: Bool) { + isEditButtonHovered = editHovered + isDeleteButtonHovered = deleteHovered + editButtonBackground.isHidden = !editHovered + deleteButtonBackground.isHidden = !deleteHovered + + let buttonIconConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.buttonIconSize, weight: .medium) + let trashIcon = trashFilled ? "trash.fill" : "trash" + deleteButton.image = NSImage(systemSymbolName: trashIcon, accessibilityDescription: "Delete")?.withSymbolConfiguration(buttonIconConfig) + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeIconConstants.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeIconConstants.swift new file mode 100644 index 00000000..3461a0f4 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeIconConstants.swift @@ -0,0 +1,21 @@ +import Foundation + +// MARK: - Agent Mode Icon Constants + +enum AgentModeIcon { + /// Icon for Plan mode (SF Symbol: checklist) + static let plan = "checklist" + + /// Icon for Agent mode (Custom asset: Agent) + static let agent = "Agent" + + /// Icon for create/add actions (SF Symbol: plus) + static let plus = "plus" + + /// Returns the appropriate icon name for a given agent mode name + /// - Parameter modeName: The name of the agent mode + /// - Returns: The icon name to use, or nil for custom agents + static func icon(for modeName: String) -> String { + return modeName.lowercased() == "plan" ? plan : agent + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeMenu.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeMenu.swift new file mode 100644 index 00000000..81e76aef --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeMenu.swift @@ -0,0 +1,165 @@ +import AppKit +import ConversationServiceProvider + +// MARK: - Agent Mode Menu Builder + +struct AgentModeMenu { + let builtInAgentModes: [ConversationMode] + let customAgents: [ConversationMode] + let selectedAgent: ConversationMode + let fontScale: Double + let onSelectAgent: (ConversationMode) -> Void + let onEditAgent: (ConversationMode) -> Void + let onDeleteAgent: (ConversationMode) -> Void + let onCreateAgent: () -> Void + + func createMenu() -> NSMenu { + let menu = NSMenu() + + let menuHasSelection = true // Always show checkmarks for clarity + + // Calculate the maximum width needed across all items + let maxWidth = calculateMaxMenuItemWidth(menuHasSelection: menuHasSelection) + + // Add built-in agent modes + addBuiltInModes(to: menu, menuHasSelection: menuHasSelection, width: maxWidth) + + // Add custom agents if any + if !customAgents.isEmpty { + menu.addItem(.separator()) + addCustomAgents(to: menu, menuHasSelection: menuHasSelection, width: maxWidth) + } + + // Add create option + menu.addItem(.separator()) + addCreateOption(to: menu, menuHasSelection: menuHasSelection, width: maxWidth) + + return menu + } + + private func calculateMaxMenuItemWidth(menuHasSelection: Bool) -> CGFloat { + var maxWidth: CGFloat = 0 + + // Check built-in modes + for mode in builtInAgentModes { + let width = AgentModeButtonMenuItem.calculateMenuItemWidth( + name: mode.name, + hasIcon: true, + isSelected: selectedAgent.id == mode.id, + menuHasSelection: menuHasSelection, + hasEditDelete: false, + fontScale: fontScale + ) + maxWidth = max(maxWidth, width) + } + + // Check custom agents + for agent in customAgents { + let width = AgentModeButtonMenuItem.calculateMenuItemWidth( + name: agent.name, + hasIcon: false, + isSelected: selectedAgent.id == agent.id, + menuHasSelection: menuHasSelection, + hasEditDelete: true, + fontScale: fontScale + ) + maxWidth = max(maxWidth, width) + } + + // Check create option + let createWidth = AgentModeButtonMenuItem.calculateMenuItemWidth( + name: "Create an agent", + hasIcon: true, + isSelected: false, + menuHasSelection: menuHasSelection, + hasEditDelete: false, + fontScale: fontScale + ) + maxWidth = max(maxWidth, createWidth) + + return maxWidth + } + + private func addBuiltInModes(to menu: NSMenu, menuHasSelection: Bool, width: CGFloat) { + for mode in builtInAgentModes { + let agentItem = NSMenuItem() + // Determine icon: use checklist for Plan, Agent icon for others + let iconName = AgentModeIcon.icon(for: mode.name) + let agentView = AgentModeButtonMenuItem( + name: mode.name, + iconName: iconName, + isSelected: selectedAgent.id == mode.id, + menuHasSelection: menuHasSelection, + fontScale: fontScale, + fixedWidth: width, + onSelect: { [onSelectAgent] in + onSelectAgent(mode) + menu.cancelTracking() + } + ) + agentView.toolTip = mode.description + agentItem.view = agentView + menu.addItem(agentItem) + } + } + + private func addCustomAgents(to menu: NSMenu, menuHasSelection: Bool, width: CGFloat) { + for agent in customAgents { + let agentItem = NSMenuItem() + agentItem.representedObject = agent + + // Create custom view for the menu item + let customView = AgentModeButtonMenuItem( + name: agent.name, + iconName: nil, + isSelected: selectedAgent.id == agent.id, + menuHasSelection: menuHasSelection, + fontScale: fontScale, + fixedWidth: width, + onSelect: { [onSelectAgent] in + onSelectAgent(agent) + menu.cancelTracking() + }, + onEdit: { [onEditAgent] in + onEditAgent(agent) + menu.cancelTracking() + }, + onDelete: { [onDeleteAgent] in + onDeleteAgent(agent) + menu.cancelTracking() + } + ) + + customView.toolTip = agent.description + agentItem.view = customView + menu.addItem(agentItem) + } + } + + private func addCreateOption(to menu: NSMenu, menuHasSelection: Bool, width: CGFloat) { + let createItem = NSMenuItem() + let createView = AgentModeButtonMenuItem( + name: "Create an agent", + iconName: AgentModeIcon.plus, + isSelected: false, + menuHasSelection: menuHasSelection, + fontScale: fontScale, + fixedWidth: width, + onSelect: { [onCreateAgent] in + onCreateAgent() + menu.cancelTracking() + } + ) + createItem.view = createView + menu.addItem(createItem) + } + + func showMenu(relativeTo button: NSButton) { + let menu = createMenu() + + // Show menu aligned to the button's edge, positioned below the button + let buttonFrame = button.frame + let menuOrigin = NSPoint(x: buttonFrame.minX, y: buttonFrame.maxY) + menu.popUp(positioning: menu.items.first, at: menuOrigin, in: button.superview) + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift new file mode 100644 index 00000000..97268560 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift @@ -0,0 +1,304 @@ +import AppKit +import AppKitExtension +import ChatService +import Combine +import ConversationServiceProvider +import GitHubCopilotService +import Persist +import SharedUIComponents +import SwiftUI +import SystemUtils +import Workspace +import XcodeInspector + +public extension Notification.Name { + static let gitHubCopilotChatModeDidChange = Notification + .Name("com.github.CopilotForXcode.ChatModeDidChange") +} + +public struct ChatModePicker: View { + @Binding var chatMode: String + @Binding var selectedAgent: ConversationMode + + let projectRootURL: URL? + @Environment(\.colorScheme) var colorScheme + @State var isAgentModeFFEnabled: Bool + @State var isEditorPreviewFFEnabled: Bool + @State var isCustomAgentPolicyEnabled: Bool + @State private var cancellables = Set() + @State private var builtInAgents: [ConversationMode] = [] + @State private var customAgents: [ConversationMode] = [] + @State private var isCreateSheetPresented = false + @State private var agentToDelete: ConversationMode? + @State private var showDeleteConfirmation = false + var onScopeChange: (PromptTemplateScope, String?) -> Void + + public init( + projectRootURL: URL?, + chatMode: Binding, + selectedAgent: Binding, + onScopeChange: @escaping (PromptTemplateScope, String?) -> Void = { _, _ in } + ) { + _chatMode = chatMode + _selectedAgent = selectedAgent + self.projectRootURL = projectRootURL + self.onScopeChange = onScopeChange + isAgentModeFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.agentMode + isEditorPreviewFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures + isCustomAgentPolicyEnabled = CopilotPolicyNotifierImpl.shared.copilotPolicy.customAgentEnabled + } + + private func setAskMode() { + chatMode = ChatMode.Ask.rawValue + AppState.shared.setSelectedChatMode(ChatMode.Ask.rawValue) + onScopeChange(.chatPanel, nil) + NotificationCenter.default.post( + name: .gitHubCopilotChatModeDidChange, + object: nil + ) + } + + private func setAgentMode(_ agent: ConversationMode) { + chatMode = ChatMode.Agent.rawValue + selectedAgent = agent + AppState.shared.setSelectedChatMode(ChatMode.Agent.rawValue) + AppState.shared.setSelectedAgentSubMode(agent.id) + + // Load agents if switching from Ask mode + Task { + await loadCustomAgentsAsync() + } + onScopeChange(.agentPanel, agent.model) + NotificationCenter.default.post( + name: .gitHubCopilotChatModeDidChange, + object: nil + ) + } + + private func subscribeToFeatureFlagsDidChangeEvent() { + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in + isAgentModeFFEnabled = featureFlags.agentMode + isEditorPreviewFFEnabled = featureFlags.editorPreviewFeatures + }) + .store(in: &cancellables) + } + + private func subscribeToPolicyDidChangeEvent() { + CopilotPolicyNotifierImpl.shared.policyDidChange.sink(receiveValue: { policy in + isCustomAgentPolicyEnabled = policy.customAgentEnabled + }) + .store(in: &cancellables) + } + + private func loadCustomAgents() { + Task { + await loadCustomAgentsAsync() + + // Only restore if we're in Agent mode + if chatMode == ChatMode.Agent.rawValue { + loadSelectedAgentSubMode() + } + } + } + + private func loadCustomAgentsAsync() async { + guard let modes = await SharedChatService.shared.loadConversationModes() else { + // Fallback: create default built-in modes when server returns nil + builtInAgents = [.defaultAgent] + customAgents = [] + return + } + + // Filter built-in modes (exclude Edit) + builtInAgents = modes.filter { $0.isBuiltIn && $0.kind == .Agent } + + // Filter for custom agent modes (non-built-in) + customAgents = modes.filter { !$0.isBuiltIn && $0.kind == .Agent } + } + + private func deleteCustomAgent(_ agent: ConversationMode) { + agentToDelete = agent + showDeleteConfirmation = true + } + + private func performDelete() { + guard let agent = agentToDelete, + let uriString = agent.uri, + let fileURL = URL(string: uriString) else { + return + } + + do { + try FileManager.default.removeItem(at: fileURL) + loadCustomAgents() + } catch { + // Error handling + } + agentToDelete = nil + } + + private func openAgentFileInXcode(_ agent: ConversationMode) { + guard let uriString = agent.uri, let fileURL = URL(string: uriString) else { + return + } + + NSWorkspace.openFileInXcode(fileURL: fileURL) + } + + private func createNewAgent() { + isCreateSheetPresented = true + } + + private var displayName: String { + return selectedAgent.name + } + + private var displayIconName: String? { + // Custom agents don't have icons + if !selectedAgent.isBuiltIn { + return nil + } + // Use checklist icon for Plan, Agent icon for others + return AgentModeIcon.icon(for: selectedAgent.name) + } + + public var body: some View { + VStack { + if isAgentModeFFEnabled { + HStack(spacing: -1) { + ModeButton( + title: "Ask", + isSelected: chatMode == ChatMode.Ask.rawValue, + activeBackground: colorScheme == .dark ? Color.white.opacity(0.25) : Color.white, + activeTextColor: Color.primary, + inactiveTextColor: Color.primary.opacity(0.5), + action: { + setAskMode() + } + ) + + AgentModeButton( + title: displayName, + isSelected: chatMode == ChatMode.Agent.rawValue, + activeBackground: Color.accentColor, + activeTextColor: Color.white, + inactiveTextColor: Color.primary.opacity(0.5), + chatMode: chatMode, + builtInAgentModes: builtInAgents, + customAgents: customAgents, + selectedAgent: selectedAgent, + selectedIconName: displayIconName, + isCustomAgentEnabled: isEditorPreviewFFEnabled && isCustomAgentPolicyEnabled, + onSelectAgent: { setAgentMode($0) }, + onEditAgent: { openAgentFileInXcode($0) }, + onDeleteAgent: { deleteCustomAgent($0) }, + onCreateAgent: { createNewAgent() } + ) + } + .scaledPadding(1) + .scaledFrame(height: 22, alignment: .topLeading) + .background(.primary.opacity(0.1)) + .cornerRadius(16) + .padding(4) + .help("Set Agent") + } else { + EmptyView() + } + } + .task { + subscribeToFeatureFlagsDidChangeEvent() + subscribeToPolicyDidChangeEvent() + await loadCustomAgentsAsync() + loadSelectedAgentSubMode() + if !isAgentModeFFEnabled { + setAskMode() + } + } + .onChange(of: isAgentModeFFEnabled) { newAgentModeFFEnabled in + if !newAgentModeFFEnabled { + setAskMode() + } + } + .onChange(of: isEditorPreviewFFEnabled) { newValue in + // If editor preview is disabled and current agent is not the default agent, reset to default + if !newValue && chatMode == ChatMode.Agent.rawValue && !selectedAgent.isDefaultAgent { + let defaultAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent + setAgentMode(defaultAgent) + } + } + .onChange(of: isCustomAgentPolicyEnabled) { newValue in + // If custom agent policy is disabled and current agent is not the default agent, reset to default + if !newValue && chatMode == ChatMode.Agent.rawValue && !selectedAgent.isDefaultAgent { + let defaultAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent + setAgentMode(defaultAgent) + } + } + // Minimal refresh: when app becomes active (e.g. user returns from editing an agent file in Xcode) + // Reload custom agents to pick up external changes without adding complex file monitoring. + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + loadCustomAgents() + } + .onChange(of: selectedAgent) { newAgent in + // When selectedAgent changes externally (e.g., from handoff), + // call setAgentMode to trigger all side effects + // Guard: only trigger if we're not already in the correct state to avoid redundant work + guard chatMode != ChatMode.Agent.rawValue || + AppState.shared.getSelectedAgentSubMode() != newAgent.id else { + return + } + setAgentMode(newAgent) + } + .sheet(isPresented: $isCreateSheetPresented) { + CreateCustomCopilotFileView( + promptType: .agent, + editorPluginVersion: SystemUtils.editorPluginVersionString, + getCurrentProjectURL: { projectRootURL }, + onSuccess: { _ in + loadCustomAgents() + }, + onError: { _ in + // Handle error silently or log it + } + ) + } + .confirmationDialog( + // `agentToDelete` should always be non-nil, adding fallback for compilation safety + "Are you sure you want to delete '\(agentToDelete?.name ?? "Agent")'?", + isPresented: $showDeleteConfirmation + ) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { performDelete() } + } + } + + private func loadSelectedAgentSubMode() { + let subMode = AppState.shared.getSelectedAgentSubMode() + + // Try to find the agent + if let agent = findAgent(byId: subMode) { + // If it's not the default agent and custom agents are disabled, reset to default + if !agent.isDefaultAgent && (!isEditorPreviewFFEnabled || !isCustomAgentPolicyEnabled) { + selectedAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent + AppState.shared.setSelectedAgentSubMode("Agent") + return + } + selectedAgent = agent + return + } + + // Default to Agent mode if nothing matches + selectedAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent + } + + private func findAgent(byId id: String) -> ConversationMode? { + // Check built-in agents first + if let builtIn = builtInAgents.first(where: { $0.id == id }) { + return builtIn + } + // Check custom agents + if let custom = customAgents.first(where: { $0.id == id }) { + return custom + } + return nil + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift new file mode 100644 index 00000000..7964d448 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift @@ -0,0 +1,32 @@ +import SwiftUI +import SharedUIComponents + +public struct ModeButton: View { + let title: String + let isSelected: Bool + let activeBackground: Color + let activeTextColor: Color + let inactiveTextColor: Color + let action: () -> Void + + public var body: some View { + Button(action: action) { + Text(title) + .scaledFont(size: 12) + .scaledPadding(.horizontal, 6) + .scaledPadding(.vertical, 2) + .frame(maxHeight: .infinity, alignment: .center) + .background(isSelected ? activeBackground : Color.clear) + .foregroundColor(isSelected ? activeTextColor : inactiveTextColor) + .cornerRadius(16) + .shadow(color: .black.opacity(0.05), radius: 0.375, x: 0, y: 1) + .shadow(color: .black.opacity(0.15), radius: 0.125, x: 0, y: 0.25) + .overlay( + RoundedRectangle(cornerRadius: 5) + .inset(by: -0.25) + .stroke(.black.opacity(0.02), lineWidth: 0.5) + ) + } + .buttonStyle(PlainButtonStyle()) + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift new file mode 100644 index 00000000..53eeeb6e --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift @@ -0,0 +1,287 @@ +import Foundation +import Combine +import Persist +import GitHubCopilotService +import ConversationServiceProvider + +public let SELECTED_LLM_KEY = "selectedLLM" +public let SELECTED_CHATMODE_KEY = "selectedChatMode" +public let SELECTED_AGENT_SUBMODE_KEY = "selectedAgentSubMode" + +public extension Notification.Name { + static let gitHubCopilotSelectedModelDidChange = Notification.Name("com.github.CopilotForXcode.SelectedModelDidChange") +} + +public extension AppState { + func isSelectedModelSupportVision() -> Bool? { + if let savedModel = get(key: SELECTED_LLM_KEY) { + return savedModel["supportVision"]?.boolValue + } + return nil + } + + func getSelectedModel() -> LLMModel? { + guard let savedModel = get(key: SELECTED_LLM_KEY) else { + return nil + } + + guard let modelName = savedModel["modelName"]?.stringValue, + let modelFamily = savedModel["modelFamily"]?.stringValue, + let id = savedModel["id"]?.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, + id: id, + billing: billing, + providerName: providerName, + supportVision: supportVision + ) + } + + func setSelectedModel(_ model: LLMModel) { + update(key: SELECTED_LLM_KEY, value: model) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .gitHubCopilotSelectedModelDidChange, object: nil) + } + } + + func modelScope() -> PromptTemplateScope { + return isAgentModeEnabled() ? .agentPanel : .chatPanel + } + + func getSelectedChatMode() -> String { + if let savedMode = get(key: SELECTED_CHATMODE_KEY), + let modeName = savedMode.stringValue { + return convertChatMode(modeName) + } + + // Default to "Agent" + return "Agent" + } + + func setSelectedChatMode(_ mode: String) { + update(key: SELECTED_CHATMODE_KEY, value: mode) + } + + func isAgentModeEnabled() -> Bool { + return getSelectedChatMode() == "Agent" + } + + func getSelectedAgentSubMode() -> String { + if let savedSubMode = get(key: SELECTED_AGENT_SUBMODE_KEY), + let subMode = savedSubMode.stringValue { + return subMode + } + // Default to "Agent" + return "Agent" + } + + func setSelectedAgentSubMode(_ subMode: String) { + update(key: SELECTED_AGENT_SUBMODE_KEY, value: subMode) + } + + private func convertChatMode(_ mode: String) -> String { + switch mode { + case "Ask": + return "Ask" + default: + return "Agent" + } + } +} + +public class CopilotModelManagerObservable: ObservableObject { + static let shared = CopilotModelManagerObservable() + + @Published var availableChatModels: [LLMModel] = [] + @Published var availableAgentModels: [LLMModel] = [] + @Published var defaultChatModel: LLMModel? + @Published var defaultAgentModel: LLMModel? + @Published var availableChatBYOKModels: [LLMModel] = [] + @Published var availableAgentBYOKModels: [LLMModel] = [] + private var cancellables = Set() + + private init() { + // Initial load + availableChatModels = CopilotModelManager.getAvailableChatLLMs(scope: .chatPanel) + availableAgentModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel) + defaultChatModel = CopilotModelManager.getDefaultChatModel(scope: .chatPanel) + defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel) + availableChatBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .chatPanel) + availableAgentBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel) + + // Setup notification to update when models change + NotificationCenter.default.publisher(for: .gitHubCopilotModelsDidChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.availableChatModels = CopilotModelManager.getAvailableChatLLMs(scope: .chatPanel) + self?.availableAgentModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel) + self?.defaultChatModel = CopilotModelManager.getDefaultChatModel(scope: .chatPanel) + self?.defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel) + self?.availableChatBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .chatPanel) + self?.availableAgentBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel) + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .gitHubCopilotShouldSwitchFallbackModel) + .receive(on: DispatchQueue.main) + .sink { _ in + if let fallbackModel = CopilotModelManager.getFallbackLLM( + scope: AppState.shared + .isAgentModeEnabled() ? .agentPanel : .chatPanel + ) { + AppState.shared.setSelectedModel( + .init( + modelName: fallbackModel.modelName, + modelFamily: fallbackModel.modelFamily, + id: 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, + id: $0.id, + billing: $0.billing, + supportVision: $0.capabilities.supports.vision + ) + } + } + + static func getDefaultChatModel(scope: PromptTemplateScope = .chatPanel) -> LLMModel? { + let LLMs = CopilotModelManager.getAvailableLLMs() + let LLMsInScope = LLMs.filter({ $0.scopes.contains(scope) }) + let defaultModel = LLMsInScope.first(where: { $0.isChatDefault && !$0.isAutoModel }) + // If a default model is found, return it + if let defaultModel = defaultModel { + return LLMModel( + modelName: defaultModel.modelName, + modelFamily: defaultModel.modelFamily, + id: defaultModel.id, + 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, + id: gpt4_1.id, + billing: gpt4_1.billing, + supportVision: gpt4_1.capabilities.supports.vision + ) + } + + // If no default model is found, fallback to the first available model + if let firstModel = LLMsInScope.first(where: { !$0.isAutoModel }) { + return LLMModel( + modelName: firstModel.modelName, + modelFamily: firstModel.modelFamily, + id: firstModel.id, + 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, + id: $0.modelId, + billing: nil, + providerName: $0.providerName.rawValue, + supportVision: $0.modelCapabilities?.vision ?? false + ) + } + } +} + +public struct LLMModel: Codable, Hashable, Equatable { + public let displayName: String? + public let modelName: String + public let modelFamily: String + public let id: String + public let billing: CopilotModelBilling? + public let providerName: String? + public let supportVision: Bool + + public init( + displayName: String? = nil, + modelName: String, + modelFamily: String, + id: String, + billing: CopilotModelBilling?, + providerName: String? = nil, + supportVision: Bool + ) { + self.displayName = displayName + self.modelName = modelName + self.modelFamily = modelFamily + self.id = id + self.billing = billing + self.providerName = providerName + self.supportVision = supportVision + } +} + +public extension LLMModel { + /// Apply to `Copilot Models` + var isPremiumModel: Bool { billing?.isPremium == true } + /// Apply to `Copilot Models` + var isStandardModel: Bool { !isPremiumModel || billing == nil } + /// Apply to `Copilot Models` + var isAutoModel: Bool { isStandardModel && modelName == "Auto" } +} + +extension CopilotModel { + var isAutoModel: Bool { modelName == "Auto" } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift new file mode 100644 index 00000000..7b32efc8 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift @@ -0,0 +1,87 @@ +import AppKit +import Foundation + +public struct ScopeCache { + var modelMultiplierCache: [String: String] = [:] + var cachedMaxWidth: CGFloat = 0 + var lastModelsHash: Int = 0 +} + +// MARK: - Model Menu Item Formatting +public struct ModelMenuItemFormatter { + public static let minimumPadding: Int = 48 + + public static let attributes: [NSAttributedString.Key: NSFont] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] + + public static var spaceWidth: CGFloat { + "\u{200A}".size(withAttributes: attributes).width + } + + public static var minimumPaddingWidth: CGFloat { + spaceWidth * CGFloat(minimumPadding) + } + + /// Creates an attributed string for model menu items with proper spacing and formatting + public static func createModelMenuItemAttributedString( + modelName: String, + isSelected: Bool, + multiplierText: String, + targetWidth: CGFloat? = nil + ) -> AttributedString { + let displayName = isSelected ? "✓ \(modelName)" : " \(modelName)" + + var fullString = displayName + var attributedString = AttributedString(fullString) + + if !multiplierText.isEmpty { + let displayNameWidth = displayName.size(withAttributes: attributes).width + let multiplierTextWidth = multiplierText.size(withAttributes: attributes).width + + // Calculate padding needed + let neededPaddingWidth: CGFloat + + if let targetWidth = targetWidth { + neededPaddingWidth = targetWidth - displayNameWidth - multiplierTextWidth + } else { + neededPaddingWidth = minimumPaddingWidth + } + + let finalPaddingWidth = max(neededPaddingWidth, minimumPaddingWidth) + let numberOfSpaces = Int(round(finalPaddingWidth / spaceWidth)) + let padding = String(repeating: "\u{200A}", count: max(minimumPadding, numberOfSpaces)) + fullString = "\(displayName)\(padding)\(multiplierText)" + + attributedString = AttributedString(fullString) + + if let range = attributedString.range( + of: multiplierText, + options: .backwards + ) { + attributedString[range].foregroundColor = .secondary + } + } + + return attributedString + } + + /// Gets the multiplier text for a model (e.g., "2x", "Included", provider name, or "Variable") + public static func getMultiplierText(for model: LLMModel) -> String { + if model.isAutoModel { + return "Variable" + } else if let billing = model.billing { + let multiplier = billing.multiplier + if multiplier == 0 { + return "Included" + } else { + let numberPart = multiplier.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", multiplier) + : String(format: "%.2f", multiplier) + return "\(numberPart)x" + } + } else if let providerName = model.providerName, !providerName.isEmpty { + return providerName + } else { + return "" + } + } +} diff --git a/Core/Sources/ConversationTab/Styles.swift b/Core/Sources/ConversationTab/Styles.swift index 6c117c9a..eca980d5 100644 --- a/Core/Sources/ConversationTab/Styles.swift +++ b/Core/Sources/ConversationTab/Styles.swift @@ -35,6 +35,8 @@ extension NSAppearance { extension View { var messageBubbleCornerRadius: Double { 8 } + var hoverableImageCornerRadius: Double { 4 } + var inputAreaTextEditorCornerRadius: Double { 12 } func codeBlockLabelStyle() -> some View { relativeLineSpacing(.em(0.225)) @@ -42,14 +44,16 @@ extension View { FontFamilyVariant(.monospaced) FontSize(.em(0.85)) } - .padding(16) - .padding(.top, 14) + .padding(.leading, 8) + .padding(.top, 24) + .padding(.bottom, 8) } func codeBlockStyle( _ configuration: CodeBlockConfiguration, backgroundColor: Color, - labelColor: Color + labelColor: Color, + context: MarkdownActionProvider? = nil ) -> some View { background(backgroundColor) .clipShape(RoundedRectangle(cornerRadius: 6)) @@ -61,16 +65,29 @@ extension View { .padding(.leading, 8) .lineLimit(1) Spacer() - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(configuration.content, forType: .string) + + HStack(spacing: 4) { + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(configuration.content, forType: .string) + } + + if let context = context, context.supportInsert { + InsertButton { + if let onInsert = context.onInsert { + onInsert(configuration.content) + } + } + } } } + .padding(.trailing, 8) } .overlay { RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.05), lineWidth: 1) } .markdownMargin(top: 4, bottom: 16) + .frame(maxWidth: .infinity) } } @@ -165,3 +182,35 @@ struct RoundedCorners: Shape { } } +// Chat Message Styles +extension View { + + func chatContextReferenceStyle(isCurrentEditor: Bool, r: Double) -> some View { + background( + Color(nsColor: .windowBackgroundColor).opacity(0.5) + ) + .cornerRadius(isCurrentEditor ? 99 : r) + .overlay( + RoundedRectangle(cornerRadius: isCurrentEditor ? 99 : r) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } +} + +// MARK: - Code Review Background Styles + +struct CodeReviewCardBackground: View { + var body: some View { + RoundedRectangle(cornerRadius: 12) + .stroke(.black.opacity(0.17), lineWidth: 1) + .background(Color.gray.opacity(0.05)) + } +} + +struct CodeReviewHeaderBackground: View { + var body: some View { + RoundedRectangle(cornerRadius: 12) + .stroke(.black.opacity(0.17), lineWidth: 1) + .background(Color.gray.opacity(0.1)) + } +} diff --git a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift new file mode 100644 index 00000000..7ac6ef3c --- /dev/null +++ b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift @@ -0,0 +1,361 @@ +import ChatService +import ComposableArchitecture +import ConversationServiceProvider +import GitHubCopilotService +import SharedUIComponents +import SwiftUI +import Terminal +import XcodeInspector + +struct RunInTerminalToolView: View { + let tool: AgentToolCall + let command: String? + let explanation: String? + let isBackground: Bool? + let chat: StoreOf + 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 + @Environment(\.colorScheme) var colorScheme + + init(tool: AgentToolCall, chat: StoreOf) { + self.tool = tool + self.chat = chat + + let input = (tool.invokeParams?.input as? [String: AnyCodable]) ?? tool.input + + if let input { + self.command = input["command"]?.value as? String + self.explanation = input["explanation"]?.value as? String + self.isBackground = input["isBackground"]?.value as? Bool + self.title = (isBackground != nil && isBackground!) ? "Run command in background terminal" : "Run command in terminal" + } else { + self.command = nil + self.explanation = nil + self.isBackground = nil + } + } + + var terminalSession: TerminalSession? { + return TerminalSessionManager.shared.getSession(for: tool.id) + } + + var statusIcon: some View { + Group { + switch tool.status { + case .running: + ProgressView() + .controlSize(.small) + .scaleEffect(0.7) + case .completed: + Image(systemName: "checkmark") + .foregroundColor(.green.opacity(0.5)) + case .error: + Image(systemName: "xmark.circle") + .foregroundColor(.red.opacity(0.5)) + case .cancelled: + Image(systemName: "slash.circle") + .foregroundColor(.gray.opacity(0.5)) + case .waitForConfirmation: + EmptyView() + case .accepted: + EmptyView() + } + } + } + + var body: some View { + WithPerceptionTracking { + if tool.status == .waitForConfirmation || terminalSession != nil { + VStack { + HStack { + Image("Terminal") + .resizable() + .scaledToFit() + .scaledFrame(width: 16, height: 16) + + Text(self.title) + .scaledFont(size: chatFontSize, weight: .semibold) + .foregroundStyle(.primary) + .background(Color.clear) + .frame(maxWidth: .infinity, alignment: .leading) + } + + toolView + } + .scaledPadding(8) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + } else { + toolView + } + } + } + + var codeBackgroundColor: Color { + if colorScheme == .light, let color = codeBackgroundColorLight.value { + return color.swiftUIColor + } else if let color = codeBackgroundColorDark.value { + return color.swiftUIColor + } + 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 + .scaledFrame(width: 16, height: 16) + + Text(command!) + .lineLimit(nil) + .textSelection(.enabled) + .scaledFont(size: chatFontSize, design: .monospaced) + .scaledPadding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(codeForegroundColor) + .background(codeBackgroundColor) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.05), lineWidth: 1) + } + } + } else { + Text("Invalid parameter in the toolcall for runInTerminal") + } + + if let terminalSession = terminalSession { + XTermView( + terminalSession: terminalSession, + onTerminalInput: terminalSession.handleTerminalInput + ) + .scaledFrame(minHeight: 200, maxHeight: 400) + } else if tool.status == .waitForConfirmation { + ThemedMarkdownText(text: explanation ?? "", chat: chat) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Button(action: { + chat.send(.toolCallCancelled(tool.id)) + }) { + Text("Skip") + .scaledFont(.body) + } + + if #available(macOS 13.0, *), + FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval && + CopilotPolicyNotifierImpl.shared.copilotPolicy.agentModeAutoApprovalEnabled, + let command, !command.isEmpty { + SplitButton( + title: "Allow", + isDisabled: false, + primaryAction: { + chat.send(.toolCallAccepted(tool.id)) + }, + menuItems: terminalMenuItems(command: command), + style: .prominent + ) + } else { + Button(action: { + chat.send(.toolCallAccepted(tool.id)) + }) { + Text("Allow") + .scaledFont(.body) + } + .buttonStyle(BorderedProminentButtonStyle()) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .scaledPadding(.top, 4) + } + } + } + } + + @available(macOS 13.0, *) + private func terminalMenuItems(command: String) -> [SplitButtonMenuItem] { + var items: [SplitButtonMenuItem] = [] + + let subCommands = ToolAutoApprovalManager.extractSubCommandsWithTreeSitter(command) + let commandNames = extractCommandNamesForMenu(subCommands) + let commandNamesLabel = formatCommandNameListForMenu(commandNames) + + let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedSubCommands = subCommands + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + let shouldShowExactCommandLineItems = !( + trimmedSubCommands.count == 1 && + trimmedSubCommands[0] == trimmedCommand && + commandNames.contains(trimmedCommand) + ) + + let conversationId = tool.invokeParams?.conversationId ?? "" + let hasConversationId = !conversationId.isEmpty + + // Session-scoped + if hasConversationId, !commandNames.isEmpty { + items.append( + SplitButtonMenuItem(title: sessionAllowCommandsTitle(commandNamesLabel: commandNamesLabel, commandCount: commandNames.count)) { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .terminal( + scope: .session(conversationId), + commands: commandNames + ) + ) + ) + } + ) + } + + // Global + if !commandNames.isEmpty { + items.append( + SplitButtonMenuItem(title: alwaysAllowCommandsTitle(commandNamesLabel: commandNamesLabel, commandCount: commandNames.count)) { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .terminal( + scope: .global, + commands: commandNames + ) + ) + ) + } + ) + } + + items.append(.divider()) + + if shouldShowExactCommandLineItems { + // Session-scoped exact command line + if hasConversationId { + items.append( + SplitButtonMenuItem(title: "Allow Exact Command Line in this Session") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .terminal( + scope: .session(conversationId), + commands: [command] + ) + ) + ) + } + ) + } + + // Global exact command line + items.append( + SplitButtonMenuItem(title: "Always Allow Exact Command Line") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .terminal( + scope: .global, + commands: [command] + ) + ) + ) + } + ) + + items.append(.divider()) + } + + // Session-scoped allow all + if hasConversationId { + items.append( + SplitButtonMenuItem(title: "Allow All Commands in this Session") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .terminal( + scope: .session(conversationId), + commands: [] + ) + ) + ) + } + ) + } + + items.append(.divider()) + items.append( + SplitButtonMenuItem(title: "Configure Auto Approve...") { + chat.send(.openAutoApproveSettings) + } + ) + + return items + } + + private func formatSubCommandListForMenu(_ subCommands: [String]) -> String { + let trimmed = subCommands.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + guard !trimmed.isEmpty else { return "(none)" } + return trimmed.joined(separator: ", ") + } + + private func extractCommandNamesForMenu(_ subCommands: [String]) -> [String] { + var result: [String] = [] + var seen: Set = [] + + for subCommand in subCommands { + guard let name = ToolAutoApprovalManager.extractTerminalCommandName(fromSubCommand: subCommand) else { + continue + } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + guard !seen.contains(trimmed) else { continue } + seen.insert(trimmed) + result.append(trimmed) + } + + return result + } + + private func formatCommandNameListForMenu(_ commandNames: [String]) -> String { + let trimmed = commandNames.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + guard !trimmed.isEmpty else { return "(none)" } + + func suffixEllipsis(_ name: String) -> String { "`\(name) ...`" } + + return trimmed.map(suffixEllipsis).joined(separator: ", ") + } + + private func sessionAllowCommandsTitle(commandNamesLabel: String, commandCount: Int) -> String { + if commandCount == 1 { + return "Allow \(commandNamesLabel) in this Session" + } + return "Allow Commands \(commandNamesLabel) in this Session" + } + + private func alwaysAllowCommandsTitle(commandNamesLabel: String, commandCount: Int) -> String { + if commandCount == 1 { + return "Always Allow \(commandNamesLabel)" + } + return "Always Allow Commands \(commandNamesLabel)" + } +} diff --git a/Core/Sources/ConversationTab/TerminalViews/XTermView.swift b/Core/Sources/ConversationTab/TerminalViews/XTermView.swift new file mode 100644 index 00000000..23e1fbd0 --- /dev/null +++ b/Core/Sources/ConversationTab/TerminalViews/XTermView.swift @@ -0,0 +1,100 @@ +import SwiftUI +import Logger +import WebKit +import Terminal + +struct XTermView: NSViewRepresentable { + @ObservedObject var terminalSession: TerminalSession + var onTerminalInput: (String) -> Void + + var terminalOutput: String { + terminalSession.terminalOutput + } + + func makeNSView(context: Context) -> WKWebView { + let webpagePrefs = WKWebpagePreferences() + webpagePrefs.allowsContentJavaScript = true + let preferences = WKWebViewConfiguration() + preferences.defaultWebpagePreferences = webpagePrefs + preferences.userContentController.add(context.coordinator, name: "terminalInput") + + let webView = WKWebView(frame: .zero, configuration: preferences) + webView.navigationDelegate = context.coordinator + #if DEBUG + webView.configuration.preferences.setValue(true, forKey: "developerExtrasEnabled") + #endif + + // Load the terminal bundle resources + let terminalBundleBaseURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/webViewDist/terminal") + let htmlFileURL = terminalBundleBaseURL.appendingPathComponent("terminal.html") + webView.loadFileURL(htmlFileURL, allowingReadAccessTo: terminalBundleBaseURL) + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + // When terminalOutput changes, send the new data to the terminal + if context.coordinator.lastOutput != terminalOutput { + let newOutput = terminalOutput.suffix(from: + terminalOutput.index(terminalOutput.startIndex, + offsetBy: min(context.coordinator.lastOutput.count, terminalOutput.count))) + + if !newOutput.isEmpty { + context.coordinator.lastOutput = terminalOutput + if context.coordinator.isWebViewLoaded { + context.coordinator.writeToTerminal(text: String(newOutput), webView: webView) + } else { + context.coordinator.pendingOutput = (context.coordinator.pendingOutput ?? "") + String(newOutput) + } + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + var parent: XTermView + var lastOutput: String = "" + var isWebViewLoaded = false + var pendingOutput: String? + + init(_ parent: XTermView) { + self.parent = parent + super.init() + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + isWebViewLoaded = true + if let pending = pendingOutput { + writeToTerminal(text: pending, webView: webView) + pendingOutput = nil + } + } + + func writeToTerminal(text: String, webView: WKWebView) { + let escapedOutput = text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\n", with: "\\r\\n") + .replacingOccurrences(of: "\r", with: "\\r") + + let jsCode = "writeToTerminal('\(escapedOutput)');" + DispatchQueue.main.async { + webView.evaluateJavaScript(jsCode) { _, error in + if let error = error { + Logger.client.info("XTerm: Error writing to terminal: \(error)") + } + } + } + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == "terminalInput", let input = message.body as? String { + DispatchQueue.main.async { + self.parent.onTerminalInput(input) + } + } + } + } +} diff --git a/Core/Sources/ConversationTab/ViewExtension.swift b/Core/Sources/ConversationTab/ViewExtension.swift new file mode 100644 index 00000000..181f3cbd --- /dev/null +++ b/Core/Sources/ConversationTab/ViewExtension.swift @@ -0,0 +1,140 @@ +import SwiftUI +import ComposableArchitecture + +let ITEM_SELECTED_COLOR = Color("ItemSelectedColor") + +struct HoverBackgroundModifier: ViewModifier { + var isHovered: Bool + + func body(content: Content) -> some View { + content + .background(isHovered ? ITEM_SELECTED_COLOR : Color.clear) + } +} + +struct HoverRadiusBackgroundModifier: ViewModifier { + var isHovered: Bool + var hoverColor: Color? + var cornerRadius: CGFloat = 0 + var showBorder: Bool = false + var borderColor: Color = .white.opacity(0.07) + var borderWidth: CGFloat = 1 + + func body(content: Content) -> some View { + content + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(isHovered ? hoverColor ?? ITEM_SELECTED_COLOR : Color.clear) + ) + .clipShape( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + ) + .overlay( + (isHovered && showBorder) ? + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(borderColor, lineWidth: borderWidth) : + nil + ) + } +} + +struct HoverForegroundModifier: ViewModifier { + var isHovered: Bool + var defaultColor: Color + + func body(content: Content) -> some View { + content.foregroundColor(isHovered ? Color.white : defaultColor) + } +} + +extension View { + public func hoverBackground(isHovered: Bool) -> some View { + self.modifier(HoverBackgroundModifier(isHovered: isHovered)) + } + + public func hoverRadiusBackground(isHovered: Bool, cornerRadius: CGFloat) -> some View { + self.modifier(HoverRadiusBackgroundModifier(isHovered: isHovered, cornerRadius: cornerRadius)) + } + + public func hoverRadiusBackground(isHovered: Bool, hoverColor: Color?, cornerRadius: CGFloat) -> some View { + self.modifier(HoverRadiusBackgroundModifier(isHovered: isHovered, hoverColor: hoverColor, cornerRadius: cornerRadius)) + } + + 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 { + self.modifier(HoverForegroundModifier(isHovered: isHovered, defaultColor: defaultColor)) + } + + public func hoverPrimaryForeground(isHovered: Bool) -> some View { + self.hoverForeground(isHovered: isHovered, defaultColor: .primary) + } + + public func hoverSecondaryForeground(isHovered: Bool) -> some View { + self.hoverForeground(isHovered: isHovered, defaultColor: .secondary) + } + + // MARK: - Editor Mode + + /// Dims the view when in edit mode and provides tap/keyboard exit functionality + /// - Parameters: + /// - chat: The chat store + /// - messageId: Optional message ID to determine if this specific message should be dimmed + /// - isDimmed: Whether this view should be dimmed (defaults to true when editing affects this view) + /// - allowTapToExit: Whether tapping on this view should exit edit mode (defaults to true) + func dimWithExitEditMode( + _ chat: StoreOf, + applyTo messageId: String? = nil, + isDimmed: Bool? = nil, + allowTapToExit: Bool = true + ) -> some View { + let editUserMessageEffectedMessageIds = chat.editUserMessageEffectedMessages.map { $0.id } + let shouldDim = isDimmed ?? { + guard chat.editorMode.isEditingUserMessage else { return false } + guard let messageId else { return true } + return editUserMessageEffectedMessageIds.contains(messageId) + }() + + let isInEditMode = chat.editorMode.isEditingUserMessage + let shouldAllowTapExit = allowTapToExit && isInEditMode + + return self + .opacity(shouldDim && isInEditMode ? 0.5 : 1) + .overlay( + Group { + if shouldAllowTapExit { + Color.clear + .contentShape(Rectangle()) // Ensure the entire area is tappable + .onTapGesture { + if shouldAllowTapExit { + chat.send(.setEditorMode(.input)) + } + } + } + } + ) + .background( + // Global escape key handler - only add once per view hierarchy + Group { + if isInEditMode { + Button("") { + chat.send(.setEditorMode(.input)) + } + .keyboardShortcut(.escape, modifiers: []) + .opacity(0) + .accessibilityHidden(true) + } + } + ) + } +} diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index d71fc316..c690a208 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -4,289 +4,367 @@ import Foundation import MarkdownUI import SharedUIComponents import SwiftUI +import ConversationServiceProvider +import ChatTab +import ChatAPIService +import HostAppActivator struct BotMessage: View { var r: Double { messageBubbleCornerRadius } - let id: String - let text: String - let references: [DisplayedChatMessage.Reference] + let message: DisplayedChatMessage let chat: StoreOf + var id: String { + message.id + } + var text: String { message.text } + var references: [ConversationReference] { message.references } + var followUp: ConversationFollowUp? { message.followUp } + var errorMessages: [String] { message.errorMessages } + var steps: [ConversationProgressStep] { message.steps } + var editAgentRounds: [AgentRound] { message.editAgentRounds } + var panelMessages: [CopilotShowMessageParams] { message.panelMessages } + var codeReviewRound: CodeReviewRound? { message.codeReviewRound } + @Environment(\.colorScheme) var colorScheme + @AppStorage(\.chatFontSize) var chatFontSize - @State var isReferencesPresented = false - @State var isReferencesHovered = false + @State var isHovering = false + + struct ReferenceButton: View { + let references: [ConversationReference] + let chat: StoreOf + + @AppStorage(\.chatFontSize) var chatFontSize + + func MakeReferenceTitle(references: [ConversationReference]) -> String { + guard !references.isEmpty else { + return "" + } + + let count = references.count + let title = count > 1 ? "Used \(count) references" : "Used \(count) reference" + return title + } + + var body: some View { + let files = references.map { $0.filePath } + let fileHelpTexts = Dictionary(uniqueKeysWithValues: references.compactMap { reference in + guard reference.url != nil else { return nil } + return (reference.filePath, reference.getPathRelativeToHome()) + }) + let progressMessage = Text(MakeReferenceTitle(references: references)) + .foregroundStyle(.secondary) + + HStack(spacing: 0) { + ExpandableFileListView( + progressMessage: progressMessage, + files: files, + chatFontSize: chatFontSize, + helpText: "View referenced files", + onFileClick: { filePath in + if let reference = references.first(where: { $0.filePath == filePath }) { + chat.send(.referenceClicked(reference)) + } + }, + fileHelpTexts: fileHelpTexts + ) + + Spacer() + } + } + } var body: some View { - HStack(alignment: .bottom) { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 0) { - Spacer() // Pushes the buttons to the right - UpvoteButton { rating in - chat.send(.upvote(id, rating)) + WithPerceptionTracking { + HStack { + VStack(alignment: .leading, spacing: 8) { + if !references.isEmpty { + WithPerceptionTracking { + ReferenceButton( + references: references, + chat: chat + ) + } } - DownvoteButton { rating in - chat.send(.downvote(id, rating)) + // progress step + if steps.count > 0 { + ProgressStep(steps: steps) + } - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - chat.send(.copyCode(id)) - } - } - - if !references.isEmpty { - Button(action: { - isReferencesPresented.toggle() - }, label: { - HStack(spacing: 4) { - Image(systemName: "plus.circle") - Text("Used \(references.count) references") - } - .padding(8) - .background { - RoundedRectangle(cornerRadius: r - 4) - .foregroundStyle(Color(isReferencesHovered ? .black : .clear)) + if !panelMessages.isEmpty { + WithPerceptionTracking { + ForEach(panelMessages.indices, id: \.self) { index in + FunctionMessage(text: panelMessages[index].message, chat: chat) + } } - .overlay { - RoundedRectangle(cornerRadius: r - 4) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + + if editAgentRounds.count > 0 { + ProgressAgentRound(rounds: editAgentRounds, chat: chat) + } + + if !text.isEmpty { + Group{ + ThemedMarkdownText(text: text, chat: chat) } - .foregroundStyle(.secondary) - }) - .buttonStyle(.plain) - .popover(isPresented: $isReferencesPresented, arrowEdge: .trailing) { - ReferenceList(references: references, chat: chat) + .scaledPadding(.leading, 2) + .scaledPadding(.vertical, 4) + } + + if let codeReviewRound = codeReviewRound { + CodeReviewMainView( + store: chat, round: codeReviewRound + ) + .frame(maxWidth: .infinity) + } + + if !errorMessages.isEmpty { + buildErrorMessageView() } - } - ThemedMarkdownText(text) - } - .frame(alignment: .trailing) - .padding() - .background { - RoundedCorners(tl: r, tr: r, bl: 0, br: r) - .fill(Color.contentBackground) - } - .overlay { - RoundedCorners(tl: r, tr: r, bl: 0, br: r) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - .padding(.leading, 8) - .shadow(color: .black.opacity(0.05), radius: 6) - .contextMenu { - Button("Copy") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) + HStack { + if shouldShowTurnStatus() { + TurnStatusView(message: message) + .modify { view in + if message.turnStatus == .inProgress { + view + .scaledPadding(.leading, 6) + } else { + view + } + } + } + + Spacer() + + ResponseToolBar( + id: id, + chat: chat, + text: text, + message: message + ) + .conditionalFontWeight(.medium) + .opacity(shouldShowToolBar() ? 1 : 0) + .scaledPadding(.trailing, -20) + } } - - Button("Set as Extra System Prompt") { - chat.send(.setAsExtraPromptButtonTapped(id)) + .padding(.leading, message.parentTurnId != nil ? 4 : 0) + .shadow(color: .black.opacity(0.05), radius: 6) + .contextMenu { + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + .scaledFont(.body) + + Button("Set as Extra System Prompt") { + chat.send(.setAsExtraPromptButtonTapped(id)) + } + .scaledFont(.body) + + Divider() + + Button("Delete") { + chat.send(.deleteMessageButtonTapped(id)) + } + .scaledFont(.body) } - - Divider() - - Button("Delete") { - chat.send(.deleteMessageButtonTapped(id)) + .onHover { + isHovering = $0 } } } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.trailing, 2) } -} - -struct ReferenceList: View { - let references: [DisplayedChatMessage.Reference] - let chat: StoreOf - - var body: some View { - WithPerceptionTracking { - ScrollView { - VStack(alignment: .leading, spacing: 8) { - ForEach(0.. some View { + VStack(spacing: 4) { + ForEach(errorMessages.indices, id: \.self) { index in + if let attributedString = try? AttributedString(markdown: errorMessages[index]) { + NotificationBanner(style: .warning) { + VStack(alignment: .leading, spacing: 4) { + Text(attributedString) + + if isSettingsActionableError(errorMessages[index]) { + Button(action: { + Task { + try? launchHostAppAdvancedSettings() + } + }) { + Text("Open Settings") } + .buttonStyle(.link) } - .buttonStyle(.plain) } } } - .padding() } - .frame(maxWidth: 500, maxHeight: 500) } + .scaledPadding(.vertical, 4) + } + + private func isSettingsActionableError(_ message: String) -> Bool { + message == HardCodedToolRoundExceedErrorMessage || + message == SSLCertificateErrorMessage } -} -struct ReferenceIcon: View { - let kind: DisplayedChatMessage.Reference.Kind + private func shouldShowTurnStatus() -> Bool { + guard isLatestAssistantMessage() else { + return false + } + + if steps.isEmpty && editAgentRounds.isEmpty { + return true + } + + if !steps.isEmpty { + return !message.text.isEmpty + } + + return true + } + + private func shouldShowToolBar() -> Bool { + // Always show toolbar for historical messages + if !isLatestAssistantMessage() { return isHovering } + + // For current message, only show toolbar when message is complete + return !chat.isReceivingMessage + } + + private func isLatestAssistantMessage() -> Bool { + let lastMessage = chat.history.last + return lastMessage?.role == .assistant && lastMessage?.id == id + } +} +private struct TurnStatusView: View { + + let message: DisplayedChatMessage + + @AppStorage(\.chatFontSize) var chatFontSize + var body: some View { - RoundedRectangle(cornerRadius: 4) - .fill({ - switch kind { - case .class: - Color.purple - case .struct: - Color.purple - case .enum: - Color.purple - case .actor: - Color.purple - case .protocol: - Color.purple - case .extension: - Color.indigo - case .case: - Color.green - case .property: - Color.teal - case .typealias: - Color.orange - case .function: - Color.teal - case .method: - Color.blue - case .text: - Color.gray - case .webpage: - Color.blue - case .other: - Color.gray + HStack(spacing: 0) { + if let turnStatus = message.turnStatus { + switch turnStatus { + case .inProgress: + inProgressStatus + case .success: + completedStatus + case .cancelled: + cancelStatus + case .error: + EmptyView() + case .waitForConfirmation: + waitForConfirmationStatus } - }()) - .frame(width: 22, height: 22) - .overlay(alignment: .center) { - Group { - switch kind { - case .class: - Text("C") - case .struct: - Text("S") - case .enum: - Text("E") - case .actor: - Text("A") - case .protocol: - Text("Pr") - case .extension: - Text("Ex") - case .case: - Text("K") - case .property: - Text("P") - case .typealias: - Text("T") - case .function: - Text("𝑓") - case .method: - Text("M") - case .text: - Text("Tx") - case .webpage: - Text("Wb") - case .other: - Text("Ot") - } - } - .font(.system(size: 12).monospaced()) - .foregroundColor(.white) } + } + } + + private var inProgressStatus: some View { + HStack(spacing: 4) { + ProgressView() + .controlSize(.small) + .scaledScaleEffect(0.7) + .scaledFrame(width: 16, height: 16) + + Text("Generating...") + .scaledFont(size: chatFontSize - 1) + .foregroundColor(.secondary) + } + } + + private var completedStatus: some View { + statusView(icon: "checkmark.circle.fill", iconColor: .successLightGreen, text: "Completed") + } + + private var waitForConfirmationStatus: some View { + statusView(icon: "clock.fill", iconColor: .brown, text: "Waiting for your response") + } + + private var cancelStatus: some View { + statusView(icon: "slash.circle", iconColor: .secondary, text: "Stopped") + } + + private var errorStatus: some View { + statusView(icon: "xmark.circle.fill", iconColor: .red, text: "Error Occurred") + } + + private func statusView(icon: String, iconColor: Color, text: String) -> some View { + HStack(spacing: 4) { + Image(systemName: icon) + .scaledFont(size: chatFontSize) + .foregroundColor(iconColor) + .conditionalFontWeight(.medium) + + Text(text) + .scaledFont(size: chatFontSize - 1) + .foregroundColor(.secondary) + } } } -#Preview("Bot Message") { - BotMessage( - id: "1", - text: """ - **Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you? - ```swift - func foo() {} - ``` - """, - references: .init(repeating: .init( - title: "ReferenceList", - subtitle: "/Core/Sources/ConversationTab/Views/BotMessage.swift:100", - uri: "https://google.com", - startLine: nil, - kind: .class - ), count: 20), - chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service()) }) - ) - .padding() - .fixedSize(horizontal: true, vertical: true) -} +struct BotMessage_Previews: PreviewProvider { + static let steps: [ConversationProgressStep] = [ + .init(id: "001", title: "running step", description: "this is running step", status: .running, error: nil), + .init(id: "002", title: "completed step", description: "this is completed step", status: .completed, error: nil), + .init(id: "003", title: "failed step", description: "this is failed step", status: .failed, error: nil), + .init(id: "004", title: "cancelled step", description: "this is cancelled step", status: .cancelled, error: nil) + ] -#Preview("Reference List") { - ReferenceList(references: [ - .init( - title: "ReferenceList", - subtitle: "/Core/Sources/ConversationTab/Views/BotMessage.swift:100", - uri: "https://google.com", - startLine: nil, - kind: .class - ), - .init( - title: "BotMessage.swift:100-102", - subtitle: "/Core/Sources/ConversationTab/Views", - uri: "https://google.com", - startLine: nil, - kind: .struct - ), - .init( - title: "ReferenceList", - subtitle: "/Core/Sources/ConversationTab/Views/BotMessage.swift:100", - uri: "https://google.com", - startLine: nil, - kind: .function - ), - .init( - title: "ReferenceList", - subtitle: "/Core/Sources/ConversationTab/Views/BotMessage.swift:100", - uri: "https://google.com", - startLine: nil, - kind: .case - ), - .init( - title: "ReferenceList", - subtitle: "/Core/Sources/ConversationTab/Views/BotMessage.swift:100", - uri: "https://google.com", - startLine: nil, - kind: .extension - ), - .init( - title: "ReferenceList", - subtitle: "/Core/Sources/ConversationTab/Views/BotMessage.swift:100", - uri: "https://google.com", - startLine: nil, - kind: .webpage - ), - ], chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service()) })) -} + static let agentRounds: [AgentRound] = [ + .init(roundId: 1, reply: "this is agent step 1", toolCalls: [ + .init( + id: "toolcall_001", + name: "Tool Call 1", + progressMessage: "Read Tool Call 1", + status: .completed, + error: nil) + ]), + .init(roundId: 2, reply: "this is agent step 2", toolCalls: [ + .init( + id: "toolcall_002", + name: "Tool Call 2", + progressMessage: "Running Tool Call 2", + status: .running) + ]) + ] + static var previews: some View { + let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") + BotMessage( + message: .init( + id: "1", + role: .assistant, + text: """ + **Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you? + ```swift + func foo() {} + ``` + """, + references: .init( + repeating: .init( + uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", + status: .included, + kind: .class, + referenceType: .file), + count: 2 + ), + followUp: ConversationFollowUp(message: "followup question", id: "id", type: "type"), + errorMessages: ["Sorry, an error occurred while generating a response."], + steps: steps, + editAgentRounds: agentRounds, + panelMessages: [], + codeReviewRound: nil, + requestType: .conversation + ), + chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }), + ) + .padding() + .fixedSize(horizontal: true, vertical: true) + } +} diff --git a/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift b/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift new file mode 100644 index 00000000..108f2f64 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift @@ -0,0 +1,65 @@ +import SwiftUI +import ComposableArchitecture +import SharedUIComponents + +struct ResponseToolBar: View { + let id: String + let chat: StoreOf + let text: String + let message: DisplayedChatMessage + @AppStorage(\.chatFontSize) var chatFontSize + + var billingMultiplier: String? { + guard let multiplier = message.billingMultiplier else { + return nil + } + let rounded = (multiplier * 100).rounded() / 100 + let formatter = NumberFormatter() + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 2 + formatter.numberStyle = .decimal + let formattedMultiplier = formatter.string(from: NSNumber(value: rounded)) ?? "\(rounded)" + return "\(formattedMultiplier)x" + } + + var modelNameAndMultiplierText: String? { + guard let modelName = message.modelName else { + return nil + } + + var text = modelName + + if let billingMultiplier = billingMultiplier { + text += " • \(billingMultiplier)" + } + + return text + } + + var body: some View { + HStack(spacing: 8) { + + if let modelNameAndMultiplierText = modelNameAndMultiplierText { + Text(modelNameAndMultiplierText) + .scaledFont(size: chatFontSize - 1) + .lineLimit(1) + .foregroundColor(.secondary) + .help(modelNameAndMultiplierText) + } + + UpvoteButton { rating in + chat.send(.upvote(id, rating)) + } + + DownvoteButton { rating in + chat.send(.downvote(id, rating)) + } + + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + chat.send(.copyCode(id)) + } + } + } +} diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift new file mode 100644 index 00000000..251f4022 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift @@ -0,0 +1,41 @@ +import SwiftUI +import ComposableArchitecture + +struct ChatPanelInputArea: View { + let chat: StoreOf + let r: Double + let editorMode: Chat.EditorMode + @FocusState var focusedField: Chat.State.Field? + + var body: some View { + HStack { + InputAreaTextEditor(chat: chat, r: r, focusedField: $focusedField, editorMode: editorMode) + } + .background(Color.clear) + } + + @MainActor + var clearButton: some View { + Button(action: { + chat.send(.clearButtonTap) + }) { + Group { + if #available(macOS 13.0, *) { + Image(systemName: "eraser.line.dashed.fill") + .scaledFont(.body) + } else { + Image(systemName: "trash.fill") + .scaledFont(.body) + } + } + .padding(6) + .background { + Circle().fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1) + } + } + .buttonStyle(.plain) + } +} diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift new file mode 100644 index 00000000..88373b42 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift @@ -0,0 +1,667 @@ +import ChatService +import ComposableArchitecture +import Combine +import ConversationServiceProvider +import SwiftUIFlowLayout +import GitHubCopilotService +import GitHubCopilotViewModel +import LanguageServerProtocol +import Preferences +import SharedUIComponents +import Status +import SwiftUI +import Workspace +import XcodeInspector + +enum ShowingType { case template, agent } + +struct InputAreaTextEditor: View { + @Perception.Bindable var chat: StoreOf + let r: Double + var focusedField: FocusState.Binding + let editorMode: Chat.EditorMode + @State var cancellable = Set() + @State private var isFilePickerPresented = false + @State private var allFiles: [ConversationAttachedReference]? = nil + @State private var filteredTemplates: [ChatTemplate] = [] + @State private var filteredAgent: [ChatAgent] = [] + @State private var showingTemplates = false + @State private var dropDownShowingType: ShowingType? = nil + @State private var textEditorState: TextEditorState? = nil + + @AppStorage(\.enableCurrentEditorContext) var enableCurrentEditorContext: Bool + @State private var isCurrentEditorContextEnabled: Bool = UserDefaults.shared.value( + for: \.enableCurrentEditorContext + ) + @ObservedObject private var status: StatusObserver = .shared + @State private var isCCRFFEnabled: Bool + @State private var isCCRHovering: Bool = false + @State private var cancellables = Set() + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + init( + chat: StoreOf, + r: Double, + focusedField: FocusState.Binding, + editorMode: Chat.EditorMode + ) { + self.chat = chat + self.r = r + self.focusedField = focusedField + self.editorMode = editorMode + self.isCCRFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.ccr + } + + var isEditorActive: Bool { + editorMode == chat.editorMode + } + + var isRequestingConversation: Bool { + if chat.isReceivingMessage, + let requestType = chat.requestType, + requestType == .conversation { + return true + } + return false + } + + var isRequestingCodeReview: Bool { + if chat.isReceivingMessage, + let requestType = chat.requestType, + requestType == .codeReview { + return true + } + + return false + } + + var projectRootURL: URL? { + WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: chat.workspaceURL, + documentURL: chat.state.currentEditor?.url + ) + } + + var body: some View { + WithPerceptionTracking { + let typedMessage = chat.state.getChatContext(of: editorMode).typedMessage + VStack(spacing: 0) { + chatContextView + + if isFilePickerPresented { + FilePicker( + allFiles: $allFiles, + workspaceURL: chat.workspaceURL, + onSubmit: { ref in + chat.send(.addReference(ref)) + }, + onExit: { + isFilePickerPresented = false + focusedField.wrappedValue = .textField + } + ) + .onAppear() { + allFiles = ContextUtils.getFilesFromWorkspaceIndex(workspaceURL: chat.workspaceURL) + } + } + + if !chat.state.attachedImages.isEmpty { + ImagesScrollView(chat: chat, editorMode: editorMode) + } + + ZStack(alignment: .topLeading) { + if typedMessage.isEmpty { + Group { + chat.isAgentMode ? + Text("Edit files in your workspace in agent mode") : + Text("Ask Copilot or type / for commands") + } + .scaledFont(size: 14) + .foregroundColor(Color(nsColor: .placeholderTextColor)) + .padding(8) + .padding(.horizontal, 4) + } + + HStack(spacing: 0) { + AutoresizingCustomTextEditor( + text: Binding( + get: { typedMessage }, + set: { newValue in chat.send(.updateTypedMessage(newValue)) } + ), + font: .systemFont(ofSize: 14 * fontScale), + isEditable: true, + maxHeight: 400, + onSubmit: { + if (dropDownShowingType == nil) { + submitChatMessage() + } + dropDownShowingType = nil + }, + onTextEditorStateChanged: { (state: TextEditorState?) in + DispatchQueue.main.async { + textEditorState = state + } + } + ) + .focused(focusedField, equals: isEditorActive ? .textField : nil) + .bind($chat.focusedField, to: focusedField) + .padding(8) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: typedMessage) { newValue in + Task { + await onTypedMessageChanged(newValue: newValue) + } + } + /// When chat mode changed, the chat tamplate and agent need to be reloaded + .onChange(of: chat.isAgentMode) { _ in + guard isEditorActive else { return } + Task { + await onTypedMessageChanged(newValue: typedMessage) + } + } + } + .frame(maxWidth: .infinity) + } + .padding(.top, 4) + + HStack(spacing: 0) { + ModeAndModelPicker(projectRootURL: projectRootURL, selectedAgent: $chat.selectedAgent) + + Spacer() + + if chat.editorMode.isDefault { + codeReviewButton + .buttonStyle(HoverButtonStyle(padding: 0, hoverColor: .clear)) + .opacity(isRequestingConversation ? 0 : 1) + } + + ZStack { + sendButton + .opacity(isRequestingConversation || isRequestingCodeReview ? 0 : 1) + .foregroundColor( + typedMessage.isEmpty ? Color(nsColor: .tertiaryLabelColor) : Color( + "IconStrokeColor" + ) + ) + .disabled(typedMessage.isEmpty) + + stopButton + .opacity(isRequestingConversation || isRequestingCodeReview ? 1 : 0) + .foregroundColor(Color("IconStrokeColor")) + } + .buttonStyle( + HoverButtonStyle( + padding: 0, + hoverColor: Color(nsColor: .quaternaryLabelColor), + backgroundColor: Color(nsColor: .quinaryLabel), + cornerRadius: .infinity + ) + ) + } + .padding(8) + .padding(.top, -4) + } + .overlay(alignment: .top) { + dropdownOverlay + } + .onAppear() { + guard editorMode.isDefault else { return } + subscribeToActiveDocumentChangeEvent() + // Check quota for CCR + Task { + if status.quotaInfo == nil, + let service = try? GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() { + _ = try? await service.checkQuota() + } + } + } + .task { + subscribeToFeatureFlagsDidChangeEvent() + } + .background { + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(.quaternary, lineWidth: 1) + } + .background { + if isEditorActive { + Button(action: { + chat.send(.returnButtonTapped) + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) + .accessibilityHidden(true) + + Button(action: { + focusedField.wrappedValue = .textField + }) { + EmptyView() + } + .keyboardShortcut("l", modifiers: [.command]) + .accessibilityHidden(true) + + buildReloadContextButtons() + } + } + + } + } + + private var reloadNextContextButton: some View { + Button(action: { + chat.send(.reloadNextContext) + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.downArrow, modifiers: []) + .accessibilityHidden(true) + } + + private var reloadPreviousContextButton: some View { + Button(action: { + chat.send(.reloadPreviousContext) + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.upArrow, modifiers: []) + .accessibilityHidden(true) + } + + @ViewBuilder + private func buildReloadContextButtons() -> some View { + if let textEditorState = textEditorState { + switch textEditorState { + case .empty, .singleLine: + ZStack { + reloadPreviousContextButton + reloadNextContextButton + } + case .multipleLines(let cursorAt): + switch cursorAt { + case .first: + reloadPreviousContextButton + case .last: + reloadNextContextButton + case .middle: + EmptyView() + } + } + } else { + EmptyView() + } + } + + private var sendButton: some View { + Button(action: { + submitChatMessage() + }) { + Image(systemName: "paperplane") + .scaledFont(size: 12, weight: .medium) + .padding(.leading, 5) + .padding(.trailing, 6) + .padding(.top, 6.5) + .padding(.bottom, 5.5) + } + .keyboardShortcut(KeyEquivalent.return, modifiers: []) + .help("Send") + } + + private var stopButton: some View { + Button(action: { + chat.send(.stopRespondingButtonTapped) + }) { + Image(systemName: "stop.fill") + .scaledFont(size: 12, weight: .medium) + .padding(8) + } + .keyboardShortcut(KeyEquivalent.escape, modifiers: []) + .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 { + 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 + .foregroundColor(isCCRHovering ? .primary : Color("IconStrokeColor")) + } + .scaledFont(.body) + .onHover { hovering in + isCCRHovering = hovering + } + .opacity(isRequestingCodeReview ? 0 : 1) + .help("Code Review") + } else { + codeReviewIcon + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + .help(ccrDisabledTooltip) + } + } + } + + private func subscribeToFeatureFlagsDidChangeEvent() { + FeatureFlagNotifierImpl.shared.featureFlagsDidChange + .sink(receiveValue: { isCCRFFEnabled = $0.ccr }) + .store(in: &cancellables) + } + + private var dropdownOverlay: some View { + Group { + if dropDownShowingType != nil { + if dropDownShowingType == .template { + ChatDropdownView(items: $filteredTemplates, prefixSymbol: "/") { template in + chat.send(.updateTypedMessage("/" + template.id + " ")) + if template.id == "releaseNotes" { + submitChatMessage() + } + } + } else if dropDownShowingType == .agent { + ChatDropdownView(items: $filteredAgent, prefixSymbol: "@") { agent in + chat.send(.updateTypedMessage("@" + agent.id + " ")) + } + } + } + } + } + + func onTypedMessageChanged(newValue: String) async { + guard chat.editorMode.isDefault else { return } + if newValue.hasPrefix("/") { + filteredTemplates = await chatTemplateCompletion(text: newValue) + dropDownShowingType = filteredTemplates.isEmpty ? nil : .template + } else if newValue.hasPrefix("@") && !chat.isAgentMode { + filteredAgent = await chatAgentCompletion(text: newValue) + dropDownShowingType = filteredAgent.isEmpty ? nil : .agent + } else { + dropDownShowingType = nil + } + } + + enum ChatContextButtonType { case imageAttach, contextAttach} + + private var chatContextView: some View { + let buttonItems: [ChatContextButtonType] = [.contextAttach, .imageAttach] + // Always use the latest current editor from state + let currentEditorItem: [ConversationFileReference] = [chat.state.currentEditor].compactMap { + $0 + } + let references = chat.state.getChatContext(of: editorMode).attachedReferences + let chatContextItems: [Any] = buttonItems.map { + $0 as ChatContextButtonType + } + currentEditorItem + references + return FlowLayout(mode: .scrollable, items: chatContextItems, itemSpacing: 4) { item in + if let buttonType = item as? ChatContextButtonType { + if buttonType == .imageAttach { + VisionMenuView(chat: chat) + } else if buttonType == .contextAttach { + // File picker button + Button(action: { + withAnimation { + isFilePickerPresented.toggle() + if !isFilePickerPresented { + focusedField.wrappedValue = .textField + } + } + }) { + Image(systemName: "paperclip") + .resizable() + .aspectRatio(contentMode: .fill) + .scaledFrame(width: 16, height: 16) + .scaledPadding(4) + .foregroundColor(.primary.opacity(0.85)) + .scaledFont(size: 11, weight: .semibold) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .help("Add Context") + .cornerRadius(6) + } + } else if let select = item as? ConversationFileReference, select.isCurrentEditor { + makeCurrentEditorView(select) + } else if let select = item as? ConversationAttachedReference { + makeReferenceItemView(select) + } + } + .padding(.horizontal, 8) + .padding(.top, 8) + } + + @ViewBuilder + func makeCurrentEditorView(_ ref: ConversationFileReference) -> some View { + let toggleTrailingPadding: CGFloat = { + if #available(macOS 26.0, *) { + return 8 + } else { + return 4 + } + }() + + HStack(alignment: .center, spacing: 0) { + makeContextFileNameView(url: ref.url, isCurrentEditor: true, selection: ref.selection) + + Toggle("", isOn: $isCurrentEditorContextEnabled) + .toggleStyle(SwitchToggleStyle(tint: .blue)) + .controlSize(.mini) + .frame(width: 34) + .padding(.trailing, toggleTrailingPadding) + .onChange(of: isCurrentEditorContextEnabled) { newValue in + enableCurrentEditorContext = newValue + } + } + .chatContextReferenceStyle(isCurrentEditor: true, r: r) + } + + @ViewBuilder + func makeReferenceItemView(_ ref: ConversationAttachedReference) -> some View { + HStack(spacing: 0) { + makeContextFileNameView(url: ref.url, isCurrentEditor: false, isDirectory: ref.isDirectory) + + Button(action: { chat.send(.removeReference(ref)) }) { + Image(systemName: "xmark") + .resizable() + .scaledFrame(width: 8, height: 8) + .foregroundColor(.primary.opacity(0.85)) + .padding(4) + } + .buttonStyle(HoverButtonStyle()) + } + .chatContextReferenceStyle(isCurrentEditor: false, r: r) + } + + @ViewBuilder + func makeContextFileNameView( + url: URL, + isCurrentEditor: Bool, + isDirectory: Bool = false, + selection: LSPRange? = nil + ) -> some View { + drawFileIcon(url, isDirectory: isDirectory) + .scaledToFit() + .scaledFrame(width: 16, height: 16) + .foregroundColor(.primary.opacity(0.85)) + .padding(4) + .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) + + HStack(spacing: 0) { + Text(url.lastPathComponent) + + Group { + if isCurrentEditor, let selection { + let startLine = selection.start.line + let endLine = selection.end.line + if startLine == endLine { + Text(String(format: ":%d", selection.start.line + 1)) + } else { + Text(String(format: ":%d-%d", selection.start.line + 1, selection.end.line + 1)) + } + } + } + .foregroundColor(.secondary) + } + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor( + isCurrentEditor && !isCurrentEditorContextEnabled + ? .secondary + : .primary.opacity(0.85) + ) + .scaledFont(.body) + .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) + .help(url.getPathRelativeToHome()) + } + + func chatTemplateCompletion(text: String) async -> [ChatTemplate] { + guard text.count >= 1 && text.first == "/" else { return [] } + + let prefix = String(text.dropFirst()).lowercased() + let promptTemplates: [ChatTemplate] = await SharedChatService.shared.loadChatTemplates() ?? [] + let releaseNotesTemplate: ChatTemplate = .init( + id: "releaseNotes", + description: "What's New", + shortDescription: "What's New", + scopes: [.chatPanel, .agentPanel] + ) + + let templates = promptTemplates + [releaseNotesTemplate] + let skippedTemplates = [ "feedback", "help" ] + + return templates.filter { + $0.scopes.contains(chat.isAgentMode ? .agentPanel : .chatPanel) && + $0.id.lowercased().hasPrefix(prefix) && + !skippedTemplates.contains($0.id) + } + } + + func chatAgentCompletion(text: String) async -> [ChatAgent] { + guard text.count >= 1 && text.first == "@" else { return [] } + let prefix = text.dropFirst() + var chatAgents = await SharedChatService.shared.loadChatAgents() ?? [] + + if let index = chatAgents.firstIndex(where: { $0.slug == "project" }) { + let projectAgent = chatAgents[index] + chatAgents[index] = .init(slug: "workspace", name: "workspace", description: "Ask about your workspace", avatarUrl: projectAgent.avatarUrl) + } + + /// only enable the @workspace + let includedAgents = ["workspace"] + + return chatAgents.filter { $0.slug.hasPrefix(prefix) && includedAgents.contains($0.slug) } + } + + func subscribeToActiveDocumentChangeEvent() { + var task: Task? + var currentFocusedEditor: SourceEditor? + + Publishers.CombineLatest3( + XcodeInspector.shared.$latestActiveXcode, + XcodeInspector.shared.$activeDocumentURL + .removeDuplicates(), + XcodeInspector.shared.$focusedEditor + .removeDuplicates() + ) + .receive(on: DispatchQueue.main) + .sink { newXcode, newDocURL, newFocusedEditor in + var currentEditor: ConversationFileReference? + + // First check for realtimeWorkspaceURL if activeWorkspaceURL is nil + if let realtimeURL = newXcode?.realtimeDocumentURL, newDocURL == nil { + if supportedFileExtensions.contains(realtimeURL.pathExtension) { + currentEditor = ConversationFileReference(url: realtimeURL, isCurrentEditor: true) + } + } else if let docURL = newDocURL, supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") { + currentEditor = ConversationFileReference(url: docURL, isCurrentEditor: true) + } + + if var currentEditor = currentEditor { + if let selection = newFocusedEditor?.getContent().selections.first, + selection.start != selection.end { + currentEditor.selection = .init(start: selection.start, end: selection.end) + } + + chat.send(.setCurrentEditor(currentEditor)) + } + + if currentFocusedEditor != newFocusedEditor { + task?.cancel() + task = nil + currentFocusedEditor = newFocusedEditor + + if let editor = currentFocusedEditor { + task = Task { @MainActor in + for await _ in await editor.axNotifications.notifications() + .filter({ $0.kind == .selectedTextChanged }) { + handleSourceEditorSelectionChanged(editor) + } + } + } + } + } + .store(in: &cancellable) + } + + private func handleSourceEditorSelectionChanged(_ sourceEditor: SourceEditor) { + guard let fileURL = sourceEditor.realtimeDocumentURL, + let currentEditorURL = chat.currentEditor?.url, + fileURL == currentEditorURL + else { + return + } + + var currentEditor: ConversationFileReference = .init(url: fileURL, isCurrentEditor: true) + + if let selection = sourceEditor.getContent().selections.first, + selection.start != selection.end { + currentEditor.selection = .init(start: selection.start, end: selection.end) + } + + chat.send(.setCurrentEditor(currentEditor)) + } + + func submitChatMessage() { + chat.send(.sendButtonTapped(UUID().uuidString)) + } +} diff --git a/Core/Sources/ConversationTab/Views/CheckPoint.swift b/Core/Sources/ConversationTab/Views/CheckPoint.swift new file mode 100644 index 00000000..5c6e4a86 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CheckPoint.swift @@ -0,0 +1,238 @@ +import SwiftUI +import ComposableArchitecture +import SharedUIComponents +import AppKit + +struct CheckPoint: View { + let chat: StoreOf + let messageId: String + + @State private var isHovering: Bool = false + @State private var window: NSWindow? + @AppStorage(\.chatFontSize) var chatFontSize + @AppStorage(\.suppressRestoreCheckpointConfirmation) var suppressRestoreCheckpointConfirmation + @Environment(\.colorScheme) var colorScheme + + private var isPendingCheckpoint: Bool { + chat.pendingCheckpointMessageId == messageId + } + + var body: some View { + WithPerceptionTracking { + HStack(spacing: 4) { + checkpointIcon + + checkpointLine + .overlay(alignment: .leading) { + checkpointContent + } + } + .scaledFrame(height: chatFontSize) + .onHover { isHovering = $0 } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .background(WindowAccessor { window in + // Store window reference for later use + self.window = window + }) + } + } + + var checkpointIcon: some View { + Image(systemName: "bookmark") + .resizable() + .scaledToFit() + .scaledFrame(width: chatFontSize, height: chatFontSize) + .foregroundStyle(.secondary) + } + + var checkpointLine: some View { + DashedLine() + .stroke(style: StrokeStyle(dash: [3])) + .foregroundStyle(.gray) + .scaledFrame(height: 1) + } + + @ViewBuilder + var checkpointContent: some View { + HStack(spacing: 12) { + if isPendingCheckpoint { + HStack(spacing: 12) { + undoButton + + Text("Checkpoint Restored") + .scaledFont(size: chatFontSize) + .foregroundStyle(.secondary) + .scaledPadding(.horizontal, 2) + .background(Color.chatWindowBackgroundColor) + } + } else if isHovering { + restoreButton + .transition(.opacity.combined(with: .move(edge: .leading))) + } + + Spacer() + } + } + + var hasSubsequentFileEdit: Bool { + for message in chat.state.getMessages(after: messageId, through: chat.pendingCheckpointMessageId) { + if !message.fileEdits.isEmpty { + return true + } + } + + return false + } + + var restoreButton: some View { + ActionButton( + title: "Restore Checkpoint", + helpText: "Restore workspace and chat to this point", + action: { + if !suppressRestoreCheckpointConfirmation && hasSubsequentFileEdit { + showRestoreAlert() + } else { + handleRestore() + } + } + ) + } + + func handleRestore() { + Task { @MainActor in + await chat.send(.restoreCheckPoint(messageId)).finish() + } + } + + var undoButton: some View { + ActionButton( + title: "Undo", + helpText: "Reapply discarded workspace changes and chat", + action: { + Task { @MainActor in + await chat.send(.undoCheckPoint).finish() + } + } + ) + } + + var accessibilityLabel: String { + if isPendingCheckpoint { + "Checkpoint restored. Tap to redo changes." + } else { + "Checkpoint. Tap to restore to this point." + } + } + + func showRestoreAlert() { + let alert = NSAlert() + alert.messageText = "Restore Checkpoint" + alert.informativeText = "This will remove all subsequent requests and edits. Do you want to proceed?" + + alert.addButton(withTitle: "Restore") + alert.addButton(withTitle: "Cancel") + + alert.showsSuppressionButton = true + alert.suppressionButton?.title = "Don't ask again" + + alert.alertStyle = .warning + + let targetWindow = window ?? NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first { + $0.isVisible + } + + if let targetWindow = targetWindow { + alert.beginSheetModal(for: targetWindow) { response in + self.handleAlertResponse(response, alert: alert) + } + } else { + let response = alert.runModal() + handleAlertResponse(response, alert: alert) + } + } + + private func handleAlertResponse(_ response: NSApplication.ModalResponse, alert: NSAlert) { + if response == .alertFirstButtonReturn { + handleRestore() + } + + suppressRestoreCheckpointConfirmation = alert.suppressionButton?.state == .on + } +} + +private struct ActionButton: View { + let title: String + let helpText: String + let action: () -> Void + + @Environment(\.colorScheme) private var colorScheme + @AppStorage(\.chatFontSize) private var chatFontSize + + private var adaptiveTextColor: Color { + colorScheme == .light ? .black.opacity(0.75) : .white.opacity(0.75) + } + + var body: some View { + Button(action: action) { + Text(title) + .scaledFont(.footnote) + .scaledPadding(4) + .foregroundStyle(adaptiveTextColor) + } + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color(nsColor: .windowBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(.gray, lineWidth: 0.5) + ) + ) + .buttonStyle(HoverButtonStyle(padding: 0)) + .scaledPadding(.leading, 8) + .help(helpText) + .accessibilityLabel(title) + .accessibilityHint(helpText) + } +} + +private struct DashedLine: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.midY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) + return path + } +} + +struct WindowAccessor: NSViewRepresentable { + var callback: (NSWindow?) -> Void + + func makeNSView(context: Context) -> NSView { + return WindowTrackingView(callback: callback) + } + + func updateNSView(_ nsView: NSView, context: Context) { + if let windowTrackingView = nsView as? WindowTrackingView { + windowTrackingView.callback = callback + } + } +} + +private class WindowTrackingView: NSView { + var callback: (NSWindow?) -> Void + + init(callback: @escaping (NSWindow?) -> Void) { + self.callback = callback + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + callback(window) + } +} diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift new file mode 100644 index 00000000..27256b87 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift @@ -0,0 +1,69 @@ +import ComposableArchitecture +import ConversationServiceProvider +import LanguageServerProtocol +import SwiftUI +import SharedUIComponents + +// MARK: - Main View + +struct CodeReviewMainView: View { + let store: StoreOf + let round: CodeReviewRound + @State private var selectedFileUris: [DocumentUri] + @AppStorage(\.chatFontSize) var chatFontSize + + private var changedFileUris: [DocumentUri] { + round.request?.changedFileUris ?? [] + } + + private var hasChangedFiles: Bool { + !changedFileUris.isEmpty + } + + private var hasFileComments: Bool { + guard let fileComments = round.response?.fileComments else { return false } + return !fileComments.isEmpty + } + + static let HelloMessage: String = "Sure, I can help you with that." + + public init(store: StoreOf, round: CodeReviewRound) { + self.store = store + self.round = round + self.selectedFileUris = round.request?.selectedFileUris ?? [] + } + + var helloMessageView: some View { + Text(Self.HelloMessage) + .scaledFont(.system(size: chatFontSize)) + } + + var shouldShowHelloMessage: Bool { round.statusHistory.contains(.waitForConfirmation) } + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 8) { + if shouldShowHelloMessage { + helloMessageView + } + + if hasChangedFiles { + FileSelectionSection( + store: store, + round: round, + changedFileUris: changedFileUris, + selectedFileUris: $selectedFileUris + ) + } + + if hasFileComments { + ReviewResultsSection(store: store, round: round) + } + + if round.status == .completed || round.status == .error { + ReviewSummarySection(round: round) + } + } + } + } +} diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift new file mode 100644 index 00000000..bc044fa7 --- /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("codeReview") + .resizable() + .scaledToFit() + .scaledFrame(width: 16, height: 16) + + Text("You’ve selected following \(fileCount) file(s) with code changes. Review them or unselect any files you don't need, then click Continue.") + .scaledFont(.system(size: chatFontSize)) + .multilineTextAlignment(.leading) + } + } +} + +private struct FileSelectionActions: View { + let store: StoreOf + let roundId: String + let selectedFileUris: [DocumentUri] + + var body: some View { + HStack(spacing: 4) { + Button("Cancel") { + store.send(.codeReview(.cancel(id: roundId))) + } + .buttonStyle(.bordered) + .controlSize(.large) + .scaledFont(.body) + + Button("Continue") { + store.send(.codeReview(.accept(id: roundId, selectedFiles: selectedFileUris))) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .scaledFont(.body) + } + } +} + +// MARK: - File Selection List + +private struct FileSelectionList: View { + let store: StoreOf + let fileUris: [DocumentUri] + let reviewStatus: CodeReviewRound.Status + @State private var isExpanded = false + @State private var checkboxMixedState: CheckboxMixedState = .off + @Binding var selectedFileUris: [DocumentUri] + @AppStorage(\.chatFontSize) private var chatFontSize + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + private static let defaultVisibleFileCount = 5 + + private var hasMoreFiles: Bool { + fileUris.count > Self.defaultVisibleFileCount + } + + var body: some View { + let visibleFileUris = Array(fileUris.prefix(Self.defaultVisibleFileCount)) + let additionalFileUris = Array(fileUris.dropFirst(Self.defaultVisibleFileCount)) + + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 4) { + // Select All checkbox for all files + selectedAllCheckbox + .disabled(reviewStatus != .waitForConfirmation) + .scaledFrame(maxHeight: 16) + + FileToggleList( + fileUris: visibleFileUris, + reviewStatus: reviewStatus, + selectedFileUris: $selectedFileUris, + onSelectionChange: updateMixedState + ) + .padding(.leading, 16) + + if hasMoreFiles { + if !isExpanded { + ExpandFilesButton(isExpanded: $isExpanded) + } + + if isExpanded { + FileToggleList( + fileUris: additionalFileUris, + reviewStatus: reviewStatus, + selectedFileUris: $selectedFileUris, + onSelectionChange: updateMixedState + ) + .padding(.leading, 16) + } + } + } + } + .frame(alignment: .leading) + .onAppear { + updateMixedState() + } + } + + private var selectedAllCheckbox: some View { + let selectedCount = selectedFileUris.count + let totalCount = fileUris.count + let title = "All (\(selectedCount)/\(totalCount))" + let font: NSFont = .systemFont(ofSize: chatFontSize * fontScale) + + return MixedStateCheckbox( + title: title, + font: font, + state: $checkboxMixedState + ) { + switch checkboxMixedState { + case .off, .mixed: + // Select all files + selectedFileUris = fileUris + case .on: + // Deselect all files + selectedFileUris = [] + } + updateMixedState() + } + } + + private func updateMixedState() { + let selectedSet = Set(selectedFileUris) + let selectedCount = fileUris.filter { selectedSet.contains($0) }.count + let totalCount = fileUris.count + + if selectedCount == 0 { + checkboxMixedState = .off + } else if selectedCount == totalCount { + checkboxMixedState = .on + } else { + checkboxMixedState = .mixed + } + } +} + +private struct ExpandFilesButton: View { + @Binding var isExpanded: Bool + @AppStorage(\.chatFontSize) private var chatFontSize + + var body: some View { + HStack(spacing: 2) { + Image("chevron.down") + .resizable() + .scaledToFit() + .scaledFrame(width: 16, height: 16) + + Button(action: { isExpanded = true }) { + Text("Show more") + .underline() + .scaledFont(.system(size: chatFontSize)) + .lineSpacing(20) + } + .buttonStyle(PlainButtonStyle()) + } + .foregroundColor(.blue) + } +} + +private struct FileToggleList: View { + let fileUris: [DocumentUri] + let reviewStatus: CodeReviewRound.Status + @Binding var selectedFileUris: [DocumentUri] + let onSelectionChange: () -> Void + + var body: some View { + ForEach(fileUris, id: \.self) { fileUri in + FileSelectionRow( + fileUri: fileUri, + reviewStatus: reviewStatus, + isSelected: createSelectionBinding(for: fileUri) + ) + } + } + + private func createSelectionBinding(for fileUri: DocumentUri) -> Binding { + Binding( + get: { selectedFileUris.contains(fileUri) }, + set: { isSelected in + if isSelected { + if !selectedFileUris.contains(fileUri) { + selectedFileUris.append(fileUri) + } + } else { + selectedFileUris.removeAll { $0 == fileUri } + } + + onSelectionChange() + } + ) + } +} + +private struct FileSelectionRow: View { + let fileUri: DocumentUri + let reviewStatus: CodeReviewRound.Status + @Binding var isSelected: Bool + + private var fileURL: URL? { + URL(string: fileUri) + } + + private var isInteractionEnabled: Bool { + reviewStatus == .waitForConfirmation + } + + var body: some View { + HStack(alignment: .center) { + Toggle(isOn: $isSelected) { + HStack(spacing: 8) { + drawFileIcon(fileURL) + .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..1a239021 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift @@ -0,0 +1,183 @@ +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(alignment: .center, spacing: 4) { + drawFileIcon(fileURL) + .scaledToFit() + .scaledFrame(width: 16, height: 16) + + Button(action: { + if hasComments { + store.send(.codeReview(.onFileClicked(fileURL, comments[0].range.end.line))) + } + }) { + Text(fileURL.lastPathComponent) + .scaledFont(.system(size: chatFontSize)) + .foregroundColor(isHovered ? Color("ItemSelectedColor") : .primary) + } + .buttonStyle(PlainButtonStyle()) + .disabled(!hasComments) + .onHover { hovering in + isHovered = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + Text(commentCountText) + .scaledFont(size: chatFontSize - 1) + .lineSpacing(20) + .foregroundColor(.secondary) + + Spacer() + } + } +} diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift new file mode 100644 index 00000000..76fcbf6d --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift @@ -0,0 +1,49 @@ +import SwiftUI +import ConversationServiceProvider +import SharedUIComponents + +struct ReviewSummarySection: View { + var round: CodeReviewRound + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + HStack { + if round.status == .error, let errorMessage = round.error { + Text(errorMessage) + .scaledFont(size: chatFontSize) + } else if round.status == .completed, let request = round.request, let response = round.response { + CompletedSummary(request: request, response: response) + } else { + Text("Oops, failed to review changes.") + .font(.system(size: chatFontSize)) + } + + Spacer() + } + } +} + +struct CompletedSummary: View { + var request: CodeReviewRequest + var response: CodeReviewResponse + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + let changedFileUris = request.changedFileUris + let selectedFileUris = request.selectedFileUris + let allComments = response.allComments + + VStack(alignment: .leading, spacing: 8) { + + Text("Total comments: \(allComments.count)") + + if allComments.count > 0 { + Text("Review complete! We found \(allComments.count) comment(s) in your selected file(s). Click a file name to see details in the editor.") + } else { + Text("Copilot reviewed \(selectedFileUris.count) out of \(changedFileUris.count) changed files, and no comments were found.") + } + + } + .scaledFont(size: chatFontSize) + } +} diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift new file mode 100644 index 00000000..95f0e91a --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift @@ -0,0 +1,411 @@ +import ChatService +import ChatTab +import Combine +import ComposableArchitecture +import ConversationServiceProvider +import GitHubCopilotService +import SharedUIComponents +import SwiftUI + +struct ProgressAgentRound: View { + let rounds: [AgentRound] + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 8) { + ForEach(rounds, id: \.roundId) { round in + VStack(alignment: .leading, spacing: 8) { + ThemedMarkdownText(text: round.reply, chat: chat) + if let toolCalls = round.toolCalls, !toolCalls.isEmpty { + ProgressToolCalls(tools: toolCalls, chat: chat) + } + if let subAgentRounds = round.subAgentRounds, !subAgentRounds.isEmpty { + SubAgentRounds(rounds: subAgentRounds, chat: chat) + } + } + } + } + .foregroundStyle(.secondary) + } + } +} + +struct SubAgentRounds: View { + let rounds: [AgentRound] + let chat: StoreOf + + @Environment(\.colorScheme) var colorScheme + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 8) { + ForEach(rounds, id: \.roundId) { round in + VStack(alignment: .leading, spacing: 8) { + ThemedMarkdownText(text: round.reply, chat: chat) + if let toolCalls = round.toolCalls, !toolCalls.isEmpty { + ProgressToolCalls(tools: toolCalls, chat: chat) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .scaledPadding(.horizontal, 16) + .scaledPadding(.vertical, 12) + .background(RoundedRectangle(cornerRadius: 8).fill(Color("SubagentTurnBackground"))) + } + } +} + +struct ProgressToolCalls: View { + let tools: [AgentToolCall] + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 4) { + ForEach(tools) { tool in + if tool.name == ToolName.runInTerminal.rawValue && (tool.invokeParams != nil || tool.input != nil) { + RunInTerminalToolView(tool: tool, chat: chat) + } else if tool.invokeParams != nil && tool.status == .waitForConfirmation { + ToolConfirmationView(tool: tool, chat: chat) + } else if tool.isToolcallingLoopContinueTool { + // ignore rendering for internal tool calling loop continue tool + } else { + ToolStatusItemView(tool: tool) + } + } + } + } + } +} + +struct ToolConfirmationView: View { + let tool: AgentToolCall + let chat: StoreOf + + @AppStorage(\.chatFontSize) var chatFontSize + + private var toolName: String { tool.name } + private var titleText: String { tool.title ?? "" } + private var mcpServerName: String? { ToolAutoApprovalManager.extractMCPServerName(from: titleText) } + private var conversationId: String { tool.invokeParams?.conversationId ?? "" } + private var invokeMessage: String { tool.invokeParams?.message ?? "" } + private var isSensitiveFileOperation: Bool { ToolAutoApprovalManager.isSensitiveFileOperation(message: invokeMessage) } + private var sensitiveFileInfo: ToolAutoApprovalManager.SensitiveFileConfirmationInfo { + ToolAutoApprovalManager.extractSensitiveFileConfirmationInfo(from: invokeMessage) + } + + private var shouldShowMCPSplitButton: Bool { mcpServerName != nil && !conversationId.isEmpty } + private var shouldShowSensitiveFileSplitButton: Bool { + mcpServerName == nil && isSensitiveFileOperation && !conversationId.isEmpty + } + + @ViewBuilder + private var confirmationActionView: some View { + if #available(macOS 13.0, *), + FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval && + CopilotPolicyNotifierImpl.shared.copilotPolicy.agentModeAutoApprovalEnabled { + if tool.isToolcallingLoopContinueTool { + continueButton + } else if shouldShowSensitiveFileSplitButton { + sensitiveFileSplitButton + } else if shouldShowMCPSplitButton, let serverName = mcpServerName { + mcpSplitButton(serverName: serverName) + } else { + allowButton + } + } else { + legacyAllowOrContinueButton + } + } + + private var continueButton: some View { + Button(action: { + chat.send(.toolCallAccepted(tool.id)) + }) { + Text("Continue") + .scaledFont(.body) + } + .buttonStyle(.borderedProminent) + } + + private var allowButton: some View { + Button(action: { + chat.send(.toolCallAccepted(tool.id)) + }) { + Text("Allow") + .scaledFont(.body) + } + .buttonStyle(.borderedProminent) + } + + private var legacyAllowOrContinueButton: some View { + Button(action: { + chat.send(.toolCallAccepted(tool.id)) + }) { + Text(tool.isToolcallingLoopContinueTool ? "Continue" : "Allow") + .scaledFont(.body) + } + .buttonStyle(.borderedProminent) + } + + @available(macOS 13.0, *) + private var sensitiveFileMenuItems: [SplitButtonMenuItem] { + var items: [SplitButtonMenuItem] = [] + + items.append( + SplitButtonMenuItem(title: "Allow in this Session") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .sensitiveFile( + scope: .session(conversationId), + toolName: toolName, + description: sensitiveFileInfo.description, + pattern: sensitiveFileInfo.pattern + ) + ) + ) + } + ) + + let defaultPatterns = ["**/.github/instructions/*", "**/github-copilot/**/*", "outside-workspace"] + + if let pattern = sensitiveFileInfo.pattern, !pattern.isEmpty, !defaultPatterns.contains(pattern) { + items.append( + SplitButtonMenuItem(title: "Always Allow") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .sensitiveFile( + scope: .global, + toolName: toolName, + description: sensitiveFileInfo.description, + pattern: pattern + ) + ) + ) + } + ) + } + + items.append(.divider()) + items.append( + SplitButtonMenuItem(title: "Configure Auto Approve...") { + chat.send(.openAutoApproveSettings) + } + ) + + return items + } + + @available(macOS 13.0, *) + private var sensitiveFileSplitButton: some View { + SplitButton( + title: "Allow", + isDisabled: false, + primaryAction: { + chat.send(.toolCallAccepted(tool.id)) + }, + menuItems: sensitiveFileMenuItems, + style: .prominent + ) + } + + @available(macOS 13.0, *) + private func mcpMenuItems(serverName: String) -> [SplitButtonMenuItem] { + var items: [SplitButtonMenuItem] = [] + + items.append( + SplitButtonMenuItem(title: "Allow \(toolName) in this Session") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .mcpTool( + scope: .session(conversationId), + serverName: serverName, + toolName: toolName + ) + ) + ) + } + ) + + items.append( + SplitButtonMenuItem(title: "Always Allow \(toolName)") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .mcpTool( + scope: .global, + serverName: serverName, + toolName: toolName + ) + ) + ) + } + ) + + items.append(.divider()) + + items.append( + SplitButtonMenuItem(title: "Allow tools from \(serverName) in this Session") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .mcpServer( + scope: .session(conversationId), + serverName: serverName + ) + ) + ) + } + ) + + items.append( + SplitButtonMenuItem(title: "Always Allow tools from \(serverName)") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .mcpServer( + scope: .global, + serverName: serverName + ) + ) + ) + } + ) + + items.append(.divider()) + + items.append( + SplitButtonMenuItem(title: "Configure Auto Approve...") { + chat.send(.openAutoApproveSettings) + } + ) + + return items + } + + @available(macOS 13.0, *) + private func mcpSplitButton(serverName: String) -> some View { + SplitButton( + title: "Allow", + isDisabled: false, + primaryAction: { + chat.send(.toolCallAccepted(tool.id)) + }, + menuItems: mcpMenuItems(serverName: serverName), + style: .prominent + ) + } + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 8) { + if let title = tool.title { + ToolConfirmationTitleView(title: title, fontWeight: .semibold) + } else { + GenericToolTitleView(toolStatus: "Run", toolName: tool.name, fontWeight: .semibold) + } + + ThemedMarkdownText(text: tool.invokeParams?.message ?? "", chat: chat) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Button(action: { + chat.send(.toolCallCancelled(tool.id)) + }) { + Text(tool.isToolcallingLoopContinueTool ? "Cancel" : "Skip") + .scaledFont(.body) + } + + confirmationActionView + } + .frame(maxWidth: .infinity, alignment: .leading) + .scaledPadding(.top, 4) + } + .scaledPadding(8) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + } + } +} + +struct ToolConfirmationTitleView: View { + var title: String + var fontWeight: Font.Weight = .regular + + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + HStack(spacing: 4) { + Text(title) + .textSelection(.enabled) + .scaledFont(size: chatFontSize, weight: fontWeight) + .foregroundStyle(.primary) + .background(Color.clear) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct GenericToolTitleView: View { + var toolStatus: String + var toolName: String + var fontWeight: Font.Weight = .regular + + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + HStack(spacing: 4) { + Text(toolStatus) + .textSelection(.enabled) + .scaledFont(size: chatFontSize - 1, weight: fontWeight) + .foregroundStyle(.primary) + .background(Color.clear) + Text(toolName) + .textSelection(.enabled) + .scaledFont(size: chatFontSize - 1, weight: fontWeight) + .foregroundStyle(.primary) + .scaledPadding(.vertical, 2) + .scaledPadding(.horizontal, 4) + .background(Color("ToolTitleHighlightBgColor")) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .inset(by: 0.5) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct ProgressAgentRound_Preview: PreviewProvider { + static let agentRounds: [AgentRound] = [ + .init(roundId: 1, reply: "this is agent step", toolCalls: [ + .init( + id: "toolcall_001", + name: "Tool Call 1", + progressMessage: "Read Tool Call 1", + status: .completed, + error: nil), + .init( + id: "toolcall_002", + name: "Tool Call 2", + progressMessage: "Running Tool Call 2", + status: .running), + ]), + ] + + static var previews: some View { + let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") + ProgressAgentRound(rounds: agentRounds, chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) })) + .frame(width: 300, height: 300) + } +} diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ExpandableFileListView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ExpandableFileListView.swift new file mode 100644 index 00000000..f5a95a2d --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ExpandableFileListView.swift @@ -0,0 +1,219 @@ +import SwiftUI +import SharedUIComponents +import AppKit +import Terminal + +struct FileSearchResult: Hashable { + var file: String + var startLine: Int? = nil + var endLine: Int? = nil + var content: String? = nil +} + +struct ExpandableFileListView: View { + var progressMessage: ProgressMessage + var files: [FileSearchResult] + var chatFontSize: Double + var helpText: String + var onFileClick: ((String) -> Void)? = nil + var fileHelpTexts: [String: String]? = nil + + @State private var isExpanded: Bool = false + + init( + progressMessage: ProgressMessage, + files: [FileSearchResult], + chatFontSize: Double, + helpText: String, + onFileClick: ((String) -> Void)? = nil, + fileHelpTexts: [String: String]? = nil + ) { + self.progressMessage = progressMessage + self.files = files + self.chatFontSize = chatFontSize + self.helpText = helpText + self.onFileClick = onFileClick + self.fileHelpTexts = fileHelpTexts + } + + init( + progressMessage: ProgressMessage, + files: [String], + chatFontSize: Double, + helpText: String, + onFileClick: ((String) -> Void)? = nil, + fileHelpTexts: [String: String]? = nil + ) { + self.init( + progressMessage: progressMessage, + files: files.map { FileSearchResult(file: $0) }, + chatFontSize: chatFontSize, + helpText: helpText, + onFileClick: onFileClick, + fileHelpTexts: fileHelpTexts + ) + } + + private let maxVisibleRows = 5 + private let chevronWidth: CGFloat = 16 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header with chevron on the left + Button(action: { + isExpanded.toggle() + }) { + HStack(spacing: 4) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .resizable() + .scaledToFit() + .padding(4) + .scaledFrame(width: chevronWidth, height: chevronWidth) + .scaledFont(size: 10, weight: .medium) + .foregroundColor(.secondary) + + progressMessage + .scaledFont(size: chatFontSize - 1) + .lineLimit(1) + + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(helpText) + + if isExpanded { + HStack(alignment: .top, spacing: 0) { + // Vertical line aligned with chevron center + Rectangle() + .fill(Color.secondary.opacity(0.3)) + .scaledFrame(width: 1) + .scaledPadding(.leading, chevronWidth / 2 - 0.5) + + // File list + VStack(alignment: .leading, spacing: 0) { + if files.count <= maxVisibleRows { + ForEach(files, id: \.self) { fileItem in + fileRow(for: fileItem) + } + } else { + ThinScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(files, id: \.self) { fileItem in + fileRow(for: fileItem) + } + } + } + .frame(height: CGFloat(maxVisibleRows) * 23) + } + } + .scaledPadding(.leading, chevronWidth / 2) + } + .scaledPadding(.top, 4) + } + } + } + + @ViewBuilder + private func fileRow(for fileItem: FileSearchResult) -> some View { + let filePath = fileItem.file + let isDirectory = filePath.hasSuffix("/") + let cleanPath = isDirectory ? String(filePath.dropLast()) : filePath + let url = URL(string: cleanPath).flatMap { $0.scheme == "file" ? $0 : nil } ?? URL(fileURLWithPath: cleanPath) + let displayName: String = { + var name = isDirectory ? url.lastPathComponent + "/" : url.lastPathComponent + if let line = fileItem.startLine, !isDirectory { + name += ": \(line)" + if let endLine = fileItem.endLine { + name += "-\(endLine)" + } + } + return name + }() + + Button(action: { + if let onFileClick = onFileClick { + onFileClick(filePath) + } else { + if let line = fileItem.startLine, !isDirectory { + Task { + let terminal = Terminal() + do { + _ = try await terminal.runCommand( + "/usr/bin/xed", + arguments: [ + "-l", + String(line), + url.path + ], + environment: [ + "TARGET_FILE": url.path + ] + ) + } catch { + print("Failed to open file with xed: \(error)") + NSWorkspace.shared.open(url) + } + } + } else { + NSWorkspace.shared.open(url) + } + } + }) { + HStack(alignment: .center, spacing: 6) { + drawFileIcon(url, isDirectory: isDirectory) + .scaledToFit() + .scaledFrame(width: 13, height: 13) + .foregroundColor(.secondary) + + Text(displayName) + .scaledFont(size: chatFontSize - 1) + .foregroundColor(.secondary) + .lineLimit(1) + + Spacer() + } + .contentShape(Rectangle()) + } + .help(fileHelpTexts?[filePath] ?? url.path) + .buttonStyle(HoverButtonStyle()) + } +} + +// NSScrollView wrapper for thin, overlay-style scrollbars +struct ThinScrollView: NSViewRepresentable { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = false + scrollView.scrollerStyle = .overlay + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + + let hostingView = NSHostingView(rootView: content) + scrollView.documentView = hostingView + + // Ensure the hosting view can expand vertically + hostingView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingView.widthAnchor.constraint(equalTo: scrollView.widthAnchor) + ]) + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + if let hostingView = scrollView.documentView as? NSHostingView { + hostingView.rootView = content + hostingView.invalidateIntrinsicContentSize() + } + } +} diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift new file mode 100644 index 00000000..9238a932 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift @@ -0,0 +1,580 @@ +import SwiftUI +import ConversationServiceProvider +import SharedUIComponents +import ComposableArchitecture +import MarkdownUI + +struct ToolStatusItemView: View { + + let tool: AgentToolCall + + @AppStorage(\.chatFontSize) var chatFontSize + @AppStorage(\.fontScale) var fontScale + + @State private var isHoveringFileLink = false + + var statusIcon: some View { + Group { + switch tool.status { + case .running: + ProgressView() + .controlSize(.small) + .scaledScaleEffect(0.7) + case .completed: + Image(systemName: "checkmark") + .foregroundColor(.secondary) + case .error: + Image(systemName: "xmark") + .foregroundColor(.red.opacity(0.5)) + case .cancelled: + Image(systemName: "slash.circle") + .foregroundColor(.gray.opacity(0.5)) + case .waitForConfirmation: + EmptyView() + case .accepted: + EmptyView() + } + } + .scaledFont(size: chatFontSize - 1, weight: .medium) + } + + @ViewBuilder + var progressTitleText: some View { + if tool.name == ServerToolName.findFiles.rawValue { + searchProgressView( + pattern: "Searched for files matching query: (.*)", + prefix: "Searched for files matching ", + singularSuffix: "match", + pluralSuffix: "matches" + ) + } else if tool.name == ServerToolName.findTextInFiles.rawValue { + searchProgressView( + pattern: "Searched for text in files matching query: (.*)", + prefix: "Searched for text in files matching ", + singularSuffix: "result", + pluralSuffix: "results" + ) + } else if tool.name == ServerToolName.readFile.rawValue || tool.name == CopilotToolName.readFile.rawValue { + readFileProgressView + } else if tool.name == ToolName.createFile.rawValue { + createFileProgressView + } else if tool.name == ServerToolName.replaceString.rawValue { + replaceStringProgressView + } else if tool.name == ToolName.insertEditIntoFile.rawValue { + insertEditIntoFileProgressView + } else if tool.name == ServerToolName.codebase.rawValue { + codebaseSearchProgressView + } else { + otherToolsProgressView + } + } + + @ViewBuilder + func searchProgressView(pattern: String, prefix: String, singularSuffix: String, pluralSuffix: String) -> some View { + let message = tool.progressMessage ?? "" + let matchCountText: String = { + if let parsed = parsedFileListResult { + let suffix = parsed.count == 1 ? singularSuffix : pluralSuffix + return "\(parsed.count) \(suffix)" + } + return "" + }() + + if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)), + let range = Range(match.range(at: 1), in: message) { + + let query = String(message[range]) + let suffix = matchCountText.isEmpty ? "" : ": \(matchCountText)" + + HStack(spacing: 0) { + Text(prefix) + Text(query) + .scaledFont(size: chatFontSize - 1, weight: .regular, design: .monospaced) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(SecondarySystemFillColor) + .foregroundColor(.secondary) + .cornerRadius(4) + .padding(.horizontal, 2) + Text(suffix) + } + } else { + let displayMessage: String = { + if message.isEmpty { + return matchCountText + } else { + return message + (matchCountText.isEmpty ? "" : ": \(matchCountText)") + } + }() + + markdownView(text: displayMessage) + } + } + + @ViewBuilder + var readFileProgressView: some View { + let pattern = #"^Read file \[(?.+?)\]\((?.+?)\)(?:, lines (?\d+) to (?\d+))?"# + fileOperationProgressView(prefix: "Read", pattern: pattern) { match in + let message = tool.progressMessage ?? "" + if let startRange = Range(match.range(withName: "start"), in: message), + let endRange = Range(match.range(withName: "end"), in: message) { + let start = String(message[startRange]) + let end = String(message[endRange]) + Text(": \(start)-\(end)") + .foregroundColor(.secondary) + .scaledFont(size: chatFontSize - 1) + } + } + } + + @ViewBuilder + var createFileProgressView: some View { + let pattern = #"^Created \[(?.+?)\]\((?.+?)\)"# + fileOperationProgressView(suffix: "created successfully.", pattern: pattern) + } + @ViewBuilder + var replaceStringProgressView: some View { + let pattern = #"^Edited \[(?.+?)\]\((?.+?)\) with replace_string_in_file tool"# + fileOperationProgressView(prefix: "Edited", suffix: "with replace_string_in_file tool.", pattern: pattern) + } + + @ViewBuilder + var insertEditIntoFileProgressView: some View { + let pattern = #"^Edited \[(?.+?)\]\((?.+?)\) with insert_edit_into_file tool"# + fileOperationProgressView(prefix: "Edited", suffix: "with insert_edit_into_file tool.", pattern: pattern) + } + + @ViewBuilder + var codebaseSearchProgressView: some View { + let pattern = #"^Searched (?.+) for "(?.+)", (?no|\d+) results?$"# + if let regex = try? NSRegularExpression(pattern: pattern), + let message = tool.progressMessage, + let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)), + let targetRange = Range(match.range(withName: "target"), in: message), + let queryRange = Range(match.range(withName: "query"), in: message), + let countRange = Range(match.range(withName: "count"), in: message) { + + let target = String(message[targetRange]) + let query = String(message[queryRange]) + let countStr = String(message[countRange]) + let count = countStr == "no" ? "0" : countStr + let suffix = count == "1" ? "result" : "results" + + HStack(spacing: 0) { + Text("Searched \(target) for ") + Text(query) + .scaledFont(size: chatFontSize - 1, weight: .regular, design: .monospaced) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(SecondarySystemFillColor) + .foregroundColor(.secondary) + .cornerRadius(4) + .padding(.horizontal, 2) + Text(": \(count) \(suffix)") + } + } else { + markdownView(text: tool.progressMessage ?? "") + } + } + + @ViewBuilder + func fileOperationProgressView( + prefix: String? = nil, + suffix: String? = nil, + pattern: String, + @ViewBuilder extraContent: (NSTextCheckingResult) -> Content = { _ in EmptyView() } + ) -> some View { + let message = tool.progressMessage ?? "" + + if tool.name == ToolName.createFile.rawValue, tool.status == .error { + if let input = tool.invokeParams?.input, let filePath = input["filePath"]?.value as? String { + let url = URL(fileURLWithPath: filePath) + let name = url.lastPathComponent + HStack(spacing: 4) { + drawFileIcon(url) + .scaledToFit() + .scaledFrame(width: 16, height: 16) + Text(name).scaledFont(size: chatFontSize - 1) + Text("File creation failed") + } + } else { + markdownView(text: message) + } + } else if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)), + let nameRange = Range(match.range(withName: "name"), in: message), + let pathRange = Range(match.range(withName: "path"), in: message) { + + let name = String(message[nameRange]) + let pathString = String(message[pathRange]) + let url = URL(string: pathString).flatMap { $0.scheme == "file" ? $0 : nil } ?? URL(fileURLWithPath: pathString) + + HStack(spacing: 4) { + if let prefix { + Text(prefix) + } + + drawFileIcon(url) + .scaledToFit() + .scaledFrame(width: 16, height: 16) + + Button(action: { + NSWorkspace.shared.open(url) + }) { + Text(name) + .scaledFont(size: chatFontSize - 1) + .foregroundColor(isHoveringFileLink ? .primary : .secondary) + } + .buttonStyle(.plain) + .onHover { hovering in + isHoveringFileLink = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + if let suffix { + Text(suffix) + } + + extraContent(match) + .padding(.leading, -4) + } + } else { + markdownView(text: message) + } + } + + @ViewBuilder + var otherToolsProgressView: some View { + let message: String = { + var msg = tool.progressMessage ?? "" + if tool.name == ToolName.createFile.rawValue { + if let input = tool.invokeParams?.input, let filePath = input["filePath"]?.value as? String { + let fileURL = URL(fileURLWithPath: filePath) + msg += ": [\(fileURL.lastPathComponent)](\(fileURL.absoluteString))" + } + } + return msg + }() + + if message.isEmpty { + GenericToolTitleView(toolStatus: "Running", toolName: tool.name) + } else { + markdownView(text: message) + } + } + + func markdownView(text: String) -> some View { + ThemedMarkdownText( + text: text, + context: .init(supportInsert: false), + foregroundColor: .secondary + ) + .environment(\.openURL, OpenURLAction { url in + if url.scheme == "file" || url.isFileURL { + NSWorkspace.shared.open(url) + return .handled + } else { + return .systemAction + } + }) + } + + var progressErrorText: some View { + ThemedMarkdownText( + text: tool.error ?? "", + context: .init(supportInsert: false), + foregroundColor: .secondary + ) + } + + @ViewBuilder + func toolCallDetailSection(title: String, text: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .scaledFont(size: chatFontSize - 1, weight: .medium) + .foregroundColor(.secondary) + markdownView(text: text) + .toolCallDetailStyle(fontScale: fontScale) + } + } + + var mcpDetailView: some View { + VStack(alignment: .leading, spacing: 8) { + if let inputMessage = tool.inputMessage, !inputMessage.isEmpty { + toolCallDetailSection(title: "Input", text: inputMessage) + } + if let errorMessage = tool.error, !errorMessage.isEmpty { + toolCallDetailSection(title: "Output", text: errorMessage) + } + if let result = tool.result, !result.isEmpty { + toolCallDetailSection(title: "Output", text: toolResultText ?? "") + } + } + } + + var progress: some View { + HStack(spacing: 4) { + statusIcon + .scaledFrame(width: 16, height: 16) + + progressTitleText + .scaledFont(size: chatFontSize - 1) + .lineLimit(1) + + Spacer() + } + .help(tool.progressMessage ?? "") + } + + var toolResultText: String? { + tool.result?.compactMap({ item -> String? in + if case .text(let s) = item { return s } + return nil + }).joined(separator: "\n") + } + + func extractCreateFileContent(from text: String) -> String { + let pattern = #"(?s)\n?(.*?)\n?"# + if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), + let range = Range(match.range(at: 1), in: text) { + return String(text[range]) + } + return text + } + + func extractInsertEditContent(from text: String) -> String { + let pattern = #"(?s)\n?(.*?)\n?"# + if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), + let range = Range(match.range(at: 1), in: text) { + return String(text[range]) + } + return text + } + + var parsedFileListResult: (count: Int, files: [FileSearchResult])? { + guard let resultText = toolResultText, + !resultText.isEmpty else { + return nil + } + + // Parse find_files result + if tool.name == ServerToolName.findFiles.rawValue { + if resultText.hasPrefix("No files found") { + return (0, []) + } + + let pattern = "Found (\\d+) files? matching query:" + if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: resultText, range: NSRange(resultText.startIndex..., in: resultText)), + let range = Range(match.range(at: 1), in: resultText), + let count = Int(resultText[range]) { + + if let newlineIndex = resultText.firstIndex(of: "\n") { + let filesPart = resultText[resultText.index(after: newlineIndex)...] + let files = filesPart.split(separator: "\n").map { FileSearchResult(file: String($0)) } + return (count, files) + } + } + } + + // Parse grep_search result + if tool.name == ServerToolName.findTextInFiles.rawValue { + if resultText.contains("no results") { + return (0, []) + } + + let countPattern = "Searched text for: .*, (\\d+) results?" + var count = 0 + if let regex = try? NSRegularExpression(pattern: countPattern), + let match = regex.firstMatch(in: resultText, range: NSRange(resultText.startIndex..., in: resultText)), + let range = Range(match.range(at: 1), in: resultText), + let parsedCount = Int(resultText[range]) { + count = parsedCount + } + + var files: [FileSearchResult] = [] + let lines = resultText.split(separator: "\n") + // Skip the first line which is the summary + if lines.count > 1 { + for line in lines.dropFirst() { + let parts = line.split(separator: ":", maxSplits: 2) + if parts.count >= 2 { + let path = String(parts[0]) + if let lineNumber = Int(parts[1]) { + let content = parts.count > 2 ? String(parts[2]) : nil + files.append(FileSearchResult(file: path, startLine: lineNumber, content: content)) + } else { + files.append(FileSearchResult(file: path)) + } + } + } + } + + return (count, files) + } + + // Parse list_dir result + if tool.name == ServerToolName.listDir.rawValue { + let files = resultText.split(separator: "\n").map { FileSearchResult(file: String($0)) } + return (files.count, files) + } + + return nil + } + + var parsedCodebaseSearchResult: (count: Int, files: [FileSearchResult])? { + guard let details = tool.resultDetails, !details.isEmpty else { return nil } + + var files: [FileSearchResult] = [] + for item in details { + if case .fileLocation(let location) = item { + files + .append( + FileSearchResult( + file: location.uri, + startLine: location.range.start.line, + endLine: location.range.end.line + ) + ) + } + } + + return (files.count, files) + } + + var body: some View { + WithPerceptionTracking { + if tool.name == ToolName.createFile.rawValue, let resultText = toolResultText, !resultText.isEmpty { + ToolStatusDetailsView( + title: progress, + content: markdownView(text: extractCreateFileContent(from: resultText)) + ) + } else if tool.name == ServerToolName.replaceString.rawValue, let resultText = toolResultText, !resultText.isEmpty { + ToolStatusDetailsView( + title: progress, + content: markdownView(text: resultText) + ) + } else if tool.name == ToolName.insertEditIntoFile.rawValue, let resultText = toolResultText, !resultText.isEmpty { + ToolStatusDetailsView( + title: progress, + content: markdownView(text: extractInsertEditContent(from: resultText)) + ) + } else if tool.toolType == .mcp { + ToolStatusDetailsView( + title: progress, + content: mcpDetailView + ) + } else if tool.status == .error { + ToolStatusDetailsView( + title: progress, + content: progressErrorText + ) + } else if let result = parsedFileListResult, + !result.files.isEmpty { + ExpandableFileListView( + progressMessage: progressTitleText, + files: result.files, + chatFontSize: chatFontSize, + helpText: tool.progressMessage ?? "" + ) + .scaledPadding(.horizontal, 6) + } else if let result = parsedCodebaseSearchResult, + !result.files.isEmpty { + ExpandableFileListView( + progressMessage: progressTitleText, + files: result.files, + chatFontSize: chatFontSize, + helpText: tool.progressMessage ?? "" + ) + .scaledPadding(.horizontal, 6) + } else { + progress.scaledPadding(.horizontal, 6) + } + } + } +} + + +private struct ToolStatusDetailsView: View { + var title: Title + var content: Content + + @State private var isExpanded = false + @AppStorage(\.fontScale) var fontScale + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + + Button(action: { + isExpanded.toggle() + }) { + HStack(spacing: 8) { + title + + Spacer() + + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .resizable() + .scaledToFit() + .padding(4) + .scaledFrame(width: 16, height: 16) + .scaledFont(size: 10, weight: .medium) + } + .contentShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + .scaledPadding(.horizontal, 6) + .toolStatusStyle(withBackground: !isExpanded, fontScale: fontScale) + + if isExpanded { + Divider() + .background(Color.agentToolStatusDividerColor) + + content + .scaledPadding(.horizontal, 8) + } + } + .toolStatusStyle(withBackground: isExpanded, fontScale: fontScale) + } +} + +private extension View { + func toolStatusStyle(withBackground: Bool, fontScale: CGFloat) -> some View { + /// Leverage the `modify` extension to avoid refreshing of chat panel `List` view + self.modify { view in + if withBackground { + view + .scaledPadding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.agentToolStatusOutlineColor, lineWidth: 1 * fontScale) + ) + } else { + view + } + } + } + + func toolCallDetailStyle(fontScale: CGFloat) -> some View { + /// Leverage the `modify` extension to avoid refreshing of chat panel `List` view + self.modify { view in + view + .foregroundColor(.secondary) + .scaledPadding(4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(SecondarySystemFillColor) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .background( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.agentToolStatusOutlineColor, lineWidth: 1 * fontScale) + ) + } + } +} diff --git a/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift new file mode 100644 index 00000000..739b126a --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift @@ -0,0 +1,84 @@ +import SwiftUI +import ConversationServiceProvider +import ComposableArchitecture +import Combine +import ChatService +import SharedUIComponents + +struct ProgressStep: View { + let steps: [ConversationProgressStep] + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 4) { + ForEach(steps) { StatusItemView(step: $0) } + } + .foregroundStyle(.secondary) + } + } +} + + +struct StatusItemView: View { + + let step: ConversationProgressStep + + @AppStorage(\.chatFontSize) var chatFontSize + + var statusIcon: some View { + Group { + switch step.status { + case .running: + ProgressView() + .controlSize(.small) + .scaledScaleEffect(0.7) + case .completed: + Image(systemName: "checkmark") + .foregroundColor(Color.successLightGreen) + case .failed: + Image(systemName: "xmark.circle") + .foregroundColor(.red) + case .cancelled: + Image(systemName: "slash.circle") + .foregroundColor(.gray) + } + } + .scaledFont(size: chatFontSize - 1, weight: .medium) + } + + var statusTitleText: String { + if step.id == ProjectContextSkill.ProgressID && step.status == .failed { + return step.error?.message ?? step.title + } + return step.title + } + + var body: some View { + WithPerceptionTracking { + HStack(spacing: 4) { + statusIcon + .scaledFrame(width: 16, height: 16) + + Text(statusTitleText) + .scaledFont(size: chatFontSize - 1) + .lineLimit(1) + + Spacer() + } + .help(statusTitleText) + } + } +} + +struct ProgressStep_Preview: PreviewProvider { + static let steps: [ConversationProgressStep] = [ + .init(id: "001", title: "running step", description: "this is running step", status: .running, error: nil), + .init(id: "002", title: "completed step", description: "this is completed step", status: .completed, error: nil), + .init(id: "003", title: "failed step", description: "this is failed step", status: .failed, error: nil), + .init(id: "004", title: "cancelled step", description: "this is cancelled step", status: .cancelled, error: nil) + ] + static var previews: some View { + ProgressStep(steps: steps) + .frame(width: 300, height: 300) + } +} diff --git a/Core/Sources/ConversationTab/Views/FunctionMessage.swift b/Core/Sources/ConversationTab/Views/FunctionMessage.swift index 3e6b031a..0523a44e 100644 --- a/Core/Sources/ConversationTab/Views/FunctionMessage.swift +++ b/Core/Sources/ConversationTab/Views/FunctionMessage.swift @@ -1,30 +1,126 @@ import Foundation -import MarkdownUI import SwiftUI +import ChatService +import SharedUIComponents +import ComposableArchitecture +import ChatTab +import GitHubCopilotService struct FunctionMessage: View { - let id: String let text: String + let chat: StoreOf @AppStorage(\.chatFontSize) var chatFontSize + @Environment(\.openURL) private var openURL + + 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 isBYOKUser: Bool { + text.contains("You've reached your quota limit for your BYOK model") + } + + 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, isBYOKUser) { + case (true, _, _): + return Text("Monthly message limit reached. Upgrade to Copilot Pro (30-day free trial) or wait for your limit to reset.") + + case (_, true, _): + let parts = [ + "You have exceeded your free request allowance.", + switchToFallbackModelText, + "To enable additional paid premium requests, contact your organization admin." + ].filter { !$0.isEmpty } + return Text(attributedString(from: parts)) + + case (_, _, true): + let sentences = splitBYOKQuotaMessage(text) + + guard sentences.count == 2 else { fallthrough } + + let parts = [ + sentences[0], + switchToFallbackModelText, + sentences[1] + ].filter { !$0.isEmpty } + return Text(attributedString(from: parts)) + + default: + let parts = [text, switchToFallbackModelText].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: " ")) + } + } + + private func splitBYOKQuotaMessage(_ message: String) -> [String] { + // Fast path: find the first period followed by a space + capital P (for "Please") + let boundary = ". Please check with" + if let range = message.range(of: boundary) { + // First sentence ends at the period just before " Please" + let firstSentence = String(message[.. hello - > hi - """) - .padding() - .fixedSize() +struct FunctionMessage_Previews: PreviewProvider { + static var previews: some View { + let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") + FunctionMessage( + 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..98df5ebc --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ImageReferenceItemView.swift @@ -0,0 +1,62 @@ +import ConversationServiceProvider +import SwiftUI +import Foundation +import SharedUIComponents + +struct ImageReferenceItemView: View { + let item: ImageReference + @State private var showPopover = false + @AppStorage(\.fontScale) var fontScale: Double + + 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 { + // The HStack arranges its child views horizontally with a right-to-left layout direction applied via `.environment(\.layoutDirection, .rightToLeft)`. + // This ensures the views are displayed in reverse order to match the desired layout for FlowLayout. + HStack(alignment: .center, spacing: 4) { + let text = getImageTitle() + + Text(text) + .lineLimit(1) + .scaledFont(size: 12) + .truncationMode(.middle) + .scaledFrame(maxWidth: 105, alignment: .center) + .fixedSize(horizontal: true, vertical: false) + + Image(systemName: "photo") + .resizable() + .scaledToFit() + .scaledPadding(.vertical, 2) + .scaledFrame(width: 16, height: 16) + } + .foregroundColor(.primary.opacity(0.85)) + .scaledPadding(.horizontal, 4) + .scaledPadding(.vertical, 1) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .inset(by: 0.5) + .stroke(Color(nsColor: .quaternaryLabelColor), lineWidth: 1 * fontScale) + ) + .popover(isPresented: $showPopover, arrowEdge: .bottom) { + PopoverImageView(data: item.data) + } + .onTapGesture { + self.showPopover = true + } + } +} + diff --git a/Core/Sources/ConversationTab/Views/Instructions.swift b/Core/Sources/ConversationTab/Views/Instructions.swift deleted file mode 100644 index 8ee892cf..00000000 --- a/Core/Sources/ConversationTab/Views/Instructions.swift +++ /dev/null @@ -1,39 +0,0 @@ -import ComposableArchitecture -import Foundation -import MarkdownUI -import SwiftUI - -struct Instruction: View { - let chat: StoreOf - - var body: some View { - WithPerceptionTracking { - Group { - Markdown( - """ - Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. - """ - ) - .modifier(InstructionModifier()) - } - } - } - - struct InstructionModifier: ViewModifier { - @AppStorage(\.chatFontSize) var chatFontSize - - func body(content: Content) -> some View { - content - .textSelection(.enabled) - .markdownTheme(.instruction(fontSize: chatFontSize)) - .opacity(0.8) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - } - } -} - diff --git a/Core/Sources/ConversationTab/Views/NotificationBanner.swift b/Core/Sources/ConversationTab/Views/NotificationBanner.swift new file mode 100644 index 00000000..f5047793 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/NotificationBanner.swift @@ -0,0 +1,45 @@ +import SwiftUI +import SharedUIComponents + +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 + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 6) { + Image(systemName: style.iconName) + .foregroundColor(style.color) + + VStack(alignment: .leading, spacing: 8) { + content() + } + } + .scaledFont(size: chatFontSize - 1) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .scaledPadding(.vertical, 10) + .scaledPadding(.horizontal, 12) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } +} diff --git a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift index eca57a29..086d724e 100644 --- a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift +++ b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift @@ -1,29 +1,74 @@ import Foundation import MarkdownUI import SwiftUI +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 + + static let defaultForegroundColor: Color = .primary + + @StateObject private var fontScaleManager = FontScaleManager.shared + + let foregroundColor: Color + + var fontScale: Double { + fontScaleManager.currentScale + } + + var scaledChatCodeFont: NSFont { + .monospacedSystemFont(ofSize: 12 * fontScale, weight: .regular) + } + + var scaledChatFontSize: CGFloat { + chatFontSize * fontScale + } let text: String + let context: MarkdownActionProvider - init(_ text: String) { + public init(text: String, context: MarkdownActionProvider, foregroundColor: Color? = nil) { + self.text = text + self.context = context + self.foregroundColor = foregroundColor ?? Self.defaultForegroundColor + } + + init(text: String, chat: StoreOf) { self.text = text + + self.context = .init(onInsert: { content in + chat.send(.insertCode(content)) + }) + self.foregroundColor = Self.defaultForegroundColor } - var body: some View { + public var body: some View { Markdown(text) .textSelection(.enabled) .markdownTheme(.custom( - fontSize: chatFontSize, - codeFont: chatCodeFont.value.nsFont, + fontSize: scaledChatFontSize, + foregroundColor: foregroundColor, + codeFont: scaledChatCodeFont, codeBlockBackgroundColor: { if syncCodeHighlightTheme { if colorScheme == .light, let color = codeBackgroundColorLight.value { @@ -46,7 +91,8 @@ struct ThemedMarkdownText: View { } } return Color.secondary.opacity(0.7) - }() + }(), + context: context )) } } @@ -56,47 +102,87 @@ struct ThemedMarkdownText: View { extension MarkdownUI.Theme { static func custom( fontSize: Double, + foregroundColor: Color, codeFont: NSFont, codeBlockBackgroundColor: Color, - codeBlockLabelColor: Color + codeBlockLabelColor: Color, + context: MarkdownActionProvider ) -> MarkdownUI.Theme { .gitHub.text { - ForegroundColor(.primary) + ForegroundColor(foregroundColor) BackgroundColor(Color.clear) FontSize(fontSize) } .codeBlock { configuration in - let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock) + MarkdownCodeBlockView( + codeBlockConfiguration: configuration, + codeFont: codeFont, + codeBlockBackgroundColor: codeBlockBackgroundColor, + codeBlockLabelColor: codeBlockLabelColor, + context: context + ) + } + } +} + +struct MarkdownCodeBlockView: View { + let codeBlockConfiguration: CodeBlockConfiguration + let codeFont: NSFont + let codeBlockBackgroundColor: Color + let codeBlockLabelColor: Color + let context: MarkdownActionProvider + + var body: some View { + let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock) - if wrapCode { + if wrapCode { + AsyncCodeBlockView( + fenceInfo: codeBlockConfiguration.language, + content: codeBlockConfiguration.content, + font: codeFont + ) + .codeBlockLabelStyle() + .codeBlockStyle( + codeBlockConfiguration, + backgroundColor: codeBlockBackgroundColor, + labelColor: codeBlockLabelColor, + context: context + ) + // Force recreation when font size changes + .id("code-block-\(codeFont.pointSize)") + } else { + ScrollView(.horizontal) { AsyncCodeBlockView( - fenceInfo: configuration.language, - content: configuration.content, + fenceInfo: codeBlockConfiguration.language, + content: codeBlockConfiguration.content, font: codeFont ) .codeBlockLabelStyle() - .codeBlockStyle( - configuration, - backgroundColor: codeBlockBackgroundColor, - labelColor: codeBlockLabelColor - ) - } else { - ScrollView(.horizontal) { - AsyncCodeBlockView( - fenceInfo: configuration.language, - content: configuration.content, - font: codeFont - ) - .codeBlockLabelStyle() - } - .workaroundForVerticalScrollingBugInMacOS() - .codeBlockStyle( - configuration, - backgroundColor: codeBlockBackgroundColor, - labelColor: codeBlockLabelColor - ) } + .workaroundForVerticalScrollingBugInMacOS() + .codeBlockStyle( + codeBlockConfiguration, + backgroundColor: codeBlockBackgroundColor, + labelColor: codeBlockLabelColor, + context: context + ) + // Force recreation when font size changes + .id("code-block-\(codeFont.pointSize)") } } } +struct ThemedMarkdownText_Previews: PreviewProvider { + static var previews: some View { + let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") + ThemedMarkdownText( + text:""" + ```swift + let sumClosure: (Int, Int) -> Int = { (a: Int, b: Int) in + return a + b + } + ``` + """, + context: .init(onInsert: {_ in print("Inserted") })) + } +} diff --git a/Core/Sources/ConversationTab/Views/UserMessage.swift b/Core/Sources/ConversationTab/Views/UserMessage.swift index e6917bbe..4b8a22e3 100644 --- a/Core/Sources/ConversationTab/Views/UserMessage.swift +++ b/Core/Sources/ConversationTab/Views/UserMessage.swift @@ -4,59 +4,146 @@ import Foundation import MarkdownUI import SharedUIComponents import SwiftUI +import Status +import Cache +import ChatTab +import ConversationServiceProvider +import SwiftUIFlowLayout +import ChatAPIService + +private let MAX_TEXT_LENGTH = 10000 // Maximum characters to prevent crashes struct UserMessage: View { var r: Double { messageBubbleCornerRadius } let id: String let text: String + let imageReferences: [ImageReference] let chat: StoreOf + let editorCornerRadius: Double + let requestType: RequestType @Environment(\.colorScheme) var colorScheme + @State var isMessageHovering: Bool = false + + // Truncate the displayed user message if it's too long. + private var displayText: String { + if text.count > MAX_TEXT_LENGTH { + return String(text.prefix(MAX_TEXT_LENGTH)) + "\n… (message too long, rest hidden)" + } + return text + } + + private var isEditing: Bool { + if case .editUserMessage(let editId) = chat.state.editorMode { + return editId == id + } + return false + } + + private var editorMode: Chat.EditorMode { .editUserMessage(id) } + + private var isConversationMessage: Bool { requestType == .conversation } var body: some View { - HStack() { - Spacer() - VStack(alignment: .trailing) { - ThemedMarkdownText(text) - .frame(alignment: .leading) - .padding() - } - .background { - RoundedCorners(tl: r, tr: r, bl: r, br: 0) - .fill(Color.userChatContentBackground) - } - .overlay { - RoundedCorners(tl: r, tr: r, bl: r, br: 0) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + if !isEditing { + messageView + } else { + MessageInputArea(editorMode: editorMode, chat: chat, editorCornerRadius: editorCornerRadius) + } + } + + var messageView: some View { + HStack { + VStack(alignment: .leading, spacing: 8) { + textView + .scaledPadding(.vertical, 8) + .scaledPadding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: r) + .fill(isMessageHovering ? Color("DarkBlue") : Color("LightBlue")) + ) + .overlay( + Group { + if isConversationMessage { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + chat.send(.setEditorMode(.editUserMessage(id))) + } + .allowsHitTesting(true) + } + } + ) + .onHover { isHovered in + if isConversationMessage { + isMessageHovering = isHovered + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + + if !imageReferences.isEmpty { + FlowLayout(mode: .scrollable, items: imageReferences, itemSpacing: 4) { item in + ImageReferenceItemView(item: item) + } + .environment(\.layoutDirection, .rightToLeft) + } } - .shadow(color: .black.opacity(0.05), radius: 6) } - .padding(.leading, 8) - .padding(.trailing, 8) + } + + var textView: some View { + ThemedMarkdownText(text: displayText, chat: chat) } } -#Preview { - UserMessage( - id: "A", - text: #""" - Please buy me a coffee! - | Coffee | Milk | - |--------|------| - | Espresso | No | - | Latte | Yes | - ```swift - func foo() {} - ``` - ```objectivec - - (void)bar {} - ``` - """#, - chat: .init( - initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false), - reducer: { Chat(service: ChatService.service()) } +private struct MessageInputArea: View { + let editorMode: Chat.EditorMode + let chat: StoreOf + let editorCornerRadius: Double + + var body: some View { + ChatPanelInputArea( + chat: chat, + r: editorCornerRadius, + editorMode: editorMode ) - ) - .padding() - .fixedSize(horizontal: true, vertical: true) + .frame(maxWidth: .infinity) + } } +struct UserMessage_Previews: PreviewProvider { + static var previews: some View { + let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") + UserMessage( + id: "A", + text: #""" + Please buy me a coffee! + | Coffee | Milk | + |--------|------| + | Espresso | No | + | Latte | Yes | + ```swift + func foo() {} + ``` + ```objectivec + - (void)bar {} + ``` + """#, + imageReferences: [], + chat: .init( + initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false), + reducer: { Chat(service: ChatService.service(for: chatTabInfo)) } + ), + editorCornerRadius: 4, + requestType: .conversation + ) + .padding() + .fixedSize(horizontal: true, vertical: true) + .background(Color.yellow) + + } +} diff --git a/Core/Sources/ConversationTab/Views/WorkingSetView.swift b/Core/Sources/ConversationTab/Views/WorkingSetView.swift new file mode 100644 index 00000000..f572454b --- /dev/null +++ b/Core/Sources/ConversationTab/Views/WorkingSetView.swift @@ -0,0 +1,263 @@ +import SwiftUI +import ChatService +import Perception +import ComposableArchitecture +import GitHubCopilotService +import JSONRPC +import SharedUIComponents +import OrderedCollections +import ConversationServiceProvider +import ChatAPIService + +struct WorkingSetView: View { + let chat: StoreOf + + private let r: Double = 8 + + @State private var isExpanded: Bool = false + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 4) { + + WorkingSetHeader(chat: chat, isExpanded: $isExpanded) + .scaledPadding(.vertical, 2) + .scaledPadding(.leading, 7) + + if isExpanded { + VStack(spacing: 0) { + ForEach(chat.fileEditMap.elements, id: \.key.path) { element in + FileEditView(chat: chat, fileEdit: element.value) + } + } + } + } + .scaledPadding(.horizontal, 5) + .scaledPadding(.vertical, 4) + .frame(maxWidth: .infinity) + .background( + RoundedCorners(tl: r, tr: r, bl: 0, br: 0) + .fill(.ultraThickMaterial) + ) + .overlay( + RoundedCorners(tl: r, tr: r, bl: 0, br: 0) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + } +} + +struct WorkingSetHeader: View { + let chat: StoreOf + @Binding var isExpanded: Bool + + @Environment(\.colorScheme) var colorScheme + + func getTitle() -> String { + return chat.fileEditMap.count > 1 ? "\(chat.fileEditMap.count) files changed" : "1 file changed" + } + + @ViewBuilder + private func buildActionButton( + text: String, + textForegroundColor: Color = .white, + textBackgroundColor: Color = .gray, + buttonStyle: some PrimitiveButtonStyle = .bordered, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(text) + .scaledFont(size: 11) + } + .buttonStyle(buttonStyle) + } + + var body: some View { + WithPerceptionTracking { + HStack(spacing: 0) { + HStack(spacing: 2) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .resizable() + .scaledToFit() + .padding(3) + .scaledFrame(width: 16, height: 16) + .foregroundColor(.secondary) + + Text(getTitle()) + .foregroundColor(.secondary) + .scaledFont(size: 13) + + Spacer() + } + .frame(maxWidth: .infinity) + .overlay( + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + isExpanded.toggle() + } + .allowsHitTesting(true) + ) + + if chat.fileEditMap.contains(where: {_, fileEdit in + return fileEdit.status == .none + }) { + HStack(spacing: 6) { + /// Undo all edits + buildActionButton( + text: "Undo", + textForegroundColor: colorScheme == .dark ? .white : .black, + textBackgroundColor: Color("WorkingSetHeaderUndoButtonColor") + ) { + chat.send(.undoEdits(fileURLs: chat.fileEditMap.values.map { $0.fileURL })) + } + .help("Undo All Edits") + + /// Keep all edits + buildActionButton( + text: "Keep", + textBackgroundColor: Color("WorkingSetHeaderKeepButtonColor"), + buttonStyle: .borderedProminent + ) { + chat.send(.keepEdits(fileURLs: chat.fileEditMap.values.map { $0.fileURL })) + } + .help("Keep All Edits") + } + + } else { + buildActionButton(text: "Done") { + chat.send(.resetEdits) + } + .help("Done") + } + } + + } + } +} + +struct FileEditView: View { + let chat: StoreOf + let fileEdit: FileEdit + @State private var isHovering = false + + enum ActionButtonImageType { + case system(String), asset(String) + } + + @ViewBuilder + private func buildActionButton( + imageType: ActionButtonImageType, + help: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Group { + switch imageType { + case .system(let name): + Image(systemName: name) + .scaledFont(size: 15, weight: .regular) + case .asset(let name): + Image(name) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .scaledFrame(height: 16) + } + } + .foregroundColor(.white) + .scaledFrame(width: 22) + .frame(maxHeight: .infinity) + } + .buttonStyle(HoverButtonStyle(padding: 0, hoverColor: .white.opacity(0.2))) + .help(help) + } + + var actionButtons: some View { + HStack(spacing: 0) { + if fileEdit.status == .none { + buildActionButton( + imageType: .system("xmark"), + help: "Remove file" + ) { + chat.send(.discardFileEdits(fileURLs: [fileEdit.fileURL])) + } + buildActionButton( + imageType: .asset("DiffEditor"), + help: "Open changes in Diff Editor" + ) { + chat.send(.openDiffViewWindow(fileURL: fileEdit.fileURL)) + } + buildActionButton( + imageType: .asset("Discard"), + help: "Undo" + ) { + chat.send(.undoEdits(fileURLs: [fileEdit.fileURL])) + } + buildActionButton( + imageType: .system("checkmark"), + help: "Keep" + ) { + chat.send(.keepEdits(fileURLs: [fileEdit.fileURL])) + } + } + } + } + + var body: some View { + HStack(spacing: 0) { + HStack(alignment: .center, spacing: 4) { + drawFileIcon(fileEdit.fileURL) + .scaledToFit() + .scaledFrame(width: 16, height: 16) + .foregroundColor(.secondary) + + Text(fileEdit.fileURL.lastPathComponent) + .scaledFont(size: 13) + .foregroundColor(isHovering ? .white : Color("WorkingSetItemColor")) + } + + Spacer() + + if isHovering { + actionButtons + .padding(.trailing, 8) + } + } + .onHover { hovering in + isHovering = hovering + } + .scaledPadding(.leading, 7) + .scaledFrame(height: 24) + .hoverRadiusBackground( + isHovered: isHovering, + hoverColor: Color.blue, + cornerRadius: 5, + showBorder: true + ) + .onTapGesture { + chat.send(.openDiffViewWindow(fileURL: fileEdit.fileURL)) + } + } +} + + +struct WorkingSetView_Previews: PreviewProvider { + static let fileEditMap: OrderedDictionary = [ + URL(fileURLWithPath: "file:///f1.swift"): FileEdit(fileURL: URL(fileURLWithPath: "file:///f1.swift"), originalContent: "single line", modifiedContent: "single line 1", toolName: ToolName.insertEditIntoFile), + URL(fileURLWithPath: "file:///f2.swift"): FileEdit(fileURL: URL(fileURLWithPath: "file:///f2.swift"), originalContent: "multi \n line \n end", modifiedContent: "another \n mut \n li \n", status: .kept, toolName: ToolName.insertEditIntoFile) + ] + + static var previews: some View { + WorkingSetView( + chat: .init( + initialState: .init( + history: ChatPanel_Preview.history, + isReceivingMessage: true, + fileEditMap: fileEditMap + ), + reducer: { Chat(service: ChatService.service(for: ChatPanel_Preview.chatTabInfo)) } + ) + ) + } +} 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..c2e3d6b8 --- /dev/null +++ b/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift @@ -0,0 +1,18 @@ +import SwiftUI +import ComposableArchitecture + +public struct ImagesScrollView: View { + let chat: StoreOf + let editorMode: Chat.EditorMode + + public var body: some View { + let attachedImages = chat.state.getChatContext(of: editorMode).attachedImages.reversed() + return ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { + ForEach(attachedImages, id: \.self) { image in + HoverableImageView(image: image, chat: chat) + } + } + } + } +} 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 new file mode 100644 index 00000000..39d298c0 --- /dev/null +++ b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift @@ -0,0 +1,366 @@ +import Foundation +import GitHubCopilotService +import ComposableArchitecture +import Status +import SwiftUI +import Cache +import Client + +public struct SignInResponse { + public let status: SignInInitiateStatus + public let userCode: String + public let verificationURL: URL +} + +@MainActor +public class GitHubCopilotViewModel: ObservableObject { + // Add static shared instance + public static let shared = GitHubCopilotViewModel() + + @Dependency(\.toast) var toast + + @AppStorage("username") var username: String = "" + + @Published public var isRunningAction: Bool = false + @Published public var status: GitHubCopilotAccountStatus? + @Published public var version: String? + @Published public var userCode: String? + @Published public var isSignInAlertPresented = false + @Published public var signInResponse: SignInResponse? + @Published public var waitingForSignIn = false + + static var copilotAuthService: GitHubCopilotService? + + // Make init private to enforce singleton pattern + private init() {} + + public func getGitHubCopilotAuthService() throws -> GitHubCopilotService { + if let service = Self.copilotAuthService { return service } + let service = try GitHubCopilotService() + Self.copilotAuthService = service + return service + } + + public func preSignIn() async throws -> SignInResponse? { + let service = try getGitHubCopilotAuthService() + let result = try await service.signInInitiate() + + if result.status == .alreadySignedIn { + guard let user = result.user else { + toast("Missing user info.", .error) + throw NSError(domain: "Missing user info.", code: 0, userInfo: nil) + } + await Status.shared.updateAuthStatus(.loggedIn, username: user) + self.username = user + broadcastStatusChange() + return nil + } + + guard let uri = result.verificationUri, + let userCode = result.userCode, + let url = URL(string: uri) else { + toast("Verification URI is incorrect.", .error) + throw NSError(domain: "Verification URI is incorrect.", code: 0, userInfo: nil) + } + return SignInResponse( + status: SignInInitiateStatus.promptUserDeviceFlow, + userCode: userCode, + verificationURL: url + ) + } + + public func signIn() { + Task { + isRunningAction = true + defer { isRunningAction = false } + do { + guard let result = try await preSignIn() else { return } + self.signInResponse = result + self.isSignInAlertPresented = true + } catch { + toast(error.localizedDescription, .error) + } + } + } + + public func checkStatus() { + Task { + isRunningAction = true + defer { isRunningAction = false } + do { + let service = try getGitHubCopilotAuthService() + status = try await service.checkStatus() + version = try await service.version() + isRunningAction = false + } catch { + toast(error.localizedDescription, .error) + } + } + } + + public func signOut() { + Task { + isRunningAction = true + defer { isRunningAction = false } + do { + let service = try getGitHubCopilotAuthService() + status = try await service.signOut() + await Status.shared.updateAuthStatus(.notLoggedIn) + await Status.shared.updateCLSStatus(.unknown, busy: false, message: "") + await Status.shared.updateQuotaInfo(nil) + username = "" + broadcastStatusChange() + } catch { + toast(error.localizedDescription, .error) + } + + // Sign out all other CLS instances + do { + try await GitHubCopilotService.signOutAll() + } catch { + // ignore + } + } + } + + public func cancelWaiting() { + waitingForSignIn = false + } + + public func copyAndOpen(fromHostApp: Bool = false) { + waitingForSignIn = true + guard let signInResponse else { + toast("Missing sign in details.", .error) + return + } + let pasteboard = NSPasteboard.general + pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil) + pasteboard.setString(signInResponse.userCode, forType: NSPasteboard.PasteboardType.string) + toast("Sign-in code \(signInResponse.userCode) copied", .info) + NSWorkspace.shared.open(signInResponse.verificationURL) + waitForSignIn(fromHostApp: fromHostApp) + } + + public func waitForSignIn(fromHostApp: Bool = false) { + Task { + do { + guard waitingForSignIn else { return } + guard let signInResponse else { + waitingForSignIn = false + return + } + let service = try getGitHubCopilotAuthService() + let (username, status) = try await service.signInConfirm(userCode: signInResponse.userCode) + waitingForSignIn = false + self.username = username + self.status = status + await Status.shared.updateAuthStatus(.loggedIn, username: username) + broadcastStatusChange() + if !fromHostApp { + let models = try? await service.models() + if let models = models, !models.isEmpty { + CopilotModelManager.updateLLMs(models) + } + } else { + let xpcService = try getService() + _ = try? await xpcService.updateCopilotModels() + } + } catch let error as GitHubCopilotError { + switch error { + case .languageServerError(.timeout): + waitForSignIn(fromHostApp: fromHostApp) + return + case .languageServerError( + .serverError( + code: CLSErrorCode.deviceFlowFailed.rawValue, + message: _, + data: _ + ) + ): + await showSignInFailedAlert(error: error) + waitingForSignIn = false + return + default: + 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( + name: .authStatusDidChange, + object: nil + ) + } +} 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..fc44276e --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift @@ -0,0 +1,565 @@ +import AppKitExtension +import Client +import ComposableArchitecture +import ConversationServiceProvider +import SwiftUI +import Toast +import XcodeInspector +import SharedUIComponents +import Logger +import SystemUtils + +struct ChatSection: View { + @AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode + @AppStorage(\.enableFixError) var enableFixError + @AppStorage(\.enableSubagent) var enableSubagent + @ObservedObject private var featureFlags = FeatureFlagManager.shared + @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared + + var body: some View { + SettingsSection(title: "Chat Settings") { + // Copilot instructions - .github/copilot-instructions.md + CopilotInstructionSetting() + .padding(SettingsToggle.defaultPadding) + + Divider() + + // Custom Instructions - .github/instructions/*.instructions.md + PromptFileSetting(promptType: .instructions) + .padding(SettingsToggle.defaultPadding) + + Divider() + + if featureFlags.isEditorPreviewEnabled { + // Custom Prompts - .github/prompts/*.prompt.md + PromptFileSetting(promptType: .prompt) + .padding(SettingsToggle.defaultPadding) + + Divider() + + if featureFlags.isAgentModeEnabled && copilotPolicy.isCustomAgentEnabled { + // Custom Agents - .github/agents/*.agent.md + AgentFileSetting(promptType: .agent) + .padding(SettingsToggle.defaultPadding) + + Divider() + + // SubAgent toggle + SettingsToggle( + title: "Enable Subagent", + subtitle: "Allows Copilot Agent mode to call custom agents as subagent. Requires GitHub Copilot for Xcode restart to take effect.", + isOn: Binding( + get: { enableSubagent && copilotPolicy.isSubagentEnabled }, + set: { if copilotPolicy.isSubagentEnabled { enableSubagent = $0 } } + ), + badge: copilotPolicy.isSubagentEnabled + ? nil + : .disabledByPolicy(feature: "Subagents", isPlural: true) + ) + .disabled(!copilotPolicy.isSubagentEnabled) + + 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) + + if featureFlags.isAgentModeEnabled { + Divider() + + // Agent Max Tool Calling Requests + AgentMaxToolCallLoopSetting() + .padding(SettingsToggle.defaultPadding) + } + } + } +} + +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 AgentMaxToolCallLoopSetting: View { + @AppStorage(\.agentMaxToolCallingLoop) var agentMaxToolCallingLoop + @State private var numberInput: String = "" + @State private var debounceTimer: Timer? + + private static let debounceDelay: TimeInterval = 0.5 + + var body: some View { + WithPerceptionTracking { + HStack { + VStack(alignment: .leading) { + Text("Agent Max Requests") + .font(.body) + Text("Sets the maximum number of tool call requests Copilot can make in a single agent turn.") + .font(.footnote) + } + + Spacer() + + TextField("", text: $numberInput) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 40, maxWidth: 120) + .fixedSize(horizontal: true, vertical: false) + .onChange(of: numberInput) { newValue in + if newValue.isEmpty { return } + + guard let number = Int(newValue.filter { $0.isNumber }), number > 0 else { + numberInput = "" + return + } + + numberInput = "\(number)" + + debounceTimer?.invalidate() + debounceTimer = Timer.scheduledTimer( + withTimeInterval: Self.debounceDelay, + repeats: false + ) { _ in + agentMaxToolCallingLoop = number + DistributedNotificationCenter + .default() + .post(name: .githubCopilotAgentMaxToolCallingLoopDidChange, object: nil) + } + } + } + .onAppear { + numberInput = "\(agentMaxToolCallingLoop)" + } + .onDisappear { + // Flush before invalidating + if let timer = debounceTimer, timer.isValid { + timer.fire() + } + + debounceTimer?.invalidate() + debounceTimer = nil + } + } + } +} + +struct 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( + promptType: promptType, + editorPluginVersion: SystemUtils.editorPluginVersionString, + getCurrentProjectURL: { await getCurrentProjectURL() }, + onSuccess: { message in + toast(message, .info) + }, + onError: { message in + toast(message, .error) + } + ) + } + } + } + + private func openDirectory() { + Task { + guard let projectURL = await getCurrentProjectURL() else { + toast("No active workspace found", .error) + return + } + + let directory = promptType.getDirectoryPath(projectURL: projectURL) + + do { + try ensureDirectoryExists(at: directory) + NSWorkspace.shared.open(directory) + } catch { + toast("Failed to create \(promptType.directoryName) directory: \(error)", .error) + } + } + } +} + +struct AgentFileSetting: View { + let promptType: PromptType + @State private var isCreateSheetPresented = false + @Environment(\.toast) var toast + + var body: some View { + WithPerceptionTracking { + HStack { + VStack(alignment: .leading) { + Text(promptType.settingTitle) + .font(.body) + Text( + (try? AttributedString(markdown: promptType.description)) ?? AttributedString( + promptType.description + ) + ) + .font(.footnote) + } + + Spacer() + + Button("Create") { + isCreateSheetPresented = true + } + + Button("Browse \(promptType.displayName)s") { + openDirectory() + } + } + .sheet(isPresented: $isCreateSheetPresented) { + CreateCustomCopilotFileView( + promptType: promptType, + editorPluginVersion: SystemUtils.editorPluginVersionString, + getCurrentProjectURL: { await getCurrentProjectURL() }, + onSuccess: { message in + toast(message, .info) + }, + onError: { message in + toast(message, .error) + } + ) + } + } + } + + private func openDirectory() { + Task { + guard let projectURL = await getCurrentProjectURL() else { + toast("No active workspace found", .error) + return + } + + let directory = promptType.getDirectoryPath(projectURL: projectURL) + + do { + try ensureDirectoryExists(at: directory) + + // Open file picker for .agent.md files + await MainActor.run { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.init(filenameExtension: "agent.md") ?? .plainText] + panel.allowsMultipleSelection = false + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.level = .modalPanel + panel.directoryURL = directory + panel.message = "Select an existing agent file" + panel.prompt = "Select" + panel.showsHiddenFiles = false + + panel.allowsOtherFileTypes = false + panel.isExtensionHidden = false + + panel.begin { response in + if response == .OK, let selectedURL = panel.url { + // If the file doesn't exist, create it + if !FileManager.default.fileExists(atPath: selectedURL.path) { + do { + // Create empty agent file with basic structure + let template = promptType.defaultTemplate + try template.write(to: selectedURL, atomically: true, encoding: .utf8) + } catch { + toast("Failed to create agent file: \(error)", .error) + return + } + } + + // Open the file in Xcode + NSWorkspace.openFileInXcode(fileURL: selectedURL) + } + } + } + } catch { + toast("Failed to create \(promptType.directoryName) directory: \(error)", .error) + } + } + } +} + +#Preview { + ChatSection() + .frame(width: 600) +} diff --git a/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift new file mode 100644 index 00000000..d93ae8d9 --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift @@ -0,0 +1,64 @@ +import AppKit +import Client +import Foundation +import SwiftUI +import Toast +import XcodeInspector +import SystemUtils +import SharedUIComponents +import Workspace +import LanguageServerProtocol + +// MARK: - Workspace URL Helpers + +private func getCurrentWorkspaceURL() async -> URL? { + guard let service = try? getService(), + let inspectorData = try? await service.getXcodeInspectorData() else { + return nil + } + + if let url = inspectorData.realtimeActiveWorkspaceURL, + let workspaceURL = URL(string: url), + workspaceURL.path != "/" { + return workspaceURL + } else if let url = inspectorData.latestNonRootWorkspaceURL { + return URL(string: url) + } + + return nil +} + +func getCurrentProjectURL() async -> URL? { + guard let workspaceURL = await getCurrentWorkspaceURL(), + let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: workspaceURL, + documentURL: nil + ) else { + return nil + } + + return projectURL +} + +// MARK: - Workspace Folders + +func getWorkspaceFolders() async -> [WorkspaceFolder]? { + guard let workspaceURL = await getCurrentWorkspaceURL(), + let workspaceInfo = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) else { + return nil + } + + let projects = WorkspaceFile.getProjects(workspace: workspaceInfo) + return projects.map { project in + WorkspaceFolder(uri: project.uri, name: project.name) + } +} + +// MARK: - File System Helpers + +func ensureDirectoryExists(at url: URL) throws { + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: url.path) { + try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + } +} diff --git a/Core/Sources/HostApp/AdvancedSettings/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/AdvancedSettings/SuggestionSection.swift b/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift index cb86bde3..689ccaa5 100644 --- a/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift @@ -4,8 +4,10 @@ struct SuggestionSection: View { @AppStorage(\.realtimeSuggestionToggle) var realtimeSuggestionToggle @AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList @AppStorage(\.acceptSuggestionWithTab) var acceptSuggestionWithTab + @AppStorage(\.realtimeNESToggle) var realtimeNESToggle @State var isSuggestionFeatureDisabledLanguageListViewOpen = false @State private var shouldPresentTurnoffSheet = false + @ObservedObject private var featureFlags = FeatureFlagManager.shared var realtimeSuggestionBinding : Binding { Binding( @@ -23,9 +25,18 @@ struct SuggestionSection: View { var body: some View { SettingsSection(title: "Suggestion Settings") { SettingsToggle( - title: "Request suggestions while typing", + title: "Enable completions while typing", isOn: realtimeSuggestionBinding ) + + if featureFlags.isEditorPreviewEnabled { + Divider() + SettingsToggle( + title: "Enable Next Edit Suggestions (NES)", + isOn: $realtimeNESToggle + ) + } + Divider() SettingsToggle( title: "Accept suggestions with Tab", diff --git a/Core/Sources/HostApp/BYOKConfigView.swift b/Core/Sources/HostApp/BYOKConfigView.swift new file mode 100644 index 00000000..50d569da --- /dev/null +++ b/Core/Sources/HostApp/BYOKConfigView.swift @@ -0,0 +1,72 @@ +import Client +import GitHubCopilotService +import SwiftUI + +public struct BYOKConfigView: View { + @StateObject private var dataManager = BYOKModelManagerObservable() + @State private var activeSheet: BYOKSheetType? + @State private var expansionStates: [BYOKProvider: Bool] = [:] + + private let providers: [BYOKProvider] = [ + .Azure, + .OpenAI, + .Anthropic, + .Gemini, + .Groq, + .OpenRouter, + ] + + private var expansionHash: Int { + expansionStates.values.map { $0 ? 1 : 0 }.reduce(0, +) + } + + private func expansionBinding(for provider: BYOKProvider) -> Binding { + Binding( + get: { expansionStates[provider] ?? false }, + set: { expansionStates[provider] = $0 } + ) + } + + public var body: some View { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(providers, id: \.self) { provider in + BYOKProviderConfigView( + provider: provider, + dataManager: dataManager, + onSheetRequested: presentSheet, + isExpanded: expansionBinding(for: provider) + ) + } + } + .padding(16) + } + .animation(.easeInOut(duration: 0.3), value: expansionHash) + .onAppear { + Task { + await dataManager.refreshData() + } + } + .sheet(item: $activeSheet) { sheetType in + createSheetContent(for: sheetType) + } + } + + // MARK: - Sheet Management + + /// Presents the requested sheet type + private func presentSheet(_ sheetType: BYOKSheetType) { + activeSheet = sheetType + } + + /// Creates the appropriate sheet content based on the sheet type + @ViewBuilder + private func createSheetContent(for sheetType: BYOKSheetType) -> some View { + switch sheetType { + case let .apiKey(provider): + ApiKeySheet(dataManager: dataManager, provider: provider) + case let .model(provider, model): + ModelSheet(dataManager: dataManager, provider: provider, existingModel: model) + } + } +} diff --git a/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift new file mode 100644 index 00000000..4f93eee0 --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift @@ -0,0 +1,153 @@ +import GitHubCopilotService +import SwiftUI +import SharedUIComponents + +struct ApiKeySheet: View { + @ObservedObject var dataManager: BYOKModelManagerObservable + @Environment(\.dismiss) private var dismiss + + @State private var apiKey = "" + @State private var showDeleteConfirmation = false + @State private var showPopOver = false + @State private var keepCustomModels = true + let provider: BYOKProvider + + private var hasExistingApiKey: Bool { + dataManager.hasApiKey(for: provider) + } + + private var isFormInvalid: Bool { + apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + Form { + VStack(alignment: .center, spacing: 20) { + HStack(alignment: .center) { + Spacer() + Text("\(provider.title)").font(.headline) + Spacer() + AdaptiveHelpLink(action: openHelpLink) + } + + VStack(alignment: .leading, spacing: 4) { + TextFieldsContainer { + SecureField("API Key", text: $apiKey) + } + + if hasExistingApiKey { + HStack(spacing: 8) { + Toggle("Keep Custom Models", isOn: $keepCustomModels) + .toggleStyle(CheckboxToggleStyle()) + + Button(action: {}) { + Image(systemName: "questionmark.circle") + } + .buttonStyle(.borderless) + .foregroundStyle(.primary) + .onHover { hovering in + showPopOver = hovering + } + .popover(isPresented: $showPopOver, arrowEdge: .bottom) { + Text("Retains custom models \nafter API key updates.") + .multilineTextAlignment(.leading) + .padding(4) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + } + + HStack(spacing: 8) { + if hasExistingApiKey { + Button("Delete", role: .destructive) { + showDeleteConfirmation = true + } + .confirmationDialog( + "Delete \(provider.title) API Key?", + isPresented: $showDeleteConfirmation + ) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { deleteApiKey() } + } message: { + Text("This will remove all linked models and configurations. Still want to delete it?") + } + } + + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button(hasExistingApiKey ? "Update" : "Add") { updateApiKey() } + .buttonStyle(.borderedProminent) + .disabled(isFormInvalid) + } + } + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + .padding(20) + } + .onAppear { + loadExistingApiKey() + } + } + + private func loadExistingApiKey() { + apiKey = dataManager.filteredApiKeys(for: provider).first?.apiKey ?? "" + } + + private func updateApiKey() { + Task { + do { + let trimmedApiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + + var savedCustomModels: [BYOKModelInfo] = [] + + // If updating an existing API key and keeping custom models, save them first + if hasExistingApiKey && keepCustomModels { + savedCustomModels = dataManager.filteredModels(for: provider) + .filter { $0.isCustomModel } + } + + // For updates, delete the original API key first + if hasExistingApiKey { + try await dataManager.deleteApiKey(providerName: provider) + } + + // Save the new API key + try await dataManager.saveApiKey(trimmedApiKey, providerName: provider) + + // If we saved custom models and should keep them, restore them + if hasExistingApiKey && keepCustomModels && !savedCustomModels.isEmpty { + for customModel in savedCustomModels { + // Restore the custom model with the same properties + try await dataManager.saveModel(customModel) + } + } + + dismiss() + + // Fetch default models from the provider + await dataManager.listModelsWithFetch(providerName: provider) + } catch { + // Error is already handled in dataManager methods + // The error message will be displayed in the provider view + } + } + } + + private func deleteApiKey() { + Task { + do { + try await dataManager.deleteApiKey(providerName: provider) + dismiss() + } catch { + // Error handling could be improved here, but keeping it simple for now + // The error will be reflected in the UI when the sheet dismisses + } + } + } + + private func openHelpLink() { + NSWorkspace.shared.open(URL(string: BYOKHelpLink)!) + } +} diff --git a/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift b/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift new file mode 100644 index 00000000..fa0bff5f --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift @@ -0,0 +1,243 @@ +import Client +import GitHubCopilotService +import Logger +import SwiftUI +import XPCShared +import SystemUtils + +actor BYOKServiceActor { + private let service: XPCExtensionService + + // MARK: - Write Serialization + // Chains write operations so only one mutating request is in-flight at a time. + private var writeQueue: Task? = nil + + /// Enqueue a mutating operation ensuring strict sequential execution. + private func enqueueWrite(_ op: @escaping () async throws -> Void) async throws { + return try await withCheckedThrowingContinuation { continuation in + let previousQueue = writeQueue + writeQueue = Task { + // Wait for all previous operations to complete + await previousQueue?.value + + // Now execute this operation + do { + try await op() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + init(serviceFactory: () throws -> XPCExtensionService) rethrows { + self.service = try serviceFactory() + } + + // MARK: - Listing (reads can stay concurrent) + func listApiKeys() async throws -> [BYOKApiKeyInfo] { + let resp = try await service.listBYOKApiKey(BYOKListApiKeysParams()) + return resp?.apiKeys ?? [] + } + + func listModels(providerName: BYOKProviderName? = nil, + enableFetchUrl: Bool? = nil) async throws -> [BYOKModelInfo] { + let params = BYOKListModelsParams(providerName: providerName, + enableFetchUrl: enableFetchUrl) + let resp = try await service.listBYOKModels(params) + return resp?.models ?? [] + } + + // MARK: - Mutations (serialized) + func saveModel(_ model: BYOKModelInfo) async throws { + try await enqueueWrite { [service] in + _ = try await service.saveBYOKModel(model) + } + } + + func deleteModel(providerName: BYOKProviderName, modelId: String) async throws { + try await enqueueWrite { [service] in + let params = BYOKDeleteModelParams(providerName: providerName, modelId: modelId) + _ = try await service.deleteBYOKModel(params) + } + } + + func saveApiKey(_ apiKey: String, providerName: BYOKProviderName) async throws { + try await enqueueWrite { [service] in + let params = BYOKSaveApiKeyParams(providerName: providerName, apiKey: apiKey) + _ = try await service.saveBYOKApiKey(params) + } + } + + func deleteApiKey(providerName: BYOKProviderName) async throws { + try await enqueueWrite { [service] in + let params = BYOKDeleteApiKeyParams(providerName: providerName) + _ = try await service.deleteBYOKApiKey(params) + } + } +} + +@MainActor +class BYOKModelManagerObservable: ObservableObject { + @Published var availableBYOKApiKeys: [BYOKApiKeyInfo] = [] + @Published var availableBYOKModels: [BYOKModelInfo] = [] + @Published var errorMessages: [BYOKProviderName: String] = [:] + @Published var providerLoadingStates: [BYOKProviderName: Bool] = [:] + + private let serviceActor: BYOKServiceActor + + init() { + self.serviceActor = try! BYOKServiceActor { + try getService() // existing factory + } + } + + func refreshData() async { + do { + // Serialized by actor (even though we still parallelize logically, calls run one by one) + async let apiKeys = serviceActor.listApiKeys() + async let models = serviceActor.listModels() + + availableBYOKApiKeys = try await apiKeys + availableBYOKModels = try await models.sorted() + } catch { + Logger.client.error("Failed to refresh BYOK data: \(error)") + } + } + + func deleteModel(_ model: BYOKModelInfo) async throws { + try await serviceActor.deleteModel(providerName: model.providerName, modelId: model.modelId) + await refreshData() + } + + func saveModel(_ modelInfo: BYOKModelInfo) async throws { + try await serviceActor.saveModel(modelInfo) + await refreshData() + } + + func saveApiKey(_ apiKey: String, providerName: BYOKProviderName) async throws { + try await serviceActor.saveApiKey(apiKey, providerName: providerName) + await refreshData() + } + + func deleteApiKey(providerName: BYOKProviderName) async throws { + try await serviceActor.deleteApiKey(providerName: providerName) + errorMessages[providerName] = nil + await refreshData() + } + + func listModelsWithFetch(providerName: BYOKProviderName) async { + providerLoadingStates[providerName] = true + errorMessages[providerName] = nil + defer { providerLoadingStates[providerName] = false } + do { + _ = try await serviceActor.listModels(providerName: providerName, enableFetchUrl: true) + await refreshData() + } catch { + errorMessages[providerName] = error.localizedDescription + } + } + + func updateAllModels(providerName: BYOKProviderName, isRegistered: Bool) async throws { + let current = availableBYOKModels.filter { $0.providerName == providerName && $0.isRegistered != isRegistered } + guard !current.isEmpty else { return } + for model in current { + var updated = model + updated.isRegistered = isRegistered + try await serviceActor.saveModel(updated) + } + await refreshData() + } +} + +// MARK: - Provider-specific Data Filtering + +extension BYOKModelManagerObservable { + func filteredApiKeys(for provider: BYOKProviderName, modelId: String? = nil) -> [BYOKApiKeyInfo] { + availableBYOKApiKeys.filter { apiKey in + apiKey.providerName == provider && (modelId == nil || apiKey.modelId == modelId) + } + } + + func filteredModels(for provider: BYOKProviderName) -> [BYOKModelInfo] { + availableBYOKModels.filter { $0.providerName == provider } + } + + func hasApiKey(for provider: BYOKProviderName) -> Bool { + !filteredApiKeys(for: provider).isEmpty + } + + func hasModels(for provider: BYOKProviderName) -> Bool { + !filteredModels(for: provider).isEmpty + } + + func isLoadingProvider(_ provider: BYOKProviderName) -> Bool { + providerLoadingStates[provider] ?? false + } +} + +public var BYOKHelpLink: String { + var editorPluginVersion = SystemUtils.editorPluginVersionString + if editorPluginVersion == "0.0.0" { + editorPluginVersion = "main" + } + return "https://github.com/github/CopilotForXcode/blob/\(editorPluginVersion)/Docs/BYOK.md" +} + +enum BYOKSheetType: Identifiable { + case apiKey(BYOKProviderName) + case model(BYOKProviderName, BYOKModelInfo? = nil) + + var id: String { + switch self { + case let .apiKey(provider): + return "apiKey_\(provider.rawValue)" + case let .model(provider, model): + if let model = model { + return "editModel_\(provider.rawValue)_\(model.modelId)" + } else { + return "model_\(provider.rawValue)" + } + } + } +} + +enum BYOKAuthType { + case GlobalApiKey + case PerModelDeployment + + var helpText: String { + switch self { + case .GlobalApiKey: + return "Requires a single API key for all models" + case .PerModelDeployment: + return "Requires both deployment URL and API key per model" + } + } +} + +extension BYOKProviderName { + var title: String { + switch self { + case .Azure: return "Azure" + case .Anthropic: return "Anthropic" + case .Gemini: return "Gemini" + case .Groq: return "Groq" + case .OpenAI: return "OpenAI" + case .OpenRouter: return "OpenRouter" + } + } + + // MARK: - Configuration Type + + /// The configuration approach used by this provider + var authType: BYOKAuthType { + switch self { + case .Anthropic, .Gemini, .Groq, .OpenAI, .OpenRouter: return .GlobalApiKey + case .Azure: return .PerModelDeployment + } + } +} + +typealias BYOKProvider = BYOKProviderName diff --git a/Core/Sources/HostApp/BYOKSettings/ModelRowView.swift b/Core/Sources/HostApp/BYOKSettings/ModelRowView.swift new file mode 100644 index 00000000..d8487d23 --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/ModelRowView.swift @@ -0,0 +1,111 @@ +import GitHubCopilotService +import Logger +import SharedUIComponents +import SwiftUI + +struct ModelRowView: View { + var model: BYOKModelInfo + @ObservedObject var dataManager: BYOKModelManagerObservable + let isSelected: Bool + let onSelection: () -> Void + let onEditRequested: ((BYOKModelInfo) -> Void)? // New callback for edit action + @State private var isHovered: Bool = false + + // Extract foreground colors to computed properties + private var primaryForegroundColor: Color { + isSelected ? Color(nsColor: .white) : .primary + } + + private var secondaryForegroundColor: Color { + isSelected ? Color(nsColor: .white) : .secondary + } + + var body: some View { + HStack(alignment: .center, spacing: 4) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 4) { + Text(model.modelCapabilities?.name ?? model.modelId) + .foregroundColor(primaryForegroundColor) + + Text(model.modelCapabilities?.name != nil ? model.modelId : "") + .foregroundColor(secondaryForegroundColor) + .font(.callout) + + if model.isCustomModel { + Badge( + text: "Custom Model", + level: .info, + isSelected: isSelected + ) + } + } + + Group { + if let modelCapabilities = model.modelCapabilities, + modelCapabilities.toolCalling || modelCapabilities.vision { + HStack(spacing: 0) { + if modelCapabilities.toolCalling { + Text("Tools").help("Support Tool Calling") + } + if modelCapabilities.vision { + Text("・") + Text("Vision").help("Support Vision") + } + } + } else { + EmptyView() + } + } + .foregroundColor(secondaryForegroundColor) + } + + Spacer() + + // Show edit icon for custom model when selected or hovered + if model.isCustomModel { + Button(action: { + onEditRequested?(model) + }) { + Image(systemName: "gearshape") + } + .buttonStyle(HoverButtonStyle( + hoverColor: isSelected ? .white.opacity(0.1) : .hoverColor + )) + .foregroundColor(primaryForegroundColor) + .opacity((isSelected || isHovered) ? 1.0 : 0.0) + .padding(.horizontal, 12) + } + + Toggle(" ", isOn: Binding( + // Space in toggle label ensures proper checkbox centering alignment + get: { model.isRegistered }, + set: { newValue in + // Only save when user directly toggles the checkbox + Task { + do { + var newModelInfo = model + newModelInfo.isRegistered = newValue + try await dataManager.saveModel(newModelInfo) + } catch { + Logger.client.error("Failed to update model: \(error.localizedDescription)") + } + } + } + )) + .toggleStyle(.checkbox) + .labelStyle(.iconOnly) + .padding(.vertical, 4) + } + .padding(.leading, 36) + .padding(.trailing, 16) + .padding(.vertical, 4) + .contentShape(Rectangle()) + .background( + isSelected ? Color(nsColor: .controlAccentColor) : Color.clear + ) + .onTapGesture { onSelection() } + .onHover { hovering in + isHovered = hovering + } + } +} diff --git a/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift new file mode 100644 index 00000000..4ce44c91 --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift @@ -0,0 +1,171 @@ +import GitHubCopilotService +import SwiftUI +import SharedUIComponents + +struct ModelSheet: View { + @ObservedObject var dataManager: BYOKModelManagerObservable + @Environment(\.dismiss) private var dismiss + + @State private var modelId = "" + @State private var deploymentUrl = "" + @State private var apiKey = "" + @State private var customModelName = "" + @State private var supportToolCalling: Bool = true + @State private var supportVision: Bool = true + + let provider: BYOKProvider + let existingModel: BYOKModelInfo? + + // Computed property to determine if this is a per-model deployment provider + private var isPerModelDeployment: Bool { + provider.authType == .PerModelDeployment + } + + // Computed property to determine if we're editing vs adding + private var isEditing: Bool { + existingModel != nil + } + + var body: some View { + Form { + VStack(alignment: .center, spacing: 20) { + HStack(alignment: .center) { + Spacer() + Text("\(provider.title)").font(.headline) + Spacer() + AdaptiveHelpLink(action: openHelpLink) + } + + VStack(alignment: .leading, spacing: 8) { + // Deployment/Model Name Section + TextFieldsContainer { + TextField(isPerModelDeployment ? "Deployment Name" : "Model ID", text: $modelId) + } + + // Endpoint Section (only for per-model deployment) + if isPerModelDeployment { + VStack(alignment: .leading, spacing: 4) { + Text("Endpoint") + .foregroundStyle(.secondary) + .font(.callout) + .padding(.horizontal, 8) + + TextFieldsContainer { + TextField("Target URI", text: $deploymentUrl) + + Divider() + + SecureField("API Key", text: $apiKey) + } + } + } + + // Optional Section + VStack(alignment: .leading, spacing: 4) { + Text("Optional") + .foregroundStyle(.secondary) + .font(.callout) + .padding(.horizontal, 8) + + TextFieldsContainer { + TextField("Display Name", text: $customModelName) + } + + HStack(spacing: 16) { + Toggle("Support Tool Calling", isOn: $supportToolCalling) + .toggleStyle(CheckboxToggleStyle()) + Toggle("Support Vision", isOn: $supportVision) + .toggleStyle(CheckboxToggleStyle()) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + } + + HStack(spacing: 8) { + Spacer() + Button("Cancel") { dismiss() }.buttonStyle(.bordered) + Button(isEditing ? "Save" : "Add") { saveModel() } + .buttonStyle(.borderedProminent) + .disabled(isFormInvalid) + } + } + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + .padding(20) + } + .onAppear { + loadModelData() + } + } + + private var isFormInvalid: Bool { + let modelIdEmpty = modelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + + if isPerModelDeployment { + let deploymentUrlEmpty = deploymentUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let apiKeyEmpty = apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return modelIdEmpty || deploymentUrlEmpty || apiKeyEmpty + } else { + return modelIdEmpty + } + } + + private func loadModelData() { + guard let model = existingModel else { return } + + modelId = model.modelId + customModelName = model.modelCapabilities?.name ?? "" + supportToolCalling = model.modelCapabilities?.toolCalling ?? true + supportVision = model.modelCapabilities?.vision ?? true + + if isPerModelDeployment { + deploymentUrl = model.deploymentUrl ?? "" + apiKey = dataManager + .filteredApiKeys( + for: provider, + modelId: modelId + ).first?.apiKey ?? "" + } + } + + private func saveModel() { + Task { + do { + // Trim whitespace and newlines from all input fields + let trimmedModelId = modelId.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedDeploymentUrl = deploymentUrl.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedApiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedCustomModelName = customModelName.trimmingCharacters(in: .whitespacesAndNewlines) + + let modelParams = BYOKModelInfo( + providerName: provider, + modelId: trimmedModelId, + isRegistered: existingModel?.isRegistered ?? true, + isCustomModel: true, + deploymentUrl: isPerModelDeployment ? trimmedDeploymentUrl : nil, + apiKey: isPerModelDeployment ? trimmedApiKey : nil, + modelCapabilities: BYOKModelCapabilities( + name: trimmedCustomModelName.isEmpty ? trimmedModelId : trimmedCustomModelName, + toolCalling: supportToolCalling, + vision: supportVision + ) + ) + + if let originalModel = existingModel, trimmedModelId != originalModel.modelId { + // Delete existing model if the model ID has changed + try await dataManager.deleteModel(originalModel) + } + + try await dataManager.saveModel(modelParams) + dismiss() + } catch { + dataManager.errorMessages[provider] = "Failed to \(isEditing ? "update" : "add") model: \(error.localizedDescription)" + } + } + } + + private func openHelpLink() { + NSWorkspace.shared.open(URL(string: BYOKHelpLink)!) + } +} diff --git a/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift new file mode 100644 index 00000000..194c4f91 --- /dev/null +++ b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift @@ -0,0 +1,311 @@ +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 + } + } + .settingsContainerStyle(isExpanded: isExpanded) + } + + // MARK: - UI Components + + private var ProviderLabelView: some View { + Text(provider.title) + .foregroundColor( + hasApiKey ? .primary : Color( + nsColor: colorScheme == .light ? .tertiaryLabelColor : .secondaryLabelColor + ) + ) + .bold() + + Text(hasModels ? " (\(allModels.filter { $0.isRegistered }.count) of \(allModels.count) Enabled)" : "") + .foregroundColor(.primary) + } + + private var ProviderHeaderRowView: some View { + DisclosureSettingsRow( + isExpanded: $isExpanded, + isEnabled: hasApiKey, + accessibilityLabel: { expanded in "\(provider.title) \(expanded ? "collapse" : "expand")" }, + onToggle: { wasExpanded, nowExpanded in + if wasExpanded && !nowExpanded && isSearchBarVisible { + searchText = "" + withAnimation(.easeInOut) { isSearchBarVisible = false } + } + }, + title: { ProviderLabelView }, + actions: { + Group { + if let errorMessage = errorMessage { + Badge( + text: "Can't connect. Check your API key or network.", + level: .danger, + icon: "xmark.circle.fill" + ) + .help("Unable to connect to \(provider.title). \(errorMessage) Refresh or recheck your key setup.") + } + if hasApiKey { + if dataManager.isLoadingProvider(provider) { + ProgressView().controlSize(.small) + } else { + ConfiguredProviderActions + } + } else { + UnconfiguredProviderAction + } + } + .padding(.trailing, 4) + .frame(height: 30) + } + ) + } + + @ViewBuilder + private var ConfiguredProviderActions: some View { + HStack(spacing: 8) { + if provider.authType == .GlobalApiKey && isExpanded { + CollapsibleSearchField(searchText: $searchText, isExpanded: $isSearchBarVisible) + + Button(action: { Task { + await dataManager.listModelsWithFetch(providerName: provider) + }}) { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(HoverButtonStyle()) + + Button(action: openAddApiKeySheetType) { + Image(systemName: "key") + } + .buttonStyle(HoverButtonStyle()) + + Button(action: { showDeleteConfirmation = true }) { + Image(systemName: "trash") + } + .confirmationDialog( + "Delete \(provider.title) API Key?", + isPresented: $showDeleteConfirmation + + ) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { deleteApiKey() } + } message: { + Text("This will remove all linked models and configurations. Still want to delete it?") + } + .buttonStyle(HoverButtonStyle()) + } + + Toggle("", isOn: Binding( + get: { isProviderEnabled }, + set: { newValue in updateAllModels(isRegistered: newValue) } + )) + .toggleStyle(.switch) + .controlSize(.mini) + } + } + + private var UnconfiguredProviderAction: some View { + Button( + provider.authType == .PerModelDeployment ? "Add Model" : "Add", + systemImage: "plus" + ) { + openAddApiKeySheetType() + } + } + + private var ModelsListSection: some View { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(filteredModels, id: \.modelId) { model in + Divider() + ModelRowView( + model: model, + dataManager: dataManager, + isSelected: selectedModelId == model.modelId, + onSelection: { + selectedModelId = selectedModelId == model.modelId ? nil : model.modelId + isSelectedCustomModel = selectedModelId != nil && model.isCustomModel + }, + onEditRequested: { model in + openEditModelSheet(for: model) + } + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var FooterToolBar: some View { + VStack(spacing: 0) { + Divider() + HStack(spacing: 8) { + Button(action: openAddModelSheet) { + Image(systemName: "plus") + } + .foregroundColor(.primary) + .font(.title2) + .buttonStyle(.borderless) + + Divider() + + Group { + if isSelectedCustomModel { + Button(action: deleteSelectedModel) { + Image(systemName: "minus") + } + .buttonStyle(.borderless) + } else { + Image(systemName: "minus") + } + } + .font(.title2) + .foregroundColor( + isSelectedCustomModel ? .primary : Color( + nsColor: .quaternaryLabelColor + ) + ) + .help(deleteModelTooltip) + + Spacer() + } + .frame(height: 20) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(TertiarySystemFillColor) + } + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + + // MARK: - Actions + + private func openAddApiKeySheetType() { + switch provider.authType { + case .GlobalApiKey: + onSheetRequested(.apiKey(provider)) + case .PerModelDeployment: + onSheetRequested(.model(provider)) + } + } + + private func openAddModelSheet() { + onSheetRequested(.model(provider, nil)) // nil for adding new model + } + + private func openEditModelSheet(for model: BYOKModelInfo) { + onSheetRequested(.model(provider, model)) // pass model for editing + } + + private func deleteApiKey() { + Task { + do { + try await dataManager.deleteApiKey(providerName: provider) + } catch { + Logger.client.error("Failed to delete API key for \(provider.title): \(error)") + } + } + } + + private func deleteSelectedModel() { + guard let selectedModelId = selectedModelId, + let selectedModel = allModels.first(where: { $0.modelId == selectedModelId }) else { + return + } + + self.selectedModelId = nil + isSelectedCustomModel = false + + Task { + do { + try await dataManager.deleteModel(selectedModel) + } catch { + Logger.client.error("Failed to delete model for \(provider.title): \(error)") + } + } + } + + private func updateAllModels(isRegistered: Bool) { + Task { + do { + try await dataManager.updateAllModels(providerName: provider, isRegistered: isRegistered) + } catch { + Logger.client.error("Failed to register models for \(provider.title): \(error)") + } + } + } +} diff --git a/Core/Sources/HostApp/CopilotPolicyManager.swift b/Core/Sources/HostApp/CopilotPolicyManager.swift new file mode 100644 index 00000000..9cf22eff --- /dev/null +++ b/Core/Sources/HostApp/CopilotPolicyManager.swift @@ -0,0 +1,107 @@ +import Client +import Combine +import Foundation +import GitHubCopilotService +import Logger +import SwiftUI + +/// Centralized manager for GitHub Copilot policies in the HostApp +/// Use as @StateObject or @ObservedObject in SwiftUI views +@MainActor +public class CopilotPolicyManager: ObservableObject { + public static let shared = CopilotPolicyManager() + + // MARK: - Published Properties + + @Published public private(set) var isMCPContributionPointEnabled = true + @Published public private(set) var isCustomAgentEnabled = true + @Published public private(set) var isSubagentEnabled = true + @Published public private(set) var isCVERemediatorAgentEnabled = true + @Published public private(set) var isAgentModeAutoApprovalEnabled = true + + // MARK: - Private Properties + + private var cancellables = Set() + private var lastUpdateTime: Date? + private let updateThrottle: TimeInterval = 1.0 // Prevent excessive updates + + // MARK: - Initialization + + private init() { + setupNotificationObserver() + Task { + await updatePolicy() + } + } + + // MARK: - Public Methods + + /// Manually refresh policies from the service + public func refresh() async { + await updatePolicy() + } + + // MARK: - Private Methods + + private func setupNotificationObserver() { + DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotPolicyDidChange) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + await self?.updatePolicy() + } + } + .store(in: &cancellables) + } + + private func updatePolicy() async { + // Throttle updates to prevent excessive calls + if let lastUpdate = lastUpdateTime, + Date().timeIntervalSince(lastUpdate) < updateThrottle { + return + } + + lastUpdateTime = Date() + + do { + let service = try getService() + guard let policy = try await service.getCopilotPolicy() else { + Logger.client.info("Copilot policy returned nil, using defaults") + return + } + + // Update all policies at once + isMCPContributionPointEnabled = policy.mcpContributionPointEnabled + isCustomAgentEnabled = policy.customAgentEnabled + isSubagentEnabled = policy.subagentEnabled + isCVERemediatorAgentEnabled = policy.cveRemediatorAgentEnabled + isAgentModeAutoApprovalEnabled = policy.agentModeAutoApprovalEnabled + + Logger.client.info("Copilot policy updated: customAgent=\(policy.customAgentEnabled), mcp=\(policy.mcpContributionPointEnabled), subagent=\(policy.subagentEnabled)") + } catch { + Logger.client.error("Failed to update copilot policy: \(error.localizedDescription)") + } + } +} + +// MARK: - Environment Key + +private struct CopilotPolicyManagerKey: EnvironmentKey { + static let defaultValue = CopilotPolicyManager.shared +} + +public extension EnvironmentValues { + var copilotPolicyManager: CopilotPolicyManager { + get { self[CopilotPolicyManagerKey.self] } + set { self[CopilotPolicyManagerKey.self] = newValue } + } +} + +// MARK: - View Extension + +public extension View { + /// Inject the copilot policy manager into the environment + func withCopilotPolicyManager(_ manager: CopilotPolicyManager = .shared) -> some View { + self.environment(\.copilotPolicyManager, manager) + } +} diff --git a/Core/Sources/HostApp/FeatureFlagManager.swift b/Core/Sources/HostApp/FeatureFlagManager.swift new file mode 100644 index 00000000..189d5a4e --- /dev/null +++ b/Core/Sources/HostApp/FeatureFlagManager.swift @@ -0,0 +1,111 @@ +import Client +import Combine +import Foundation +import GitHubCopilotService +import Logger +import SwiftUI + +/// Centralized manager for GitHub Copilot feature flags in the HostApp +/// Use as @StateObject or @ObservedObject in SwiftUI views +@MainActor +public class FeatureFlagManager: ObservableObject { + public static let shared = FeatureFlagManager() + + // MARK: - Published Properties + + @Published public private(set) var isAgentModeEnabled = true + @Published public private(set) var isBYOKEnabled = true + @Published public private(set) var isMCPEnabled = true + @Published public private(set) var isEditorPreviewEnabled = true + @Published public private(set) var isChatEnabled = true + @Published public private(set) var isCodeReviewEnabled = true + @Published public private(set) var isAgenModeAutoApprovalEnabled = true + + // MARK: - Private Properties + + private var cancellables = Set() + private var lastUpdateTime: Date? + private let updateThrottle: TimeInterval = 1.0 // Prevent excessive updates + + // MARK: - Initialization + + private init() { + setupNotificationObserver() + Task { + await updateFeatureFlags() + } + } + + // MARK: - Public Methods + + /// Manually refresh feature flags from the service + public func refresh() async { + await updateFeatureFlags() + } + + // MARK: - Private Methods + + private func setupNotificationObserver() { + DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotFeatureFlagsDidChange) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + await self?.updateFeatureFlags() + } + } + .store(in: &cancellables) + } + + private func updateFeatureFlags() async { + // Throttle updates to prevent excessive calls + if let lastUpdate = lastUpdateTime, + Date().timeIntervalSince(lastUpdate) < updateThrottle { + return + } + + lastUpdateTime = Date() + + do { + let service = try getService() + guard let featureFlags = try await service.getCopilotFeatureFlags() else { + Logger.client.info("Feature flags returned nil, using defaults") + return + } + + // Update all flags at once + isAgentModeEnabled = featureFlags.agentMode + isBYOKEnabled = featureFlags.byok + isMCPEnabled = featureFlags.mcp + isEditorPreviewEnabled = featureFlags.editorPreviewFeatures + isChatEnabled = featureFlags.chat + isCodeReviewEnabled = featureFlags.ccr + isAgenModeAutoApprovalEnabled = featureFlags.agentModeAutoApproval + + Logger.client.info("Feature flags updated: agentMode=\(featureFlags.agentMode), byok=\(featureFlags.byok), mcp=\(featureFlags.mcp), editorPreview=\(featureFlags.editorPreviewFeatures)") + } catch { + Logger.client.error("Failed to update feature flags: \(error.localizedDescription)") + } + } +} + +// MARK: - Environment Key + +private struct FeatureFlagManagerKey: EnvironmentKey { + static let defaultValue = FeatureFlagManager.shared +} + +public extension EnvironmentValues { + var featureFlagManager: FeatureFlagManager { + get { self[FeatureFlagManagerKey.self] } + set { self[FeatureFlagManagerKey.self] = newValue } + } +} + +// MARK: - View Extension + +public extension View { + /// Inject the feature flag manager into the environment + func withFeatureFlagManager(_ manager: FeatureFlagManager = .shared) -> some View { + self.environment(\.featureFlagManager, manager) + } +} diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index 5bcfe18b..92d78a25 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -8,20 +8,29 @@ import XPCShared import Logger @Reducer -struct General { +public struct General { @ObservableState - struct State: Equatable { + 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 } - enum Action: Equatable { + public enum Action: Equatable { case appear case setupLaunchAgentIfNeeded case openExtensionManager case reloadStatus - case finishReloading(xpcServiceVersion: String, permissionGranted: ObservedAXStatus) + case finishReloading( + xpcServiceVersion: String, + xpcCLSVersion: String?, + axStatus: ObservedAXStatus, + extensionStatus: ExtensionPermissionStatus, + authStatus: AuthStatus + ) case failedReloading case retryReloading } @@ -30,7 +39,7 @@ struct General { struct ReloadStatusCancellableId: Hashable {} - var body: some ReducerOf { + public var body: some ReducerOf { Reduce { state, action in switch action { case .appear: @@ -53,7 +62,7 @@ struct General { .setupLaunchAgentForTheFirstTimeIfNeeded() } catch { Logger.ui.error("Failed to setup launch agent. \(error.localizedDescription)") - toast(error.localizedDescription, .error) + toast("Operation failed: permission denied. This may be due to missing background permissions.", .error) } await send(.reloadStatus) } @@ -67,6 +76,7 @@ struct General { _ = try await service .send(requestBody: ExtensionServiceRequests.OpenExtensionManager()) } catch { + Logger.ui.error("Failed to open extension manager. \(error.localizedDescription)") toast(error.localizedDescription, .error) await send(.failedReloading) } @@ -83,9 +93,15 @@ struct General { let xpcServiceVersion = try await service.getXPCServiceVersion().version 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, - permissionGranted: isAccessibilityPermissionGranted + xpcCLSVersion: xpcCLSVersion, + axStatus: isAccessibilityPermissionGranted, + extensionStatus: isExtensionPermissionGranted, + authStatus: xpcServiceAuthStatus )) } else { toast("Launching service app.", .info) @@ -95,7 +111,7 @@ struct General { } catch let error as XPCCommunicationBridgeError { Logger.ui.error("Failed to reach communication bridge. \(error.localizedDescription)") toast( - "Failed to reach communication bridge. \(error.localizedDescription)", + "Unable to connect to the communication bridge. The helper application didn't respond. This may be due to missing background permissions.", .error ) await send(.failedReloading) @@ -106,9 +122,12 @@ struct General { } }.cancellable(id: ReloadStatusCancellableId(), cancelInFlight: true) - case let .finishReloading(version, granted): + case let .finishReloading(version, clsVersion, axStatus, extensionStatus, authStatus): state.xpcServiceVersion = version - state.isAccessibilityPermissionGranted = granted + 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 8fd3a5ee..0cf5e8af 100644 --- a/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift +++ b/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift @@ -14,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? @@ -22,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: .init(), store: .init(initialState: .init(), reducer: { General() }) ) } diff --git a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift index 0b62b86e..81f7b9fc 100644 --- a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift +++ b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift @@ -1,10 +1,12 @@ import ComposableArchitecture +import GitHubCopilotViewModel import SwiftUI +import Client struct CopilotConnectionView: View { @AppStorage("username") var username: String = "" @Environment(\.toast) var toast - @StateObject var viewModel = GitHubCopilotViewModel() + @StateObject var viewModel: GitHubCopilotViewModel let store: StoreOf @@ -17,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() } @@ -42,7 +57,10 @@ struct CopilotConnectionView: View { isPresented: $viewModel.isSignInAlertPresented, presenting: viewModel.signInResponse) { _ in Button("Cancel", role: .cancel, action: {}) - Button("Copy Code and Open", action: viewModel.copyAndOpen) + Button( + "Copy Code and Open", + action: { viewModel.copyAndOpen(fromHostApp: true) } + ) } message: { response in Text(""" Please enter the above code in the \ @@ -53,21 +71,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" @@ -79,6 +107,9 @@ struct CopilotConnectionView: View { title: "GitHub Copilot Account Settings" ) } + .onReceive(DistributedNotificationCenter.default().publisher(for: .authStatusDidChange)) { _ in + store.send(.reloadStatus) + } } var copilotResources: some View { @@ -89,7 +120,7 @@ struct CopilotConnectionView: View { ) Divider() SettingsLink( - url: "https://github.com/orgs/community/discussions/categories/copilot", + url: "https://github.com/github/CopilotForXcode/discussions", title: "View Copilot Feedback Forum" ) } @@ -99,16 +130,16 @@ struct CopilotConnectionView: View { #Preview { CopilotConnectionView( - viewModel: .init(), + viewModel: GitHubCopilotViewModel.shared, store: .init(initialState: .init(), reducer: { General() }) ) } #Preview("Running") { - let runningModel = GitHubCopilotViewModel() + let runningModel = GitHubCopilotViewModel.shared runningModel.isRunningAction = true return CopilotConnectionView( - viewModel: runningModel, + viewModel: GitHubCopilotViewModel.shared, store: .init(initialState: .init(), reducer: { General() }) ) } diff --git a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift index 2ce752ae..19418245 100644 --- a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift +++ b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift @@ -1,10 +1,12 @@ import ComposableArchitecture import SwiftUI +import SharedUIComponents struct GeneralSettingsView: View { @AppStorage(\.extensionPermissionShown) var extensionPermissionShown: Bool @AppStorage(\.quitXPCServiceOnXcodeAndAppQuit) var quitXPCServiceOnXcodeAndAppQuit: Bool @State private var shouldPresentExtensionPermissionAlert = false + @State private var shouldShowRestartXcodeAlert = false let store: StoreOf @@ -13,12 +15,53 @@ struct GeneralSettingsView: View { case .granted: return "Granted" case .notGranted: - return "Not Granted. Required to run. Click to open System Preferences." + return "Enable accessibility in system preferences" case .unknown: return "" } } + var extensionPermissionSubtitle: any View { + switch store.isExtensionPermissionGranted { + case .notGranted: + return HStack(spacing: 0) { + Text("Enable ") + Text( + "Extensions \(Image(systemName: "puzzlepiece.extension.fill")) → Xcode Source Editor \(Image(systemName: "info.circle")) → GitHub Copilot for Xcode" + ) + .bold() + .foregroundStyle(.primary) + Text(" for faster and full-featured code completion.") + } + case .disabled: + return Text("Quit and restart Xcode to enable extension") + case .granted: + return Text("Granted") + case .unknown: + return Text("") + } + } + + var extensionPermissionBadge: BadgeItem? { + switch store.isExtensionPermissionGranted { + case .notGranted: + return .init(text: "Not Granted", level: .danger) + case .disabled: + return .init(text: "Disabled", level: .danger) + default: + return nil + } + } + + var extensionPermissionAction: () -> Void { + switch store.isExtensionPermissionGranted { + case .disabled: + return { shouldShowRestartXcodeAlert = true } + default: + return NSWorkspace.openXcodeExtensionsPreferences + } + } + var body: some View { SettingsSection(title: "General") { SettingsToggle( @@ -29,45 +72,62 @@ struct GeneralSettingsView: View { SettingsLink( url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", title: "Accessibility Permission", - subtitle: accessibilityPermissionSubtitle + subtitle: accessibilityPermissionSubtitle, + badge: store.isAccessibilityPermissionGranted == .notGranted ? + .init( + text: "Not Granted", + level: .danger + ) : nil ) Divider() SettingsLink( - url: "x-apple.systempreferences:com.apple.ExtensionsPreferences", + action: extensionPermissionAction, title: "Extension Permission", - subtitle: """ - Check for GitHub Copilot in Xcode's Editor menu. \ - Restart Xcode if greyed out. - """ + subtitle: extensionPermissionSubtitle, + badge: extensionPermissionBadge ) } footer: { HStack { Spacer() - Button("?") { - NSWorkspace.shared.open( - URL(string: "https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md")! - ) - } - .clipShape(Circle()) + AdaptiveHelpLink(action: { NSWorkspace.shared.open( + URL(string: "https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md")! + )}) } } .alert( "Enable Extension Permission", isPresented: $shouldPresentExtensionPermissionAlert ) { - Button("Open System Preferences", action: { - let url = "x-apple.systempreferences:com.apple.ExtensionsPreferences" + Button( + "Open System Preferences", + action: { + NSWorkspace.openXcodeExtensionsPreferences() + }).keyboardShortcut(.defaultAction) + Button("View How-to Guide", action: { + let url = "https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md#extension-permission" NSWorkspace.shared.open(URL(string: url)!) - }).keyboardShortcut(.defaultAction) + }) Button("Close", role: .cancel, action: {}) } message: { - Text("Enable GitHub Copilot under Xcode Source Editor extensions") + Text("To enable faster and full-featured code completion, navigate to:\nExtensions → Xcode Source Editor → GitHub Copilot for Xcode.") } .task { if extensionPermissionShown { return } extensionPermissionShown = true shouldPresentExtensionPermissionAlert = true } + .alert( + "Restart Xcode?", + isPresented: $shouldShowRestartXcodeAlert + ) { + Button("Restart Now") { + NSWorkspace.restartXcode() + }.keyboardShortcut(.defaultAction) + + Button("Cancel", role: .cancel) {} + } message: { + Text("Quit and restart Xcode to enable Github Copilot for Xcode extension.") + } } } diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index b4f5d8ad..e80c9491 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -1,29 +1,31 @@ import ComposableArchitecture +import GitHubCopilotViewModel import SwiftUI struct GeneralView: View { let store: StoreOf - @StateObject private var viewModel = GitHubCopilotViewModel() + @StateObject private var viewModel = GitHubCopilotViewModel.shared var body: some View { - ScrollView { - 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/GitHubCopilotViewModel.swift b/Core/Sources/HostApp/GitHubCopilotViewModel.swift deleted file mode 100644 index bbd01f29..00000000 --- a/Core/Sources/HostApp/GitHubCopilotViewModel.swift +++ /dev/null @@ -1,138 +0,0 @@ -import Foundation -import GitHubCopilotService -import ComposableArchitecture -import Status -import SwiftUI - -struct SignInResponse { - let userCode: String - let verificationURL: URL -} - -@MainActor -class GitHubCopilotViewModel: ObservableObject { - @Dependency(\.toast) var toast - @Dependency(\.openURL) var openURL - - @AppStorage("username") var username: String = "" - - @Published var isRunningAction: Bool = false - @Published var status: GitHubCopilotAccountStatus? - @Published var version: String? - @Published var userCode: String? - @Published var isSignInAlertPresented = false - @Published var signInResponse: SignInResponse? - @Published var waitingForSignIn = false - - static var copilotAuthService: GitHubCopilotAuthServiceType? - - func getGitHubCopilotAuthService() throws -> GitHubCopilotAuthServiceType { - if let service = Self.copilotAuthService { return service } - let service = try GitHubCopilotService() - Self.copilotAuthService = service - return service - } - - func signIn() { - Task { - isRunningAction = true - defer { isRunningAction = false } - do { - let service = try getGitHubCopilotAuthService() - let (uri, userCode) = try await service.signInInitiate() - guard let url = URL(string: uri) else { - toast("Verification URI is incorrect.", .error) - return - } - self.signInResponse = .init(userCode: userCode, verificationURL: url) - self.isSignInAlertPresented = true - } catch { - toast(error.localizedDescription, .error) - } - } - } - - func checkStatus() { - Task { - isRunningAction = true - defer { isRunningAction = false } - do { - let service = try getGitHubCopilotAuthService() - status = try await service.checkStatus() - version = try await service.version() - isRunningAction = false - } catch { - toast(error.localizedDescription, .error) - } - } - } - - func signOut() { - Task { - isRunningAction = true - defer { isRunningAction = false } - do { - let service = try getGitHubCopilotAuthService() - status = try await service.signOut() - broadcastStatusChange() - } catch { - toast(error.localizedDescription, .error) - } - } - } - - func cancelWaiting() { - waitingForSignIn = false - } - - func copyAndOpen() { - waitingForSignIn = true - guard let signInResponse else { - toast("Missing sign in details.", .error) - return - } - let pasteboard = NSPasteboard.general - 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() - } - } - - func waitForSignIn() { - Task { - do { - guard waitingForSignIn else { return } - guard let signInResponse else { - waitingForSignIn = false - return - } - let service = try getGitHubCopilotAuthService() - let (username, status) = try await service.signInConfirm(userCode: signInResponse.userCode) - waitingForSignIn = false - self.username = username - self.status = status - broadcastStatusChange() - } 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 - waitForSignIn() - return - } - throw error - } catch { - toast(error.localizedDescription, .error) - } - } - } - - func broadcastStatusChange() { - DistributedNotificationCenter.default().post( - name: .authStatusDidChange, - object: nil - ) - } -} diff --git a/Core/Sources/HostApp/HandleToast.swift b/Core/Sources/HostApp/HandleToast.swift index 564fdada..8f5d7779 100644 --- a/Core/Sources/HostApp/HandleToast.swift +++ b/Core/Sources/HostApp/HandleToast.swift @@ -17,16 +17,7 @@ struct ToastHandler: View { if let n = message.namespace, n != namespace { EmptyView() } else { - message.content - .foregroundColor(.white) - .padding(8) - .background({ - switch message.type { - case .info: return Color.accentColor - case .error: return Color(nsColor: .systemRed) - case .warning: return Color(nsColor: .systemOrange) - } - }() as Color, in: RoundedRectangle(cornerRadius: 8)) + NotificationView(message: message) .shadow(color: Color.black.opacity(0.2), radius: 4) } } @@ -41,8 +32,8 @@ extension View { @Dependency(\.toastController) var toastController return overlay(alignment: .bottom) { ToastHandler(toastController: toastController, namespace: namespace) - }.environment(\.toast) { [toastController] content, type in - toastController.toast(content: content, type: type, namespace: namespace) + }.environment(\.toast) { [toastController] content, level in + toastController.toast(content: content, level: level, namespace: namespace) } } } diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index fc03d87b..ba3c3da7 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -7,16 +7,57 @@ 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 + } + } +} + +public enum ToolsSubTab: String, CaseIterable, Identifiable { + case MCP, BuiltIn, AutoApprove + public var id: Self { self } +} + @Reducer -struct HostApp { +public struct HostApp { @ObservableState - struct State: Equatable { + public struct State: Equatable { var general = General.State() + public var activeTabIndex: TabIndex = .general + public var activeToolsSubTab: ToolsSubTab = .MCP } - enum Action: Equatable { + public enum Action: Equatable { case appear case general(General.Action) + case setActiveTab(TabIndex) + case setActiveToolsSubTab(ToolsSubTab) } @Dependency(\.toast) var toast @@ -25,18 +66,26 @@ struct HostApp { KeyboardShortcuts.userDefaults = .shared } - var body: some ReducerOf { + public var body: some ReducerOf { Scope(state: \.general, action: /Action.general) { General() } - Reduce { _, action in + Reduce { state, action in switch action { case .appear: return .none case .general: return .none + + case .setActiveTab(let index): + state.activeTabIndex = index + return .none + + case .setActiveToolsSubTab(let tab): + state.activeToolsSubTab = tab + return .none } } } @@ -66,5 +115,3 @@ extension DependencyValues { set { self[UserDefaultsDependencyKey.self] = newValue } } } - - diff --git a/Core/Sources/HostApp/LaunchAgentManager.swift b/Core/Sources/HostApp/LaunchAgentManager.swift index ee031cb5..ba8a4126 100644 --- a/Core/Sources/HostApp/LaunchAgentManager.swift +++ b/Core/Sources/HostApp/LaunchAgentManager.swift @@ -1,7 +1,7 @@ import Foundation import LaunchAgentManager -extension LaunchAgentManager { +public extension LaunchAgentManager { init() { self.init( serviceIdentifier: Bundle.main diff --git a/Core/Sources/HostApp/SharedComponents/Badge.swift b/Core/Sources/HostApp/SharedComponents/Badge.swift new file mode 100644 index 00000000..7c0b2e03 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/Badge.swift @@ -0,0 +1,121 @@ +import SwiftUI + +struct BadgeItem { + enum Level: String, Equatable { + case warning = "Warning" + case danger = "Danger" + case info = "Info" + } + + let text: String + let level: Level + let icon: String? + let isSelected: Bool + let tooltip: String? + + init(text: String, level: Level, icon: String? = nil, isSelected: Bool = false, tooltip: String? = nil) { + self.text = text + self.level = level + self.icon = icon + self.isSelected = isSelected + self.tooltip = tooltip + } +} + +struct Badge: View { + let text: String + let attributedText: AttributedString? + let level: BadgeItem.Level + let icon: String? + let isSelected: Bool + let tooltip: String? + + init(badgeItem: BadgeItem) { + text = badgeItem.text + attributedText = nil + level = badgeItem.level + icon = badgeItem.icon + isSelected = badgeItem.isSelected + tooltip = badgeItem.tooltip + } + + init(text: String, level: BadgeItem.Level, icon: String? = nil, isSelected: Bool = false, tooltip: String? = nil) { + self.text = text + self.attributedText = nil + self.level = level + self.icon = icon + self.isSelected = isSelected + self.tooltip = tooltip + } + + init(attributedText: AttributedString, level: BadgeItem.Level, icon: String? = nil, isSelected: Bool = false, tooltip: String? = nil) { + self.text = String(attributedText.characters) + self.attributedText = attributedText + self.level = level + self.icon = icon + self.isSelected = isSelected + self.tooltip = tooltip + } + + var body: some View { + HStack(alignment: .center, spacing: 2) { + if let icon = icon { + Image(systemName: icon) + .font(.caption2) + .padding(.vertical, 1) + } + if let attributedText = attributedText, attributedText.characters.count > 0 { + Text(attributedText) + .fontWeight(.semibold) + .font(.caption2) + .lineLimit(1) + .truncationMode(.middle) + } else if !text.isEmpty { + Text(text) + .fontWeight(.semibold) + .font(.caption2) + .lineLimit(1) + } + } + .padding(.vertical, 1) + .padding(.horizontal, 3) + .foregroundColor( + level == .info ? Color(nsColor: isSelected ? .white : .secondaryLabelColor) + : Color("\(level.rawValue)ForegroundColor") + ) + .background( + level == .info ? Color(nsColor: .clear) + : Color("\(level.rawValue)BackgroundColor"), + in: RoundedRectangle( + cornerRadius: 9999, + style: .circular + ) + ) + .overlay( + RoundedRectangle( + cornerRadius: 9999, + style: .circular + ) + .stroke( + level == .info ? Color(nsColor: isSelected ? .white : .tertiaryLabelColor) + : Color("\(level.rawValue)StrokeColor"), + lineWidth: 1 + ) + ) + .help(tooltip ?? text) + } +} + +extension BadgeItem { + static func disabledByPolicy(feature: String, isPlural: Bool = false) -> BadgeItem { + let verb = isPlural ? "are" : "is" + let pronoun = isPlural ? "them" : "it" + return .init( + text: "Disabled by organization policy", + level: .warning, + icon: "exclamationmark.triangle.fill", + tooltip: "\(feature) \(verb) disabled by your organization's policy. Please contact your administrator to enable \(pronoun)." + ) + } +} + diff --git a/Core/Sources/HostApp/SharedComponents/BorderedProminentWhiteButtonStyle.swift b/Core/Sources/HostApp/SharedComponents/BorderedProminentWhiteButtonStyle.swift new file mode 100644 index 00000000..7cc5db2a --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/BorderedProminentWhiteButtonStyle.swift @@ -0,0 +1,28 @@ +import SwiftUI + +extension ButtonStyle where Self == BorderedProminentWhiteButtonStyle { + static var borderedProminentWhite: BorderedProminentWhiteButtonStyle { + BorderedProminentWhiteButtonStyle() + } +} + +public struct BorderedProminentWhiteButtonStyle: ButtonStyle { + @Environment(\.colorScheme) var colorScheme + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.leading, 4) + .padding(.trailing, 8) + .padding(.vertical, 0) + .frame(height: 22, alignment: .leading) + .foregroundColor(colorScheme == .dark ? .white : .primary) + .background( + colorScheme == .dark ? Color(red: 0.43, green: 0.43, blue: 0.44) : .white + ) + .cornerRadius(5) + .overlay( + RoundedRectangle(cornerRadius: 5).stroke(.clear, lineWidth: 1) + ) + } +} + diff --git a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift new file mode 100644 index 00000000..7ab60d87 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift @@ -0,0 +1,30 @@ +import SwiftUI +import SharedUIComponents + +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) + configuration.content.foregroundColor(.primary) + } + .padding(.vertical, 12) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(backgroundColor) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .inset(by: 0.5) + .stroke(borderColor, lineWidth: 1) + ) + } +} 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..6567985c --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift @@ -0,0 +1,78 @@ +import SwiftUI +import SharedUIComponents + +public struct DisclosureSettingsRow: View { + @Binding private var isExpanded: Bool + private let isEnabled: Bool + private let background: Color + private let padding: EdgeInsets + private let spacing: CGFloat + private let accessibilityLabel: (Bool) -> String + private let onToggle: ((Bool, Bool) -> Void)? + @ViewBuilder private let title: () -> Title + @ViewBuilder private let subtitle: () -> Subtitle + @ViewBuilder private let actions: () -> Actions + + public init( + isExpanded: Binding, + isEnabled: Bool = true, + background: Color = QuaternarySystemFillColor.opacity(0.75), + padding: EdgeInsets = EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20), + spacing: CGFloat = 16, + accessibilityLabel: @escaping (Bool) -> String = { expanded in expanded ? "collapse" : "expand" }, + onToggle: ((Bool, Bool) -> Void)? = nil, + @ViewBuilder title: @escaping () -> Title, + @ViewBuilder subtitle: @escaping (() -> Subtitle) = { EmptyView() }, + @ViewBuilder actions: @escaping () -> Actions = { EmptyView() } + ) { + _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/EditableText.swift b/Core/Sources/HostApp/SharedComponents/EditableText.swift new file mode 100644 index 00000000..41db896a --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/EditableText.swift @@ -0,0 +1,55 @@ +import SwiftUI +import Perception + +struct EditableText: View { + let title: String + let initialText: String + let onCommit: (String) -> Bool + + @State private var text: String + @State private var lastCommittedText: String + @State private var isReverting: Bool = false + + init(_ title: String, text: String, onCommit: @escaping (String) -> Bool) { + self.title = title + self.initialText = text + self._text = State(initialValue: text) + self._lastCommittedText = State(initialValue: text) + self.onCommit = onCommit + } + + var body: some View { + TextField(title, text: $text, onEditingChanged: { editing in + if !editing { + commit() + } + }) + .onSubmit { + commit() + } + .onChange(of: initialText) { newValue in + if text != newValue { + text = newValue + } + if lastCommittedText != newValue { + lastCommittedText = newValue + } + } + } + + private func commit() { + guard !isReverting else { return } + guard text != lastCommittedText else { return } + + if onCommit(text) { + lastCommittedText = text + } else { + isReverting = true + // Async revert to ensure textField updates even during focus change + DispatchQueue.main.async { + text = lastCommittedText + isReverting = 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/SettingsContainerStyle.swift b/Core/Sources/HostApp/SharedComponents/SettingsContainerStyle.swift new file mode 100644 index 00000000..119edd80 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/SettingsContainerStyle.swift @@ -0,0 +1,17 @@ +import SwiftUI +import SharedUIComponents + +extension View { + func settingsContainerStyle(isExpanded: Bool) -> some View { + self + .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) + } +} diff --git a/Core/Sources/HostApp/SharedComponents/SettingsLink.swift b/Core/Sources/HostApp/SharedComponents/SettingsLink.swift index b3c00cf3..32fb296d 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsLink.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsLink.swift @@ -1,33 +1,75 @@ import SwiftUI struct SettingsLink: View { - let url: URL + let action: ()->Void let title: String - let subtitle: String? + let subtitle: AnyView? + let badge: BadgeItem? - init(_ url: URL, title: String, subtitle: String? = nil) { - self.url = url + init( + action: @escaping ()->Void, + title: String, + subtitle: Subtitle?, + badge: BadgeItem? = nil + ) { + self.action = action self.title = title - self.subtitle = subtitle + self.subtitle = subtitle.map { AnyView($0) } + self.badge = badge + } + + init( + _ url: URL, + title: String, + subtitle: String? = nil, + badge: BadgeItem? = nil + ) { + self.init( + action: { NSWorkspace.shared.open(url) }, + title: title, + subtitle: subtitle.map { Text($0) }, + badge: badge + ) } - init(url: String, title: String, subtitle: String? = nil) { - self.init(URL(string: url)!, title: title, subtitle: subtitle) + init(url: String, title: String, subtitle: String? = nil, badge: BadgeItem? = nil) { + self.init( + URL(string: url)!, + title: title, + subtitle: subtitle, + badge: badge + ) + } + + init(url: String, title: String, subtitle: Subtitle?, badge: BadgeItem? = nil) { + self.init( + action: { NSWorkspace.shared.open(URL(string: url)!) }, + title: title, + subtitle: subtitle, + badge: badge + ) } var body: some View { - Link(destination: url) { - VStack(alignment: .leading) { - Text(title) - .font(.body) - if let subtitle = subtitle { - Text(subtitle) - .font(.footnote) + Button(action: action) { + HStack{ + VStack(alignment: .leading) { + HStack{ + Text(title).font(.body) + if let badge = self.badge { + Badge(badgeItem: badge) + } + } + if let subtitle = subtitle { + subtitle.font(.footnote) + } } + Spacer() + Image(systemName: "chevron.right") } - Spacer() - Image(systemName: "chevron.right") + .contentShape(Rectangle()) // This makes the entire HStack clickable } + .buttonStyle(.plain) .foregroundStyle(.primary) .padding(10) } @@ -37,6 +79,7 @@ struct SettingsLink: View { SettingsLink( url: "https://example.com", title: "Example", - subtitle: "This is an example" + subtitle: "This is an example", + badge: .init(text: "Not Granted", level: .danger) ) } diff --git a/Core/Sources/HostApp/SharedComponents/SettingsSection.swift b/Core/Sources/HostApp/SharedComponents/SettingsSection.swift index e52d9ad3..007eeb15 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsSection.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsSection.swift @@ -1,4 +1,5 @@ import SwiftUI +import Perception struct SettingsSection: View { let title: String @@ -15,31 +16,33 @@ struct SettingsSection: View { } var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text(title) - .bold() - .padding(.horizontal, 10) - if showWarning { - HStack{ - Text("GitHub Copilot features are disabled. Please check your subscription to access them.") - .foregroundColor(Color("WarningForegroundColor")) - .padding(4) - Spacer() + WithPerceptionTracking{ + VStack(alignment: .leading, spacing: 10) { + Text(title) + .bold() + .padding(.horizontal, 10) + if showWarning { + HStack{ + Text("GitHub Copilot features are disabled. Please [check your subscription](https://github.com/settings/copilot) to access them.") + .foregroundColor(Color("WarningForegroundColor")) + .padding(4) + Spacer() + } + .background(Color("WarningBackgroundColor")) + .overlay( + RoundedRectangle(cornerRadius: 3) + .stroke(Color("WarningStrokeColor"), lineWidth: 1) + ) } - .background(Color("WarningBackgroundColor")) - .overlay( - RoundedRectangle(cornerRadius: 3) - .stroke(Color("WarningStrokeColor"), lineWidth: 1) - ) - } - VStack(alignment: .leading, spacing: 0) { - content() + VStack(alignment: .leading, spacing: 0) { + content() + } + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + footer() } - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - footer() + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) } } 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..a3dc805d 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift @@ -1,17 +1,43 @@ import SwiftUI struct SettingsToggle: View { + static let defaultPadding: CGFloat = 10 + let title: String + let subtitle: String? let isOn: Binding + let badge: BadgeItem? + + init(title: String, subtitle: String? = nil, isOn: Binding, badge: BadgeItem? = nil) { + self.title = title + self.subtitle = subtitle + self.isOn = isOn + self.badge = badge + } var body: some View { HStack(alignment: .center) { - Text(title) + VStack(alignment: .leading) { + HStack(spacing: 6) { + Text(title).font(.body) + + if let badge = badge { + Badge(badgeItem: badge) + .allowsHitTesting(true) + } + } + + if let subtitle = subtitle { + Text(subtitle).font(.footnote) + } + } Spacer() Toggle(isOn: isOn) {} + .controlSize(.mini) .toggleStyle(.switch) + .padding(.vertical, 4) } - .padding(10) + .padding(SettingsToggle.defaultPadding) } } diff --git a/Core/Sources/HostApp/SharedComponents/TransparentTableBackground.swift b/Core/Sources/HostApp/SharedComponents/TransparentTableBackground.swift new file mode 100644 index 00000000..c672e420 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/TransparentTableBackground.swift @@ -0,0 +1,12 @@ +import SwiftUI + +extension View { + @ViewBuilder + func transparentBackground() -> some View { + if #available(macOS 14.0, *) { + self.scrollContentBackground(.hidden).alternatingRowBackgrounds(.disabled) + } else { + self + } + } +} diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 0d3f0a87..c4a372cd 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -5,24 +5,36 @@ import LaunchAgentManager import SwiftUI import Toast import UpdateChecker +import Client +import Logger +import Combine @MainActor -let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() }) +public let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() }) public struct TabContainer: View { let store: StoreOf @ObservedObject var toastController: ToastController + @ObservedObject private var featureFlags = FeatureFlagManager.shared @State private var tabBarItems = [TabBarItem]() - @State var tag: Int = 0 + @Binding var tag: TabIndex public init() { toastController = ToastControllerDependencyKey.liveValue store = hostAppStore + _tag = Binding( + get: { hostAppStore.state.activeTabIndex }, + set: { hostAppStore.send(.setActiveTab($0)) } + ) } init(store: StoreOf, toastController: ToastController) { self.store = store self.toastController = toastController + _tag = Binding( + get: { store.state.activeTabIndex }, + set: { store.send(.setActiveTab($0)) } + ) } public var body: some View { @@ -31,26 +43,21 @@ 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: 2, - title: "Advanced", - image: "gearshape.2.fill" - ) + GeneralView(store: store.scope(state: \.general, action: \.general)).tabBarItem(for: .general) + AdvancedSettings().tabBarItem(for: .advanced) + if featureFlags.isAgentModeEnabled { + MCPConfigView().tabBarItem(for: .tools) + } + if featureFlags.isBYOKEnabled { + BYOKConfigView().tabBarItem(for: .byok) + } } .environment(\.tabBarTabTag, tag) .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 @@ -58,12 +65,22 @@ public struct TabContainer: View { .onAppear { store.send(.appear) } + .onChange(of: featureFlags.isAgentModeEnabled) { isEnabled in + if hostAppStore.state.activeTabIndex == .tools && !isEnabled { + hostAppStore.send(.setActiveTab(.general)) + } + } + .onChange(of: featureFlags.isBYOKEnabled) { isEnabled in + if hostAppStore.state.activeTabIndex == .byok && !isEnabled { + hostAppStore.send(.setActiveTab(.general)) + } + } } } } struct TabBar: View { - @Binding var tag: Int + @Binding var tag: TabIndex fileprivate var tabBarItems: [TabBarItem] var body: some View { @@ -82,9 +99,9 @@ struct TabBar: View { } struct TabBarButton: View { - @Binding var currentTag: Int + @Binding var currentTag: TabIndex @State var isHovered = false - var tag: Int + var tag: TabIndex var title: String var image: String var isSystemImage: Bool = true @@ -115,7 +132,7 @@ struct TabBarButton: View { .padding(.vertical, 4) .padding(.top, 4) .background( - tag == currentTag + isSelected ? Color(nsColor: .textColor).opacity(0.1) : Color.clear, in: RoundedRectangle(cornerRadius: 8) @@ -136,7 +153,7 @@ struct TabBarButton: View { private struct TabBarTabViewWrapper: View { @Environment(\.tabBarTabTag) var tabBarTabTag - var tag: Int + var tag: TabIndex var title: String var image: String var isSystemImage: Bool = true @@ -158,25 +175,20 @@ private struct TabBarTabViewWrapper: View { } private extension View { - func tabBarItem( - tag: Int, - title: String, - image: String, - isSystemImage: Bool = true - ) -> some View { + func tabBarItem(for tag: TabIndex) -> some View { TabBarTabViewWrapper( tag: tag, - title: title, - image: image, - isSystemImage: isSystemImage, + title: tag.title, + image: tag.image, + isSystemImage: tag.isSystemImage, content: { self } ) } } private struct TabBarItem: Identifiable, Equatable { - var id: Int { tag } - var tag: Int + var id: TabIndex { tag } + var tag: TabIndex var title: String var image: String var isSystemImage: Bool = true @@ -190,11 +202,11 @@ private struct TabBarItemPreferenceKey: PreferenceKey { } private struct TabBarTabTagKey: EnvironmentKey { - static var defaultValue: Int = 0 + static var defaultValue: TabIndex = .general } private extension EnvironmentValues { - var tabBarTabTag: Int { + var tabBarTabTag: TabIndex { get { self[TabBarTabTagKey.self] } set { self[TabBarTabTagKey.self] = newValue } } @@ -225,12 +237,35 @@ struct TabContainer_Toasts_Previews: PreviewProvider { TabContainer( store: .init(initialState: .init(), reducer: { HostApp() }), toastController: .init(messages: [ - .init(id: UUID(), type: .info, content: Text("info")), - .init(id: UUID(), type: .error, content: Text("error")), - .init(id: UUID(), type: .warning, content: Text("warning")), + .init(id: UUID(), level: .info, content: Text("info")), + .init(id: UUID(), level: .error, content: Text("error")), + .init(id: UUID(), level: .warning, content: Text("warning")), ]) ) .frame(width: 800) } } +@available(macOS 14.0, *) +@MainActor +public struct SettingsEnvironment: View { + @Environment(\.openSettings) public var openSettings: OpenSettingsAction + + public init() {} + + public var body: some View { + EmptyView().onAppear { + openSettings() + } + } + + public func open() { + let controller = NSHostingController(rootView: self) + let window = NSWindow(contentViewController: controller) + window.orderFront(nil) + // Close the temporary window after settings are opened + DispatchQueue.main.async { + window.close() + } + } +} diff --git a/Core/Sources/HostApp/ToolsConfigView.swift b/Core/Sources/HostApp/ToolsConfigView.swift new file mode 100644 index 00000000..7dbb1ba1 --- /dev/null +++ b/Core/Sources/HostApp/ToolsConfigView.swift @@ -0,0 +1,273 @@ +import Client +import ComposableArchitecture +import ConversationServiceProvider +import Foundation +import GitHubCopilotService +import Logger +import Persist +import SharedUIComponents +import SwiftUI +import SystemUtils +import Toast + +struct MCPConfigView: View { + @State private var mcpConfig: String = "" + @Environment(\.toast) var toast + @ObservedObject private var featureFlags = FeatureFlagManager.shared + @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared + @State private var configFilePath: String = mcpConfigFilePath + @State private var isMonitoring: Bool = false + @State private var lastModificationDate: Date? = nil + @State private var fileMonitorTask: Task? = nil + @State private var selectedMode: ConversationMode = .defaultAgent + @Environment(\.colorScheme) var colorScheme + + private var isCustomAgentEnabled: Bool { + featureFlags.isEditorPreviewEnabled && copilotPolicy.isCustomAgentEnabled + } + + private static var lastSyncTimestamp: Date? = nil + @State private var debounceTimer: Timer? + private static let refreshDebounceInterval: TimeInterval = 1.0 // 1.0 second debounce + + var body: some View { + WithPerceptionTracking { + ScrollView { + Picker("", selection: Binding( + get: { hostAppStore.state.activeToolsSubTab }, + set: { hostAppStore.send(.setActiveToolsSubTab($0)) } + )) { + if #available(macOS 26.0, *) { + Text("MCP".padded(centerTo: 24, with: "\u{2002}")).tag(ToolsSubTab.MCP) + Text("Built-In".padded(centerTo: 24, with: "\u{2002}")).tag(ToolsSubTab.BuiltIn) + Text("Auto-Approve".padded(centerTo: 24, with: "\u{2002}")).tag(ToolsSubTab.AutoApprove) + } else { + Text("MCP").tag(ToolsSubTab.MCP) + Text("Built-In").tag(ToolsSubTab.BuiltIn) + Text("Auto-Approve").tag(ToolsSubTab.AutoApprove) + } + } + .frame(width: 400) + .labelsHidden() + .pickerStyle(.segmented) + .padding(.top, 12) + .padding(.bottom, 4) + + Group { + if hostAppStore.activeToolsSubTab == .MCP { + VStack(alignment: .leading, spacing: 8) { + MCPIntroView(isMCPFFEnabled: featureFlags.isMCPEnabled) + if featureFlags.isMCPEnabled { + MCPManualInstallView() + + if featureFlags.isEditorPreviewEnabled { + MCPRegistryURLView() + } + + MCPToolsListView( + selectedMode: $selectedMode, + isCustomAgentEnabled: isCustomAgentEnabled + ) + + HStack { + Spacer() + AdaptiveHelpLink(action: { NSWorkspace.shared.open( + URL(string: "https://modelcontextprotocol.io/introduction")! + ) }) + } + } + } + .onAppear { + setupConfigFilePath() + if featureFlags.isMCPEnabled { + startMonitoringConfigFile() + } + } + .onDisappear { + stopMonitoringConfigFile() + } + .onChange(of: featureFlags.isMCPEnabled) { newMCPFFEnabled in + if newMCPFFEnabled { + startMonitoringConfigFile() + refreshConfiguration() + } else { + stopMonitoringConfigFile() + } + } + .onChange(of: isCustomAgentEnabled) { isEnabled in + if !isEnabled && !selectedMode.isDefaultAgent { + selectedMode = .defaultAgent + } + } + } else if hostAppStore.activeToolsSubTab == .BuiltIn { + BuiltInToolsListView( + selectedMode: $selectedMode, + isCustomAgentEnabled: isCustomAgentEnabled + ) + } else { + AutoApproveContainerView() + } + } + .padding(.horizontal, 20) + } + } + } + + private func setupConfigFilePath() { + let fileManager = FileManager.default + + if !fileManager.fileExists(atPath: configDirectory.path) { + try? fileManager.createDirectory(at: configDirectory, withIntermediateDirectories: true) + } + + // If the file doesn't exist, create one with a proper structure + let configFileURL = URL(fileURLWithPath: configFilePath) + if !fileManager.fileExists(atPath: configFilePath) { + try? """ + { + "servers": { + + } + } + """.write(to: configFileURL, atomically: true, encoding: .utf8) + } + + // Read the current content from file and ensure it's valid JSON + mcpConfig = readAndValidateJSON(from: configFileURL) ?? "{}" + + // Get initial modification date + lastModificationDate = getFileModificationDate(url: configFileURL) + } + + /// Reads file content and validates it as JSON, returning only the "servers" object + private func readAndValidateJSON(from url: URL) -> String? { + guard let data = try? Data(contentsOf: url) else { + return nil + } + + // Try to parse as JSON to validate + do { + // First verify it's valid JSON + let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + // Extract the "servers" object + guard let servers = jsonObject?["servers"] as? [String: Any] else { + Logger.client.info("No 'servers' key found in MCP configuration") + toast("No 'servers' key found in MCP configuration", .error) + // Return empty object if no servers section + return "{}" + } + + // Convert the servers object back to JSON data + let serversData = try JSONSerialization.data( + withJSONObject: servers, options: [.prettyPrinted]) + + // Return as a string + return String(data: serversData, encoding: .utf8) + } catch { + // If parsing fails, return nil + Logger.client.info("Parsing MCP JSON error: \(error)") + toast("Invalid JSON in MCP configuration file", .error) + return nil + } + } + + private func getFileModificationDate(url: URL) -> Date? { + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + return attributes?[.modificationDate] as? Date + } + + private func startMonitoringConfigFile() { + stopMonitoringConfigFile() // Stop existing monitoring if any + + isMonitoring = true + Logger.client.info("Starting MCP config file monitoring") + + fileMonitorTask = Task { + let configFileURL = URL(fileURLWithPath: configFilePath) + + // Check for file changes periodically + while isMonitoring { + try? await Task.sleep(nanoseconds: 3_000_000_000) // Check every 3 second for better responsiveness + + guard isMonitoring else { break } // Extra check after sleep + + let currentDate = getFileModificationDate(url: configFileURL) + + if let currentDate = currentDate, currentDate != lastModificationDate { + // File modification date has changed, update our record + Logger.client.info("MCP config file change detected") + lastModificationDate = currentDate + + // Read and validate the updated content + if let validJson = readAndValidateJSON(from: configFileURL) { + await MainActor.run { + mcpConfig = validJson + refreshConfiguration() + toast("MCP configuration file updated", .info) + } + } else { + // If JSON is invalid, show error + await MainActor.run { + toast("Invalid JSON in MCP configuration file", .error) + Logger.client.info("Invalid JSON detected during monitoring") + } + } + } + } + Logger.client.info("Stopped MCP config file monitoring") + } + } + + private func stopMonitoringConfigFile() { + guard isMonitoring else { return } + Logger.client.info("Stopping MCP config file monitoring") + isMonitoring = false + fileMonitorTask?.cancel() + fileMonitorTask = nil + } + + func refreshConfiguration() { + if MCPConfigView.lastSyncTimestamp == lastModificationDate { + return + } + + MCPConfigView.lastSyncTimestamp = lastModificationDate + + let fileURL = URL(fileURLWithPath: configFilePath) + if let jsonString = readAndValidateJSON(from: fileURL) { + UserDefaults.shared.set(jsonString, for: \.gitHubCopilotMCPConfig) + } + + // Debounce the refresh notification to avoid sending too frequently + debounceTimer?.invalidate() + debounceTimer = Timer.scheduledTimer(withTimeInterval: MCPConfigView.refreshDebounceInterval, repeats: false) { _ in + Task { + do { + let service = try getService() + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + await MainActor.run { + toast("Fetching MCP tools...", .info) + } + } catch { + await MainActor.run { + toast(error.localizedDescription, .error) + } + } + } + } + } +} + +extension String { + func padded(centerTo total: Int, with pad: Character = " ") -> String { + guard count < total else { return self } + let deficit = total - count + let left = deficit / 2 + let right = deficit - left + return String(repeating: pad, count: left) + self + String(repeating: pad, count: right) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AgentModeDescriptionView.swift b/Core/Sources/HostApp/ToolsSettings/AgentModeDescriptionView.swift new file mode 100644 index 00000000..3ae5239e --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AgentModeDescriptionView.swift @@ -0,0 +1,34 @@ +import SwiftUI +import ConversationServiceProvider + +struct AgentModeDescription { + static func descriptionText(for mode: ConversationMode) -> String { + // Check if it's the built-in "Agent" mode + if mode.isDefaultAgent { + return "The selected tools will be applied globally for all chat sessions that use the default agent." + } + + // Check if it's a custom mode + if !mode.isBuiltIn { + return "The selected tools are configured by the '\(mode.name)' custom agent. Changes to the tools will be applied to the custom agent file as well." + } + + // Other built-in modes (like Plan, etc.) + return "The selected tools are configured by the '\(mode.name)' agent. Changes to the tools are not allowed for now." + } +} + +/// Shared description view for agent modes +struct AgentModeDescriptionView: View { + let selectedMode: ConversationMode + let isLoadingMode: Bool + + var body: some View { + if !isLoadingMode { + Text(AgentModeDescription.descriptionText(for: selectedMode)) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AgentModeDropdownView.swift b/Core/Sources/HostApp/ToolsSettings/AgentModeDropdownView.swift new file mode 100644 index 00000000..69a028d9 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AgentModeDropdownView.swift @@ -0,0 +1,87 @@ +import Client +import ConversationServiceProvider +import HostAppActivator +import Logger +import Persist +import SwiftUI + +struct AgentModeDropdown: View { + @Binding var modes: [ConversationMode] + @Binding var selectedMode: ConversationMode + + public init(modes: Binding<[ConversationMode]>, selectedMode: Binding) { + _modes = modes + _selectedMode = selectedMode + } + + var builtInModes: [ConversationMode] { + modes.filter { $0.isBuiltIn } + } + + var customModes: [ConversationMode] { + modes.filter { !$0.isBuiltIn } + } + + var body: some View { + Picker(selection: Binding( + get: { selectedMode.id }, + set: { newId in + if let mode = modes.first(where: { $0.id == newId }) { + selectedMode = mode + } + } + )) { + ForEach(builtInModes, id: \.id) { mode in + Text(mode.name).tag(mode.id) + } + + if !customModes.isEmpty { + Divider() + ForEach(customModes, id: \.id) { mode in + Text(mode.name).tag(mode.id) + } + } + } label: { + Text("Applied for").fontWeight(.bold) + } + .pickerStyle(.menu) + .frame(maxWidth: 300, alignment: .leading) + .padding(.leading, -4) + .onAppear { + loadModes() + } + .onReceive(DistributedNotificationCenter.default().publisher(for: .selectedAgentSubModeDidChange)) { notification in + if let userInfo = notification.userInfo as? [String: String], + let newModeId = userInfo["agentSubMode"], + newModeId != selectedMode.id, + let mode = modes.first(where: { $0.id == newModeId }) { + Logger.client.info("AgentModeDropdown: Mode changed to: \(newModeId)") + selectedMode = mode + } + } + } + + // MARK: - Helper Methods + + private func loadModes() { + Task { + do { + let service = try getService() + let workspaceFolders = await getWorkspaceFolders() + if let fetchedModes = try await service.getModes(workspaceFolders: workspaceFolders) { + Logger.client.info("AgentModeDropdown: Fetched \(fetchedModes.count) modes") + await MainActor.run { + modes = fetchedModes.filter { $0.kind == .Agent } + + if !modes.contains(where: { $0.id == selectedMode.id }), + let firstMode = modes.first { + selectedMode = firstMode + } + } + } + } catch { + Logger.client.error("AgentModeDropdown: Failed to load modes: \(error.localizedDescription)") + } + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AppState+LanguageModelTools.swift b/Core/Sources/HostApp/ToolsSettings/AppState+LanguageModelTools.swift new file mode 100644 index 00000000..867a0df1 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AppState+LanguageModelTools.swift @@ -0,0 +1,24 @@ +import ConversationServiceProvider +import Foundation +import Persist + +public let LANGUAGE_MODEL_TOOLS_STATUS = "languageModelToolsStatus" + +extension AppState { + public func getLanguageModelToolsStatus() -> [ToolStatusUpdate]? { + guard let savedJSON = get(key: LANGUAGE_MODEL_TOOLS_STATUS), + let data = try? JSONEncoder().encode(savedJSON), + let savedStatus = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data) else { + return nil + } + return savedStatus + } + + public func updateLanguageModelToolsStatus(_ updates: [ToolStatusUpdate]) { + update(key: LANGUAGE_MODEL_TOOLS_STATUS, value: updates) + } + + public func clearLanguageModelToolsStatus() { + update(key: LANGUAGE_MODEL_TOOLS_STATUS, value: "") + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApprovalDisableView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApprovalDisableView.swift new file mode 100644 index 00000000..7a74b12f --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApprovalDisableView.swift @@ -0,0 +1,25 @@ +import Client +import Foundation +import Logger +import SharedUIComponents +import SwiftUI + +struct AutoApprovalDisableView: View { + var body: some View { + GroupBox { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "info.circle.fill") + .font(.body) + .foregroundColor(.gray) + Text( + "Auto approval is disabled by your organization's policy. To enable it, 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) + ) + ) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApproveContainerView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApproveContainerView.swift new file mode 100644 index 00000000..0c75e42b --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApproveContainerView.swift @@ -0,0 +1,32 @@ +// AutoApproveContainerView.swift +// Container view for the auto-approve feature in Tools Settings +// Created: 2026-01-08 +// +// This view wraps EditsAutoApproveView in a VStack for layout. + +import AppKit +import Logger +import SharedUIComponents +import SwiftUI + +struct AutoApproveContainerView: View { + @ObservedObject private var featureFlags = FeatureFlagManager.shared + @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared + + private var isAutoApprovalEnabled: Bool { + featureFlags.isAgenModeAutoApprovalEnabled && copilotPolicy.isAgentModeAutoApprovalEnabled + } + + var body: some View { + VStack(spacing: 16) { + if isAutoApprovalEnabled { + EditsAutoApproveView() + TerminalAutoApproveView() + MCPAutoApproveView() + } else { + AutoApprovalDisableView() + } + } + .padding(.bottom, 20) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/EditsAutoApproveView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/EditsAutoApproveView.swift new file mode 100644 index 00000000..4d9415f6 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/EditsAutoApproveView.swift @@ -0,0 +1,280 @@ +import AppKit +import Client +import Logger +import Preferences +import SharedUIComponents +import SwiftUI +import UserDefaultsObserver +import ComposableArchitecture + +struct EditsAutoApproveView: View { + @State private var isExpanded: Bool = true + @StateObject private var viewModel = ViewModel() + @State private var selection = Set() + + let rowHeight: CGFloat = 28 + + private var canRemoveSelection: Bool { + guard !selection.isEmpty else { return false } + return !viewModel.rules.contains { rule in + selection.contains(rule.id) && rule.isDefault + } + } + + var body: some View { + VStack(spacing: 0) { + DisclosureSettingsRow( + isExpanded: $isExpanded, + accessibilityLabel: { $0 ? "Collapse edits auto-approve section" : "Expand edits auto-approve section" }, + title: { Text("Edits Auto-Approve").font(.headline) }, + subtitle: { Text("Controls whether file edits generated by Copilot are approved automatically. Set to **true** to auto-approve edits to matching files; set to **false** to always require explicit approval.") } + ) + + if isExpanded { + VStack(alignment: .leading, spacing: 0) { + Divider() + + rulesTable + + Divider() + + toolbar + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + } + .settingsContainerStyle(isExpanded: isExpanded) + .onAppear { + viewModel.loadRules() + } + } + + @ViewBuilder + private var rulesTable: some View { + if #available(macOS 13.5, *) { + Table(viewModel.rules, selection: $selection) { + TableColumn(Text("Pattern").bold()) { rule in + if rule.isDefault { + Text(rule.pattern).help(rule.pattern) + } else { + EditableText("Pattern", text: rule.pattern) { newText in + viewModel.updateRule(id: rule.id, pattern: newText) + } + .help("Click to edit pattern") + } + } + TableColumn("Description") { rule in + if rule.isDefault { + Text(rule.description).help(rule.description) + } else { + EditableText("Description", text: rule.description) { newText in + viewModel.updateRule(id: rule.id, description: newText) + } + .help(rule.description) + } + } + TableColumn("Type") { rule in + Text(rule.isDefault ? "Default" : "Custom") + .foregroundStyle(.secondary) + } + TableColumn("Auto-Approve") { rule in + Toggle(rule.isDefault ? "Default to false" : "", isOn: Binding( + get: { rule.autoApprove }, + set: { viewModel.updateRule(id: rule.id, autoApprove: $0) } + )) + .disabled(rule.isDefault) + } + } + .frame(height: CGFloat(max(viewModel.rules.count, 1)) * rowHeight + 40) + .padding(.horizontal, 20) + .transparentBackground() + } + } + + @ViewBuilder + private var toolbar: some View { + HStack(spacing: 8) { + Button(action: { viewModel.addRule() }) { + Image(systemName: "plus") + } + .foregroundColor(.primary) + .buttonStyle(.borderless) + .padding(.leading, 8) + + Divider() + + Group { + if canRemoveSelection { + Button(action: { + viewModel.removeRules(ids: selection) + selection.removeAll() + }) { + Image(systemName: "minus") + } + .buttonStyle(.borderless) + } else { + Image(systemName: "minus") + } + } + .foregroundColor( + canRemoveSelection ? .primary : Color( + nsColor: .quaternaryLabelColor + ) + ) + .help("Remove selected rules") + + Spacer() + } + .frame(height: 24) + .background(TertiarySystemFillColor) + } +} + +extension EditsAutoApproveView { + final class ViewModel: ObservableObject { + @Dependency(\.toast) var toast + + struct Rule: Identifiable { + var id = UUID() + var pattern: String + var description: String + var autoApprove: Bool + var isDefault: Bool + } + + @Published var rules: [Rule] = [] + private let defaults = UserDefaults.autoApproval + private var observer = UserDefaultsObserver( + object: UserDefaults.autoApproval, + forKeyPaths: [UserDefaultPreferenceKeys().sensitiveFilesGlobalApprovals.key], + context: nil + ) + + private let defaultRules: [Rule] = [ + Rule(pattern: "**/.github/instructions/*", description: "Github instructions files", autoApprove: false, isDefault: true), + Rule(pattern: "**/github-copilot/**/*", description: "Github Copilot settings and token files", autoApprove: false, isDefault: true), + ] + + init() { + observer.onChange = { [weak self] in + DispatchQueue.main.async { + self?.loadRules() + } + } + } + + func loadRules() { + var loadedRules: [Rule] = [] + + // Load from UserDefaults + let state = defaults.value(for: \.sensitiveFilesGlobalApprovals) + let savedRules = state.rules + + func findExistingID(pattern: String) -> UUID { + return rules.first(where: { $0.pattern == pattern })?.id ?? UUID() + } + + // Add default rules first + for defaultRule in defaultRules { + var rule = defaultRule + // If it exists in persisted config, override properties that can be changed (autoApprove) + // We keep the default description unless we want to allow overriding it. + if let savedRule = savedRules[defaultRule.pattern] { + rule.autoApprove = savedRule.autoApprove + if !savedRule.description.isEmpty { + rule.description = savedRule.description + } + } + rule.id = findExistingID(pattern: rule.pattern) + loadedRules.append(rule) + } + + // Add custom rules + for (patternKey, value) in savedRules { + // Skip if it's a default rule + if defaultRules.contains(where: { $0.pattern == patternKey }) { continue } + + let id = findExistingID(pattern: patternKey) + + loadedRules.append(Rule(id: id, pattern: patternKey, description: value.description, autoApprove: value.autoApprove, isDefault: false)) + } + + rules = loadedRules.sorted { + if $0.isDefault != $1.isDefault { + return $0.isDefault // Defaults first + } + return $0.pattern < $1.pattern + } + } + + func addRule() { + var counter = 0 + var newPattern = "New Pattern" + while rules.contains(where: { $0.pattern == newPattern }) { + counter += 1 + newPattern = "New Pattern \(counter)" + } + rules.append(Rule(pattern: newPattern, description: "Description", autoApprove: false, isDefault: false)) + saveRules() + } + + func removeRules(ids: Set) { + rules.removeAll { ids.contains($0.id) && !$0.isDefault } + saveRules() + } + + @discardableResult + func updateRule(id: UUID, pattern: String? = nil, description: String? = nil, autoApprove: Bool? = nil) -> Bool { + guard let index = rules.firstIndex(where: { $0.id == id }) else { return false } + + if let pattern { + var newPattern = pattern.filter { !$0.isNewline } + newPattern = newPattern.trimmingCharacters(in: .whitespacesAndNewlines) + + if !rules.contains(where: { $0.id != id && $0.pattern == newPattern }) { + rules[index].pattern = newPattern + } else { + toast("Duplicate patterns are not allowed. Please ensure each rule has a unique pattern.", .warning) + return false + } + } + if let description { rules[index].description = description } + if let autoApprove { rules[index].autoApprove = autoApprove } + + saveRules() + return true + } + + func saveRules() { + // Check for duplicate patterns + let patterns = rules.map(\.pattern) + let uniquePatterns = Set(patterns) + if patterns.count != uniquePatterns.count { + return + } + + var state = defaults.value(for: \.sensitiveFilesGlobalApprovals) + var newRules: [String: SensitiveFileRule] = [:] + + for rule in rules { + newRules[rule.pattern] = SensitiveFileRule(description: rule.description, autoApprove: rule.autoApprove) + } + + state.rules = newRules + defaults.set(state, for: \.sensitiveFilesGlobalApprovals) + Task { + do { + let service = try getService() + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + } catch { + toast(error.localizedDescription, .error) + } + } + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/MCPAutoApproveView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/MCPAutoApproveView.swift new file mode 100644 index 00000000..962f2630 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/MCPAutoApproveView.swift @@ -0,0 +1,294 @@ +import AppKit +import Combine +import Client +import GitHubCopilotService +import Logger +import Preferences +import SharedUIComponents +import SwiftUI +import UserDefaultsObserver + +struct MCPAutoApproveView: View { + @State private var isExpanded: Bool = true + @StateObject private var viewModel = ViewModel() + + var body: some View { + VStack(spacing: 0) { + DisclosureSettingsRow( + isExpanded: $isExpanded, + accessibilityLabel: { $0 ? "Collapse MCP auto-approve section" : "Expand MCP auto-approve section" }, + title: { Text("MCP Auto-Approve").font(.headline) }, + subtitle: { Text("Controls whether MCP tool calls triggered by Copilot are automatically approved. You can enable MCP auto-approval per server or per tool.") } + ) + + if isExpanded { + Divider() + AgentTrustToolAnnotationsSetting() + .padding(.horizontal, 26) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + Divider() + if #available(macOS 14.0, *) { + if viewModel.rows.isEmpty { + Text(noRunningServersMessage) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .environment(\.openURL, OpenURLAction { url in + if url.scheme == "action", url.host == "open-mcp-tab" { + hostAppStore.send(.setActiveTab(.tools)) + hostAppStore.send(.setActiveToolsSubTab(.MCP)) + return .handled + } + NSWorkspace.openFileInXcode(fileURL: url) + return .handled + }) + .frame(maxWidth: .infinity, alignment: .center) + .padding() + .background(QuaternarySystemFillColor.opacity(0.75)) + } else { + Table(viewModel.rows, children: \.children) { + TableColumn(Text("MCP Server").bold()) { row in + HStack(alignment: .center, spacing: 4) { + if case .runAny = row.type { + Image(systemName: "play.rectangle.on.rectangle") + .foregroundColor(.secondary) + } else if case .tool = row.type { + Image(systemName: "play.rectangle.on.rectangle") + .opacity(0) + .accessibilityHidden(true) + } + + Text(row.title) + if case .tool = row.type { + Text("without approval") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + TableColumn("Auto-Approve") { row in + if case .server = row.type { + EmptyView() + } else { + Toggle(isOn: binding(for: row)) { + Text("") + } + .toggleStyle(CheckboxToggleStyle()) + .labelsHidden() + } + } + .width(100) + } + .frame(minHeight: 300, maxHeight: .infinity) + .transparentBackground() + .padding(.horizontal, 10) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + } + } + } + .settingsContainerStyle(isExpanded: isExpanded) + } + + private var noRunningServersMessage: AttributedString { + var text = AttributedString(localized: "No running MCP servers found. Please verify the status in the MCP section or add configs in mcp.json.") + if let range = text.range(of: "mcp.json") { + text[range].link = URL(fileURLWithPath: mcpConfigFilePath) + } + if let range = text.range(of: "MCP section") { + text[range].link = URL(string: "action://open-mcp-tab") + } + return text + } + + private func binding(for row: RowItem) -> Binding { + Binding( + get: { + switch row.type { + case .server(let name): + return viewModel.isServerAllowed(name) + case .runAny(let serverName): + return viewModel.isServerAllowed(serverName) + case .tool(let serverName, let toolName): + return viewModel.isToolAllowed(serverName: serverName, toolName: toolName) + } + }, + set: { newValue in + switch row.type { + case .server(let name), .runAny(let name): + viewModel.setServerAllowed(name, allowed: newValue) + case .tool(let serverName, let toolName): + viewModel.setToolAllowed(serverName, toolName: toolName, allowed: newValue) + } + } + ) + } +} + +struct RowItem: Identifiable { + let id: String + let title: String + let type: ItemType + var children: [RowItem]? +} + +enum ItemType: Equatable { + case server(String) + case runAny(serverName: String) + case tool(serverName: String, toolName: String) +} + +extension MCPAutoApproveView { + @MainActor + class ViewModel: ObservableObject { + @Published var rows: [RowItem] = [] + private var serverTools: [MCPServerToolsCollection] = [] + private var approvals: AutoApprovedMCPServers = AutoApprovedMCPServers() + private var cancellables = Set() + + private let mcpToolManager = CopilotMCPToolManagerObservable.shared + private var observer: UserDefaultsObserver? + + @Environment(\.toast) private var toast + + init() { + // Observe tools availability + mcpToolManager.$availableMCPServerTools + .sink { [weak self] tools in + guard let self = self else { return } + self.serverTools = tools + self.rebuildRows() + } + .store(in: &cancellables) + + // Observe user defaults + observer = UserDefaultsObserver( + object: UserDefaults.autoApproval, + forKeyPaths: [UserDefaultPreferenceKeys().mcpServersGlobalApprovals.key], + context: nil + ) + + observer?.onChange = { [weak self] in + guard let self = self else { return } + DispatchQueue.main.async { + self.loadApprovals() + } + } + + // Initial load so the table reflects saved state on first appearance. + loadApprovals() + } + + private func rebuildRows() { + rows = serverTools + .filter { $0.status == .running } + .map { server in + let isAllowed = approvals.servers[server.name]?.isServerAllowed ?? false + var children: [RowItem] = [] + + // "Run any tool" row + children.append(RowItem( + id: "run-any-\(server.name)", + title: "Run any tool without approval", + type: .runAny(serverName: server.name), + children: nil + )) + + // Tools rows (only if not allowed globally) + if !isAllowed { + let toolRows = server.tools.map { tool in + RowItem( + id: "tool-\(server.name)-\(tool.name)", + title: tool.name, + type: .tool(serverName: server.name, toolName: tool.name), + children: nil + ) + } + children.append(contentsOf: toolRows) + } + + return RowItem( + id: "server-\(server.name)", + title: server.name, + type: .server(server.name), + children: children + ) + } + } + + private func loadApprovals() { + self.approvals = UserDefaults.autoApproval.value(for: \.mcpServersGlobalApprovals) + rebuildRows() + } + + func isServerAllowed(_ serverName: String) -> Bool { + return approvals.servers[serverName]?.isServerAllowed ?? false + } + + func isToolAllowed(serverName: String, toolName: String) -> Bool { + return approvals.servers[serverName]?.allowedTools.contains(toolName) ?? false + } + + func setServerAllowed(_ serverName: String, allowed: Bool) { + var currentApprovals = UserDefaults.autoApproval.value(for: \.mcpServersGlobalApprovals) + var serverState = currentApprovals.servers[serverName] ?? MCPServerApprovalState() + + serverState.isServerAllowed = allowed + currentApprovals.servers[serverName] = serverState + + save(currentApprovals) + // Rebuild happens via observer + } + + func setToolAllowed(_ serverName: String, toolName: String, allowed: Bool) { + var currentApprovals = UserDefaults.autoApproval.value(for: \.mcpServersGlobalApprovals) + var serverState = currentApprovals.servers[serverName] ?? MCPServerApprovalState() + + if allowed { + serverState.allowedTools.insert(toolName) + } else { + serverState.allowedTools.remove(toolName) + } + currentApprovals.servers[serverName] = serverState + + save(currentApprovals) + } + + private func save(_ approvals: AutoApprovedMCPServers) { + UserDefaults.autoApproval.set(approvals, for: \.mcpServersGlobalApprovals) + notifyChange() + } + + private func notifyChange() { + Task { + do { + let service = try getService() + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + } catch { + toast(error.localizedDescription, .error) + } + } + } + } +} + +struct AgentTrustToolAnnotationsSetting: View { + @AppStorage(\.trustToolAnnotations) var trustToolAnnotations + + var body: some View { + SettingsToggle( + title: "Trust MCP Tool Annotations", + subtitle: "If enabled, Copilot will use tool annotations to decide whether to automatically approve readonly MCP tool calls.", + isOn: $trustToolAnnotations + ) + .onChange(of: trustToolAnnotations) { _ in + DistributedNotificationCenter + .default() + .post(name: .githubCopilotAgentTrustToolAnnotationsDidChange, object: nil) + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/TerminalAutoApproveView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/TerminalAutoApproveView.swift new file mode 100644 index 00000000..bec514f8 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/TerminalAutoApproveView.swift @@ -0,0 +1,233 @@ +import AppKit +import Client +import Logger +import Preferences +import SharedUIComponents +import SwiftUI +import UserDefaultsObserver +import ComposableArchitecture + +struct TerminalAutoApproveView: View { + @State private var isExpanded: Bool = true + @StateObject private var viewModel = ViewModel() + @State private var selection = Set() + + let rowHeight: CGFloat = 28 + + private var canRemoveSelection: Bool { + !selection.isEmpty + } + + var body: some View { + VStack(spacing: 0) { + DisclosureSettingsRow( + isExpanded: $isExpanded, + accessibilityLabel: { $0 ? "Collapse terminal auto-approve section" : "Expand terminal auto-approve section" }, + title: { Text("Terminal Auto-Approve").font(.headline) }, + subtitle: { + Text( + "Controls whether chat-initiated terminal commands are automatically approved. Set to **true** to auto-approve matching commands; set to **false** to always require explicit approval." + ) + } + ) + + if isExpanded { + VStack(alignment: .leading, spacing: 0) { + Divider() + + rulesTable + + Divider() + + toolbar + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + } + .settingsContainerStyle(isExpanded: isExpanded) + .onAppear { + viewModel.loadRules() + } + } + + @ViewBuilder + private var rulesTable: some View { + Table(viewModel.rules, selection: $selection) { + TableColumn("Command") { rule in + EditableText("Command", text: rule.command) { newText in + viewModel.updateRule(id: rule.id, command: newText) + } + .help("Click to edit command") + } + TableColumn("Auto-Approve") { rule in + Toggle("", isOn: Binding( + get: { rule.autoApprove }, + set: { viewModel.updateRule(id: rule.id, autoApprove: $0) } + )) + } + } + .frame(height: CGFloat(max(viewModel.rules.count, 1)) * rowHeight + 42) + .padding(.horizontal, 20) + .transparentBackground() + } + + @ViewBuilder + private var toolbar: some View { + HStack(spacing: 8) { + Button(action: { viewModel.addRule() }) { + Image(systemName: "plus") + } + .foregroundColor(.primary) + .buttonStyle(.borderless) + .padding(.leading, 8) + + Divider() + + Group { + if canRemoveSelection { + Button(action: { + viewModel.removeRules(ids: selection) + selection.removeAll() + }) { + Image(systemName: "minus") + } + .buttonStyle(.borderless) + } else { + Image(systemName: "minus") + } + } + .foregroundColor( + canRemoveSelection ? .primary : Color( + nsColor: .quaternaryLabelColor + ) + ) + .help("Remove selected rules") + + Spacer() + } + .frame(height: 24) + .background(TertiarySystemFillColor) + } +} + +extension TerminalAutoApproveView { + final class ViewModel: ObservableObject { + @Dependency(\.toast) var toast + + struct Rule: Identifiable { + var id = UUID() + var command: String + var autoApprove: Bool + } + + @Published var rules: [Rule] = [] + + private let defaults = UserDefaults.autoApproval + private var observer = UserDefaultsObserver( + object: UserDefaults.autoApproval, + forKeyPaths: [UserDefaultPreferenceKeys().terminalCommandsGlobalApprovals.key], + context: nil + ) + + init() { + observer.onChange = { [weak self] in + DispatchQueue.main.async { + self?.loadRules() + } + } + } + + func loadRules() { + let state = defaults.value(for: \.terminalCommandsGlobalApprovals) + let savedRules = state.commands + + func findExistingID(command: String) -> UUID { + rules.first(where: { $0.command == command })?.id ?? UUID() + } + + var loadedRules: [Rule] = [] + for (commandKey, autoApprove) in savedRules { + loadedRules.append( + Rule(id: findExistingID(command: commandKey), command: commandKey, autoApprove: autoApprove) + ) + } + + rules = loadedRules.sorted { $0.command.localizedCaseInsensitiveCompare($1.command) == .orderedAscending } + } + + func addRule() { + var counter = 0 + var newCommand = "New Command" + while rules.contains(where: { $0.command == newCommand }) { + counter += 1 + newCommand = "New Command \(counter)" + } + rules.append(Rule(command: newCommand, autoApprove: false)) + saveRules() + } + + func removeRules(ids: Set) { + rules.removeAll { ids.contains($0.id) } + saveRules() + } + + @discardableResult + func updateRule(id: UUID, command: String? = nil, autoApprove: Bool? = nil) -> Bool { + guard let index = rules.firstIndex(where: { $0.id == id }) else { return false } + + if let command { + var newCommand = command.filter { !$0.isNewline } + newCommand = newCommand.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !newCommand.isEmpty else { + toast("Command cannot be empty.", .warning) + return false + } + + if !rules.contains(where: { $0.id != id && $0.command == newCommand }) { + rules[index].command = newCommand + } else { + toast("Duplicate commands are not allowed. Please ensure each rule has a unique command.", .warning) + return false + } + } + if let autoApprove { rules[index].autoApprove = autoApprove } + + saveRules() + return true + } + + func saveRules() { + let commands = rules.map(\.command) + let uniqueCommands = Set(commands) + if commands.count != uniqueCommands.count { + return + } + if commands.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) { + toast("Command cannot be empty.", .warning) + return + } + + var state = defaults.value(for: \.terminalCommandsGlobalApprovals) + var newRules: [String: Bool] = [:] + for rule in rules { + newRules[rule.command] = rule.autoApprove + } + state.commands = newRules + defaults.set(state, for: \.terminalCommandsGlobalApprovals) + + Task { + do { + let service = try getService() + try await service.postNotification( + name: Notification.Name.githubCopilotAgentAutoApprovalDidChange.rawValue + ) + } catch { + toast(error.localizedDescription, .error) + } + } + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift b/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift new file mode 100644 index 00000000..6cdd7a0c --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift @@ -0,0 +1,222 @@ +import Client +import Combine +import ConversationServiceProvider +import GitHubCopilotService +import Logger +import Persist +import SwiftUI +import SharedUIComponents + +struct BuiltInToolsListView: View { + @ObservedObject private var builtInToolManager = CopilotBuiltInToolManagerObservable.shared + @State private var isSearchBarVisible: Bool = false + @State private var searchText: String = "" + @State private var toolEnabledStates: [String: Bool] = [:] + @State private var modes: [ConversationMode] = [] + @Binding var selectedMode: ConversationMode + let isCustomAgentEnabled: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + GroupBox(label: headerView) { + contentView + } + .groupBoxStyle(CardGroupBoxStyle()) + } + .onAppear { + initializeToolStates() + // Refresh client tools to get any late-arriving server tools + Task { + do { + let service = try getService() + _ = try await service.refreshClientTools() + } catch { + Logger.client.error("Failed to refresh client tools: \(error)") + } + } + } + .onChange(of: builtInToolManager.availableLanguageModelTools) { _ in + initializeToolStates() + } + .onChange(of: selectedMode) { _ in + toolEnabledStates = [:] // Clear state immediately + initializeToolStates() + } + .onReceive(DistributedNotificationCenter.default().publisher(for: .gitHubCopilotCustomAgentToolsDidChange)) { _ in + Logger.client.info("Custom agent tools change notification received in BuiltInToolsListView") + if !selectedMode.isDefaultAgent { + Task { + await reloadModesAndUpdateStates() + } + } + } + } + + // MARK: - Header View + + private var headerView: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center) { + Text("Built-In Tools").fontWeight(.bold) + if isCustomAgentEnabled { + AgentModeDropdown(modes: $modes, selectedMode: $selectedMode) + } + Spacer() + CollapsibleSearchField(searchText: $searchText, isExpanded: $isSearchBarVisible) + } + .clipped() + + AgentModeDescriptionView(selectedMode: selectedMode, isLoadingMode: false) + } + } + + // MARK: - Content View + + private var contentView: some View { + let filteredTools = filteredLanguageModelTools() + + if filteredTools.isEmpty { + return AnyView(EmptyStateView()) + } else { + return AnyView(toolsListView(tools: filteredTools)) + } + } + + // MARK: - Tools List View + + private func toolsListView(tools: [LanguageModelTool]) -> some View { + VStack(spacing: 0) { + ForEach(tools, id: \.name) { tool in + ToolRow( + toolName: tool.displayName ?? tool.name, + toolDescription: tool.displayDescription, + toolStatus: tool.status, + isServerEnabled: true, + isToolEnabled: toolBindingFor(tool), + isInteractionAllowed: isInteractionAllowed(), + onToolToggleChanged: { isEnabled in + handleToolToggleChange(tool: tool, isEnabled: isEnabled) + } + ) + } + } + } + + // MARK: - Helper Methods + + private func initializeToolStates() { + // When mode changes, recalculate everything from scratch + var map: [String: Bool] = [:] + for tool in builtInToolManager.availableLanguageModelTools { + map[tool.name] = isToolEnabledInMode(tool) + } + toolEnabledStates = map + } + + private func toolBindingFor(_ tool: LanguageModelTool) -> Binding { + Binding( + get: { + toolEnabledStates[tool.name] ?? isToolEnabledInMode(tool) + }, + set: { newValue in + toolEnabledStates[tool.name] = newValue + } + ) + } + + private func filteredLanguageModelTools() -> [LanguageModelTool] { + let key = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !key.isEmpty else { return builtInToolManager.availableLanguageModelTools } + + return builtInToolManager.availableLanguageModelTools.filter { tool in + tool.name.lowercased().contains(key) || + (tool.description?.lowercased().contains(key) ?? false) || + (tool.displayName?.lowercased().contains(key) ?? false) + } + } + + private func handleToolToggleChange(tool: LanguageModelTool, isEnabled: Bool) { + let toolUpdate = ToolStatusUpdate(name: tool.name, status: isEnabled ? .enabled : .disabled) + updateToolStatus([toolUpdate]) + } + + private func updateToolStatus(_ toolUpdates: [ToolStatusUpdate]) { + Task { + do { + let service = try getService() + + if !selectedMode.isDefaultAgent { + let chatMode = selectedMode.kind + let customChatModeId = selectedMode.isBuiltIn == false ? selectedMode.id : nil + let workspaceFolders = await getWorkspaceFolders() + + let updatedTools = try await service + .updateToolsStatus( + toolUpdates, + chatAgentMode: chatMode, + customChatModeId: customChatModeId, + workspaceFolders: workspaceFolders + ) + + if updatedTools == nil { + Logger.client.error("Failed to update built-in tool status: No updated tools returned") + } + + await reloadModesAndUpdateStates() + } else { + let updatedTools = try await service.updateToolsStatus(toolUpdates) + if updatedTools == nil { + Logger.client.error("Failed to update built-in tool status: No updated tools returned") + } + } + } catch { + Logger.client.error("Failed to update built-in tool status: \(error.localizedDescription)") + } + } + } + + @MainActor + private func reloadModesAndUpdateStates() async { + do { + let service = try getService() + let workspaceFolders = await getWorkspaceFolders() + if let fetchedModes = try await service.getModes(workspaceFolders: workspaceFolders) { + modes = fetchedModes.filter { $0.kind == .Agent } + + if let updatedMode = modes.first(where: { $0.id == selectedMode.id }) { + selectedMode = updatedMode + + for tool in builtInToolManager.availableLanguageModelTools { + if let customTools = updatedMode.customTools { + toolEnabledStates[tool.name] = customTools.contains(tool.name) + } else { + toolEnabledStates[tool.name] = false + } + } + } + } + } catch { + Logger.client.error("Failed to reload modes: \(error.localizedDescription)") + } + } + + private func isToolEnabledInMode(_ tool: LanguageModelTool) -> Bool { + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: tool.name, + currentStatus: tool.status, + selectedMode: selectedMode + ) + } + + private func isInteractionAllowed() -> Bool { + return AgentModeToolHelpers.isInteractionAllowed(selectedMode: selectedMode) + } +} + +/// Empty state view when no tools are available +private struct EmptyStateView: View { + var body: some View { + Text("No built-in tools available. Make sure background permissions are granted.") + .foregroundColor(.secondary) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/CopilotBuiltInToolManagerObservable.swift b/Core/Sources/HostApp/ToolsSettings/CopilotBuiltInToolManagerObservable.swift new file mode 100644 index 00000000..ae36f221 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/CopilotBuiltInToolManagerObservable.swift @@ -0,0 +1,51 @@ +import Client +import Combine +import ConversationServiceProvider +import Logger +import Persist +import SwiftUI + +class CopilotBuiltInToolManagerObservable: ObservableObject { + static let shared = CopilotBuiltInToolManagerObservable() + + @Published var availableLanguageModelTools: [LanguageModelTool] = [] + private var cancellables = Set() + + private init() { + DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotToolsDidChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + Task { + await self.refreshLanguageModelTools() + } + } + .store(in: &cancellables) + + Task { + await refreshLanguageModelTools() + } + } + + @MainActor + public func refreshLanguageModelTools() async { + do { + let service = try getService() + let languageModelTools = try await service.getAvailableLanguageModelTools() + + guard let tools = languageModelTools else { return } + + // Update the published list with all tools (both enabled and disabled) + availableLanguageModelTools = tools + + // Update AppState for persistence + let statusUpdates = tools.map { + ToolStatusUpdate(name: $0.name, status: $0.status) + } + AppState.shared.updateLanguageModelToolsStatus(statusUpdates) + } catch { + Logger.client.error("Failed to fetch language model tools: \(error)") + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift new file mode 100644 index 00000000..8c3444d2 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift @@ -0,0 +1,58 @@ +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 } + Logger.client.info("MCP tools change notification received") + 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 { + Logger.client.info("Refreshing MCP server tools...") + 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. + Logger.client.info("MCP tools data not ready yet, skipping refresh") + return + } + + let totalToolsCount = tools.reduce(0) { $0 + $1.tools.count } + let serverNames = tools.map { $0.name }.joined(separator: ", ") + Logger.client.info("Refreshed MCP tools - Servers: \(tools.count), Total tools: \(totalToolsCount), Server names: [\(serverNames)]") + + self.availableMCPServerTools = tools + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPConfigConstants.swift b/Core/Sources/HostApp/ToolsSettings/MCPConfigConstants.swift new file mode 100644 index 00000000..07220aef --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPConfigConstants.swift @@ -0,0 +1,4 @@ +import Foundation + +let configDirectory = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/github-copilot/xcode") +let mcpConfigFilePath = configDirectory.appendingPathComponent("mcp.json").path diff --git a/Core/Sources/HostApp/ToolsSettings/MCPIntroView.swift b/Core/Sources/HostApp/ToolsSettings/MCPIntroView.swift new file mode 100644 index 00000000..ac84bcce --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPIntroView.swift @@ -0,0 +1,45 @@ +import Client +import Foundation +import Logger +import SharedUIComponents +import SwiftUI + +struct MCPIntroView: View { + let isMCPFFEnabled: Bool + + public init(isMCPFFEnabled: Bool) { + self.isMCPFFEnabled = isMCPFFEnabled + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if !isMCPFFEnabled { + GroupBox { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "info.circle.fill") + .font(.body) + .foregroundColor(.gray) + Text( + "MCP servers are disabled by your organization’s policy. To enable them, please contact your administrator. [Get More Info about Copilot policies](https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-organization/manage-policies)" + ) + } + } + .groupBoxStyle( + CardGroupBoxStyle( + backgroundColor: Color(nsColor: .textBackgroundColor) + ) + ) + } + } + } +} + +#Preview { + MCPIntroView(isMCPFFEnabled: true) + .frame(width: 800) +} + +#Preview { + MCPIntroView(isMCPFFEnabled: false) + .frame(width: 800) +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift new file mode 100644 index 00000000..6909b851 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift @@ -0,0 +1,145 @@ +import AppKit +import Logger +import SharedUIComponents +import SwiftUI + +struct MCPManualInstallView: View { + @State private var isExpanded: Bool = false + + var body: some View { + VStack(spacing: 0) { + DisclosureSettingsRow( + isExpanded: $isExpanded, + accessibilityLabel: { $0 ? "Collapse MCP configuration section" : "Expand MCP configuration section" }, + title: { Text("MCP Configuration").font(.headline) }, + subtitle: { Text("Add MCP Servers to power AI with tools for files, databases, and external APIs.") }, + actions: { + HStack(spacing: 8) { + Button { + openMCPRunTimeLogFolder() + } label: { + HStack(spacing: 0) { + Image(systemName: "folder") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Open MCP Log Folder") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .help("Open MCP Runtime Log Folder") + + Button { + openConfigFile() + } label: { + HStack(spacing: 0) { + Image(systemName: "square.and.pencil") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Edit Config") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .help("Configure your MCP server") + } + .padding(.vertical, 12) + } + ) + + if isExpanded { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("Example Configuration").foregroundColor(.primary.opacity(0.85)) + CopyButton( + copy: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(exampleConfig, forType: .string) + }, + foregroundColor: .primary.opacity(0.85), + fontWeight: .semibold + ) + .frame(width: 10, height: 10) + } + .padding(.leading, 4) + + exampleConfigView() + } + .padding(.top, 8) + .padding([.leading, .trailing, .bottom], 20) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + } + .settingsContainerStyle(isExpanded: isExpanded) + } + + var exampleConfig: String { + """ + { + "servers": { + "my-mcp-server": { + "type": "stdio", + "command": "my-command", + "args": [], + "env": { + "TOKEN": "my_token" + } + } + } + } + """ + } + + @ViewBuilder + private func exampleConfigView() -> some View { + Text(exampleConfig) + .font(.system(.body, design: .monospaced)) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + Color(nsColor: .textBackgroundColor).opacity(0.5) + ) + .textSelection(.enabled) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .inset(by: 0.5) + .stroke(Color("GroupBoxStrokeColor"), lineWidth: 1) + ) + } + + private func openMCPRunTimeLogFolder() { + let url = URL( + fileURLWithPath: FileLoggingLocation.mcpRuntimeLogsPath.description, + isDirectory: true + ) + + // Create directory if it doesn't exist + if !FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.createDirectory( + atPath: url.path, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + Logger.client.error("Failed to create MCP runtime log folder: \(error)") + return + } + } + + NSWorkspace.shared.open(url) + } + + private func openConfigFile() { + let url = URL(fileURLWithPath: mcpConfigFilePath) + NSWorkspace.shared.open(url) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift new file mode 100644 index 00000000..c54712ca --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift @@ -0,0 +1,435 @@ +import Client +import Foundation +import GitHubCopilotService +import Logger +import SwiftUI + +// MARK: - Installation Option + +public struct InstallationOption { + public let displayName: String + public let description: String + public let config: [String: Any] + public let isDefault: Bool + + public init(displayName: String, description: String, config: [String: Any], isDefault: Bool = false) { + self.displayName = displayName + self.description = description + self.config = config + self.isDefault = isDefault + } +} + +// MARK: - Registry Types + +private struct RegistryType { + let displayName: String + let commandName: String + + func buildArguments(for package: Package) -> [String] { + let identifier = package.identifier + let version = package.version ?? "" + + switch package.registryType { + case "npm": + return ["-y", version.isEmpty ? identifier : "\(identifier)@\(version)"] + case "pypi": + return [version.isEmpty ? identifier : "\(identifier)==\(version)"] + case "oci": + return ["run", "-i", "--rm", version.isEmpty ? identifier : "\(identifier):\(version)"] + case "nuget": + var args = [version.isEmpty ? identifier : "\(identifier)@\(version)", "--yes"] + if package.packageArguments?.isEmpty == false { args.append("--") } + return args + default: + return [version.isEmpty ? identifier : "\(identifier)@\(version)"] + } + } +} + +private let registryTypes: [String: RegistryType] = [ + "npm": RegistryType(displayName: "NPM", commandName: "npx"), + "pypi": RegistryType(displayName: "PyPI", commandName: "uvx"), + "oci": RegistryType(displayName: "OCI", commandName: "docker"), + "nuget": RegistryType(displayName: "NuGet", commandName: "dnx") +] + +public extension Remote { + var transportType: TransportType { + switch self { + case .streamableHTTP(let transport): + return transport.type + case .sse(let transport): + return transport.type + } + } + + var url: String { + switch self { + case .streamableHTTP(let transport): + return transport.url + case .sse(let transport): + return transport.url + } + } + + var headers: [KeyValueInput]? { + switch self { + case .streamableHTTP(let transport): + return transport.headers + case .sse(let transport): + return transport.headers + } + } +} + +// MARK: - MCP Registry Service + +@MainActor +public class MCPRegistryService: ObservableObject { + public static let shared = MCPRegistryService() + public static let apiVersion = "v0.1" + @AppStorage(\.mcpRegistryBaseURL) var mcpRegistryBaseURL + + private init() {} + + public static func getServerName(from serverDetail: MCPRegistryServerDetail) -> String { + return serverDetail.name + } + + public func getRegistryBaseURL() throws -> String { + let url = mcpRegistryBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !url.isEmpty else { + throw MCPRegistryError.registryURLNotConfigured + } + return url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + public func getRegistryURL() throws -> String { + return try getRegistryBaseURL() + "/\(MCPRegistryService.apiVersion)/servers" + } + + // MARK: - Installation Options + + public func getAllInstallationOptions(for serverDetail: MCPRegistryServerDetail) -> [InstallationOption] { + var options: [InstallationOption] = [] + + // Add remote options + serverDetail.remotes?.enumerated().forEach { index, remote in + let config = createServerConfig(for: serverDetail, remote: remote) + options.append(InstallationOption( + displayName: "\(remote.transportType.displayText): \(remote.url)", + description: "Connect to remote server at \(remote.url)", + config: config, + isDefault: index == 0 && options.isEmpty + )) + } + + // Add package options + serverDetail.packages?.enumerated().forEach { index, package in + let config = createServerConfig(for: serverDetail, package: package) + let registryDisplay = package.registryType.registryDisplayText + + options.append(InstallationOption( + displayName: "\(registryDisplay) : \(package.identifier)", + description: "Install \(package.identifier) from \(registryDisplay)", + config: config, + isDefault: index == 0 && options.isEmpty + )) + } + + return options + } + + public func createServerConfiguration(for serverDetail: MCPRegistryServerDetail) throws -> [String: Any] { + let options = getAllInstallationOptions(for: serverDetail) + guard let defaultOption = options.first(where: { $0.isDefault }) ?? options.first else { + throw MCPRegistryError.noInstallationOptionsAvailable(serverName: serverDetail.name) + } + return defaultOption.config + } + + // MARK: - Install/Uninstall Operations + + public func installMCPServer(_ serverDetail: MCPRegistryServerDetail, installationOption: InstallationOption? = nil) async throws { + Logger.client.info("Installing MCP Server '\(serverDetail.name)'...") + + let serverConfig: [String: Any] + if let option = installationOption { + serverConfig = option.config + } else { + serverConfig = try createServerConfiguration(for: serverDetail) + } + + var currentConfig = loadConfiguration() ?? [:] + if currentConfig["servers"] == nil { + currentConfig["servers"] = [String: Any]() + } + + guard var serversDict = currentConfig["servers"] as? [String: Any] else { + throw MCPRegistryError.invalidConfigurationStructure + } + + serversDict[serverDetail.name] = serverConfig + currentConfig["servers"] = serversDict + + try saveConfiguration(currentConfig) + Logger.client.info("Successfully installed MCP Server '\(serverDetail.name)'") + } + + public func uninstallMCPServer(_ serverDetail: MCPRegistryServerDetail) async throws { + Logger.client.info("Uninstalling MCP Server '\(serverDetail.name)'...") + + var currentConfig = loadConfiguration() ?? [:] + guard var serversDict = currentConfig["servers"] as? [String: Any] else { + throw MCPRegistryError.serverNotFound(serverName: serverDetail.name) + } + + guard serversDict[serverDetail.name] != nil else { + throw MCPRegistryError.serverNotFound(serverName: serverDetail.name) + } + + serversDict.removeValue(forKey: serverDetail.name) + currentConfig["servers"] = serversDict + + try saveConfiguration(currentConfig) + Logger.client.info("Successfully uninstalled MCP Server '\(serverDetail.name)'") + } + + // MARK: - Configuration Creation + + public func createServerConfig(for serverDetail: MCPRegistryServerDetail, remote: Remote) -> [String: Any] { + var config: [String: Any] = [ + "type": "http", + "url": remote.url + ] + + // Add headers if present + if let headers = remote.headers, !headers.isEmpty { + let headersDict = Dictionary(headers.map { ($0.name, $0.value ?? "") }) { first, _ in first } + config["requestInit"] = ["headers": headersDict] + } + + addMetadata(to: &config, serverDetail: serverDetail) + return config + } + + public func createServerConfig(for serverDetail: MCPRegistryServerDetail, package: Package) -> [String: Any] { + let registryType = registryTypes[package.registryType] + let command = package.runtimeHint ?? registryType?.commandName ?? package.registryType + + var config: [String: Any] = [ + "type": "stdio", + "command": command + ] + + // Build arguments + var args: [String] = [] + + // Runtime arguments + package.runtimeArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) } + + // Default arguments if no runtime arguments + if package.runtimeArguments?.isEmpty != false { + args + .append( + contentsOf: registryType?.buildArguments(for: package) ?? [package.identifier] + ) + } + + // Package arguments + package.packageArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) } + + config["args"] = args + + // Environment variables + if let envVars = package.environmentVariables, !envVars.isEmpty { + config["env"] = Dictionary(envVars.map { ($0.name, $0.value ?? "") }) { first, _ in first } + } + + addMetadata(to: &config, serverDetail: serverDetail) + return config + } + + private func addMetadata(to config: inout [String: Any], serverDetail: MCPRegistryServerDetail) { + guard let baseURL = try? getRegistryBaseURL() else { return } + + let api: [String: Any] = [ + "baseUrl": baseURL, + "version": MCPRegistryService.apiVersion + ] + + let mcpServer: [String: Any] = [ + "name": Self.getServerName(from: serverDetail), + "version": serverDetail.version + ] + + config["x-metadata"] = [ + "registry": [ + "api": api, + "mcpServer": mcpServer + ] + ] + } + + private func extractArgumentValues(from argument: Argument) -> [String] { + switch argument { + case let .positional(positionalArg): + return (positionalArg.value ?? positionalArg.valueHint).map { [$0] } ?? [] + case let .named(namedArg): + return [namedArg.name] + (namedArg.value.map { [$0] } ?? []) + } + } + + // MARK: - Configuration File Management + + private func loadConfiguration() -> [String: Any]? { + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + guard FileManager.default.fileExists(atPath: mcpConfigFilePath), + let data = try? Data(contentsOf: configFileURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return jsonObject + } + + private func saveConfiguration(_ config: [String: Any]) throws { + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + + // Ensure directory exists + let configDirectory = configFileURL.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: configDirectory.path) { + try FileManager.default.createDirectory(at: configDirectory, withIntermediateDirectories: true) + } + + // Save configuration + let jsonData = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted]) + try jsonData.write(to: configFileURL) + + // Note: UserDefaults update and notification will be handled by ToolsConfigView's file monitor + // with debouncing to prevent duplicate notifications + } + + // MARK: - Server Installation Status + + public func isServerInstalled(_ serverDetail: MCPRegistryServerDetail) -> Bool { + guard let config = loadConfiguration(), + let serversDict = config["servers"] as? [String: Any], + let expectedKey = expectedRegistryKey(for: serverDetail) else { return false } + return serversDict.values.contains { (value) -> Bool in + guard let serverConfigDict = value as? [String: Any], + let key = registryKey(from: serverConfigDict) else { return false } + return key == expectedKey + } + } + + // MARK: - Option Installed Helpers + + public func isPackageOptionInstalled(serverDetail: MCPRegistryServerDetail, package: Package) -> Bool { + guard isServerInstalled(serverDetail), + let config = loadConfiguration(), + let serversDict = config["servers"] as? [String: Any], + let expectedKey = expectedRegistryKey(for: serverDetail) else { return false } + + let command = package.runtimeHint ?? registryTypes[package.registryType]?.commandName ?? ( + package.registryType + ) + let expectedArgsFirst: String? = { + var args: [String] = [] + package.runtimeArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) } + if package.runtimeArguments?.isEmpty != false { + args.append( + contentsOf: registryTypes[package.registryType]?.buildArguments(for: package) ?? [package.identifier] + ) + } + package.packageArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) } + return args.first + }() + + return serversDict.values.contains { value in + guard let cfg = value as? [String: Any], + let key = registryKey(from: cfg), + key == expectedKey, + (cfg["type"] as? String)?.lowercased() == "stdio", + let c = cfg["command"] as? String, + let args = cfg["args"] as? [String] else { return false } + return c == command && args.first == expectedArgsFirst + } + } + + public func isRemoteOptionInstalled(serverDetail: MCPRegistryServerDetail, remote: Remote) -> Bool { + guard isServerInstalled(serverDetail), + let config = loadConfiguration(), + let serversDict = config["servers"] as? [String: Any], + let expectedKey = expectedRegistryKey(for: serverDetail) else { return false } + + return serversDict.values.contains { value in + guard let cfg = value as? [String: Any], + let key = registryKey(from: cfg), + key == expectedKey, + (cfg["type"] as? String)?.lowercased() == "http", + let url = cfg["url"] as? String else { return false } + return url == remote.url + } + } + + public func createRegistryServerKey(registryBaseURL: String, serverName: String) -> String { + let trimmedBaseURL = registryBaseURL + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return "\(trimmedBaseURL)|\(serverName)" + } + + // MARK: - Registry Key Helpers + + private func expectedRegistryKey(for serverDetail: MCPRegistryServerDetail) -> String? { + guard let registryBaseURL = try? getRegistryBaseURL() else { return nil } + return createRegistryServerKey( + registryBaseURL: registryBaseURL, + serverName: Self.getServerName(from: serverDetail) + ) + } + + private func registryKey(from serverConfig: [String: Any]) -> String? { + guard let metadata = serverConfig["x-metadata"] as? [String: Any], + let registry = metadata["registry"] as? [String: Any], + let api = registry["api"] as? [String: Any], + let baseUrl = api["baseUrl"] as? String, + let mcpServer = registry["mcpServer"] as? [String: Any], + let name = mcpServer["name"] as? String else { return nil } + return createRegistryServerKey(registryBaseURL: baseUrl, serverName: name) + } +} + +// MARK: - Error Types + +public enum MCPRegistryError: LocalizedError { + case registryURLNotConfigured + case noInstallationOptionsAvailable(serverName: String) + case invalidConfigurationStructure + case serverNotFound(serverName: String) + case configurationFileError(String) + + public var errorDescription: String? { + switch self { + case .registryURLNotConfigured: + return "MCP Registry base URL is not configured. Please configure the registry URL in Settings > Tools > GitHub Copilot > MCP to browse and install servers from the registry." + case let .noInstallationOptionsAvailable(serverName): + return "Cannot create server configuration for '\(serverName)' - no installation options available" + case .invalidConfigurationStructure: + return "Invalid MCP configuration file structure" + case let .serverNotFound(serverName): + return "MCP Server '\(serverName)' not found in configuration" + case let .configurationFileError(message): + return "Configuration file error: \(message)" + } + } +} + +// MARK: - Extensions + +extension String { + var registryDisplayText: String { + return registryTypes[self]?.displayName ?? self.capitalized + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift new file mode 100644 index 00000000..af7621e5 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift @@ -0,0 +1,160 @@ +import GitHubCopilotService +import SwiftUI +import SharedUIComponents + +struct MCPRegistryURLInputField: View { + @Binding var urlText: String + @AppStorage(\.mcpRegistryBaseURLHistory) private var urlHistory + @State private var showHistory: Bool = false + @FocusState private var isFocused: Bool + + let defaultMCPRegistryBaseURL = "https://api.mcp.github.com" + let maxURLLength: Int + let isSheet: Bool + let mcpRegistryEntry: MCPRegistryEntry? + let onValidationChange: ((Bool) -> Void)? + let onCommit: (() -> Void)? + + private var isRegistryOnly: Bool { + mcpRegistryEntry?.registryAccess == .registryOnly + } + + init( + urlText: Binding, + maxURLLength: Int = 2048, + isSheet: Bool = false, + mcpRegistryEntry: MCPRegistryEntry? = nil, + onValidationChange: ((Bool) -> Void)? = nil, + onCommit: (() -> Void)? = nil + ) { + self._urlText = urlText + self.maxURLLength = maxURLLength + self.isSheet = isSheet + self.mcpRegistryEntry = mcpRegistryEntry + self.onValidationChange = onValidationChange + self.onCommit = onCommit + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + if isSheet { + TextFieldsContainer { + TextField("MCP Registry Base URL", text: $urlText) + .focused($isFocused) + .disabled(isRegistryOnly) + .onChange(of: urlText) { newValue in + handleURLChange(newValue) + } + .onSubmit { + onCommit?() + } + } + } else { + TextField("MCP Registry Base URL:", text: $urlText) + .textFieldStyle(.roundedBorder) + .focused($isFocused) + .disabled(isRegistryOnly) + .onChange(of: urlText) { newValue in + handleURLChange(newValue) + } + .onSubmit { + onCommit?() + } + } + + Menu { + ForEach(urlHistory, id: \.self) { url in + Button(url) { + urlText = url + isFocused = false + onCommit?() + } + } + + Divider() + + Button("Reset to Default") { + urlText = defaultMCPRegistryBaseURL + onCommit?() + } + + if !urlHistory.isEmpty { + Button("Clear History") { + urlHistory = [] + } + } + } label: { + Image(systemName: "chevron.down") + .resizable() + .scaledToFit() + .frame(width: 11, height: 11) + .padding(isSheet ? 9 : 3) + } + .labelStyle(.iconOnly) + .menuIndicator(.hidden) + .buttonStyle( + HoverButtonStyle( + hoverColor: SecondarySystemFillColor, + backgroundColor: SecondarySystemFillColor, + cornerRadius: isSheet ? 12 : 6 + ) + ) + .opacity(isRegistryOnly ? 0.5 : 1) + .disabled(isRegistryOnly) + } + + if isRegistryOnly { + Badge( + text: "This URL is managed by \(mcpRegistryEntry!.owner.login) and cannot be modified", + level: .info, + icon: "info.circle.fill" + ) + } + } + .onAppear { + if isRegistryOnly, let entryURL = mcpRegistryEntry?.url { + urlText = entryURL + } + } + .onChange(of: mcpRegistryEntry) { newEntry in + if newEntry?.registryAccess == .registryOnly, let entryURL = newEntry?.url { + urlText = entryURL + } + } + } + + private func handleURLChange(_ newValue: String) { + // If registryOnly, force the URL back to the registry entry URL + if isRegistryOnly, let entryURL = mcpRegistryEntry?.url { + urlText = entryURL + return + } + + let limitedText = String(newValue.prefix(maxURLLength)) + if limitedText != newValue { + urlText = limitedText + } + + let isValid = limitedText.isEmpty || isValidURL(limitedText) + onValidationChange?(isValid) + } + + private func isValidURL(_ string: String) -> Bool { + guard !string.isEmpty else { return true } + return URL(string: string) != nil && (string.hasPrefix("http://") || string.hasPrefix("https://")) + } +} + +extension Array where Element == String { + mutating func addToHistory(_ url: String, maxItems: Int = 10) { + // Remove if already exists + removeAll { $0 == url } + // Add to beginning + insert(url, at: 0) + // Keep only maxItems + if count > maxItems { + removeLast(count - maxItems) + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift new file mode 100644 index 00000000..efbc922a --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift @@ -0,0 +1,75 @@ +import GitHubCopilotService +import SwiftUI +import SharedUIComponents + +struct MCPRegistryURLSheet: View { + @AppStorage(\.mcpRegistryBaseURL) private var mcpRegistryBaseURL + @AppStorage(\.mcpRegistryBaseURLHistory) private var mcpRegistryBaseURLHistory + @Environment(\.dismiss) private var dismiss + @State private var originalMcpRegistryBaseURL: String = "" + @State private var isFormValid: Bool = true + + let mcpRegistryEntry: MCPRegistryEntry? + let onURLUpdated: (() -> Void)? + + init(mcpRegistryEntry: MCPRegistryEntry? = nil, onURLUpdated: (() -> Void)? = nil) { + self.mcpRegistryEntry = mcpRegistryEntry + self.onURLUpdated = onURLUpdated + } + + var body: some View { + Form { + VStack(alignment: .center, spacing: 20) { + HStack(alignment: .center) { + Spacer() + Text("MCP Registry Base URL").font(.headline) + Spacer() + AdaptiveHelpLink(action: openHelpLink) + } + + VStack(alignment: .leading, spacing: 4) { + MCPRegistryURLInputField( + urlText: $originalMcpRegistryBaseURL, + isSheet: true, + mcpRegistryEntry: mcpRegistryEntry, + onValidationChange: { isValid in + isFormValid = isValid + } + ) + } + + HStack(spacing: 8) { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button("Update") { + // Check if URL changed before updating + originalMcpRegistryBaseURL = originalMcpRegistryBaseURL + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if originalMcpRegistryBaseURL != mcpRegistryBaseURL { + mcpRegistryBaseURL = originalMcpRegistryBaseURL + onURLUpdated?() + } + dismiss() + } + .buttonStyle(.borderedProminent) + .disabled(!isFormValid || mcpRegistryEntry?.registryAccess == .registryOnly) + } + } + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + .padding(20) + } + .onAppear { + loadExistingURL() + } + } + + private func loadExistingURL() { + originalMcpRegistryBaseURL = mcpRegistryBaseURL + } + + private func openHelpLink() { + NSWorkspace.shared.open(URL(string: "https://docs.github.com/en/copilot/how-tos/provide-context/use-mcp/select-an-mcp-registry")!) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift new file mode 100644 index 00000000..b3cb3537 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift @@ -0,0 +1,232 @@ +import AppKit +import Logger +import SharedUIComponents +import SwiftUI +import Client +import XPCShared +import GitHubCopilotService +import ComposableArchitecture + +struct MCPRegistryURLView: View { + @State private var isExpanded: Bool = false + @AppStorage(\.mcpRegistryBaseURL) var mcpRegistryBaseURL + @AppStorage(\.mcpRegistryBaseURLHistory) private var mcpRegistryBaseURLHistory + @State private var isLoading: Bool = false + @State private var tempURLText: String = "" + @State private var errorMessage: String = "" + @State private var mcpRegistry: [MCPRegistryEntry]? = nil + + private let maxURLLength = 2048 + private let mcpRegistryUrlVersion = "/v0.1/servers" + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + DisclosureSettingsRow( + isExpanded: $isExpanded, + accessibilityLabel: { $0 ? "Collapse mcp registry base URL section" : "Expand mcp registry base URL section" }, + title: { Text("MCP Registry Base URL").font(.headline) + Text(" (Optional)") }, + subtitle: { Text("Connect to available MCP servers for your AI workflows using the Registry URL.") }, + actions: { + HStack(spacing: 8) { + if isLoading { + ProgressView().controlSize(.small) + } + + Button { + isExpanded = true + } label: { + HStack(spacing: 0) { + Image(systemName: "square.and.pencil") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Edit URL") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .help("Configure your MCP Registry Base URL") + .disabled(mcpRegistry?.first?.registryAccess == .registryOnly) + + Button { Task{ await loadMCPServers() } } label: { + HStack(spacing: 0) { + Image(systemName: "square.grid.2x2") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Browse MCP Servers...") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .help("Browse MCP Servers") + } + .padding(.vertical, 12) + } + ) + + if isExpanded { + VStack(alignment: .leading, spacing: 8) { + MCPRegistryURLInputField( + urlText: $tempURLText, + maxURLLength: maxURLLength, + isSheet: false, + mcpRegistryEntry: mcpRegistry?.first, + onValidationChange: { _ in + // Only validate, don't update mcpRegistryURL here + }, + onCommit: { + // Update mcpRegistryURL when user presses Enter + tempURLText = tempURLText + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if tempURLText != mcpRegistryBaseURL { + mcpRegistryBaseURL = tempURLText + } + } + ) + + if !errorMessage.isEmpty { + Badge(text: errorMessage, level: .danger, icon: "xmark.circle.fill") + } + } + .padding(.leading, 36) + .padding([.trailing, .bottom], 20) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + .onAppear { + tempURLText = mcpRegistryBaseURL + } + } + } + .settingsContainerStyle(isExpanded: isExpanded) + .onAppear { + tempURLText = mcpRegistryBaseURL + Task { await getMCPRegistryAllowlist() } + } + .onReceive(DistributedNotificationCenter.default().publisher(for: .authStatusDidChange)) { _ in + Task { await getMCPRegistryAllowlist() } + } + .onChange(of: mcpRegistryBaseURL) { newValue in + // Update the temp text to reflect the new URL + tempURLText = newValue + Task { await updateGalleryWindowIfOpen() } + } + .onChange(of: mcpRegistry) { _ in + Task { await updateGalleryWindowIfOpen() } + } + } + } + + private func loadMCPServers() async { + // Update mcpRegistryURL with current tempURLText before loading + tempURLText = tempURLText.trimmingCharacters(in: .whitespacesAndNewlines) + if tempURLText != mcpRegistryBaseURL { + mcpRegistryBaseURL = tempURLText + } + + isLoading = true + defer { isLoading = false } + do { + let service = try getService() + let serverList = try await service.listMCPRegistryServers( + .init(baseUrl: mcpRegistryBaseURL + mcpRegistryUrlVersion, limit: 30, version: "latest") + ) + + guard let serverList = serverList, !serverList.servers.isEmpty else { + Logger.client.info("No MCP servers found at registry URL: \(mcpRegistryBaseURL)") + return + } + + // Add to history on successful load + mcpRegistryBaseURLHistory.addToHistory(mcpRegistryBaseURL) + errorMessage = "" + + MCPServerGalleryWindow.open(serverList: serverList, mcpRegistryEntry: mcpRegistry?.first) + } catch { + Logger.client.error("Failed to load MCP servers from registry: \(error.localizedDescription)") + if let serviceError = error as? XPCExtensionServiceError { + errorMessage = serviceError.underlyingError?.localizedDescription ?? serviceError.localizedDescription + } else { + errorMessage = error.localizedDescription + } + isExpanded = true + } + } + + private func getMCPRegistryAllowlist() async { + isLoading = true + defer { isLoading = false } + do { + let service = try getService() + + // Only fetch allowlist if user is logged in + let authStatus = try await service.getXPCServiceAuthStatus() + guard authStatus?.status == .loggedIn else { + Logger.client.info("User not logged in, skipping MCP registry allowlist fetch") + return + } + + let result = try await service.getMCPRegistryAllowlist() + + guard let result = result, !result.mcpRegistries.isEmpty else { + if result == nil { + Logger.client.error("Failed to get allowlist result") + } else { + mcpRegistry = [] + } + return + } + + if let firstRegistry = result.mcpRegistries.first { + let entry = MCPRegistryEntry( + url: firstRegistry.url, + registryAccess: firstRegistry.registryAccess, + owner: firstRegistry.owner + ) + mcpRegistry = [entry] + Logger.client.info("Current MCP Registry Entry: \(entry)") + + // If registryOnly, force the URL to be the registry URL + if entry.registryAccess == .registryOnly { + mcpRegistryBaseURL = entry.url + tempURLText = entry.url + } + } + } catch { + Logger.client.error("Failed to get MCP allowlist from registry: \(error)") + } + } + + private func updateGalleryWindowIfOpen() async { + // Only update if the gallery window is currently open + guard MCPServerGalleryWindow.isOpen() else { + return + } + + isLoading = true + defer { isLoading = false } + + // Let the view model handle the entire update flow including clearing and fetching + if let error = await MCPServerGalleryWindow.refreshFromURL(mcpRegistryEntry: mcpRegistry?.first) { + // Display error in the URL view + if let serviceError = error as? XPCExtensionServiceError { + errorMessage = serviceError.underlyingError?.localizedDescription ?? serviceError.localizedDescription + } else { + errorMessage = error.localizedDescription + } + isExpanded = true + } else { + errorMessage = "" + } + } +} + +#Preview { + MCPRegistryURLView() + .padding() + .frame(width: 900) +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift new file mode 100644 index 00000000..6c086ba6 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift @@ -0,0 +1,590 @@ +import SwiftUI +import AppKit +import GitHubCopilotService +import SharedUIComponents +import Foundation + +@available(macOS 13.0, *) +struct MCPServerDetailSheet: View { + let server: MCPRegistryServerDetail + let meta: ServerMeta? + @State private var selectedTab = TabType.Packages + @State private var expandedPackages: Set = [] + @State private var expandedRemotes: Set = [] + @State private var packageConfigs: [Int: [String: Any]] = [:] + @State private var remoteConfigs: [Int: [String: Any]] = [:] + // Track installation progress per item so we can disable buttons / show feedback + @State private var installingPackages: Set = [] + @State private var installingRemotes: Set = [] + // Track whether the server (any option) is already installed + @State private var isInstalled: Bool + // Overwrite confirmation alert + @State private var showOverwriteAlert: Bool = false + @State private var pendingInstallAction: (() -> Void)? = nil + + @Environment(\.dismiss) private var dismiss + + enum TabType: String, CaseIterable, Identifiable { + case Packages, Remotes, Metadata + var id: Self { self } + } + + init(response: MCPRegistryServerResponse) { + self.server = response.server + self.meta = response.meta + // Determine installed status using registry service (same logic as gallery view) + _isInstalled = State(initialValue: MCPRegistryService.shared.isServerInstalled(server)) + } + + // Shared visual constants + private let labelColumnWidth: CGFloat = 80 + private let detailTopPadding: CGFloat = 6 + + var body: some View { + VStack(spacing: 0) { + // Header + headerSection + + // Tab selector + tabSelector + + // Content + OverlayScrollView { + VStack(alignment: .leading, spacing: 16) { + switch selectedTab { + case .Packages: + packagesTab + case .Remotes: + remotesTab + case .Metadata: + metadataTab + } + } + .padding(28) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 400) + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(action: { dismiss() }) { Text("Close") } + } + ToolbarItem(placement: .secondaryAction) { + if isInstalled { + Button("Open Config") { openConfig() } + .help("Open mcp.json") + } + } + } + .toolbarRole(.automatic) + .frame(width: 600, height: 450) + .background(Color(nsColor: .controlBackgroundColor)) + .onAppear { + isInstalled = MCPRegistryService.shared.isServerInstalled(server) + } + .alert("Overwrite Existing Installation?", isPresented: $showOverwriteAlert) { + Button("Cancel", role: .cancel) { pendingInstallAction = nil } + Button("Overwrite", role: .destructive) { + pendingInstallAction?() + pendingInstallAction = nil + } + } message: { + Text("Installing this option will replace the currently installed variant of this server.") + } + } + + // MARK: - Header Section + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center) { + Text(server.title ?? server.name) + .font(.system(size: 18, weight: .semibold)) + + if let status = meta?.official?.status, status == .deprecated { + statusBadge(status) + } + + Spacer() + } + + HStack(spacing: 24) { + HStack(spacing: 6) { + Image(systemName: "tag") + Text(server.version) + } + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + + if let publishedAt = meta?.official?.publishedAt { + dateMetadataTag(title: "Published ", dateString: publishedAt, image: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + + if let updatedAt = meta?.official?.updatedAt { + dateMetadataTag(title: "Updated ", dateString: updatedAt, image: "icloud.and.arrow.up") + } + + if let repo = server.repository, !repo.url.isEmpty, !repo.source.isEmpty { + if let repoURL = URL(string: repo.url) { + HStack(spacing: 6) { + Image(systemName: "link") + Link(destination: repoURL) { + Text("Repository") + } + .onHover { hovering in + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + } + + Text(server.description) + .font(.system(size: 13)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(2) + .padding(.top, 4) + } + .padding(28) + .background(Color(nsColor: .windowBackgroundColor)) + } + + private func dateMetadataTag(title: String, dateString: String, image: String) -> some View { + HStack(spacing: 6) { + Image(systemName: image) + if let date = parseDate(dateString) { + (Text("\(title)\(relativeDateString(date))")) + .help(formatExactDate(date)) + } else { + Text("\(title) \(dateString)").help(dateString) + } + } + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + + // MARK: - Tab Selector + + private var tabSelector: some View { + HStack(spacing: 0) { + Picker("", selection: $selectedTab) { + Text("Packages (\(server.packages?.count ?? 0))") + .tag(TabType.Packages) + Text("Remotes (\(server.remotes?.count ?? 0))") + .tag(TabType.Remotes) + if meta?.official != nil { + Text("Metadata") + .tag(TabType.Metadata) + } + } + .pickerStyle(.segmented) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.3)) + .overlay( + Rectangle() + .fill(Color(nsColor: .separatorColor)) + .frame(height: 1), + alignment: .bottom + ) + } + + // MARK: - Packages Tab + + private var packagesTab: some View { + Group { + if let packages = server.packages, !packages.isEmpty { + ForEach(Array(packages.enumerated()), id: \.offset) { index, package in + packageItem(package, index: index) + } + } else { + EmptyStateView(message: "No packages available for this server", type: .Packages) + } + } + } + + private func packageItem(_ package: Package, index: Int) -> some View { + let isExpanded = expandedPackages.contains(index) + let optionInstalled = MCPRegistryService.shared.isPackageOptionInstalled(serverDetail: server, package: package) + let metadata: [ServerInstallationOptionView.Metadata] = { + var rows: [ServerInstallationOptionView.Metadata] = [] + rows.append(.init(label: "ID", value: package.identifier, monospaced: true)) + if let registryURL = package.registryBaseUrl { + rows.append(.init(label: "Registry", value: registryURL)) + } + if let runtime = package.runtimeHint { rows.append(.init(label: "Runtime", value: runtime)) } + return rows + }() + return ServerInstallationOptionView( + title: package.registryType.registryDisplayText, + iconSystemName: "shippingbox", + versionTag: package.version, + metadata: metadata, + isExpanded: isExpanded, + isInstalled: isInstalled, // overall server installed + isInstalling: installingPackages.contains(index), + showUninstall: optionInstalled, + labelColumnWidth: labelColumnWidth, + onToggleExpand: { + if isExpanded { + expandedPackages.remove(index) + } else { + expandedPackages.insert(index) + if packageConfigs[index] == nil { packageConfigs[index] = generateServerConfig(for: package) } + } + }, + onInstall: { handlePackageInstallButton(package, index: index, optionInstalled: optionInstalled) }, + onUninstall: { uninstallServer() }, + config: packageConfigs[index] + ) + } + + // MARK: - Remotes Tab + + private var remotesTab: some View { + Group { + if let remotes = server.remotes, !remotes.isEmpty { + ForEach(Array(remotes.enumerated()), id: \.offset) { index, remote in + remoteItem(remote, index: index) + } + } else { + EmptyStateView( + message: "No remote endpoints configured for this server", + type: .Remotes + ) + } + } + } + + private func remoteItem(_ remote: Remote, index: Int) -> some View { + let isExpanded = expandedRemotes.contains(index) + let optionInstalled = MCPRegistryService.shared.isRemoteOptionInstalled(serverDetail: server, remote: remote) + let metadata: [ServerInstallationOptionView.Metadata] = [ + .init(label: "URL", value: remote.url, monospaced: true) + ] + return ServerInstallationOptionView( + title: remote.transportType.displayText, + iconSystemName: "globe", + versionTag: nil, + metadata: metadata, + isExpanded: isExpanded, + isInstalled: isInstalled, + isInstalling: installingRemotes.contains(index), + showUninstall: optionInstalled, + labelColumnWidth: labelColumnWidth, + onToggleExpand: { + if isExpanded { + expandedRemotes.remove(index) + } else { + expandedRemotes.insert(index) + if remoteConfigs[index] == nil { remoteConfigs[index] = generateServerConfig(for: remote) } + } + }, + onInstall: { handleRemoteInstallButton(remote, index: index, optionInstalled: optionInstalled) }, + onUninstall: { uninstallServer() }, + config: remoteConfigs[index] + ) + } + + // MARK: - Metadata Tab + + private var metadataTab: some View { + VStack(alignment: .leading, spacing: 16) { + if let officialMeta = meta?.official { + officialMetadataSection(officialMeta) + } + } + } + + private func officialMetadataSection(_ official: OfficialMeta) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Official Registry") + .font(.system(size: 14, weight: .medium)) + } + + VStack(alignment: .leading, spacing: 8) { + if let publishedAt = official.publishedAt { + metadataRow( + label: "Published", + value: parseDate(publishedAt) != nil ? formatExactDate( + parseDate(publishedAt)! + ) : publishedAt + ) + } + + if let updatedAt = official.updatedAt { + metadataRow( + label: "Updated", + value: parseDate(updatedAt) != nil ? formatExactDate( + parseDate(updatedAt)! + ) : updatedAt + ) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + ) + } + + private func metadataRow(label: String, value: String, isLink: Bool = false) -> some View { + HStack(spacing: 8) { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .leading) + + if isLink, let url = URL(string: value) { + Link(value, destination: url) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.blue) + } else { + Text(value) + .font(.system(size: 12, design: label.contains("ID") || label.contains("Commit") ? .monospaced : .default)) + .foregroundColor(.primary) + .textSelection(.enabled) + } + } + } + + private func serverConfigView(_ config: [String: Any]) -> some View { + ZStack(alignment: .topTrailing) { + VStack(alignment: .leading, spacing: 8) { + Text(formatConfigAsJSON(config)) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 2) + } + .padding(12) + + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(formatConfigAsJSON(config), forType: .string) + } + .padding(6) + .help("Copy configuration to clipboard") + } + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .textBackgroundColor).opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + + + private func formatConfigAsJSON(_ config: [String: Any]) -> String { + do { + let jsonData = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) + return String(data: jsonData, encoding: .utf8) ?? "{}" + } catch { + return "{}" + } + } + + // MARK: - Configuration Generation Helpers + + private func generateServerConfig(for package: Package) -> [String: Any] { + return MCPRegistryService.shared.createServerConfig(for: server, package: package) + } + + private func generateServerConfig(for remote: Remote) -> [String: Any] { + return MCPRegistryService.shared.createServerConfig(for: server, remote: remote) + } + + // MARK: - Install Helpers + + private func performPackageInstall(_ package: Package, index: Int) { + guard !installingPackages.contains(index) else { return } + installingPackages.insert(index) + Task { + let config = packageConfigs[index] ?? generateServerConfig(for: package) + // Cache generated config for preview if needed later + if packageConfigs[index] == nil { packageConfigs[index] = config } + let option = InstallationOption( + displayName: package.registryType.registryDisplayText, + description: "Install \(package.identifier)", + config: config + ) + do { + try await MCPRegistryService.shared.installMCPServer(server, installationOption: option) + // Mark installed locally so UI reflects the state immediately + isInstalled = true + } catch { + // Silently fail for now; could surface error UI later + } + installingPackages.remove(index) + } + } + + private func handlePackageInstallButton(_ package: Package, index: Int, optionInstalled: Bool) { + if isInstalled && !optionInstalled { + // Show overwrite confirmation + pendingInstallAction = { performPackageInstall(package, index: index) } + showOverwriteAlert = true + } else { + performPackageInstall(package, index: index) + } + } + + private func performRemoteInstall(_ remote: Remote, index: Int) { + guard !installingRemotes.contains(index) else { return } + installingRemotes.insert(index) + Task { + let config = remoteConfigs[index] ?? generateServerConfig(for: remote) + if remoteConfigs[index] == nil { remoteConfigs[index] = config } + let option = InstallationOption( + displayName: "\(remote.transportType.rawValue)", + description: "Install remote endpoint \(remote.url)", + config: config + ) + do { + try await MCPRegistryService.shared.installMCPServer(server, installationOption: option) + isInstalled = true + } catch { + // Silently fail for now + } + installingRemotes.remove(index) + } + } + + private func handleRemoteInstallButton(_ remote: Remote, index: Int, optionInstalled: Bool) { + if isInstalled && !optionInstalled { + pendingInstallAction = { performRemoteInstall(remote, index: index) } + showOverwriteAlert = true + } else { + performRemoteInstall(remote, index: index) + } + } + + private func uninstallServer() { + Task { + do { + try await MCPRegistryService.shared.uninstallMCPServer(server) + isInstalled = false + } catch { + // TODO: Consider surfacing error to user + } + } + } + + // MARK: - Helper Views + + private func statusBadge(_ status: ServerStatus) -> some View { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.orange) + .padding(.horizontal, 6) + .help("The server is deprecated.") + } + + private struct EmptyStateView: View { + let message: String + let type: PackageType + + enum PackageType: String { + case Packages, Remotes, Metadata + } + + var Logo: some View { + switch type { + case .Packages: + return Image(systemName: "shippingbox") + case .Remotes: + return Image(systemName: "globe") + case .Metadata: + return Image(systemName: "info.circle") + } + } + + var body: some View { + VStack(spacing: 12) { + Logo.font(.system(size: 32)) + + Text(message) + .font(.system(size: 13)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + } + + // MARK: - Utilities + + private func parseDate(_ dateString: String) -> Date? { + // Try multiple ISO8601 formatters in order of specificity + let formatters: [ISO8601DateFormatter] = [ + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }(), + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }(), + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime, .withTimeZone] + return formatter + }(), + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime] + return formatter + }() + ] + + // Try each formatter until one succeeds + for formatter in formatters { + if let date = formatter.date(from: dateString) { + return date + } + } + + return nil + } + + private func formatExactDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .full + formatter.timeStyle = .medium + return formatter.string(from: date) + } + + private func relativeDateString(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: date, relativeTo: Date()) + } + + // MARK: - Open Config / Selection Support + + private func openConfig() { + // Simplified to just open the MCP config file, mirroring manual install behavior. + let url = URL(fileURLWithPath: mcpConfigFilePath) + NSWorkspace.shared.open(url) + } +} + diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift new file mode 100644 index 00000000..31e138fa --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift @@ -0,0 +1,360 @@ +import AppKit +import Client +import CryptoKit +import GitHubCopilotService +import Logger +import SharedUIComponents +import SwiftUI +import XPCShared + +enum MCPServerGalleryWindow { + static let identifier = "MCPServerGalleryWindow" + private static weak var currentViewModel: MCPServerGalleryViewModel? + + @MainActor static func open( + serverList: MCPRegistryServerList, + mcpRegistryEntry: MCPRegistryEntry? = nil + ) { + if let existing = NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) { + // Update existing window with new data + update(serverList: serverList, mcpRegistryEntry: mcpRegistryEntry) + existing.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let viewModel = MCPServerGalleryViewModel( + initialList: serverList, + mcpRegistryEntry: mcpRegistryEntry + ) + currentViewModel = viewModel + + let controller = NSHostingController( + rootView: MCPServerGalleryView( + viewModel: viewModel + ) + ) + + let window = NSWindow(contentViewController: controller) + window.title = "MCP Servers Marketplace" + window.identifier = NSUserInterfaceItemIdentifier(identifier) + window.setContentSize(NSSize(width: 800, height: 600)) + window.minSize = NSSize(width: 600, height: 400) + window.styleMask.insert([.titled, .closable, .resizable, .miniaturizable]) + window.isReleasedWhenClosed = false + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + @MainActor static func update( + serverList: MCPRegistryServerList, + mcpRegistryEntry: MCPRegistryEntry? = nil + ) { + currentViewModel?.updateData(serverList: serverList, mcpRegistryEntry: mcpRegistryEntry) + } + + @MainActor static func refreshFromURL(mcpRegistryEntry: MCPRegistryEntry? = nil) async -> Error? { + return await currentViewModel?.refreshFromURL(mcpRegistryEntry: mcpRegistryEntry) + } + + static func isOpen() -> Bool { + return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) != nil + } +} + +// MARK: - Stable ID helper + +extension MCPRegistryServerResponse { + var stableID: String { + server.name + server.version + } +} + +private struct IdentifiableServerResponse: Identifiable { + let response: MCPRegistryServerResponse + var id: String { response.stableID } +} + +struct MCPServerGalleryView: View { + @ObservedObject var viewModel: MCPServerGalleryViewModel + @State private var isShowingURLSheet = false + @State private var searchTask: Task? + + init(viewModel: MCPServerGalleryViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + + var body: some View { + VStack(spacing: 0) { + if let error = viewModel.lastError { + if let serviceError = error as? XPCExtensionServiceError { + Badge(text: serviceError.underlyingError?.localizedDescription ?? serviceError.localizedDescription, level: .danger, icon: "xmark.circle.fill") + } else { + Badge(text: error.localizedDescription, level: .danger, icon: "xmark.circle.fill") + } + } + + tableHeaderView + serverListView + } + .padding(20) + .background(Color(nsColor: .controlBackgroundColor)) + .background(.ultraThinMaterial) + .onAppear { + viewModel.loadInstalledServers() + } + .sheet(isPresented: $isShowingURLSheet) { + urlSheet + } + .sheet(isPresented: Binding( + get: { viewModel.infoSheetServer != nil }, + set: { isPresented in + if !isPresented { + viewModel.dismissInfo() + } + } + )) { + if let server = viewModel.infoSheetServer { + infoSheet(server) + } + } + .searchable(text: $viewModel.searchText, prompt: "Search") + .onChange(of: viewModel.searchText) { newValue in + // Debounce search input before triggering a new server-side query + searchTask?.cancel() + searchTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) // 0.3s + if !Task.isCancelled { + viewModel.refreshForSearch() + } + } + } + .toolbar { + ToolbarItem { + Button(action: { viewModel.refresh() }) { + Image(systemName: "arrow.clockwise") + } + .help("Refresh") + } + + ToolbarItem { + Button(action: { isShowingURLSheet = true }) { + Image(systemName: "square.and.pencil") + } + .help("Configure your MCP Registry Base URL") + } + } + } + + private var tableHeaderView: some View { + VStack(spacing: 0) { + HStack { + Text("Name") + .font(.system(size: 11, weight: .bold)) + .padding(.horizontal, 8) + .frame(width: 220, alignment: .leading) + + Divider().frame(height: 20) + + Text("Description") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Text("Actions") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + } + .padding(.trailing, 8) + .frame(width: 120, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.clear) + + Divider() + } + } + + private var serverListView: some View { + ZStack { + ScrollView { + LazyVStack(spacing: 0) { + serverRows + + if viewModel.shouldShowLoadMoreSentinel { + Color.clear + .frame(height: 1) + .onAppear { viewModel.loadMoreIfNeeded() } + .accessibilityHidden(true) + } + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + .padding(.vertical, 12) + Spacer() + } + } + } + } + + if viewModel.isRefreshing { + VStack(spacing: 12) { + ProgressView() + Text("Loading servers...") + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.95)) + } + } + } + + private var serverRows: some View { + ForEach(Array(viewModel.filteredServers.enumerated()), id: \.element.stableID) { index, server in + let isInstalled = viewModel.isServerInstalled(serverId: server.stableID) + row(for: server, index: index, isInstalled: isInstalled) + .background(rowBackground(for: index)) + .cornerRadius(8) + .onAppear { + handleRowAppear(index: index) + } + } + } + + private var urlSheet: some View { + MCPRegistryURLSheet( + mcpRegistryEntry: viewModel.mcpRegistryEntry, + onURLUpdated: { + viewModel.refresh() + } + ) + .frame(width: 500, height: 200) + } + + private func rowBackground(for index: Int) -> Color { + index.isMultiple(of: 2) ? Color.clear : Color.primary.opacity(0.03) + } + + private func handleRowAppear(index: Int) { + let currentFilteredCount = viewModel.filteredServers.count + let totalServerCount = viewModel.servers.count + + // Prefetch when approaching the end of filtered results + if index >= currentFilteredCount - 5 { + // If we're filtering and the filtered results are small compared to total servers, + // or if we're near the end of all available data, try to load more + if currentFilteredCount < 20 || index >= totalServerCount - 5 { + viewModel.loadMoreIfNeeded() + } + } + } + + // MARK: - Subviews + + private func row(for response: MCPRegistryServerResponse, index: Int, isInstalled: Bool) -> some View { + HStack { + Text(response.server.title ?? response.server.name) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 8) + .frame(width: 220, alignment: .leading) + + Divider().frame(height: 20).foregroundColor(Color.clear) + + Text(response.server.description) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 8) { + if isInstalled { + Button("Uninstall") { + Task { + await viewModel.uninstallServer(response.server) + } + } + .buttonStyle(DestructiveButtonStyle()) + .help("Uninstall") + } else { + if #available(macOS 13.0, *) { + SplitButton( + title: "Install", + isDisabled: viewModel.hasNoDeployments(response.server), + primaryAction: { + // Install with default configuration + Task { + await viewModel.installServer(response.server) + } + }, + menuItems: { + let options = viewModel.getInstallationOptions(for: response.server) + guard !options.isEmpty else { return [] } + return [SplitButtonMenuItem.header("Install Server With")] + options.map { option in + SplitButtonMenuItem(title: option.displayName) { + Task { + await viewModel.installServer(response.server, configuration: option.displayName) + } + } + } + }() + ) + .help("Install") + } else { + Button("Install") { + Task { + await viewModel.installServer(response.server) + } + } + .disabled(viewModel.hasNoDeployments(response.server)) + .help("Install") + } + } + + Button { + viewModel.showInfo(response) + } label: { + Image(systemName: "info.circle") + .font(.system(size: 13)) + .foregroundColor(.primary) + .multilineTextAlignment(.trailing) + } + .buttonStyle(.plain) + .help("View Details") + } + .padding(.horizontal, 8) + .frame(width: 120, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + private func infoSheet(_ response: MCPRegistryServerResponse) -> some View { + if #available(macOS 13.0, *) { + return AnyView(MCPServerDetailSheet(response: response)) + } else { + return AnyView(EmptyView()) + } + } +} + +func defaultInstallation(for server: MCPRegistryServerDetail) -> String { + // Get the first available type from remotes or packages + if let firstRemote = server.remotes?.first { + return firstRemote.transportType.rawValue + } + if let firstPackage = server.packages?.first { + return firstPackage.registryType + } + return "" +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift new file mode 100644 index 00000000..26cfaf63 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift @@ -0,0 +1,320 @@ +import Client +import CryptoKit +import Foundation +import GitHubCopilotService +import Logger +import SwiftUI + +@MainActor +final class MCPServerGalleryViewModel: ObservableObject { + // Input invariants + private let pageSize: Int + + // User / UI state + @Published var searchText: String = "" + + // Data + @Published private(set) var servers: [MCPRegistryServerResponse] + @Published private(set) var installedServers: Set = [] + @Published private(set) var registryMetadata: MCPRegistryServerListMetadata? + + // Loading flags + @Published private(set) var isInitialLoading: Bool = false + @Published private(set) var isLoadingMore: Bool = false + @Published private(set) var isRefreshing: Bool = false + + // Transient presentation state + @Published var pendingServer: MCPRegistryServerResponse? + @Published var infoSheetServer: MCPRegistryServerResponse? + @Published var mcpRegistryEntry: MCPRegistryEntry? + @Published private(set) var lastError: Error? + + @AppStorage(\.mcpRegistryBaseURL) var mcpRegistryBaseURL + @AppStorage(\.mcpRegistryBaseURLHistory) private var mcpRegistryBaseURLHistory + + // Service integration + private let registryService = MCPRegistryService.shared + + init( + initialList: MCPRegistryServerList, + mcpRegistryEntry: MCPRegistryEntry? = nil, + pageSize: Int = 30 + ) { + self.pageSize = pageSize + servers = initialList.servers + registryMetadata = initialList.metadata + self.mcpRegistryEntry = mcpRegistryEntry + } + + // MARK: - Derived Data + + var filteredServers: [MCPRegistryServerResponse] { + // Only filter for latest official servers; search is handled server-side. + // Also ensure we don't surface duplicate stable IDs, which can confuse SwiftUI's diffing. + var seen = Set() + return servers.compactMap { server in + let id = server.stableID + if seen.contains(id) { return nil } + seen.insert(id) + return server + } + } + + var shouldShowLoadMoreSentinel: Bool { + // Show load more sentinel if there's more data available + if let next = registryMetadata?.nextCursor, !next.isEmpty { + return true + } + return false + } + + func isServerInstalled(serverId: String) -> Bool { + // Find the server by ID and check installation status using the service + if let server = servers.first(where: { $0.stableID == serverId }) { + return registryService.isServerInstalled(server.server) + } + + // Fallback to the existing key-based check for backwards compatibility + let key = createRegistryServerKey(registryBaseURL: mcpRegistryBaseURL, serverName: serverId) + return installedServers.contains(key) + } + + func hasNoDeployments(_ server: MCPRegistryServerDetail) -> Bool { + return server.remotes?.isEmpty ?? true && server.packages?.isEmpty ?? true + } + + // MARK: - User Intents (Updated with Service Integration) + + func requestInstall(_ server: MCPRegistryServerDetail) { + Task { + await installServer(server) + } + } + + func requestInstallWithConfiguration(_ server: MCPRegistryServerDetail, configuration: String) { + Task { + await installServer(server, configuration: configuration) + } + } + + func installServer(_ server: MCPRegistryServerDetail, configuration: String? = nil) async { + do { + let installationOption: InstallationOption? + + if let configName = configuration { + // Find the specific installation option + let options = registryService.getAllInstallationOptions(for: server) + installationOption = options.first { option in + option.displayName.contains(configName) || + option.description.contains(configName) + } + } else { + installationOption = nil + } + + try await registryService.installMCPServer(server, installationOption: installationOption) + + // Refresh installed servers list + loadInstalledServers() + + Logger.client.info("Successfully installed MCP Server '\(server.name)'") + + } catch { + Logger.client.error("Failed to install server '\(server.name)': \(error)") + // TODO: Consider adding error handling UI feedback here + } + } + + func uninstallServer(_ server: MCPRegistryServerDetail) async { + do { + try await registryService.uninstallMCPServer(server) + + // Refresh installed servers list + loadInstalledServers() + + Logger.client.info("Successfully uninstalled MCP Server '\(server.name)'") + + } catch { + Logger.client.error("Failed to uninstall server '\(server.name)': \(error)") + // TODO: Consider adding error handling UI feedback here + } + } + + func refresh() { + Task { + isRefreshing = true + defer { isRefreshing = false } + + // Clear the current server list and search text + servers = [] + registryMetadata = nil + searchText = "" + + // Load servers from the base URL with empty query + _ = await loadServerList(resetToFirstPage: true) + } + } + + // Called from Settings view to refresh with optional new registry entry + func refreshFromURL(mcpRegistryEntry: MCPRegistryEntry? = nil) async -> Error? { + isRefreshing = true + defer { isRefreshing = false } + + // Clear the current server list and reset search text when URL changes + servers = [] + registryMetadata = nil + searchText = "" + self.mcpRegistryEntry = mcpRegistryEntry + Logger.client.info("Cleared gallery view model data for refresh") + + // Load servers from the base URL + let error = await loadServerList(resetToFirstPage: true) + + // Reload installed servers after fetching new data + loadInstalledServers() + + return error + } + + func updateData(serverList: MCPRegistryServerList, mcpRegistryEntry: MCPRegistryEntry? = nil) { + servers = serverList.servers + registryMetadata = serverList.metadata + self.mcpRegistryEntry = mcpRegistryEntry + searchText = "" + loadInstalledServers() + Logger.client.info("Updated gallery view model with \(serverList.servers.count) servers and registry entry: \(String(describing: mcpRegistryEntry))") + } + + func clearData() { + servers = [] + registryMetadata = nil + searchText = "" + Logger.client.info("Cleared gallery view model data") + } + + /// Refresh the server list in response to a search query change without + /// resetting the search text. This is used by the debounced searchable field. + func refreshForSearch() { + Task { + isRefreshing = true + defer { isRefreshing = false } + + // Clear current data but keep the active search query + servers = [] + registryMetadata = nil + + _ = await loadServerList(resetToFirstPage: true) + } + } + + func showInfo(_ server: MCPRegistryServerResponse) { + infoSheetServer = server + } + + func dismissInfo() { + infoSheetServer = nil + } + + // MARK: - Data Loading + + func loadMoreIfNeeded() { + guard !isLoadingMore, + !isInitialLoading, + let nextCursor = registryMetadata?.nextCursor, + !nextCursor.isEmpty + else { return } + + Task { + await loadServerList(resetToFirstPage: false) + } + } + + private func loadServerList(resetToFirstPage: Bool) async -> Error? { + if resetToFirstPage { + isInitialLoading = true + } else { + isLoadingMore = true + } + + defer { + isInitialLoading = false + isLoadingMore = false + } + + lastError = nil + + do { + let service = try getService() + let cursor = resetToFirstPage ? nil : registryMetadata?.nextCursor + + let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + + let serverList = try await service.listMCPRegistryServers( + .init( + baseUrl: registryService.getRegistryURL(), + cursor: cursor, + limit: pageSize, + search: trimmedQuery.isEmpty ? nil : trimmedQuery, + version: "latest" + ) + ) + + if resetToFirstPage { + // Replace all servers when refreshing or resetting + servers = serverList?.servers ?? [] + registryMetadata = serverList?.metadata + } else { + // Append when loading more + servers.append(contentsOf: serverList?.servers ?? []) + registryMetadata = serverList?.metadata + } + + mcpRegistryBaseURLHistory.addToHistory(mcpRegistryBaseURL) + + return nil + } catch { + Logger.client.error("Failed to load MCP servers: \(error)") + lastError = error + return error + } + } + + func loadInstalledServers() { + // Clear the set and rebuild it + installedServers.removeAll() + + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + guard FileManager.default.fileExists(atPath: mcpConfigFilePath), + let data = try? Data(contentsOf: configFileURL), + let currentConfig = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let serversDict = currentConfig["servers"] as? [String: Any] else { + return + } + + for (_, serverConfig) in serversDict { + guard + let serverConfigDict = serverConfig as? [String: Any], + let metadata = serverConfigDict["x-metadata"] as? [String: Any], + let registry = metadata["registry"] as? [String: Any], + let api = registry["api"] as? [String: Any], + let baseUrl = api["baseUrl"] as? String, + let mcpServer = registry["mcpServer"] as? [String: Any], + let name = mcpServer["name"] as? String + else { continue } + + installedServers.insert( + createRegistryServerKey(registryBaseURL: baseUrl, serverName: name) + ) + } + } + + private func createRegistryServerKey(registryBaseURL: String, serverName: String) -> String { + return registryService.createRegistryServerKey(registryBaseURL: registryBaseURL, serverName: serverName) + } + + // MARK: - Installation Options Helper + + func getInstallationOptions(for server: MCPRegistryServerDetail) -> [InstallationOption] { + return registryService.getAllInstallationOptions(for: server) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/ServerInstallationOptionView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/ServerInstallationOptionView.swift new file mode 100644 index 00000000..fcc129e4 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/ServerInstallationOptionView.swift @@ -0,0 +1,170 @@ +import SwiftUI +import AppKit +import Foundation +import SharedUIComponents + +struct ServerInstallationOptionView: View { + struct Metadata: Identifiable { + let id = UUID() + let label: String + let value: String + var monospaced: Bool = false + var isLink: Bool = false + } + + let title: String + let iconSystemName: String + let versionTag: String? + let metadata: [Metadata] + + // State/control flags passed from parent + let isExpanded: Bool + let isInstalled: Bool + let isInstalling: Bool + let showUninstall: Bool + + // Layout constants + let labelColumnWidth: CGFloat + + // Behavior closures supplied by parent + let onToggleExpand: () -> Void + let onInstall: () -> Void + let onUninstall: () -> Void + + // Optional configuration JSON (already generated by parent) shown when expanded + let config: [String: Any]? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + header + if isExpanded, let config { + configSection(config) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + ) + .animation(.easeInOut(duration: 0.2), value: isExpanded) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 8) { + Label(title, systemImage: iconSystemName) + .font(.system(size: 14, weight: .medium)) + + if let versionTag { + Text(versionTag) + .font(.system(size: 12, design: .monospaced)) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Capsule().fill(Color.green.opacity(0.15))) + } + + Spacer() + + Button(isExpanded ? "Hide" : "Preview") { onToggleExpand() } + .buttonStyle(.bordered) + .help(isExpanded ? "Hide configuration details" : "Preview configuration details") + + if showUninstall { + Button("Uninstall") { onUninstall() } + .buttonStyle(DestructiveButtonStyle()) + .help("Uninstall this installed option") + } else { + Button(action: onInstall) { + if isInstalling { + ProgressView().controlSize(.mini) + } else { + Text("Install") + } + } + .disabled(isInstalling) + .buttonStyle(.borderedProminent) + .help("Install this server using the selected option") + } + } + + // Metadata rows + Group { + ForEach(metadata) { item in + HStack(spacing: 6) { + Text(item.label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .frame(width: labelColumnWidth, alignment: .leading) + + if item.isLink, let url = URL(string: item.value) { + Link(item.value, destination: url) + .font(.system(size: 12, design: item.monospaced ? .monospaced : .default)) + .foregroundColor(.primary) + .textSelection(.enabled) + } else { + Text(item.value) + .font(.system(size: 12, design: item.monospaced ? .monospaced : .default)) + .foregroundColor(.primary) + .textSelection(.enabled) + } + } + } + } + .padding(.top, 6) + } + } + + private func configSection(_ config: [String: Any]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Divider().padding(.vertical, 4) + HStack { + Text("Server Configuration") + .font(.system(size: 13, weight: .medium)) + Spacer() + } + configView(config) + } + } + + @ViewBuilder + private func configView(_ config: [String: Any]) -> some View { + ZStack(alignment: .topTrailing) { + VStack(alignment: .leading, spacing: 8) { + Text(formatConfigAsJSON(config)) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 2) + } + .padding(12) + + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(formatConfigAsJSON(config), forType: .string) + } + .padding(6) + .help("Copy configuration to clipboard") + } + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .textBackgroundColor).opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + + private func formatConfigAsJSON(_ config: [String: Any]) -> String { + do { + let data = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) + return String(data: data, encoding: .utf8) ?? "{}" + } catch { return "{}" } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift new file mode 100644 index 00000000..47abc27a --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift @@ -0,0 +1,459 @@ +import SwiftUI +import Persist +import GitHubCopilotService +import Client +import Logger +import Foundation +import SharedUIComponents +import ConversationServiceProvider + +/// Section for a single server's tools +struct MCPServerToolsSection: View { + let serverTools: MCPServerToolsCollection + @Binding var isServerEnabled: Bool + var forceExpand: Bool = false + var isInteractionAllowed: Bool = true + @Binding var modes: [ConversationMode] + @Binding var selectedMode: ConversationMode + @State private var toolEnabledStates: [String: Bool] = [:] + @State private var isExpanded: Bool = true + @State private var checkboxMixedState: CheckboxMixedState = .off + private var originalServerName: String { serverTools.name } + + @State private var isShowingDeleteConfirmation: Bool = false + + private var serverToggleLabel: some View { + HStack(spacing: 8) { + Text("MCP Server: \(serverTools.name)") + .fontWeight(.medium) + .foregroundStyle( + serverTools.status == .running ? .primary : .tertiary + ) + if serverTools.status == .error || serverTools.status == .blocked { + let message = extractErrorMessage(serverTools.error?.description ?? "") + if serverTools.status == .error { + Badge( + attributedText: createErrorMessage(message), + level: .danger, + icon: "xmark.circle.fill" + ) + .environment((\.openURL), OpenURLAction { url in + if url.absoluteString == "mcp://open-config" { + openMCPConfigFile() + return .handled + } + return .systemAction + }) + } else if serverTools.status == .blocked { + Badge(text: serverTools.registryInfo ?? "Blocked", level: .warning, icon: "exclamationmark.triangle.fill") + } + } else if let registryInfo = serverTools.registryInfo { + Text(registryInfo) + .foregroundStyle(.secondary) + .font(.system(size: 11)) + } + } + } + + private func openMCPConfigFile() { + let url = URL(fileURLWithPath: mcpConfigFilePath) + NSWorkspace.shared.open(url) + } + + private func createErrorMessage(_ baseMessage: String) -> AttributedString { + if hasServerConfigPlaceholders() { + let prefix = baseMessage.isEmpty ? "" : baseMessage + ". " + var attributedString = AttributedString(prefix + "You may need to update placeholders in ") + + var mcpLink = AttributedString("mcp.json") + mcpLink.link = URL(string: "mcp://open-config") + mcpLink.underlineStyle = .single + + attributedString.append(mcpLink) + attributedString.append(AttributedString(".")) + + return attributedString + } else { + return AttributedString(baseMessage) + } + } + + private var serverToggle: some View { + HStack(spacing: 8) { + MixedStateCheckbox( + title: "", + font: .systemFont(ofSize: 13), + state: $checkboxMixedState + ) { + switch checkboxMixedState { + case .off, .mixed: + // Enable all tools + updateAllToolsStatus(enabled: true) + case .on: + // Disable all tools + updateAllToolsStatus(enabled: false) + } + updateMixedState() + } + .disabled(serverTools.status == .error || serverTools.status == .blocked || !isInteractionAllowed) + + serverToggleLabel + .contentShape(Rectangle()) + .onTapGesture { + if serverTools.status != .error && serverTools.status != .blocked { + withAnimation { + isExpanded.toggle() + } + } + } + + Spacer() + + Button(action: { isShowingDeleteConfirmation = true }) { + Image(systemName: "trash").font(.system(size: 12)) + } + .buttonStyle(HoverButtonStyle()) + .padding(-4) + } + .padding(.leading, 4) + } + + private var divider: some View { + Divider() + .padding(.leading, 36) + .padding(.top, 2) + .padding(.bottom, 4) + } + + private var toolsList: some View { + VStack(spacing: 0) { + divider + ForEach(serverTools.tools, id: \.name) { tool in + ToolRow( + toolName: tool.name, + toolDescription: tool.description, + toolStatus: tool._status, + isServerEnabled: isServerEnabled, + isToolEnabled: toolBindingFor(tool), + isInteractionAllowed: isInteractionAllowed, + onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) } + ) + .padding(.leading, 36) + } + } + .onChange(of: serverTools) { newValue in + initializeToolStates(server: newValue) + updateMixedState() + } + } + + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Conditional view rendering based on error state + if serverTools.status == .error || serverTools.status == .blocked { + // No disclosure group for error state + VStack(spacing: 0) { + serverToggle + .padding(.leading, 11) + .padding(.trailing, 4) + divider.padding(.top, 4) + } + } else { + // Regular DisclosureGroup for non-error state + DisclosureGroup(isExpanded: $isExpanded) { + toolsList + } label: { + serverToggle + } + .onAppear { + initializeToolStates(server: serverTools) + updateMixedState() + if forceExpand { + isExpanded = true + } + } + .onChange(of: forceExpand) { newForceExpand in + if newForceExpand { + isExpanded = true + } + } + .onChange(of: selectedMode) { _ in + toolEnabledStates = [:] + initializeToolStates(server: serverTools) + updateMixedState() + } + .onChange(of: selectedMode.customTools) { _ in + Task { + await reloadModesAndUpdateStates() + } + } + .onReceive(DistributedNotificationCenter.default().publisher(for: .gitHubCopilotCustomAgentToolsDidChange)) { _ in + Logger.client.info("Custom agent tools change notification received in MCPServerToolsSection") + if !selectedMode.isDefaultAgent { + Task { + await reloadModesAndUpdateStates() + } + } + } + + if !isExpanded { + divider + } + } + } + .confirmationDialog( + "Do you want to delete '\(serverTools.name)'?", + isPresented: $isShowingDeleteConfirmation + ) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { deleteServerConfig() } + } + } + + private func deleteServerConfig() { + let fileURL = URL(fileURLWithPath: mcpConfigFilePath) + + guard let data = try? Data(contentsOf: fileURL) else { + Logger.client.error("Failed to read mcp.json when deleting server config.") + return + } + + guard var rootObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + Logger.client.error("Failed to parse mcp.json when deleting server config.") + return + } + + if var servers = rootObject["servers"] as? [String: Any] { + servers.removeValue(forKey: serverTools.name) + rootObject["servers"] = servers + } + + do { + let newData = try JSONSerialization.data(withJSONObject: rootObject, options: [.prettyPrinted, .sortedKeys]) + try newData.write(to: fileURL) + } catch { + Logger.client.error("Failed to write updated mcp.json when deleting server config: \(error.localizedDescription)") + } + } + + private func extractErrorMessage(_ description: String) -> String { + guard let messageRange = description.range(of: "message:"), + let stackRange = description.range(of: "stack:") else { + return description + } + let start = description.index(messageRange.upperBound, offsetBy: 0) + let end = description.index(stackRange.lowerBound, offsetBy: 0) + return description[start.. Bool { + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + + guard FileManager.default.fileExists(atPath: mcpConfigFilePath), + let data = try? Data(contentsOf: configFileURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let servers = jsonObject["servers"] as? [String: Any], + let serverConfig = servers[serverTools.name] else { + return false + } + + // Convert server config to JSON string + guard let serverData = try? JSONSerialization.data(withJSONObject: serverConfig, options: []), + let serverConfigString = String(data: serverData, encoding: .utf8) else { + return false + } + + // Check for placeholder patterns ending with }" + // Matches: "{PLACEHOLDER}", "${PLACEHOLDER}", "key={PLACEHOLDER}", "key=${PLACEHOLDER}", "${prefix:PLACEHOLDER}" + let placeholderPattern = "\"([a-zA-Z0-9_]+=)?\\$?\\{[a-zA-Z0-9_:\\-\\.]+\\}\"" + + guard let regex = try? NSRegularExpression(pattern: placeholderPattern, options: []) else { + return false + } + + let range = NSRange(serverConfigString.startIndex.. Binding { + Binding( + get: { + toolEnabledStates[tool.name] ?? isToolEnabledInMode(tool.name, currentStatus: tool._status) + }, + set: { toolEnabledStates[tool.name] = $0 } + ) + } + + private func handleToolToggleChange(tool: MCPTool, isEnabled: Bool) { + toolEnabledStates[tool.name] = isEnabled + + // Update server state based on tool states + updateServerState() + + // Update mixed state + updateMixedState() + + // Update only this specific tool status + updateToolStatus(tool: tool, isEnabled: isEnabled) + } + + private func updateServerState() { + // If any tool is enabled, server should be enabled + // If all tools are disabled, server should be disabled + let allToolsDisabled = serverTools.tools.allSatisfy { tool in + !(toolEnabledStates[tool.name] ?? (tool._status == .enabled)) + } + + isServerEnabled = !allToolsDisabled + } + + private func updateToolStatus(tool: MCPTool, isEnabled: Bool) { + let serverUpdate = UpdateMCPToolsStatusServerCollection( + name: serverTools.name, + tools: [UpdatedMCPToolsStatus(name: tool.name, status: isEnabled ? .enabled : .disabled)] + ) + + updateMCPStatus([serverUpdate]) + } + + private func updateAllToolsStatus(enabled: Bool) { + isServerEnabled = enabled + + // Get all tools for this server from the original collection + let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools + .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools + + // Update all tool states - includes both visible and filtered-out tools + for tool in allServerTools { + toolEnabledStates[tool.name] = enabled + } + + // Create status update for all tools + let serverUpdate = UpdateMCPToolsStatusServerCollection( + name: serverTools.name, + tools: allServerTools.map { + UpdatedMCPToolsStatus(name: $0.name, status: enabled ? .enabled : .disabled) + } + ) + + updateMCPStatus([serverUpdate]) + } + + private func updateMixedState() { + let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools + .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools + + let enabledCount = allServerTools.filter { tool in + toolEnabledStates[tool.name] ?? (tool._status == .enabled) + }.count + + let totalCount = allServerTools.count + + if enabledCount == 0 { + checkboxMixedState = .off + } else if enabledCount == totalCount { + checkboxMixedState = .on + } else { + checkboxMixedState = .mixed + } + } + + private func updateMCPStatus(_ serverUpdates: [UpdateMCPToolsStatusServerCollection]) { + let isDefaultAgentMode = selectedMode.isDefaultAgent + Task { + do { + let service = try getService() + + if !isDefaultAgentMode { + let chatMode = selectedMode.kind + let customChatModeId = selectedMode.isBuiltIn == false ? selectedMode.id : nil + let workspaceFolders = await getWorkspaceFolders() + + try await service + .updateMCPServerToolsStatus( + serverUpdates, + chatAgentMode: chatMode, + customChatModeId: customChatModeId, + workspaceFolders: workspaceFolders + ) + } else { + try await service.updateMCPServerToolsStatus(serverUpdates) + } + } catch { + Logger.client.error("Failed to update MCP status: \(error.localizedDescription)") + } + } + } + + @MainActor + private func reloadModesAndUpdateStates() async { + do { + let service = try getService() + let workspaceFolders = await getWorkspaceFolders() + if let fetchedModes = try await service.getModes(workspaceFolders: workspaceFolders) { + modes = fetchedModes.filter { $0.kind == .Agent } + + if let updatedMode = modes.first(where: { $0.id == selectedMode.id }) { + selectedMode = updatedMode + + let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools + .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools + + for tool in allServerTools { + let toolName = "\(serverTools.name)/\(tool.name)" + if let customTools = updatedMode.customTools { + toolEnabledStates[tool.name] = customTools.contains(toolName) + } else { + toolEnabledStates[tool.name] = false + } + } + + updateMixedState() + updateServerState() + } + } + } catch { + Logger.client.error("Failed to reload modes: \(error.localizedDescription)") + } + } + + private func isToolEnabledInMode(_ toolName: String, currentStatus: ToolStatus) -> Bool { + let configurationKey = "\(serverTools.name)/\(toolName)" + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: configurationKey, + currentStatus: currentStatus, + selectedMode: selectedMode + ) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift new file mode 100644 index 00000000..ecf30952 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift @@ -0,0 +1,38 @@ +import SwiftUI +import GitHubCopilotService +import ConversationServiceProvider + +/// Main list view containing all the tools +struct MCPToolsListContainerView: View { + let mcpServerTools: [MCPServerToolsCollection] + @Binding var serverToggleStates: [String: Bool] + let searchKey: String + let expandedServerNames: Set + var isInteractionAllowed: Bool = true + @Binding var modes: [ConversationMode] + @Binding var selectedMode: ConversationMode + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(mcpServerTools, id: \.name) { serverTools in + MCPServerToolsSection( + serverTools: serverTools, + isServerEnabled: serverToggleBinding(for: serverTools.name), + forceExpand: expandedServerNames.contains(serverTools.name) && !searchKey.isEmpty, + isInteractionAllowed: isInteractionAllowed, + modes: $modes, + selectedMode: $selectedMode + ) + } + } + .padding(.vertical, 4) + .id(selectedMode.id) + } + + private func serverToggleBinding(for serverName: String) -> Binding { + Binding( + get: { serverToggleStates[serverName] ?? true }, + set: { serverToggleStates[serverName] = $0 } + ) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift new file mode 100644 index 00000000..ba8e1b4f --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift @@ -0,0 +1,114 @@ +import Combine +import GitHubCopilotService +import Persist +import SwiftUI +import SharedUIComponents +import ConversationServiceProvider + +struct MCPToolsListView: View { + @ObservedObject private var mcpToolManager = CopilotMCPToolManagerObservable.shared + @State private var serverToggleStates: [String: Bool] = [:] + @State private var isSearchBarVisible: Bool = false + @State private var searchText: String = "" + @State private var modes: [ConversationMode] = [] + @Binding var selectedMode: ConversationMode + let isCustomAgentEnabled: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + GroupBox( + label: + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center) { + Text("Available MCP Tools").fontWeight(.bold) + if isCustomAgentEnabled { + AgentModeDropdown(modes: $modes, selectedMode: $selectedMode) + } + Spacer() + CollapsibleSearchField(searchText: $searchText, isExpanded: $isSearchBarVisible) + } + .clipped() + + AgentModeDescriptionView(selectedMode: selectedMode, isLoadingMode: false) + } + ) { + let filteredServerTools = filteredMCPServerTools() + if filteredServerTools.isEmpty { + EmptyStateView() + } else { + ToolsListView( + mcpServerTools: filteredServerTools, + serverToggleStates: $serverToggleStates, + searchKey: searchText, + expandedServerNames: expandedServerNames(filteredServerTools: filteredServerTools), + isInteractionAllowed: isInteractionAllowed(), + modes: $modes, + selectedMode: $selectedMode + ) + } + } + .groupBoxStyle(CardGroupBoxStyle()) + } + .onAppear(perform: updateServerToggleStates) + .onChange(of: mcpToolManager.availableMCPServerTools) { _ in + updateServerToggleStates() + } + .onChange(of: selectedMode) { _ in + updateServerToggleStates() + } + } + + private func updateServerToggleStates() { + serverToggleStates = mcpToolManager.availableMCPServerTools.reduce(into: [:]) { result, server in + result[server.name] = !server.tools.isEmpty && !server.tools.allSatisfy { $0._status != .enabled } + } + } + + private func filteredMCPServerTools() -> [MCPServerToolsCollection] { + let key = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !key.isEmpty else { return mcpToolManager.availableMCPServerTools } + return mcpToolManager.availableMCPServerTools.compactMap { server in + // If server name contains the search key, return the entire server with all tools + if server.name.lowercased().contains(key) { + return server + } + + // Otherwise, filter tools by name and description + let filteredTools = server.tools.filter { tool in + tool.name.lowercased().contains(key) || (tool.description?.lowercased().contains(key) ?? false) + } + if filteredTools.isEmpty { return nil } + return MCPServerToolsCollection( + name: server.name, + status: server.status, + tools: filteredTools, + error: server.error + ) + } + } + + private func expandedServerNames(filteredServerTools: [MCPServerToolsCollection]) -> Set { + // Expand all groups that have at least one tool in the filtered list + Set(filteredServerTools.map { $0.name }) + } + + private func isInteractionAllowed() -> Bool { + return AgentModeToolHelpers.isInteractionAllowed(selectedMode: selectedMode) + } +} + +/// Empty state view when no tools are available +private struct EmptyStateView: View { + var body: some View { + Text("No MCP tools available. Make sure your MCP server is configured correctly and running.") + .foregroundColor(.secondary) + } +} + +// Private components now defined in separate files: +// MCPToolsListContainerView - in MCPToolsListContainerView.swift +// MCPServerToolsSection - in MCPServerToolsSection.swift + +/// Private alias for maintaining backward compatibility +private typealias ToolsListView = MCPToolsListContainerView +private typealias ServerToolsSection = MCPServerToolsSection diff --git a/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift new file mode 100644 index 00000000..d8df5965 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift @@ -0,0 +1,43 @@ +import SwiftUI +import ConversationServiceProvider + +/// Individual tool row +struct ToolRow: View { + let toolName: String + let toolDescription: String? + let toolStatus: ToolStatus + let isServerEnabled: Bool + @Binding var isToolEnabled: Bool + var isInteractionAllowed: Bool = true + let onToolToggleChanged: (Bool) -> Void + + var body: some View { + HStack(alignment: .center) { + Toggle(isOn: Binding( + get: { isToolEnabled }, + set: { newValue in + isToolEnabled = newValue + onToolToggleChanged(newValue) + } + )) { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 8) { + Text(toolName).fontWeight(.medium) + + if let description = toolDescription { + Text(description) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .lineLimit(1) + .help(description) + } + } + + Divider().padding(.vertical, 4) + } + } + .disabled(!isInteractionAllowed) + } + .padding(.vertical, 0) + } +} diff --git a/Core/Sources/KeyBindingManager/KeyBindingManager.swift b/Core/Sources/KeyBindingManager/KeyBindingManager.swift index 2fcf67fa..e0a22188 100644 --- a/Core/Sources/KeyBindingManager/KeyBindingManager.swift +++ b/Core/Sources/KeyBindingManager/KeyBindingManager.swift @@ -5,16 +5,24 @@ public final class KeyBindingManager { public init( workspacePool: WorkspacePool, acceptSuggestion: @escaping () -> Void, + acceptNESSuggestion: @escaping () -> Void, expandSuggestion: @escaping () -> Void, collapseSuggestion: @escaping () -> Void, - dismissSuggestion: @escaping () -> Void + dismissSuggestion: @escaping () -> Void, + rejectNESSuggestion: @escaping () -> Void, + goToNextEditSuggestion: @escaping () -> Void, + isNESPanelOutOfFrame: @escaping () -> Bool ) { tabToAcceptSuggestion = .init( workspacePool: workspacePool, acceptSuggestion: acceptSuggestion, - dismissSuggestion: dismissSuggestion, + acceptNESSuggestion: acceptNESSuggestion, + dismissSuggestion: dismissSuggestion, expandSuggestion: expandSuggestion, - collapseSuggestion: collapseSuggestion + collapseSuggestion: collapseSuggestion, + rejectNESSuggestion: rejectNESSuggestion, + goToNextEditSuggestion: goToNextEditSuggestion, + isNESPanelOutOfFrame: isNESPanelOutOfFrame ) } diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift index f2d4c147..07568796 100644 --- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -8,6 +8,7 @@ import SuggestionBasic import UserDefaultsObserver import Workspace import XcodeInspector +import SuggestionWidget final class TabToAcceptSuggestion { let hook: CGEventHookType = CGEventHook(eventsOfInterest: [.keyDown]) { message in @@ -16,9 +17,13 @@ final class TabToAcceptSuggestion { let workspacePool: WorkspacePool let acceptSuggestion: () -> Void + let acceptNESSuggestion: () -> Void let expandSuggestion: () -> Void let collapseSuggestion: () -> Void let dismissSuggestion: () -> Void + let rejectNESSuggestion: () -> Void + let goToNextEditSuggestion: () -> Void + let isNESPanelOutOfFrame: () -> Bool private var modifierEventMonitor: Any? private let userDefaultsObserver = UserDefaultsObserver( object: UserDefaults.shared, forKeyPaths: [ @@ -47,16 +52,24 @@ final class TabToAcceptSuggestion { init( workspacePool: WorkspacePool, acceptSuggestion: @escaping () -> Void, + acceptNESSuggestion: @escaping () -> Void, dismissSuggestion: @escaping () -> Void, expandSuggestion: @escaping () -> Void, - collapseSuggestion: @escaping () -> Void + collapseSuggestion: @escaping () -> Void, + rejectNESSuggestion: @escaping () -> Void, + goToNextEditSuggestion: @escaping () -> Void, + isNESPanelOutOfFrame: @escaping () -> Bool ) { _ = ThreadSafeAccessToXcodeInspector.shared self.workspacePool = workspacePool self.acceptSuggestion = acceptSuggestion + self.acceptNESSuggestion = acceptNESSuggestion self.dismissSuggestion = dismissSuggestion + self.rejectNESSuggestion = rejectNESSuggestion self.expandSuggestion = expandSuggestion self.collapseSuggestion = collapseSuggestion + self.goToNextEditSuggestion = goToNextEditSuggestion + self.isNESPanelOutOfFrame = isNESPanelOutOfFrame hook.add( .init( @@ -121,18 +134,48 @@ final class TabToAcceptSuggestion { } func handleEvent(_ event: CGEvent) -> CGEventManipulation.Result { - let (accept, reason) = Self.shouldAcceptSuggestion( - event: event, - workspacePool: workspacePool, - xcodeInspector: ThreadSafeAccessToXcodeInspector.shared - ) - if let reason = reason { - Logger.service.debug("TabToAcceptSuggestion: \(accept ? "" : "not") accepting due to: \(reason)") - } - if accept { - acceptSuggestion() - return .discarded + let keycode = Int(event.getIntegerValueField(.keyboardEventKeycode)) + let tab = 48 + let escape = 53 + + if keycode == tab { + let (accept, reason, codeSuggestionType) = Self.shouldAcceptSuggestion( + event: event, + workspacePool: workspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspector.shared + ) + if let reason = reason { + Logger.service.debug("TabToAcceptSuggestion: \(accept ? "" : "not") accepting due to: \(reason)") + } + if accept, let codeSuggestionType { + switch codeSuggestionType { + case .codeCompletion: + acceptSuggestion() + case .nes: + if isNESPanelOutOfFrame() { + goToNextEditSuggestion() + } else { + acceptNESSuggestion() + } + } + return .discarded + } + return .unchanged + } else if keycode == escape { + let (shouldReject, reason) = Self.shouldRejectNESSuggestion( + event: event, + workspacePool: workspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspector.shared + ) + if let reason = reason { + Logger.service.debug("ShouldRejectNESSuggestion: \(shouldReject ? "" : "not") rejecting due to: \(reason)") + } + if shouldReject { + rejectNESSuggestion() + return .discarded + } } + return .unchanged } @@ -146,36 +189,93 @@ final class TabToAcceptSuggestion { } extension TabToAcceptSuggestion { + + enum SuggestionAction { + case acceptSuggestion, rejectNESSuggestion + } + /// Returns whether a given keyboard event should be intercepted and trigger /// accepting a suggestion. static func shouldAcceptSuggestion( event: CGEvent, workspacePool: WorkspacePool, xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol + ) -> (accept: Bool, reason: String?, codeSuggestionType: CodeSuggestionType?) { + let (isValidEvent, eventReason) = Self.validateEvent(event) + guard isValidEvent else { return (false, eventReason, nil) } + + let (isValidFilespace, filespaceReason, codeSuggestionType) = Self.validateFilespace( + event, + workspacePool: workspacePool, + xcodeInspector: xcodeInspector, + suggestionAction: .acceptSuggestion + ) + guard isValidFilespace else { return (false, filespaceReason, nil) } + + return (true, nil, codeSuggestionType) + } + + static func shouldRejectNESSuggestion( + event: CGEvent, + workspacePool: WorkspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol ) -> (accept: Bool, reason: String?) { - let keycode = Int(event.getIntegerValueField(.keyboardEventKeycode)) - let tab = 48 - guard keycode == tab else { return (false, nil) } + let (isValidEvent, eventReason) = Self.validateEvent(event) + guard isValidEvent else { return (false, eventReason) } + + let (isValidFilespace, filespaceReason, _) = Self.validateFilespace( + event, + workspacePool: workspacePool, + xcodeInspector: xcodeInspector, + suggestionAction: .rejectNESSuggestion + ) + guard isValidFilespace else { return (false, filespaceReason) } + + return (true, nil) + } + + static private func validateEvent(_ event: CGEvent) -> (Bool, String?) { if event.flags.contains(.maskHelp) { return (false, nil) } if event.flags.contains(.maskShift) { return (false, nil) } if event.flags.contains(.maskControl) { return (false, nil) } if event.flags.contains(.maskCommand) { return (false, nil) } + + return (true, nil) + } + + static private func validateFilespace( + _ event: CGEvent, + workspacePool: WorkspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol, + suggestionAction: SuggestionAction + ) -> (Bool, String?, CodeSuggestionType?) { guard xcodeInspector.hasActiveXcode else { - return (false, "No active Xcode") + return (false, "No active Xcode", nil) } guard xcodeInspector.hasFocusedEditor else { - return (false, "No focused editor") + return (false, "No focused editor", nil) } guard let fileURL = xcodeInspector.activeDocumentURL else { - return (false, "No active document") + return (false, "No active document", nil) } guard let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL) else { - return (false, "No filespace") + return (false, "No filespace", nil) } - if filespace.presentingSuggestion == nil { - return (false, "No suggestion") + + var codeSuggestionType: CodeSuggestionType? = { + if let _ = filespace.presentingSuggestion { return .codeCompletion } + if let _ = filespace.presentingNESSuggestion { return .nes } + return nil + }() + guard let codeSuggestionType = codeSuggestionType else { + return (false, "No suggestion", nil) } - return (true, nil) + + if suggestionAction == .rejectNESSuggestion, codeSuggestionType != .nes { + return (false, "Invalid NES suggestion", nil) + } + + return (true, nil, codeSuggestionType) } } diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift index 2dfa2695..c311439d 100644 --- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift +++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift @@ -33,6 +33,14 @@ public struct LaunchAgentManager { await removeObsoleteLaunchAgent() } } + + @available(macOS 13.0, *) + public func isBackgroundPermissionGranted() async -> Bool { + // On macOS 13+, check SMAppService status + let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") + let status = bridgeLaunchAgent.status + return status != .requiresApproval + } public func setupLaunchAgent() async throws { if #available(macOS 13, *) { diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift new file mode 100644 index 00000000..d1837411 --- /dev/null +++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift @@ -0,0 +1,159 @@ +import Foundation +import ChatAPIService +import Persist +import Logger +import ConversationServiceProvider + +extension ChatMessage { + + struct TurnItemData: Codable { + var content: String + var contentImageReferences: [ImageReference] + var rating: ConversationRating + var references: [ConversationReference] + var followUp: ConversationFollowUp? + var suggestedTitle: String? + var errorMessages: [String] = [] + var steps: [ConversationProgressStep] + var editAgentRounds: [AgentRound] + var parentTurnId: String? + var panelMessages: [CopilotShowMessageParams] + var fileEdits: [FileEdit] + var turnStatus: ChatMessage.TurnStatus? + let requestType: RequestType + var modelName: String? + var billingMultiplier: Float? + + // Custom decoder to provide default value for steps + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + content = try container.decode(String.self, forKey: .content) + contentImageReferences = try container.decodeIfPresent([ImageReference].self, forKey: .contentImageReferences) ?? [] + rating = try container.decode(ConversationRating.self, forKey: .rating) + references = try container.decode([ConversationReference].self, forKey: .references) + followUp = try container.decodeIfPresent(ConversationFollowUp.self, forKey: .followUp) + suggestedTitle = try container.decodeIfPresent(String.self, forKey: .suggestedTitle) + errorMessages = try container.decodeIfPresent([String].self, forKey: .errorMessages) ?? [] + steps = try container.decodeIfPresent([ConversationProgressStep].self, forKey: .steps) ?? [] + editAgentRounds = try container.decodeIfPresent([AgentRound].self, forKey: .editAgentRounds) ?? [] + parentTurnId = try container.decodeIfPresent(String.self, forKey: .parentTurnId) + panelMessages = try container.decodeIfPresent([CopilotShowMessageParams].self, forKey: .panelMessages) ?? [] + fileEdits = try container.decodeIfPresent([FileEdit].self, forKey: .fileEdits) ?? [] + turnStatus = try container.decodeIfPresent(ChatMessage.TurnStatus.self, forKey: .turnStatus) + requestType = try container.decodeIfPresent(RequestType.self, forKey: .requestType) ?? .conversation + modelName = try container.decodeIfPresent(String.self, forKey: .modelName) + billingMultiplier = try container.decodeIfPresent(Float.self, forKey: .billingMultiplier) + } + + // Default memberwise init for encoding + init( + content: String, + contentImageReferences: [ImageReference]? = nil, + rating: ConversationRating, + references: [ConversationReference], + followUp: ConversationFollowUp?, + suggestedTitle: String?, + errorMessages: [String] = [], + steps: [ConversationProgressStep]?, + editAgentRounds: [AgentRound]? = nil, + parentTurnId: String? = nil, + panelMessages: [CopilotShowMessageParams]? = nil, + fileEdits: [FileEdit]? = nil, + turnStatus: ChatMessage.TurnStatus? = nil, + requestType: RequestType = .conversation, + modelName: String? = nil, + billingMultiplier: Float? = nil + ) { + self.content = content + self.contentImageReferences = contentImageReferences ?? [] + self.rating = rating + self.references = references + self.followUp = followUp + self.suggestedTitle = suggestedTitle + self.errorMessages = errorMessages + self.steps = steps ?? [] + self.editAgentRounds = editAgentRounds ?? [] + self.parentTurnId = parentTurnId + self.panelMessages = panelMessages ?? [] + self.fileEdits = fileEdits ?? [] + self.turnStatus = turnStatus + self.requestType = requestType + self.modelName = modelName + self.billingMultiplier = billingMultiplier + } + } + + func toTurnItem() -> TurnItem { + let turnItemData = TurnItemData( + content: self.content, + contentImageReferences: self.contentImageReferences, + rating: self.rating, + references: self.references, + followUp: self.followUp, + suggestedTitle: self.suggestedTitle, + errorMessages: self.errorMessages, + steps: self.steps, + editAgentRounds: self.editAgentRounds, + parentTurnId: self.parentTurnId, + panelMessages: self.panelMessages, + fileEdits: self.fileEdits, + turnStatus: self.turnStatus, + requestType: self.requestType, + modelName: self.modelName, + billingMultiplier: self.billingMultiplier + ) + + // TODO: handle exception + let encoder = JSONEncoder() + let encodeData = (try? encoder.encode(turnItemData)) ?? Data() + let data = String(data: encodeData, encoding: .utf8) ?? "{}" + + return TurnItem(id: self.id, conversationID: self.chatTabID, CLSTurnID: self.clsTurnID, role: role.rawValue, data: data, createdAt: self.createdAt, updatedAt: self.updatedAt) + } + + static func from(_ turnItem: TurnItem) -> ChatMessage? { + var chatMessage: ChatMessage? = nil + + do { + if let jsonData = turnItem.data.data(using: .utf8) { + let decoder = JSONDecoder() + let turnItemData = try decoder.decode(TurnItemData.self, from: jsonData) + + chatMessage = .init( + id: turnItem.id, + chatTabID: turnItem.conversationID, + clsTurnID: turnItem.CLSTurnID, + role: ChatMessage.Role(rawValue: turnItem.role)!, + content: turnItemData.content, + contentImageReferences: turnItemData.contentImageReferences, + references: turnItemData.references, + followUp: turnItemData.followUp, + suggestedTitle: turnItemData.suggestedTitle, + errorMessages: turnItemData.errorMessages, + rating: turnItemData.rating, + steps: turnItemData.steps, + editAgentRounds: turnItemData.editAgentRounds, + parentTurnId: turnItemData.parentTurnId, + panelMessages: turnItemData.panelMessages, + fileEdits: turnItemData.fileEdits, + turnStatus: turnItemData.turnStatus, + requestType: turnItemData.requestType, + modelName: turnItemData.modelName, + billingMultiplier: turnItemData.billingMultiplier, + createdAt: turnItem.createdAt, + updatedAt: turnItem.updatedAt + ) + } + } catch { + Logger.client.error("Failed to restore chat message: \(error)") + } + + return chatMessage + } +} + +extension Array where Element == ChatMessage { + func toTurnItems() -> [TurnItem] { + return self.map { $0.toTurnItem() } + } +} diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift new file mode 100644 index 00000000..f642cb71 --- /dev/null +++ b/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift @@ -0,0 +1,48 @@ +import Foundation +import ChatTab +import Persist +import Logger + +extension ChatTabInfo { + + func toConversationItem() -> ConversationItem { + // Currently, no additional data to store. + let data = "{}" + + return ConversationItem(id: self.id, title: self.title, isSelected: self.isSelected, CLSConversationID: self.CLSConversationID, data: data, createdAt: self.createdAt, updatedAt: self.updatedAt) + } + + static func from(_ conversationItem: ConversationItem, with metadata: StorageMetadata) -> ChatTabInfo? { + var chatTabInfo: ChatTabInfo? = nil + + chatTabInfo = .init( + id: conversationItem.id, + title: conversationItem.title, + isSelected: conversationItem.isSelected, + CLSConversationID: conversationItem.CLSConversationID, + createdAt: conversationItem.createdAt, + updatedAt: conversationItem.updatedAt, + workspacePath: metadata.workspacePath, + username: metadata.username) + + return chatTabInfo + } +} + + +extension Array where Element == ChatTabInfo { + func toConversationItems() -> [ConversationItem] { + 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/ChatMessageStore.swift b/Core/Sources/PersistMiddleware/Stores/ChatMessageStore.swift new file mode 100644 index 00000000..f3061006 --- /dev/null +++ b/Core/Sources/PersistMiddleware/Stores/ChatMessageStore.swift @@ -0,0 +1,32 @@ +import Persist +import ChatAPIService + +public struct ChatMessageStore { + public static func save(_ chatMessage: ChatMessage, with metadata: StorageMetadata) { + let turnItem = chatMessage.toTurnItem() + ConversationStorageService.shared.operate( + OperationRequest([.upsertTurn([turnItem])]), + metadata: metadata) + } + + public static func delete(by id: String, with metadata: StorageMetadata) { + ConversationStorageService.shared.operate( + OperationRequest([.delete([.turn(id: id)])]), metadata: metadata) + } + + public static func deleteAll(by ids: [String], with metadata: StorageMetadata) { + ConversationStorageService.shared.operate( + OperationRequest([.delete(ids.map { .turn(id: $0)})]), metadata: metadata) + } + + public static func getAll(by conversationID: String, metadata: StorageMetadata) -> [ChatMessage] { + var chatMessages: [ChatMessage] = [] + + let turnItems = ConversationStorageService.shared.fetchTurnItems(for: conversationID, metadata: metadata) + if turnItems.count > 0 { + chatMessages = turnItems.compactMap { ChatMessage.from($0) } + } + + return chatMessages + } +} diff --git a/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift b/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift new file mode 100644 index 00000000..da9bccd3 --- /dev/null +++ b/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift @@ -0,0 +1,52 @@ +import Persist +import ChatTab + +public struct ChatTabInfoStore { + public static func saveAll(_ chatTabInfos: [ChatTabInfo], with metadata: StorageMetadata) { + let conversationItems = chatTabInfos.toConversationItems() + ConversationStorageService.shared.operate( + OperationRequest([.upsertConversation(conversationItems)]), metadata: metadata) + } + + public static func delete(by id: String, with metadata: StorageMetadata) { + ConversationStorageService.shared.operate( + OperationRequest( + [.delete([.conversation(id: id), .turnByConversationID(conversationID: id)])]), + metadata: metadata) + } + + public static func getAll(with metadata: StorageMetadata) -> [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 conversationPreviewItems = ConversationStorageService.shared.fetchConversationPreviewItems(metadata: metadata) + if conversationPreviewItems.count > 0 { + previewInfos = conversationPreviewItems.compactMap { ChatTabPreviewInfo.from($0) } + } + + return previewInfos + } +} diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index f21e2dac..6b8d0094 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -8,6 +8,10 @@ import Dependencies import Preferences import SuggestionBasic import SuggestionWidget +import PersistMiddleware +import ChatService +import Persist +import Workspace #if canImport(ChatTabPersistent) import ChatTabPersistent @@ -19,9 +23,9 @@ struct GUI { struct State: Equatable { var suggestionWidgetState = WidgetFeature.State() - var chatTabGroup: ChatPanelFeature.ChatTabGroup { - get { suggestionWidgetState.chatPanelState.chatTabGroup } - set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue } + var chatHistory: ChatHistory { + get { suggestionWidgetState.chatPanelState.chatHistory } + set { suggestionWidgetState.chatPanelState.chatHistory = newValue } } var promptToCodeGroup: PromptToCodeGroup.State { @@ -34,11 +38,13 @@ struct GUI { case start case openChatPanel(forceDetach: Bool) case createAndSwitchToChatTabIfNeeded - case createAndSwitchToBrowserTabIfNeeded(url: URL) +// case createAndSwitchToBrowserTabIfNeeded(url: URL) case sendCustomCommandToActiveChat(CustomCommand) case toggleWidgetsHotkeyPressed case suggestionWidget(WidgetFeature.Action) + case switchWorkspace(path: String, name: String, username: String) + case initWorkspaceChatTabIfNeeded(path: String, username: String) static func promptToCodeGroup(_ action: PromptToCodeGroup.Action) -> Self { .suggestionWidget(.panel(.sharedPanel(.promptToCodeGroup(action)))) @@ -63,27 +69,54 @@ struct GUI { } Scope( - state: \.chatTabGroup, + state: \.chatHistory, action: \.suggestionWidget.chatPanel ) { - Reduce { _, action in + Reduce { state, action in switch action { case let .createNewTapButtonClicked(kind): +// return .run { send in +// if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) { +// await send(.createNewTab(chatTabInfo)) +// } +// } + // The chat workspace should exist before create tab + guard let currentChatWorkspace = state.currentChatWorkspace else { return .none } + return .run { send in - if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) { + if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind, with: currentChatWorkspace) { await send(.appendAndSelectTab(chatTabInfo)) } } - - case let .closeTabButtonClicked(id): - return .run { _ in - chatTabPool.removeTab(of: id) + 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 +// chatTabPool.removeTab(of: id) +// } case let .chatTab(_, .openNewTab(builder)): + // The chat workspace should exist before create tab + guard let currentChatWorkspace = state.currentChatWorkspace else { return .none } return .run { send in if let (_, chatTabInfo) = await chatTabPool - .createTab(from: builder.chatTabBuilder) + .createTab(from: builder.chatTabBuilder, with: currentChatWorkspace) { await send(.appendAndSelectTab(chatTabInfo)) } @@ -125,14 +158,17 @@ struct GUI { } case .createAndSwitchToChatTabIfNeeded: - if let selectedTabInfo = state.chatTabGroup.selectedTabInfo, + // The chat workspace should exist before create tab + guard let currentChatWorkspace = state.chatHistory.currentChatWorkspace else { return .none } + + if let selectedTabInfo = currentChatWorkspace.selectedTabInfo, chatTabPool.getTab(of: selectedTabInfo.id) is ConversationTab { // Already in Chat tab return .none } - if let firstChatTabInfo = state.chatTabGroup.tabInfo.first(where: { + if let firstChatTabInfo = state.chatHistory.currentChatWorkspace?.tabInfo.first(where: { chatTabPool.getTab(of: $0.id) is ConversationTab }) { return .run { send in @@ -142,59 +178,76 @@ struct GUI { } } return .run { send in - if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil) { + if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil, with: currentChatWorkspace) { await send( .suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo))) ) } } - case let .createAndSwitchToBrowserTabIfNeeded(url): - #if canImport(BrowserChatTab) - func match(_ tabURL: URL?) -> Bool { - guard let tabURL else { return false } - return tabURL == url - || tabURL.absoluteString.hasPrefix(url.absoluteString) - } - - if let selectedTabInfo = state.chatTabGroup.selectedTabInfo, - let tab = chatTabPool.getTab(of: selectedTabInfo.id) as? BrowserChatTab, - match(tab.url) - { - // Already in the target Browser tab - return .none - } - - if let firstChatTabInfo = state.chatTabGroup.tabInfo.first(where: { - guard let tab = chatTabPool.getTab(of: $0.id) as? BrowserChatTab, - match(tab.url) - else { return false } - return true - }) { - return .run { send in - await send(.suggestionWidget(.chatPanel(.tabClicked( - id: firstChatTabInfo.id - )))) - } + case let .switchWorkspace(path, name, username): + return .run { send in + await send( + .suggestionWidget(.chatPanel(.switchWorkspace(path, name, username))) + ) } - + case let .initWorkspaceChatTabIfNeeded(path, username): + let identifier = WorkspaceIdentifier(path: path, username: username) + guard let chatWorkspace = state.chatHistory.workspaces[id: identifier], chatWorkspace.tabInfo.isEmpty + else { return .none } return .run { send in - if let (_, chatTabInfo) = await chatTabPool.createTab( - for: .init(BrowserChatTab.urlChatBuilder( - url: url, - externalDependency: ChatTabFactory - .externalDependenciesForBrowserChatTab() - )) - ) { + if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil, with: chatWorkspace) { await send( - .suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo))) - ) + .suggestionWidget(.chatPanel(.appendTabToWorkspace(chatTabInfo, chatWorkspace))) + ) } } - - #else - return .none - #endif +// case let .createAndSwitchToBrowserTabIfNeeded(url): +// #if canImport(BrowserChatTab) +// func match(_ tabURL: URL?) -> Bool { +// guard let tabURL else { return false } +// return tabURL == url +// || tabURL.absoluteString.hasPrefix(url.absoluteString) +// } +// +// if let selectedTabInfo = state.chatTabGroup.selectedTabInfo, +// let tab = chatTabPool.getTab(of: selectedTabInfo.id) as? BrowserChatTab, +// match(tab.url) +// { +// // Already in the target Browser tab +// return .none +// } +// +// if let firstChatTabInfo = state.chatTabGroup.tabInfo.first(where: { +// guard let tab = chatTabPool.getTab(of: $0.id) as? BrowserChatTab, +// match(tab.url) +// else { return false } +// return true +// }) { +// return .run { send in +// await send(.suggestionWidget(.chatPanel(.tabClicked( +// id: firstChatTabInfo.id +// )))) +// } +// } +// +// return .run { send in +// if let (_, chatTabInfo) = await chatTabPool.createTab( +// for: .init(BrowserChatTab.urlChatBuilder( +// url: url, +// externalDependency: ChatTabFactory +// .externalDependenciesForBrowserChatTab() +// )) +// ) { +// await send( +// .suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo))) +// ) +// } +// } +// +// #else +// return .none +// #endif case let .sendCustomCommandToActiveChat(command): @Sendable func stopAndHandleCommand(_ tab: ConversationTab) async { @@ -203,8 +256,10 @@ struct GUI { } try? await tab.service.handleCustomCommand(command) } + + guard var currentChatWorkspace = state.chatHistory.currentChatWorkspace else { return .none } - if let info = state.chatTabGroup.selectedTabInfo, + if let info = currentChatWorkspace.selectedTabInfo, let activeTab = chatTabPool.getTab(of: info.id) as? ConversationTab { return .run { send in @@ -213,20 +268,26 @@ struct GUI { } } - if let info = state.chatTabGroup.tabInfo.first(where: { + let chatWorkspace = currentChatWorkspace + if var info = currentChatWorkspace.tabInfo.first(where: { chatTabPool.getTab(of: $0.id) is ConversationTab }), let chatTab = chatTabPool.getTab(of: info.id) as? ConversationTab { - state.chatTabGroup.selectedTabId = chatTab.id + let (originalTab, currentTab) = currentChatWorkspace.switchTab(to: &info) + let updatedChatWorkspace = currentChatWorkspace + return .run { send in + await send(.suggestionWidget(.chatPanel(.updateChatHistory(updatedChatWorkspace)))) await send(.openChatPanel(forceDetach: false)) await stopAndHandleCommand(chatTab) + await send(.suggestionWidget(.chatPanel(.saveChatTabInfo([originalTab, currentTab], chatWorkspace)))) + await send(.suggestionWidget(.chatPanel(.syncChatTabInfo([originalTab, currentTab])))) } } return .run { send in - guard let (chatTab, chatTabInfo) = await chatTabPool.createTab(for: nil) + guard let (chatTab, chatTabInfo) = await chatTabPool.createTab(for: nil, with: chatWorkspace) else { return } @@ -252,15 +313,15 @@ struct GUI { return .none #endif - case let .suggestionWidget(.chatPanel(.closeTabButtonClicked(id))): - #if canImport(ChatTabPersistent) - // when a tab is closed, remove it from persistence. - return .run { send in - await send(.persistent(.chatTabClosed(id: id))) - } - #else - return .none - #endif +// case let .suggestionWidget(.chatPanel(.closeTabButtonClicked(id))): +// #if canImport(ChatTabPersistent) +// // when a tab is closed, remove it from persistence. +// return .run { send in +// await send(.persistent(.chatTabClosed(id: id))) +// } +// #else +// return .none +// #endif case .suggestionWidget: return .none @@ -271,20 +332,21 @@ struct GUI { #endif } } - }.onChange(of: \.chatTabGroup.tabInfo) { old, new in - Reduce { _, _ in - guard old.map(\.id) != new.map(\.id) else { - return .none - } - #if canImport(ChatTabPersistent) - return .run { send in - await send(.persistent(.chatOrderChanged)) - }.debounce(id: Debounce.updateChatTabOrder, for: 1, scheduler: DispatchQueue.main) - #else - return .none - #endif - } } +// .onChange(of: \.chatCollection.selectedChatGroup?.tabInfo) { old, new in +// Reduce { _, _ in +// guard old.map(\.id) != new.map(\.id) else { +// return .none +// } +// #if canImport(ChatTabPersistent) +// return .run { send in +// await send(.persistent(.chatOrderChanged)) +// }.debounce(id: Debounce.updateChatTabOrder, for: 1, scheduler: DispatchQueue.main) +// #else +// return .none +// #endif +// } +// } } } @@ -294,12 +356,18 @@ public final class GraphicalUserInterfaceController { let widgetController: SuggestionWidgetController let widgetDataSource: WidgetDataSource let chatTabPool: ChatTabPool + + // Used for restoring. Handle concurrency + var restoredChatHistory: Set = Set() class WeakStoreHolder { weak var store: StoreOf? } init() { + @Dependency(\.workspacePool) var workspacePool + @Dependency(\.workspaceInvoker) var workspaceInvoker + let chatTabPool = ChatTabPool() let suggestionDependency = SuggestionWidgetControllerDependency() let setupDependency: (inout DependencyValues) -> Void = { dependencies in @@ -337,13 +405,13 @@ public final class GraphicalUserInterfaceController { dependency: suggestionDependency ) - chatTabPool.createStore = { id in + chatTabPool.createStore = { info in store.scope( state: { state in - state.chatTabGroup.tabInfo[id: id] ?? .init(id: id, title: "") + state.chatHistory.currentChatWorkspace?.tabInfo[id: info.id] ?? info }, action: { childAction in - .suggestionWidget(.chatPanel(.chatTab(id: id, action: childAction))) + .suggestionWidget(.chatPanel(.chatTab(id: info.id, action: childAction))) } ) } @@ -361,6 +429,12 @@ public final class GraphicalUserInterfaceController { await commandHandler.handleCustomCommand(command) } } + + workspaceInvoker.invokeFilespaceUpdate = { fileURL, content in + guard let (workspace, _) = try? await workspacePool.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + else { return } + await workspace.didUpdateFilespace(fileURL: fileURL, content: content) + } } func start() { @@ -376,30 +450,78 @@ 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, title: "") - guard let chatTap = await builder.build(store: createStore(id)) else { return nil } - setTab(chatTap) - return (chatTap, info) + 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) } @MainActor func createTab( - for kind: ChatTabKind? + for kind: ChatTabKind?, + with chatWorkspace: ChatWorkspace ) async -> (any ChatTab, ChatTabInfo)? { let id = UUID().uuidString - let info = ChatTabInfo(id: id, title: "") + let info = ChatTabInfo(id: id, workspacePath: chatWorkspace.workspacePath, username: chatWorkspace.username) guard let builder = kind?.builder else { - let chatTap = ConversationTab(store: createStore(id)) - setTab(chatTap) - return (chatTap, info) + let chatTab = ConversationTab(store: createStore(info), with: info) + setTab(chatTab) + return (chatTab, info) } - guard let chatTap = await builder.build(store: createStore(id)) else { return nil } - setTab(chatTap) - return (chatTap, info) + guard let chatTab = await builder.build(store: createStore(info)) else { return nil } + 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 } } + +extension GraphicalUserInterfaceController { + + @MainActor + public func restore(path workspacePath: String, name workspaceName: String, username: String) async -> Void { + let workspaceIdentifier = WorkspaceIdentifier(path: workspacePath, username: username) + guard !restoredChatHistory.contains(workspaceIdentifier) else { return } + + // only restore once regardless of success or fail + restoredChatHistory.insert(workspaceIdentifier) + + 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: [selectedChatTabInfo], + tabCollection: [], + 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/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index 01611d11..2d0ecffc 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -9,6 +9,8 @@ import ChatAPIService import PromptToCodeService import SuggestionBasic import SuggestionWidget +import WorkspaceSuggestionService +import Workspace @MainActor final class WidgetDataSource {} @@ -47,7 +49,7 @@ extension WidgetDataSource: SuggestionWidgetDataSource { onAcceptSuggestionTapped: { Task { let handler = PseudoCommandHandler() - await handler.acceptSuggestion() + await handler.acceptSuggestion(.codeCompletion) NSWorkspace.activatePreviousActiveXcode() } }, @@ -63,5 +65,45 @@ extension WidgetDataSource: SuggestionWidgetDataSource { } return nil } + + func nesSuggestionForFile(at url: URL) async -> NESCodeSuggestionProvider? { + for workspace in await Service.shared.workspacePool.workspaces.values { + if let filespace = workspace.filespaces[url], + let nesSuggestion = filespace.presentingNESSuggestion + { + let sourceSnapshot = await getSourceSnapshot(from: filespace) + return .init( + fileURL: url, + code: nesSuggestion.text, + sourceSnapshot: sourceSnapshot, + range: nesSuggestion.range, + language: filespace.language.rawValue, + onRejectSuggestionTapped: { + Task { + let handler = PseudoCommandHandler() + await handler.rejectNESSuggestions() + } + }, + onAcceptNESSuggestionTapped: { + Task { + let handler = PseudoCommandHandler() + await handler.acceptSuggestion(.nes) + NSWorkspace.activatePreviousActiveXcode() + } + }, + onDismissNESSuggestionTapped: { + // Refer to Code Completion suggestion, the `dismiss` action is not support + } + ) + } + } + + return nil + } } + +@WorkspaceActor +private func getSourceSnapshot(from filespace: Filespace) -> FilespaceSuggestionSnapshot { + return filespace.nesSuggestionSourceSnapshot +} diff --git a/Core/Sources/Service/Helpers.swift b/Core/Sources/Service/Helpers.swift index 0dfede82..99dc2a65 100644 --- a/Core/Sources/Service/Helpers.swift +++ b/Core/Sources/Service/Helpers.swift @@ -1,10 +1,12 @@ import Foundation +import GitHubCopilotService import LanguageServerProtocol extension NSError { static func from(_ error: Error) -> NSError { if let error = error as? ServerError { var message = "Unknown" + var errorData: Codable? = nil switch error { case let .handlerUnavailable(handler): message = "Handler unavailable: \(handler)." @@ -28,16 +30,38 @@ extension NSError { message = "Unable to send request: \(error.localizedDescription)." case let .unableToSendNotification(error): message = "Unable to send notification: \(error.localizedDescription)." - case let .serverError(code, m, _): + case let .serverError(code, m, data): message = "Server error: (\(code)) \(m)." + errorData = data case let .invalidRequest(error): message = "Invalid request: \(error?.localizedDescription ?? "Unknown")." case .timeout: message = "Timeout." + case .unknownError: + message = "Unknown error: \(error.localizedDescription)." } - return NSError(domain: "com.github.CopilotForXcode", code: -1, userInfo: [ - NSLocalizedDescriptionKey: message, - ]) + + var userInfo: [String: Any] = [NSLocalizedDescriptionKey: message] + + // Try to encode errorData to JSON for XPC transfer + if let errorData = errorData { + // Try to decode as MCPRegistryErrorData first + if let jsonData = try? JSONEncoder().encode(errorData), + let mcpErrorData = try? JSONDecoder().decode(MCPRegistryErrorData.self, from: jsonData) { + userInfo["errorType"] = mcpErrorData.errorType + if let status = mcpErrorData.status { + userInfo["status"] = status + } + if let shouldRetry = mcpErrorData.shouldRetry { + userInfo["shouldRetry"] = shouldRetry + } + } else if let jsonData = try? JSONEncoder().encode(errorData) { + // Fallback to encoding any Codable type + userInfo["serverErrorData"] = jsonData + } + } + + return NSError(domain: "com.github.CopilotForXcode", code: -1, userInfo: userInfo) } if let error = error as? CancellationError { return NSError(domain: "com.github.CopilotForXcode", code: -100, userInfo: [ diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index e285ba59..b3fd109a 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -69,20 +69,13 @@ public actor RealtimeSuggestionController { let handler = { [weak self] in guard let self else { return } await cancelInFlightTasks() - await self.triggerPrefetchDebounced() await self.notifyEditingFileChange(editor: sourceEditor.element) + await self.triggerPrefetchDebounced() } - - if #available(macOS 13.0, *) { - for await _ in valueChange._throttle(for: .milliseconds(200)) { - if Task.isCancelled { return } - await handler() - } - } else { - for await _ in valueChange { - if Task.isCancelled { return } - await handler() - } + + for await _ in valueChange { + if Task.isCancelled { return } + await handler() } } group.addTask { @@ -125,12 +118,18 @@ public actor RealtimeSuggestionController { do { try await XcodeInspector.shared.safe.latestActiveXcode? .triggerCopilotCommand(name: "Sync Text Settings") - await Status.shared.updateExtensionStatus(.succeeded) + await Status.shared.updateExtensionStatus(.granted) } catch { if filespace.codeMetadata.uti?.isEmpty ?? true { filespace.codeMetadata.uti = nil } - await Status.shared.updateExtensionStatus(.failed) + if let cantRunError = error as? AppInstanceInspector.CantRunCommand { + if cantRunError.errorDescription.contains("No bundle found") { + await Status.shared.updateExtensionStatus(.notGranted) + } else if cantRunError.errorDescription.contains("found but disabled") { + await Status.shared.updateExtensionStatus(.disabled) + } + } } } } @@ -144,9 +143,10 @@ public actor RealtimeSuggestionController { )) if Task.isCancelled { return } - - guard UserDefaults.shared.value(for: \.realtimeSuggestionToggle) - else { return } + + // check if user loggin + let authStatus = await Status.shared.getAuthStatus() + guard authStatus.status == .loggedIn else { return } if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally), let fileURL = await XcodeInspector.shared.safe.activeDocumentURL, diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 3fb9afa8..ab6c35e2 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -13,6 +13,10 @@ import XcodeInspector import XcodeThemeController import XPCShared import SuggestionWidget +import Status +import ChatService +import Persist +import PersistMiddleware @globalActor public enum ServiceActor { public actor TheActor {} @@ -58,7 +62,10 @@ public final class Service { keyBindingManager = .init( workspacePool: workspacePool, acceptSuggestion: { - Task { await PseudoCommandHandler().acceptSuggestion() } + Task { await PseudoCommandHandler().acceptSuggestion(.codeCompletion) } + }, + acceptNESSuggestion: { + Task { await PseudoCommandHandler().acceptSuggestion(.nes) } }, expandSuggestion: { if !ExpandableSuggestionService.shared.isSuggestionExpanded { @@ -72,11 +79,21 @@ public final class Service { }, dismissSuggestion: { Task { await PseudoCommandHandler().dismissSuggestion() } + }, + rejectNESSuggestion: { + Task { await PseudoCommandHandler().rejectNESSuggestions() } + }, + goToNextEditSuggestion: { + Task { await PseudoCommandHandler().goToNextEditSuggestion() } + }, + isNESPanelOutOfFrame: { [weak guiController] in + guiController?.store.state.suggestionWidgetState.panelState.nesSuggestionPanelState.isPanelOutOfFrame ?? false } ) let scheduledCleaner = ScheduledCleaner() scheduledCleaner.service = self + Logger.telemetryLogger = TelemetryLogger() } @MainActor @@ -89,16 +106,58 @@ public final class Service { keyBindingManager.start() Task { - await XcodeInspector.shared.safe.$activeDocumentURL - .removeDuplicates() - .filter { $0 != .init(fileURLWithPath: "/") } - .compactMap { $0 } - .sink { [weak self] fileURL in - Task { - try await self?.workspacePool - .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + await Publishers.CombineLatest( + XcodeInspector.shared.safe.$activeDocumentURL + .removeDuplicates(), + XcodeInspector.shared.safe.$latestActiveXcode + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] documentURL, latestXcode in + Task { + let fileURL = documentURL ?? latestXcode?.realtimeDocumentURL + guard fileURL != nil, fileURL != .init(fileURLWithPath: "/") else { + return + } + do { + let _ = try await self?.workspacePool + .fetchOrCreateWorkspaceAndFilespace( + fileURL: fileURL! + ) + } catch let error as Workspace.WorkspaceFileError { + Logger.workspacePool + .info(error.localizedDescription) } - }.store(in: &cancellable) + catch { + Logger.workspacePool.error(error) + } + } + }.store(in: &cancellable) + + // Combine both workspace and auth status changes into a single stream + await Publishers.CombineLatest3( + XcodeInspector.shared.safe.$latestActiveXcode, + XcodeInspector.shared.safe.$activeWorkspaceURL + .removeDuplicates(), + StatusObserver.shared.$authStatus + .removeDuplicates() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] newXcode, newURL, newStatus in + // First check for realtimeWorkspaceURL if activeWorkspaceURL is nil + if let realtimeURL = newXcode?.realtimeWorkspaceURL, newURL == nil { + self?.onNewActiveWorkspaceURLOrAuthStatus( + newURL: realtimeURL, + newStatus: newStatus + ) + } else if let newURL = newURL { + // Then use activeWorkspaceURL if available + self?.onNewActiveWorkspaceURLOrAuthStatus( + newURL: newURL, + newStatus: newStatus + ) + } + } + .store(in: &cancellable) } } @@ -108,6 +167,18 @@ public final class Service { keyBindingManager.stopForExit() await scheduledCleaner.closeAllChildProcesses() } + + private func getDisplayNameOfXcodeWorkspace(url: URL) -> String { + var name = url.lastPathComponent + let suffixes = [".xcworkspace", ".xcodeproj", ".playground"] + for suffix in suffixes { + if name.hasSuffix(suffix) { + name = String(name.dropLast(suffix.count)) + break + } + } + return name + } } public extension Service { @@ -120,3 +191,42 @@ public extension Service { } } +// internal extension +extension Service { + + func onNewActiveWorkspaceURLOrAuthStatus(newURL: URL?, newStatus: AuthStatus) { + Task { @MainActor in + // check path + guard let path = newURL?.path, path != "/", + // check auth status + newStatus.status == .loggedIn, + let username = newStatus.username, !username.isEmpty, + // Switch workspace only when the `workspace` or `username` is not the same as the current one + ( + self.guiController.store.chatHistory.selectedWorkspacePath != path || + self.guiController.store.chatHistory.currentUsername != username + ) + else { return } + + await self.doSwitchWorkspace(workspaceURL: newURL!, username: username) + } + } + + /// - Parameters: + /// - workspaceURL: The active workspace URL that need switch to + /// - path: Path of the workspace URL + /// - username: Curent github username + @MainActor + func doSwitchWorkspace(workspaceURL: URL, username: String) async { + // get workspace display name + let name = self.getDisplayNameOfXcodeWorkspace(url: workspaceURL) + let path = workspaceURL.path + + // switch workspace and username and wait for it to complete + await self.guiController.store.send(.switchWorkspace(path: path, name: name, username: username)).finish() + // restore if needed + await self.guiController.restore(path: path, name: name, username: username) + // init chat tab if no history tab (only after workspace is fully switched and restored) + await self.guiController.store.send(.initWorkspaceChatTabIfNeeded(path: path, username: username)).finish() + } +} diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index ac0b46e5..2bdb8b91 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -9,12 +9,16 @@ import Workspace import WorkspaceSuggestionService import XcodeInspector import XPCShared +import AXHelper +import GitHubCopilotService /// It's used to run some commands without really triggering the menu bar item. /// /// For example, we can use it to generate real-time suggestions without Apple Scripts. struct PseudoCommandHandler { static var lastTimeCommandFailedToTriggerWithAccessibilityAPI = Date(timeIntervalSince1970: 0) + static var lastBundleNotFoundTime = Date(timeIntervalSince1970: 0) + static var lastBundleDisabledTime = Date(timeIntervalSince1970: 0) private var toast: ToastController { ToastControllerDependencyKey.liveValue } func presentPreviousSuggestion() async { @@ -51,20 +55,95 @@ struct PseudoCommandHandler { func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async { guard let filespace = await getFilespace(), let (workspace, _) = try? await Service.shared.workspacePool - .fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return } + .fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return } if Task.isCancelled { return } + + let codeCompletionEnabled = UserDefaults.shared.value(for: \.realtimeSuggestionToggle) + // Enabled both by Feature Flag and User. + let nesEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures && UserDefaults.shared.value(for: \.realtimeNESToggle) + guard codeCompletionEnabled || nesEnabled else { + cleanupAllSuggestions(filespace: filespace, presenter: nil) + return + } // Can't use handler if content is not available. guard let editor = await getEditorContent(sourceEditor: sourceEditor) else { return } - let fileURL = filespace.fileURL let presenter = PresentInWindowSuggestionPresenter() presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } + do { + if codeCompletionEnabled { + try await _generateRealtimeCodeCompletionSuggestions( + editor: editor, + sourceEditor: sourceEditor, + filespace: filespace, + workspace: workspace, + presenter: presenter + ) + } else { + cleanupCodeCompletionSuggestion(filespace: filespace, presenter: presenter) + } + + if nesEnabled, + (codeCompletionEnabled == false || filespace.presentingSuggestion == nil) { + try await _generateRealtimeNESSuggestions( + editor: editor, + sourceEditor: sourceEditor, + filespace: filespace, + workspace: workspace, + presenter: presenter + ) + } else { + cleanupNESSuggestion(filespace: filespace, presenter: presenter) + } + + } catch { + cleanupAllSuggestions(filespace: filespace, presenter: presenter) + } + } + + @WorkspaceActor + private func cleanupCodeCompletionSuggestion( + filespace: Filespace, + presenter: PresentInWindowSuggestionPresenter? + ) { + filespace.reset() + presenter?.discardSuggestion(fileURL: filespace.fileURL) + } + + @WorkspaceActor + private func cleanupNESSuggestion( + filespace: Filespace, + presenter: PresentInWindowSuggestionPresenter? + ) { + filespace.resetNESSuggestion() + presenter?.discardNESSuggestion(fileURL: filespace.fileURL) + } + + @WorkspaceActor + private func cleanupAllSuggestions( + filespace: Filespace, + presenter: PresentInWindowSuggestionPresenter? + ) { + cleanupCodeCompletionSuggestion(filespace: filespace, presenter: presenter) + cleanupNESSuggestion(filespace: filespace, presenter: presenter) + filespace.resetSnapshot() + filespace.resetNESSnapshot() + } + + @WorkspaceActor + func _generateRealtimeCodeCompletionSuggestions( + editor: EditorContent, + sourceEditor: SourceEditor?, + filespace: Filespace, + workspace: Workspace, + presenter: PresentInWindowSuggestionPresenter + ) async throws { if filespace.presentingSuggestion != nil { // Check if the current suggestion is still valid. if filespace.validateSuggestions( @@ -73,30 +152,78 @@ struct PseudoCommandHandler { ) { return } else { + filespace.reset() presenter.discardSuggestion(fileURL: filespace.fileURL) } } - - do { - try await workspace.generateSuggestions( - forFileAt: fileURL, - editor: editor + + let fileURL = filespace.fileURL + + try await workspace.generateSuggestions( + forFileAt: fileURL, + editor: editor + ) + let editorContent = sourceEditor?.getContent() + if let editorContent { + _ = filespace.validateSuggestions( + lines: editorContent.lines, + cursorPosition: editorContent.cursorPosition ) - if let sourceEditor { - let editorContent = sourceEditor.getContent() - _ = filespace.validateSuggestions( - lines: editorContent.lines, - cursorPosition: editorContent.cursorPosition + } + + if !filespace.errorMessage.isEmpty { + presenter + .presentWarningMessage( + filespace.errorMessage, + url: "https://github.com/github-copilot/signup/copilot_individual" ) - } - if filespace.presentingSuggestion != nil { - presenter.presentSuggestion(fileURL: fileURL) - workspace.notifySuggestionShown(fileFileAt: fileURL) + } + if filespace.presentingSuggestion != nil { + presenter.presentSuggestion(fileURL: fileURL) + workspace.notifySuggestionShown(fileFileAt: fileURL) + } else { + presenter.discardSuggestion(fileURL: fileURL) + } + } + + @WorkspaceActor + func _generateRealtimeNESSuggestions( + editor: EditorContent, + sourceEditor: SourceEditor?, + filespace: Filespace, + workspace: Workspace, + presenter: PresentInWindowSuggestionPresenter + ) async throws { + if filespace.presentingNESSuggestion != nil { + // Check if the current NES suggestion is still valid. + if filespace.validateNESSuggestions( + lines: editor.lines, + cursorPosition: editor.cursorPosition + ) { + return } else { - presenter.discardSuggestion(fileURL: fileURL) + filespace.resetNESSuggestion() + presenter.discardNESSuggestion(fileURL: filespace.fileURL) } - } catch { - return + } + + let fileURL = filespace.fileURL + + try await workspace.generateNESSuggestions(forFileAt: fileURL, editor: editor) + + let editorContent = sourceEditor?.getContent() + if let editorContent { + _ = filespace.validateNESSuggestions( + lines: editorContent.lines, + cursorPosition: editorContent.cursorPosition + ) + } + // TODO: handle errorMessage if any + if filespace.presentingNESSuggestion != nil { + presenter.presentNESSuggestion(fileURL: fileURL) + workspace.notifyNESSuggestionShown(forFileAt: fileURL) + } else { + presenter.discardNESSuggestion(fileURL: fileURL) } } @@ -117,6 +244,24 @@ struct PseudoCommandHandler { PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: fileURL) } } + + @WorkspaceActor + func invalidateRealtimeNESSuggestionsIfNeeded(fileURL: URL, sourceEditor: SourceEditor) async { + guard let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } + + if filespace.presentingNESSuggestion == nil { + return // skip if there's no NES suggestion presented. + } + + let content = sourceEditor.getContent() + if !filespace.validateNESSuggestions( + lines: content.lines, + cursorPosition: content.cursorPosition + ) { + PresentInWindowSuggestionPresenter().discardNESSuggestion(fileURL: fileURL) + } + } func rejectSuggestions() async { let handler = WindowBaseCommandHandler() @@ -132,6 +277,21 @@ struct PseudoCommandHandler { usesTabsForIndentation: false )) } + + func rejectNESSuggestions() async { + let handler = WindowBaseCommandHandler() + _ = try? await handler.rejectNESSuggestion(editor: .init( + content: "", + lines: [], + uti: "", + cursorPosition: .outOfScope, + cursorOffset: -1, + selections: [], + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false + )) + } func handleCustomCommand(_ command: CustomCommand) async { guard let editor = await { @@ -193,14 +353,14 @@ struct PseudoCommandHandler { The app is using a fallback solution to accept suggestions. \ For better experience, please restart Xcode to re-activate the Copilot \ menu item. - """, type: .warning) + """, level: .warning) } throw error } } catch { guard let xcode = ActiveApplicationMonitor.shared.activeXcode - ?? ActiveApplicationMonitor.shared.latestXcode else { return } + ?? ActiveApplicationMonitor.shared.latestXcode else { return } let application = AXUIElementCreateApplication(xcode.processIdentifier) guard let focusElement = application.focusedElement, focusElement.description == "Source Editor" @@ -238,30 +398,64 @@ struct PseudoCommandHandler { } } - func acceptSuggestion() async { + func acceptSuggestion(_ suggestionType: CodeSuggestionType) async { do { if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { throw CancellationError() } do { - try await XcodeInspector.shared.safe.latestActiveXcode? - .triggerCopilotCommand(name: "Accept Suggestion") + switch suggestionType { + case .codeCompletion: + try await XcodeInspector.shared.safe.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Suggestion") + case .nes: + try await XcodeInspector.shared.safe.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Next Edit Suggestion") + } } catch { - let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI + let lastBundleNotFoundTime = Self.lastBundleNotFoundTime + let lastBundleDisabledTime = Self.lastBundleDisabledTime let now = Date() - if now.timeIntervalSince(last) > 60 * 60 { - Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now - toast.toast(content: """ - Xcode is relying on a fallback solution for Copilot suggestions. \ - For optimal performance, please restart Xcode to reactivate Copilot. - """, type: .warning) + if let cantRunError = error as? AppInstanceInspector.CantRunCommand { + if cantRunError.errorDescription.contains("No bundle found") { + // Extension permission not granted + if now.timeIntervalSince(lastBundleNotFoundTime) > 60 * 60 { + Self.lastBundleNotFoundTime = now + toast.toast( + title: "GitHub Copilot Extension Permission Not Granted", + content: """ + Enable Extensions → Xcode Source Editor → GitHub Copilot \ + for Xcode for faster and full-featured code completion. \ + [View How-to Guide](https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md#extension-permission) + """, + level: .warning, + button: .init( + title: "Enable", + action: { NSWorkspace.openXcodeExtensionsPreferences() } + ) + ) + } + } else if cantRunError.errorDescription.contains("found but disabled") { + if now.timeIntervalSince(lastBundleDisabledTime) > 60 * 60 { + Self.lastBundleDisabledTime = now + toast.toast( + title: "GitHub Copilot Extension Disabled", + content: "Quit and restart Xcode to enable extension.", + level: .warning, + button: .init( + title: "Restart Xcode", + action: { NSWorkspace.restartXcode() } + ) + ) + } + } } throw error } } catch { guard let xcode = ActiveApplicationMonitor.shared.activeXcode - ?? ActiveApplicationMonitor.shared.latestXcode else { return } + ?? ActiveApplicationMonitor.shared.latestXcode else { return } let application = AXUIElementCreateApplication(xcode.processIdentifier) guard let focusElement = application.focusedElement, focusElement.description == "Source Editor" @@ -280,7 +474,7 @@ struct PseudoCommandHandler { } let handler = WindowBaseCommandHandler() do { - guard let result = try await handler.acceptSuggestion(editor: .init( + let editor: EditorContent = .init( content: content, lines: lines, uti: "", @@ -290,7 +484,18 @@ struct PseudoCommandHandler { tabSize: 0, indentSize: 0, usesTabsForIndentation: false - )) else { return } + ) + + let result = try await { + switch suggestionType { + case .codeCompletion: + return try await handler.acceptSuggestion(editor: editor) + case .nes: + return try await handler.acceptNESSuggestion(editor: editor) + } + }() + + guard let result else { return } try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) } catch { @@ -298,6 +503,27 @@ struct PseudoCommandHandler { } } } + + func goToNextEditSuggestion() async { + do { + guard let sourceEditor = await XcodeInspector.shared.safe.focusedEditor, + let fileURL = sourceEditor.realtimeDocumentURL + else { return } + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + + guard let suggestion = await workspace.getNESSuggestion(forFileAt: fileURL) + else { return } + + AXHelper.scrollSourceEditorToLine( + suggestion.range.start.line, + content: sourceEditor.getContent().content, + focusedElement: sourceEditor.element + ) + } catch { + // Handle if needed + } + } func dismissSuggestion() async { guard let documentURL = await XcodeInspector.shared.safe.activeDocumentURL else { return } @@ -324,69 +550,27 @@ extension PseudoCommandHandler { _ result: UpdatedContent, focusElement: AXUIElement ) throws { - let oldPosition = focusElement.selectedTextRange - let oldScrollPosition = focusElement.parent?.verticalScrollBar?.doubleValue - - let error = AXUIElementSetAttributeValue( - focusElement, - kAXValueAttribute as CFString, - result.content as CFTypeRef - ) - - if error != AXError.success { - PresentInWindowSuggestionPresenter() - .presentErrorMessage("Fail to set editor content.") - } - - // recover selection range - - if let selection = result.newSelection { - var range = SourceEditor.convertCursorRangeToRange(selection, in: result.content) - if let value = AXValueCreate(.cfRange, &range) { - AXUIElementSetAttributeValue( - focusElement, - kAXSelectedTextRangeAttribute as CFString, - value - ) - } - } else if let oldPosition { - var range = CFRange( - location: oldPosition.lowerBound, - length: 0 - ) - if let value = AXValueCreate(.cfRange, &range) { - AXUIElementSetAttributeValue( - focusElement, - kAXSelectedTextRangeAttribute as CFString, - value - ) + try AXHelper().injectUpdatedCodeWithAccessibilityAPI( + result, + focusElement: focusElement, + onError: { + PresentInWindowSuggestionPresenter() + .presentErrorMessage("Fail to set editor content.") } - } - - // recover scroll position - - if let oldScrollPosition, - let scrollBar = focusElement.parent?.verticalScrollBar - { - AXUIElementSetAttributeValue( - scrollBar, - kAXValueAttribute as CFString, - oldScrollPosition as CFTypeRef - ) - } + ) } func getFileContent(sourceEditor: AXUIElement?) async - -> ( - content: String, - lines: [String], - selections: [CursorRange], - cursorPosition: CursorPosition, - cursorOffset: Int - )? + -> ( + content: String, + lines: [String], + selections: [CursorRange], + cursorPosition: CursorPosition, + cursorOffset: Int + )? { guard let xcode = ActiveApplicationMonitor.shared.activeXcode - ?? ActiveApplicationMonitor.shared.latestXcode else { return nil } + ?? ActiveApplicationMonitor.shared.latestXcode else { return nil } let application = AXUIElementCreateApplication(xcode.processIdentifier) guard let focusElement = sourceEditor ?? application.focusedElement, focusElement.description == "Source Editor" @@ -407,7 +591,7 @@ extension PseudoCommandHandler { guard let fileURL = await getFileURL(), let (_, filespace) = try? await Service.shared.workspacePool - .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return nil } return filespace } diff --git a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift index 3d612e82..7aa5d20a 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift @@ -11,8 +11,12 @@ protocol SuggestionCommandHandler { @ServiceActor func rejectSuggestion(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor + func rejectNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor + func acceptNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor func presentRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index d97618eb..4e0b2a74 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -57,8 +57,21 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { if filespace.presentingSuggestion != nil { presenter.presentSuggestion(fileURL: fileURL) workspace.notifySuggestionShown(fileFileAt: fileURL) + presenter.discardNESSuggestion(fileURL: fileURL) } else { presenter.discardSuggestion(fileURL: fileURL) + try Task.checkCancellation() + + // When no code completion generated, fallback to NES + try await workspace.generateNESSuggestions(forFileAt: fileURL, editor: editor) + + try Task.checkCancellation() + + if filespace.presentingNESSuggestion != nil { + presenter.presentNESSuggestion(fileURL: fileURL) + } else { + presenter.discardNESSuggestion(fileURL: fileURL) + } } } @@ -137,6 +150,28 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { workspace.rejectSuggestion(forFileAt: fileURL, editor: editor) presenter.discardSuggestion(fileURL: fileURL) } + + func rejectNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? { + Task { + do { + try await _rejectNESSuggestion(editor: editor) + } catch { + presenter.presentError(error) + } + } + return nil + } + + @WorkspaceActor + private func _rejectNESSuggestion(editor: EditorContent) async throws { + guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + else { return } + + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + workspace.rejectNESSuggestion(forFileAt: fileURL, editor: editor) + presenter.discardNESSuggestion(fileURL: fileURL) + } @WorkspaceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? { @@ -174,6 +209,41 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + + @WorkspaceActor + func acceptNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? { + guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + else { return nil } + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + + let injector = SuggestionInjector() + var lines = editor.lines + var cursorPosition = editor.cursorPosition + var extraInfo = SuggestionInjector.ExtraInfo() + + if let acceptedSuggestion = workspace.acceptNESSuggestion( + forFileAt: fileURL, editor: editor + ) { + injector.acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursorPosition, + completion: acceptedSuggestion, + extraInfo: &extraInfo, + isNES: true + ) + + presenter.discardNESSuggestion(fileURL: fileURL) + + return .init( + content: String(lines.joined(separator: "")), + newSelection: .cursor(cursorPosition), + modifications: extraInfo.modifications + ) + } + + return nil + } func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? { guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL @@ -431,43 +501,45 @@ extension WindowBaseCommandHandler { }.result } + // not used feature + // commit it to avoid init error for ChatService func executeSingleRoundDialog( systemPrompt: String?, overwriteSystemPrompt: Bool, prompt: String, receiveReplyInNotification: Bool ) async throws { - guard !prompt.isEmpty else { return } - let service = ChatService.service() - - let result = try await service.handleSingleRoundDialogCommand( - systemPrompt: systemPrompt, - overwriteSystemPrompt: overwriteSystemPrompt, - prompt: prompt - ) - - guard receiveReplyInNotification else { return } - - let granted = try await UNUserNotificationCenter.current() - .requestAuthorization(options: [.alert]) - - if granted { - let content = UNMutableNotificationContent() - content.title = "Reply" - content.body = result - let request = UNNotificationRequest( - identifier: "reply", - content: content, - trigger: nil - ) - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - presenter.presentError(error) - } - } else { - presenter.presentErrorMessage("Notification permission is not granted.") - } +// guard !prompt.isEmpty else { return } +// let service = ChatService.service() +// +// let result = try await service.handleSingleRoundDialogCommand( +// systemPrompt: systemPrompt, +// overwriteSystemPrompt: overwriteSystemPrompt, +// prompt: prompt +// ) +// +// guard receiveReplyInNotification else { return } +// +// let granted = try await UNUserNotificationCenter.current() +// .requestAuthorization(options: [.alert]) +// +// if granted { +// let content = UNMutableNotificationContent() +// content.title = "Reply" +// content.body = result +// let request = UNNotificationRequest( +// identifier: "reply", +// content: content, +// trigger: nil +// ) +// do { +// try await UNUserNotificationCenter.current().add(request) +// } catch { +// presenter.presentError(error) +// } +// } else { +// presenter.presentErrorMessage("Notification permission is not granted.") +// } } } diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift index 29780d48..80f60141 100644 --- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -11,6 +11,13 @@ struct PresentInWindowSuggestionPresenter { controller.suggestCode() } } + + func presentNESSuggestion(fileURL: URL) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.suggestNESCode() + } + } func expandSuggestion(fileURL: URL) { Task { @MainActor in @@ -25,6 +32,13 @@ struct PresentInWindowSuggestionPresenter { controller.discardSuggestion() } } + + func discardNESSuggestion(fileURL: URL) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.discardNESSuggestion() + } + } func markAsProcessing(_ isProcessing: Bool) { Task { @MainActor in @@ -49,6 +63,20 @@ struct PresentInWindowSuggestionPresenter { } } + func presentWarningMessage(_ message: String, url: String?) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.presentWarning(message: message, url: url) + } + } + + func dismissWarning() { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.dismissWarning() + } + } + func closeChatRoom(fileURL: URL) { Task { @MainActor in let controller = Service.shared.guiController.widgetController diff --git a/Core/Sources/Service/TelemetryLogger.swift b/Core/Sources/Service/TelemetryLogger.swift new file mode 100644 index 00000000..1bdf3181 --- /dev/null +++ b/Core/Sources/Service/TelemetryLogger.swift @@ -0,0 +1,42 @@ +import Logger +import Foundation +import TelemetryService + +public class TelemetryLogger: TelemetryLoggerProvider { + public func sendError( + error: any Error, + category: String, + file: StaticString, + line: UInt, + function: StaticString, + callStackSymbols: [String] + ) { + TelemetryService.shared.sendError( + error, + category: category, + file: file, + line: line, + function: function, + from: callStackSymbols + ) + } + + public func sendError( + message: String, + category: String, + file: StaticString, + line: UInt, + function: StaticString, + callStackSymbols: [String] + ) { + TelemetryService.shared + .sendError( + message, + category: category, + file: file, + line: line, + function: function, + from: callStackSymbols + ) + } +} diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index a2ef2ca7..b64e841c 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -6,6 +6,11 @@ import Logger import Preferences import Status import XPCShared +import HostAppActivator +import XcodeInspector +import GitHubCopilotViewModel +import Workspace +import ConversationServiceProvider public class XPCService: NSObject, XPCServiceProtocol { // MARK: - Service @@ -16,12 +21,33 @@ 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 { reply(await Status.shared.getAXStatus()) } } + + public func getXPCServiceExtensionPermission( + withReply reply: @escaping (ExtensionPermissionStatus) -> Void + ) { + Task { + reply(await Status.shared.getExtensionStatus()) + } + } // MARK: - Suggestion @@ -95,6 +121,15 @@ public class XPCService: NSObject, XPCServiceProtocol { try await handler.rejectSuggestion(editor: editor) } } + + public func getNESSuggestionRejectedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.rejectNESSuggestion(editor: editor) + } + } public func getSuggestionAcceptedCode( editorContent: Data, @@ -104,6 +139,15 @@ public class XPCService: NSObject, XPCServiceProtocol { try await handler.acceptSuggestion(editor: editor) } } + + public func getNESSuggestionAcceptedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.acceptNESSuggestion(editor: editor) + } + } public func getPromptToCodeAcceptedCode( editorContent: Data, @@ -144,12 +188,23 @@ public class XPCService: NSObject, XPCServiceProtocol { } public func openChat( - editorContent: Data, - withReply reply: @escaping (Data?, Error?) -> Void + withReply reply: @escaping (Error?) -> Void ) { - let handler = PseudoCommandHandler() - handler.openChat(forceDetach: false) - reply(nil, nil) + Task { + do { + // Check if app is already running + if let _ = getRunningHostApp() { + // App is already running, use the chat service + let handler = PseudoCommandHandler() + handler.openChat(forceDetach: true) + } else { + try launchHostAppDefault() + } + reply(nil) + } catch { + reply(error) + } + } } public func promptToCode( @@ -193,6 +248,29 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(nil) } } + + public func toggleRealtimeNES(withReply reply: @escaping (Error?) -> Void) { + guard AXIsProcessTrusted() else { + reply(NoAccessToAccessibilityAPIError()) + return + } + Task { @ServiceActor in + await Service.shared.realtimeSuggestionController.cancelInFlightTasks() + let on = !UserDefaults.shared.value(for: \.realtimeNESToggle) + UserDefaults.shared.set(on, for: \.realtimeNESToggle) + Task { @MainActor in + Service.shared.guiController.store + .send(.suggestionWidget(.toastPanel(.toast(.toast( + "Next Edit Suggestions (NES) is turned \(on ? "on" : "off")", + .info, + nil + ))))) + Service.shared.guiController.store + .send(.suggestionWidget(.panel(.onRealtimeNESToggleChanged(on)))) + } + reply(nil) + } + } public func postNotification(name: String, withReply reply: @escaping () -> Void) { reply() @@ -219,6 +297,472 @@ 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, + chatAgentMode: Data?, + customChatModeId: Data?, + workspaceFolders: Data? + ) { + // Decode the data + let decoder = JSONDecoder() + var collections: [UpdateMCPToolsStatusServerCollection] = [] + var folders: [WorkspaceFolder]? = nil + var mode: ChatMode? = nil + var modeId: String? = nil + do { + collections = try decoder.decode([UpdateMCPToolsStatusServerCollection].self, from: tools) + if let workspaceFolders = workspaceFolders { + folders = try? decoder.decode([WorkspaceFolder].self, from: workspaceFolders) + } + if let chatAgentMode = chatAgentMode { + mode = try? decoder.decode(ChatMode.self, from: chatAgentMode) + } + if let customChatModeId = customChatModeId { + modeId = try? decoder.decode(String.self, from: customChatModeId) + } + if collections.isEmpty { + return + } + } catch { + Logger.service.error("Failed to decode MCP server collections or workspace folders: \(error)") + return + } + + Task { @MainActor in + // Only use auth service when ALL three parameters are provided. + if mode != nil, modeId != nil, folders != nil { + do { + if let uri = folders!.first?.uri, let projectRootURL = URL(string: uri) { + if let service = GitHubCopilotService.getProjectGithubCopilotService( + for: projectRootURL + ) { + let params = UpdateMCPToolsStatusParams( + chatModeKind: mode, + customChatModeId: modeId, + workspaceFolders: folders, + servers: collections + ) + try await service.updateMCPToolsStatus(params: params) + } + } + } catch { + Logger.service.error("Failed to update MCP tool status via auth service: \(error)") + } + } else { + // Fallback to legacy/global update when context not fully provided. + await GitHubCopilotService.updateAllClsMCP(collections: collections) + } + } + } + + // MARK: - MCP Registry + + public func listMCPRegistryServers(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) { + let decoder = JSONDecoder() + var listMCPRegistryServersParams: MCPRegistryListServersParams? + do { + listMCPRegistryServersParams = try decoder.decode(MCPRegistryListServersParams.self, from: params) + } catch { + Logger.service.error("Failed to decode MCP Registry list servers parameters: \(error)") + return + } + + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.listMCPRegistryServers(listMCPRegistryServersParams!) + let data = try? JSONEncoder().encode(response) + reply(data, nil) + } catch { + Logger.service.error("Failed to list MCP Registry servers: \(error)") + reply(nil, NSError.from(error)) + } + } + } + + public func getMCPRegistryServer(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) { + let decoder = JSONDecoder() + var getMCPRegistryServerParams: MCPRegistryGetServerParams? + do { + getMCPRegistryServerParams = try decoder.decode(MCPRegistryGetServerParams.self, from: params) + } catch { + Logger.service.error("Failed to decode MCP Registry get server parameters: \(error)") + return + } + + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.getMCPRegistryServer(getMCPRegistryServerParams!) + let data = try? JSONEncoder().encode(response) + reply(data, nil) + } catch { + Logger.service.error("Failed to get MCP Registry servers: \(error)") + reply(nil, NSError.from(error)) + } + } + } + + public func getMCPRegistryAllowlist(withReply reply: @escaping (Data?, Error?) -> Void) { + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.getMCPRegistryAllowlist() + let data = try? JSONEncoder().encode(response) + reply(data, nil) + } catch { + Logger.service.error("Failed to get MCP Registry allowlist: \(error)") + reply(nil, NSError.from(error)) + } + } + } + + // MARK: - Language Model Tools + public func getAvailableLanguageModelTools(withReply reply: @escaping (Data?) -> Void) { + let availableLanguageModelTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() + if let availableLanguageModelTools = availableLanguageModelTools { + // Encode and send the data + let data = try? JSONEncoder().encode(availableLanguageModelTools) + reply(data) + } else { + reply(nil) + } + } + + public func refreshClientTools(withReply reply: @escaping (Data?) -> Void) { + Task { @MainActor in + await GitHubCopilotService.refreshClientTools() + let availableLanguageModelTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() + if let availableLanguageModelTools = availableLanguageModelTools { + let data = try? JSONEncoder().encode(availableLanguageModelTools) + reply(data) + } else { + reply(nil) + } + } + } + + public func updateToolsStatus( + tools: Data, + chatAgentMode: Data?, + customChatModeId: Data?, + workspaceFolders: Data?, + withReply reply: @escaping (Data?) -> Void + ) { + // Decode the data + let decoder = JSONDecoder() + var toolStatusUpdates: [ToolStatusUpdate] = [] + var folders: [WorkspaceFolder]? = nil + var mode: ChatMode? = nil + var modeId: String? = nil + do { + toolStatusUpdates = try decoder.decode([ToolStatusUpdate].self, from: tools) + if let workspaceFolders = workspaceFolders { + folders = try? decoder.decode([WorkspaceFolder].self, from: workspaceFolders) + } + if let chatAgentMode = chatAgentMode { + mode = try? decoder.decode(ChatMode.self, from: chatAgentMode) + } + if let customChatModeId = customChatModeId { + modeId = try? decoder.decode(String.self, from: customChatModeId) + } + if toolStatusUpdates.isEmpty { + let emptyData = try JSONEncoder().encode([LanguageModelTool]()) + reply(emptyData) + return + } + } catch { + Logger.service.error("Failed to decode built-in tools or workspace folders: \(error)") + reply(nil) + return + } + + Task { @MainActor in + var updatedTools: [LanguageModelTool] = [] + if mode != nil, modeId != nil, folders != nil { + // Use auth service path when all three context parameters are present. + do { + if let uri = folders!.first?.uri, let projectRootURL = URL(string: uri) { + if let service = GitHubCopilotService.getProjectGithubCopilotService( + for: projectRootURL + ) { + updatedTools = try await service.updateToolsStatus( + params: .init( + chatmodeKind: mode, + customChatModeId: modeId, + workspaceFolders: folders, + tools: toolStatusUpdates + ) + ) + } + } + } catch { + Logger.service.error("Failed contextual tools update: \(error)") + updatedTools = await GitHubCopilotService.updateAllCLSTools(tools: toolStatusUpdates) + } + } else { + // Fallback without contextual parameters. + updatedTools = await GitHubCopilotService.updateAllCLSTools(tools: toolStatusUpdates) + } + // Encode and return the updated tools + do { + let data = try JSONEncoder().encode(updatedTools) + reply(data) + } catch { + Logger.service.error("Failed to encode updated tools: \(error)") + reply(nil) + } + } + } + + // MARK: - FeatureFlags + public func getCopilotFeatureFlags( + withReply reply: @escaping (Data?) -> Void + ) { + let featureFlags = FeatureFlagNotifierImpl.shared.featureFlags + let data = try? JSONEncoder().encode(featureFlags) + reply(data) + } + + public func getCopilotPolicy( + withReply reply: @escaping (Data?) -> Void + ) { + let copilotPolicy = CopilotPolicyNotifierImpl.shared.copilotPolicy + let data = try? JSONEncoder().encode(copilotPolicy) + reply(data) + } + + public func getModes(workspaceFolders: Data?, withReply reply: @escaping (Data?, Error?) -> Void) { + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + var folders: [WorkspaceFolder]? = nil + if let workspaceFolders = workspaceFolders { + folders = try JSONDecoder().decode([WorkspaceFolder].self, from: workspaceFolders) + } + + let modes = try await service.modes(workspaceFolders: folders) + let data = try JSONEncoder().encode(modes) + reply(data, nil) + } catch { + Logger.service.error("Failed to get modes: \(error.localizedDescription)") + reply(nil, NSError.from(error)) + } + } + } + + // MARK: - Auth + public func signOutAllGitHubCopilotService() { + Task { @MainActor in + 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) + } + } + + public func updateCopilotModels(withReply reply: @escaping (Data?, Error?) -> Void) { + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let models = try await service.models() + CopilotModelManager.updateLLMs(models) + let data = try JSONEncoder().encode(models) + reply(data, nil) + } catch { + Logger.service.error("Failed to get models: \(error.localizedDescription)") + reply(nil, NSError.from(error)) + } + } + } + + // MARK: - BYOK + public func saveBYOKApiKey(_ params: Data, withReply reply: @escaping (Data?) -> Void) { + let decoder = JSONDecoder() + var saveApiKeyParams: BYOKSaveApiKeyParams? = nil + do { + saveApiKeyParams = try decoder.decode(BYOKSaveApiKeyParams.self, from: params) + if saveApiKeyParams == nil { + return + } + } catch { + Logger.service.error("Failed to save BYOK API Key: \(error)") + return + } + + Task { @MainActor in + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.saveBYOKApiKey(saveApiKeyParams!) + let data = try? JSONEncoder().encode(response) + reply(data) + } + } + + public func listBYOKApiKeys(_ params: Data, withReply reply: @escaping (Data?) -> Void) { + let decoder = JSONDecoder() + var listApiKeysParams: BYOKListApiKeysParams? = nil + do { + listApiKeysParams = try decoder.decode(BYOKListApiKeysParams.self, from: params) + if listApiKeysParams == nil { + return + } + } catch { + Logger.service.error("Failed to list BYOK API keys: \(error)") + return + } + + Task { @MainActor in + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.listBYOKApiKeys(listApiKeysParams!) + if !response.apiKeys.isEmpty { + BYOKModelManager.updateApiKeys(apiKeys: response.apiKeys) + } + let data = try? JSONEncoder().encode(response) + reply(data) + } + } + + public func deleteBYOKApiKey(_ params: Data, withReply reply: @escaping (Data?) -> Void) { + let decoder = JSONDecoder() + var deleteApiKeyParams: BYOKDeleteApiKeyParams? = nil + do { + deleteApiKeyParams = try decoder.decode(BYOKDeleteApiKeyParams.self, from: params) + if deleteApiKeyParams == nil { + return + } + } catch { + Logger.service.error("Failed to delete BYOK API Key: \(error)") + return + } + + Task { @MainActor in + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.deleteBYOKApiKey(deleteApiKeyParams!) + let data = try? JSONEncoder().encode(response) + reply(data) + } + } + + public func saveBYOKModel(_ params: Data, withReply reply: @escaping (Data?) -> Void) { + let decoder = JSONDecoder() + var saveModelParams: BYOKSaveModelParams? = nil + do { + saveModelParams = try decoder.decode(BYOKSaveModelParams.self, from: params) + if saveModelParams == nil { + return + } + } catch { + Logger.service.error("Failed to save BYOK model: \(error)") + return + } + + Task { @MainActor in + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.saveBYOKModel(saveModelParams!) + let data = try? JSONEncoder().encode(response) + reply(data) + } + } + + public func listBYOKModels(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) { + let decoder = JSONDecoder() + var listModelsParams: BYOKListModelsParams? = nil + do { + listModelsParams = try decoder.decode(BYOKListModelsParams.self, from: params) + if listModelsParams == nil { + return + } + } catch { + Logger.service.error("Failed to list BYOK models: \(error)") + return + } + + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.listBYOKModels(listModelsParams!) + if !response.models.isEmpty && listModelsParams?.enableFetchUrl == true { + for model in response.models { + _ = try await service.saveBYOKModel(model) + } + } + let fullModelResponse = try await service.listBYOKModels(BYOKListModelsParams()) + BYOKModelManager.updateBYOKModels(BYOKModels: fullModelResponse.models) + let data = try? JSONEncoder().encode(response) + reply(data, nil) + } catch { + Logger.service.error("Failed to list BYOK models: \(error)") + reply(nil, NSError.from(error)) + } + } + } + + public func deleteBYOKModel(_ params: Data, withReply reply: @escaping (Data?) -> Void) { + let decoder = JSONDecoder() + var deleteModelParams: BYOKDeleteModelParams? = nil + do { + deleteModelParams = try decoder.decode(BYOKDeleteModelParams.self, from: params) + if deleteModelParams == nil { + return + } + } catch { + Logger.service.error("Failed to delete BYOK model: \(error)") + return + } + + Task { @MainActor in + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.deleteBYOKModel(deleteModelParams!) + let data = try? JSONEncoder().encode(response) + reply(data) + } + } } struct NoAccessToAccessibilityAPIError: Error, LocalizedError { @@ -228,4 +772,3 @@ struct NoAccessToAccessibilityAPIError: Error, LocalizedError { init() {} } - diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Core/Sources/SuggestionInjector/SuggestionInjector.swift index df78acf5..c2edff6c 100644 --- a/Core/Sources/SuggestionInjector/SuggestionInjector.swift +++ b/Core/Sources/SuggestionInjector/SuggestionInjector.swift @@ -20,7 +20,8 @@ public struct SuggestionInjector { cursorPosition: inout CursorPosition, completion: CodeSuggestion, extraInfo: inout ExtraInfo, - suggestionLineLimit: Int? = nil + suggestionLineLimit: Int? = nil, + isNES: Bool = false ) { extraInfo.didChangeContent = true extraInfo.didChangeCursorPosition = true @@ -77,6 +78,35 @@ public struct SuggestionInjector { at: toBeInserted[0].startIndex ) } + + // appending suffix text not in range if needed. + if isNES, + let lastRemovedLine, + !lastRemovedLine.isEmptyOrNewLine, + end.character >= 0, + end.character < lastRemovedLine.count, + !toBeInserted.isEmpty + { + let suffixStartIndex = lastRemovedLine.utf16.index( + lastRemovedLine.utf16.startIndex, + offsetBy: end.character, + limitedBy: lastRemovedLine.utf16.endIndex + ) ?? lastRemovedLine.utf16.endIndex + var suffix = String(lastRemovedLine[suffixStartIndex...]) + if suffix.last?.isNewline ?? false { + suffix.removeLast(1) + } + let lastIndex = toBeInserted.endIndex - 1 + var lastLine = toBeInserted[lastIndex] + if lastLine.last?.isNewline ?? false { + lastLine.removeLast(1) + lastLine.append(contentsOf: suffix) + lastLine.append(lineEnding) + } else { + lastLine.append(contentsOf: suffix) + } + toBeInserted[lastIndex] = lastLine + } let recoveredSuffixLength = recoverSuffixIfNeeded( endOfReplacedContent: end, diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index 2802d787..1766001c 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -64,6 +64,28 @@ public extension SuggestionService { return try await getSuggestion(request, workspaceInfo) } + + func getNESSuggestions( + _ request: SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo, + ) async throws -> [SuggestionBasic.CodeSuggestion] { + var getNESSuggestion = suggestionProvider.getNESSuggestions(_:workspaceInfo:) + let configuration = await configuration + + for middleware in middlewares.reversed() { + getNESSuggestion = { [getNESSuggestion] request, workspaceInfo in + try await middleware.getNESSuggestion( + request, + configuration: configuration, + next: { [getNESSuggestion] request in + try await getNESSuggestion(request, workspaceInfo) + } + ) + } + } + + return try await getNESSuggestion(request, workspaceInfo) + } func notifyAccepted( _ suggestion: SuggestionBasic.CodeSuggestion, diff --git a/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift new file mode 100644 index 00000000..fd423990 --- /dev/null +++ b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift @@ -0,0 +1,1186 @@ +import AppKit +import ChatService +import ComposableArchitecture +import ConversationServiceProvider +import ConversationTab +import GitHubCopilotService +import LanguageServerProtocol +import Logger +import SharedUIComponents +import SuggestionBasic +import SwiftUI +import XcodeInspector + +struct SelectedAgentModel: Equatable { + let displayName: String + let modelName: String + let source: ModelSource + + enum ModelSource: Equatable { + case copilot + case byok(provider: String) + } +} + +struct AgentConfigurationWidgetView: View { + let store: StoreOf + + @State private var showPopover = false + @State private var isHovered = false + @State private var selectedToolStates: [String: [String: Bool]] = [:] + @State private var selectedModel: SelectedAgentModel? = nil + @State private var searchText = "" + @State private var isSearchFieldExpanded = false + @State private var generateHandoffExample: Bool = true + @Environment(\.colorScheme) var colorScheme + + var body: some View { + WithPerceptionTracking { + if store.isPanelDisplayed { + VStack { + buildAgentConfigurationButton() + .popover(isPresented: $showPopover) { + buildConfigView(currentMode: store.currentMode).padding(.horizontal, 4) + } + } + .animation(.easeInOut(duration: 0.2), value: store.isPanelDisplayed) + .onChange(of: showPopover) { newValue in + if newValue { + // Load state from agent file when popover is opened + loadToolStatesFromAgentFile(currentMode: store.currentMode) + // Refresh client tools to get any late-arriving server tools + Task { + await GitHubCopilotService.refreshClientTools() + } + } + } + } + } + } + + @ViewBuilder + private func buildAgentConfigurationButton() -> some View { + let fontSize = store.lineHeight * 0.7 + let lineHeight = store.lineHeight + + ZStack { + Button(action: { showPopover.toggle() }) { + HStack(spacing: 4) { + Image(systemName: "square.and.pencil") + .resizable() + .scaledToFit() + .frame(width: fontSize, height: fontSize) + Text("Customize Agent") + .font(.system(size: fontSize)) + .fixedSize() + } + .frame(height: lineHeight) + .foregroundColor(isHovered ? Color("ItemSelectedColor") : .secondary) + } + .buttonStyle(.plain) + .contentShape(Capsule()) + .help("Configure tools and model for custom agent") + .onHover { isHovered = $0 } + } + } + + @ViewBuilder + private func buildConfigView(currentMode: ConversationMode?) -> some View { + if let currentMode = currentMode { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text("Configure Model") + .font(.system(size: 15, weight: .bold)) + + Text("The AI model to use when running the prompt. If not specified, the currently selected model in model picker is used.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .padding(.bottom, 8) + + AgentModelPickerSection( + selectedModel: $selectedModel + ) + + Divider() + + if currentMode.handOffs?.isEmpty ?? true { + Text("Configure Handoffs") + .font(.system(size: 15, weight: .bold)) + + Text("Suggested next actions or prompts to transition between custom agents. Handoff buttons appear as interactive suggestions after a chat response completes.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + + Toggle(isOn: $generateHandoffExample) { + Text("Generate Handoff Example") + .font(.system(size: 11, weight: .regular)) + } + .toggleStyle(.checkbox) + .help("Adds a starter handoff example to the agent file YAML frontmatter.") + + Divider() + } + + // Title with Search + HStack { + Text("Configure Tools") + .font(.system(size: 15, weight: .bold)) + + Spacer() + + CollapsibleSearchField( + searchText: $searchText, + isExpanded: $isSearchFieldExpanded, + placeholderString: "Search tools..." + ) + } + + Text("A list of built-in tools and MCP tools that are available for this agent. If a given tool is not available when running the agent, it is ignored.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .padding(.bottom, 8) + + // MCP Tools Section + AgentToolsSection( + title: "MCP Tools", + currentMode: currentMode, + selectedToolStates: $selectedToolStates, + searchText: searchText + ) + + // Built-In Tools Section + AgentBuiltInToolsSection( + title: "Built-In Tools", + currentMode: currentMode, + selectedToolStates: $selectedToolStates, + searchText: searchText + ) + } + .padding(12) + } + .frame(width: 500, height: 600) + + Divider() + + // Buttons + HStack(spacing: 12) { + Button(action: { showPopover = false }) { + Text("Cancel") + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Button(action: { + updateAgentTools(selectedToolStates: selectedToolStates, currentMode: currentMode) + applyAgentFileChanges( + selectedModel: selectedModel, + generateHandoffExample: generateHandoffExample, + currentMode: currentMode + ) + showPopover = false + }) { + Text("Apply") + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .keyboardShortcut(.defaultAction) + } + .padding(12) + } + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } else { + // Should never be shown since widget only displays when mode exists + VStack { + Text("No agent mode available") + .foregroundColor(.secondary) + } + .frame(width: 500, height: 600) + } + } + + // MARK: - Helper functions + + // MARK: - Agent File Utilities + + private struct AgentFileAccess { + let documentURL: URL + let content: String + } + + private func validateAndReadAgentFile() -> AgentFileAccess? { + guard let documentURL = store.withState({ $0.focusedEditor?.realtimeDocumentURL }) else { + Logger.extension.error("Could not access agent file - documentURL is nil") + return nil + } + guard documentURL.pathExtension == "md" else { + Logger.extension.error("Could not access agent file - invalid extension") + return nil + } + guard documentURL.lastPathComponent.hasSuffix(".agent.md") else { + Logger.extension.error("Could not access agent file - filename does not end with .agent.md") + return nil + } + guard let content = try? String(contentsOf: documentURL) else { + Logger.extension.error("Could not access agent file - unable to read file") + return nil + } + return AgentFileAccess(documentURL: documentURL, content: content) + } + + private struct YAMLFrontmatterInfo { + var lines: [String] + let frontmatterEndIndex: Int? + let modelLineIndex: Int? + let toolsLineIndex: Int? + let handoffsLineIndex: Int? + } + + private func parseYAMLFrontmatter(content: String) -> YAMLFrontmatterInfo { + var lines = content.components(separatedBy: .newlines) + var inFrontmatter = false + var frontmatterEndIndex: Int? + var modelLineIndex: Int? + var toolsLineIndex: Int? + var handoffsLineIndex: Int? + + for (idx, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed == "---" { + if !inFrontmatter { + inFrontmatter = true + } else { + inFrontmatter = false + frontmatterEndIndex = idx + break + } + } else if inFrontmatter { + if trimmed.hasPrefix("model:") { + modelLineIndex = idx + } else if trimmed.hasPrefix("tools:") { + toolsLineIndex = idx + } else if trimmed.hasPrefix("handoffs:") || trimmed.hasPrefix("handOffs:") { + handoffsLineIndex = idx + } + } + } + + return YAMLFrontmatterInfo( + lines: lines, + frontmatterEndIndex: frontmatterEndIndex, + modelLineIndex: modelLineIndex, + toolsLineIndex: toolsLineIndex, + handoffsLineIndex: handoffsLineIndex + ) + } + + private func writeToAgentFile(url: URL, content: String, successMessage: String) { + do { + try content.write(to: url, atomically: true, encoding: .utf8) + Logger.extension.info(successMessage) + } catch { + Logger.extension.error("Error writing agent file: \(error)") + } + } + + private func formatModelLine(_ selectedModel: SelectedAgentModel?) -> String? { + guard let model = selectedModel else { return nil } + let sourceLabel: String + switch model.source { + case .copilot: + sourceLabel = "copilot" + case let .byok(provider): + sourceLabel = provider + } + return "model: '\(model.displayName) (\(sourceLabel))'" + } + + private func loadMCPToolStates(enabledTools: Set) { + guard let mcpServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() else { return } + for server in mcpServerTools { + for tool in server.tools { + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: server.name, + toolName: tool.name + ) + selectedToolStates["mcp"]?[configurationKey] = enabledTools.contains(configurationKey) + } + } + } + + private func loadBuiltInToolStates(enabledTools: Set) { + guard let builtInTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() else { return } + for tool in builtInTools { + selectedToolStates["builtin"]?[tool.name] = enabledTools.contains(tool.name) + } + } + + private func collectMCPToolUpdates(selectedToolStates: [String: [String: Bool]]) -> [UpdateMCPToolsStatusServerCollection] { + guard let mcpStates = selectedToolStates["mcp"], + let mcpServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() else { + return [] + } + + return mcpServerTools.map { server in + let toolUpdates = server.tools.map { tool in + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: server.name, + toolName: tool.name + ) + let isEnabled = mcpStates[configurationKey] ?? false + return UpdatedMCPToolsStatus( + name: tool.name, + status: isEnabled ? .enabled : .disabled + ) + } + return UpdateMCPToolsStatusServerCollection( + name: server.name, + tools: toolUpdates + ) + } + } + + private func collectBuiltInToolUpdates(selectedToolStates: [String: [String: Bool]]) -> [ToolStatusUpdate] { + guard let builtInStates = selectedToolStates["builtin"], + let builtInTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() else { + return [] + } + + return builtInTools.map { tool in + let isEnabled = builtInStates[tool.name] ?? false + return ToolStatusUpdate( + name: tool.name, + status: isEnabled ? .enabled : .disabled + ) + } + } + + private func updateMCPToolsViaAPI( + service: GitHubCopilotService, + mcpCollections: [UpdateMCPToolsStatusServerCollection], + chatModeKind: ChatMode?, + customChatModeId: String?, + workspaceFolders: [WorkspaceFolder] + ) async { + guard !mcpCollections.isEmpty else { return } + do { + let _ = try await service.updateMCPToolsStatus( + params: UpdateMCPToolsStatusParams( + chatModeKind: chatModeKind, + customChatModeId: customChatModeId, + workspaceFolders: workspaceFolders, + servers: mcpCollections + ) + ) + Logger.extension.info("MCP tools updated via API") + + // Notify Settings app about custom agent tool changes + DistributedNotificationCenter.default().postNotificationName( + .gitHubCopilotCustomAgentToolsDidChange, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } catch { + Logger.extension.error("Error updating MCP tools via API: \(error)") + } + } + + private func updateBuiltInToolsViaAPI( + service: GitHubCopilotService, + builtInToolUpdates: [ToolStatusUpdate], + chatModeKind: ChatMode?, + customChatModeId: String?, + workspaceFolders: [WorkspaceFolder] + ) async { + guard !builtInToolUpdates.isEmpty else { return } + do { + let _ = try await service.updateToolsStatus( + params: UpdateToolsStatusParams( + chatmodeKind: chatModeKind, + customChatModeId: customChatModeId, + workspaceFolders: workspaceFolders, + tools: builtInToolUpdates + ) + ) + Logger.extension.info("Built-in tools updated via API") + + // Notify Settings app about custom agent tool changes + DistributedNotificationCenter.default().postNotificationName( + .gitHubCopilotCustomAgentToolsDidChange, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } catch { + Logger.extension.error("Error updating built-in tools via API: \(error)") + } + } + + private func parseModelFromMode(_ mode: ConversationMode?) -> SelectedAgentModel? { + guard let mode = mode, + let modelString = mode.model else { + return nil + } + + // Parse format: "displayName (copilot)" or "displayName (providerName)" + if let openParen = modelString.lastIndex(of: "("), + let closeParen = modelString.lastIndex(of: ")") { + let displayName = String(modelString[.. Int? { + let modelLine = formatModelLine(selectedModel) + + if let modelLine = modelLine { + if let modelIdx = yamlInfo.modelLineIndex { + yamlInfo.lines[modelIdx] = modelLine + return modelIdx + } else if let endIdx = yamlInfo.frontmatterEndIndex { + yamlInfo.lines.insert(modelLine, at: endIdx) + return endIdx + } + } else if let modelIdx = yamlInfo.modelLineIndex { + yamlInfo.lines.remove(at: modelIdx) + return nil + } + return yamlInfo.modelLineIndex + } + + private func applyHandoffsUpdate(to yamlInfo: inout YAMLFrontmatterInfo, afterModelIndex modelIndex: Int?) { + guard yamlInfo.handoffsLineIndex == nil else { return } + + let snippet = [ + "handoffs:", + " - label: Start Implementation", + " agent: implementation", + " prompt: Now implement the plan outlined above.", + " send: true", + ] + + if let mIdx = modelIndex { + yamlInfo.lines.insert(contentsOf: snippet, at: mIdx + 1) + } else if let endIdx = yamlInfo.frontmatterEndIndex { + yamlInfo.lines.insert(contentsOf: snippet, at: endIdx) + } + } + + // MARK: - MCP Tools Section + + private struct AgentToolsSection: View { + let title: String + let currentMode: ConversationMode + @Binding var selectedToolStates: [String: [String: Bool]] + let searchText: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + + let mcpServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() ?? [] + + if mcpServerTools.isEmpty { + Text("No MCP tools available.") + .foregroundColor(.secondary) + .font(.system(size: 13)) + .padding(.vertical, 8) + } else { + ForEach(mcpServerTools, id: \.name) { server in + AgentMCPServerSection( + serverTools: server, + currentMode: currentMode, + selectedToolStates: $selectedToolStates, + searchText: searchText + ) + } + } + } + } + } + + // MARK: - MCP Server Section + + private struct AgentMCPServerSection: View { + let serverTools: MCPServerToolsCollection + let currentMode: ConversationMode + @Binding var selectedToolStates: [String: [String: Bool]] + let searchText: String + + @State private var isExpanded: Bool = false + @State private var checkboxState: CheckboxMixedState = .off + + private func matchesSearch(_ text: String, _ description: String?) -> Bool { + guard !searchText.isEmpty else { return true } + let lowercasedSearch = searchText.lowercased() + return text.lowercased().contains(lowercasedSearch) || + (description?.lowercased().contains(lowercasedSearch) ?? false) + } + + private var serverNameMatches: Bool { + matchesSearch(serverTools.name, nil) + } + + private var hasMatchingTools: Bool { + guard !searchText.isEmpty else { return false } + if serverNameMatches { return true } + return serverTools.tools.contains { tool in + matchesSearch(tool.name, tool.description) + } + } + + private var filteredTools: [MCPTool] { + guard !searchText.isEmpty else { return serverTools.tools } + if serverNameMatches { return serverTools.tools } + return serverTools.tools.filter { tool in + matchesSearch(tool.name, tool.description) + } + } + + var body: some View { + // Don't show this server if search is active and there are no matches + if searchText.isEmpty || hasMatchingTools { + VStack(alignment: .leading, spacing: 0) { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 0) { + Divider() + .padding(.vertical, 4) + + ForEach(filteredTools, id: \.name) { tool in + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: serverTools.name, + toolName: tool.name + ) + let isSelected = selectedToolStates["mcp"]?[configurationKey] ?? AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: configurationKey, + currentStatus: .enabled, + selectedMode: currentMode + ) + AgentToolRow( + toolName: tool.name, + toolDescription: tool.description, + isSelected: isSelected, + isBlocked: serverTools.status == .blocked || serverTools.status == .error, + onToggle: { isSelected in + if selectedToolStates["mcp"] == nil { + selectedToolStates["mcp"] = [:] + } + selectedToolStates["mcp"]?[configurationKey] = isSelected + updateServerSelectionState() + } + ) + .padding(.leading, 20) + } + } + } label: { + HStack(spacing: 8) { + MixedStateCheckbox( + title: "", + font: .systemFont(ofSize: 13), + state: $checkboxState, + action: { + // Toggle based on current state + switch checkboxState { + case .off, .mixed: + toggleAllTools(selected: true) + case .on: + toggleAllTools(selected: false) + } + } + ) + .disabled(serverTools.status == .blocked || serverTools.status == .error) + + HStack(spacing: 8) { + if serverTools.status == .blocked || serverTools.status == .error { + Text("MCP Server: \(serverTools.name)") + .font(.system(size: 13, weight: .medium)) + } else { + let selectedCount = serverTools.tools.filter { tool in + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: serverTools.name, + toolName: tool.name + ) + if let state = selectedToolStates["mcp"]?[configurationKey] { + return state + } + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: configurationKey, + currentStatus: .enabled, + selectedMode: currentMode + ) + }.count + Text("MCP Server: \(serverTools.name) ") + .font(.system(size: 13, weight: .medium)) + + Text("(\(selectedCount) of \(serverTools.tools.count) Selected)") + .font(.system(size: 13, weight: .regular)) + } + + if serverTools.status == .error { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + .font(.system(size: 11)) + } else if serverTools.status == .blocked { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.system(size: 11)) + } + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + isExpanded.toggle() + } + } + + Spacer() + } + } + .padding(.vertical, 4) + } + .disabled(serverTools.status != .running) + .onAppear { + updateServerSelectionState() + } + .onChange(of: selectedToolStates) { _ in + updateServerSelectionState() + } + .onChange(of: searchText) { _ in + if hasMatchingTools && !isExpanded && serverTools.status == .running { + isExpanded = true + } + } + } + } + + private func toggleAllTools(selected: Bool) { + if selectedToolStates["mcp"] == nil { + selectedToolStates["mcp"] = [:] + } + for tool in serverTools.tools { + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: serverTools.name, + toolName: tool.name + ) + selectedToolStates["mcp"]?[configurationKey] = selected + } + updateServerSelectionState() + } + + private func isToolSelected(_ tool: MCPTool) -> Bool { + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: serverTools.name, + toolName: tool.name + ) + if let state = selectedToolStates["mcp"]?[configurationKey] { + return state + } + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: configurationKey, + currentStatus: .enabled, + selectedMode: currentMode + ) + } + + private func updateServerSelectionState() { + guard serverTools.status != .blocked && serverTools.status != .error && !serverTools.tools.isEmpty else { + checkboxState = .off + return + } + + let selectedCount = serverTools.tools.filter { isToolSelected($0) }.count + checkboxState = selectedCount == 0 ? .off : (selectedCount == serverTools.tools.count ? .on : .mixed) + } + } + + // MARK: - Built-In Tools Section + + private struct AgentBuiltInToolsSection: View { + let title: String + let currentMode: ConversationMode + @Binding var selectedToolStates: [String: [String: Bool]] + let searchText: String + + @State private var isExpanded: Bool = false + @State private var checkboxState: CheckboxMixedState = .off + + private func matchesBuiltInSearch(_ tool: LanguageModelTool) -> Bool { + guard !searchText.isEmpty else { return true } + let lowercasedSearch = searchText.lowercased() + return tool.name.lowercased().contains(lowercasedSearch) || + (tool.displayName?.lowercased().contains(lowercasedSearch) ?? false) || + (tool.description?.lowercased().contains(lowercasedSearch) ?? false) + } + + private var builtInNameMatches: Bool { + guard !searchText.isEmpty else { return false } + let lowercasedSearch = searchText.lowercased() + return "built-in".contains(lowercasedSearch) || "builtin".contains(lowercasedSearch) + } + + private func hasMatchingTools(builtInTools: [LanguageModelTool]) -> Bool { + guard !searchText.isEmpty else { return false } + if builtInNameMatches { return true } + return builtInTools.contains { matchesBuiltInSearch($0) } + } + + private func filteredTools(builtInTools: [LanguageModelTool]) -> [LanguageModelTool] { + guard !searchText.isEmpty else { return builtInTools } + if builtInNameMatches { return builtInTools } + return builtInTools.filter { matchesBuiltInSearch($0) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + + let builtInTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? [] + + if builtInTools.isEmpty { + Text("No built-in tools available.") + .foregroundColor(.secondary) + .font(.system(size: 13)) + .padding(.vertical, 8) + } else if searchText.isEmpty || hasMatchingTools(builtInTools: builtInTools) { + VStack(alignment: .leading, spacing: 0) { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 0) { + Divider() + .padding(.vertical, 4) + + ForEach(filteredTools(builtInTools: builtInTools), id: \.name) { tool in + let isSelected = selectedToolStates["builtin"]?[tool.name] ?? AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: tool.name, + currentStatus: tool.status, + selectedMode: currentMode + ) + AgentToolRow( + toolName: tool.displayName ?? tool.name, + toolDescription: tool.description, + isSelected: isSelected, + isBlocked: false, + onToggle: { isSelected in + if selectedToolStates["builtin"] == nil { + selectedToolStates["builtin"] = [:] + } + selectedToolStates["builtin"]?[tool.name] = isSelected + updateBuiltInSelectionState(builtInTools: builtInTools) + } + ) + .padding(.leading, 20) + } + } + } label: { + HStack(spacing: 8) { + MixedStateCheckbox( + title: "", + font: .systemFont(ofSize: 13), + state: $checkboxState, + action: { + // Toggle based on current state + switch checkboxState { + case .off, .mixed: + toggleAllBuiltInTools(selected: true, builtInTools: builtInTools) + case .on: + toggleAllBuiltInTools(selected: false, builtInTools: builtInTools) + } + } + ) + + let selectedCount = builtInTools.filter { tool in + if let state = selectedToolStates["builtin"]?[tool.name] { + return state + } + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: tool.name, + currentStatus: tool.status, + selectedMode: currentMode + ) + }.count + (Text("Built-In ") + .font(.system(size: 13, weight: .medium)) + + Text("(\(selectedCount) of \(builtInTools.count) Selected)") + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.secondary)) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + isExpanded.toggle() + } + } + + Spacer() + } + } + .padding(.vertical, 4) + } + .onAppear { + updateBuiltInSelectionState(builtInTools: builtInTools) + } + .onChange(of: selectedToolStates) { _ in + updateBuiltInSelectionState(builtInTools: builtInTools) + } + .onChange(of: searchText) { _ in + if hasMatchingTools(builtInTools: builtInTools) && !isExpanded { + isExpanded = true + } + } + } + } + } + + private func toggleAllBuiltInTools(selected: Bool, builtInTools: [LanguageModelTool]) { + if selectedToolStates["builtin"] == nil { + selectedToolStates["builtin"] = [:] + } + for tool in builtInTools { + selectedToolStates["builtin"]?[tool.name] = selected + } + updateBuiltInSelectionState(builtInTools: builtInTools) + } + + private func isBuiltInToolSelected(_ tool: LanguageModelTool) -> Bool { + if let state = selectedToolStates["builtin"]?[tool.name] { + return state + } + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: tool.name, + currentStatus: tool.status, + selectedMode: currentMode + ) + } + + private func updateBuiltInSelectionState(builtInTools: [LanguageModelTool]) { + guard !builtInTools.isEmpty else { + checkboxState = .off + return + } + + let selectedCount = builtInTools.filter { isBuiltInToolSelected($0) }.count + checkboxState = selectedCount == 0 ? .off : (selectedCount == builtInTools.count ? .on : .mixed) + } + } + + // MARK: - Agent Tool Row + + private struct AgentToolRow: View { + let toolName: String + let toolDescription: String? + let isSelected: Bool + let isBlocked: Bool + let onToggle: (Bool) -> Void + + var body: some View { + HStack(alignment: .center) { + Toggle(isOn: Binding( + get: { isSelected }, + set: { onToggle($0) } + )) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(toolName) + .font(.system(size: 12, weight: .medium)) + + if let description = toolDescription { + Text(description) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .help(description) + .lineLimit(1) + } + } + } + } + .toggleStyle(.checkbox) + .disabled(isBlocked) + } + .padding(.vertical, 4) + } + } + + // MARK: - Agent Model Picker Section + + private struct AgentModelPickerSection: View { + @Binding var selectedModel: SelectedAgentModel? + @State private var copilotModels: [LLMModel] = [] + @State private var byokModels: [LLMModel] = [] + @State private var modelCache: [String: String] = [:] + + // Target width for menu items (popover width minus padding and margins) + // Popover is 500pt wide, subtract horizontal padding (12pt * 2) and menu item padding (8pt * 2) + let targetMenuItemWidth: CGFloat = 460 + let attributes: [NSAttributedString.Key: NSFont] = ModelMenuItemFormatter.attributes + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Menu { + // None option + Button(action: { + selectedModel = nil + }) { + Text(createModelMenuItemAttributedString( + modelName: "Not Specified", + isSelected: selectedModel == nil, + multiplierText: "" + )) + } + + Divider() + + if let model = copilotModels.first(where: { $0.isAutoModel }) { + Button(action: { selectModel(model) }) { + Text(createModelMenuItemAttributedString( + modelName: model.displayName ?? model.modelName, + isSelected: isModelSelected(model), + multiplierText: modelCache[model.modelName] ?? "Variable" + )) + } + + Divider() + } + + // Copilot models section + if !copilotModels.isEmpty { + Section(header: Text("Copilot Models")) { + ForEach(copilotModels.filter { !$0.isAutoModel }, id: \.modelName) { model in + Button(action: { selectModel(model) }) { + Text(createModelMenuItemAttributedString( + modelName: model.displayName ?? model.modelName, + isSelected: isModelSelected(model), + multiplierText: modelCache[model.modelName] ?? "" + )) + } + } + } + } + + // BYOK models section + if !byokModels.isEmpty { + Divider() + Section(header: Text("BYOK Models")) { + ForEach(byokModels, id: \.modelName) { model in + Button(action: { selectModel(model) }) { + Text(createModelMenuItemAttributedString( + modelName: model.displayName ?? model.modelName, + isSelected: isModelSelected(model), + multiplierText: modelCache[model.modelName] ?? "" + )) + } + } + } + } + } label: { + HStack { + Text(selectedModelDisplayText()) + .font(.system(size: 12)) + .foregroundColor(selectedModel == nil ? .secondary : .primary) + Spacer() + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.05)) + .cornerRadius(6) + } + .buttonStyle(.plain) + .onAppear { + loadModels() + } + } + } + + private func selectModel(_ model: LLMModel) { + selectedModel = SelectedAgentModel( + displayName: model.displayName ?? model.modelName, + modelName: model.modelName, + source: model.providerName == nil ? .copilot : .byok(provider: model.providerName!) + ) + } + + private func isModelSelected(_ model: LLMModel) -> Bool { + guard let selected = selectedModel else { return false } + if selected.modelName != model.modelName { return false } + + switch selected.source { + case .copilot: + return model.providerName == nil + case let .byok(provider): + return model.providerName?.lowercased() == provider.lowercased() + } + } + + private func loadModels() { + copilotModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel) + byokModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel) + + var newCache: [String: String] = [:] + let allModels = copilotModels + byokModels + for model in allModels { + newCache[model.modelName] = ModelMenuItemFormatter.getMultiplierText(for: model) + } + modelCache = newCache + } + + private func selectedModelDisplayText() -> String { + guard let model = selectedModel else { + return "Select a model..." + } + + let sourceLabel: String + switch model.source { + case .copilot: + sourceLabel = "copilot" + case let .byok(provider): + sourceLabel = provider + } + + return "\(model.displayName) (\(sourceLabel))" + } + + private func createModelMenuItemAttributedString( + modelName: String, + isSelected: Bool, + multiplierText: String + ) -> AttributedString { + return ModelMenuItemFormatter.createModelMenuItemAttributedString( + modelName: modelName, + isSelected: isSelected, + multiplierText: multiplierText, + targetWidth: targetMenuItemWidth, + ) + } + } +} diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index 3f1b772c..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 = {} @@ -18,13 +21,14 @@ final class ChatPanelWindow: NSWindow { minimizeWindow: @escaping () -> Void ) { self.minimizeWindow = minimizeWindow + // Initialize with zero rect initially to prevent flashing super.init( contentRect: .zero, - styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView], + styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView, .closable], backing: .buffered, - defer: false + defer: true // Use defer to prevent window from appearing immediately ) - + titleVisibility = .hidden addTitlebarAccessoryViewController({ let controller = NSTitlebarAccessoryViewController() @@ -41,11 +45,13 @@ final class ChatPanelWindow: NSWindow { level = widgetLevel(1) collectionBehavior = [ .fullScreenAuxiliary, - .transient, +// .transient, .fullScreenPrimary, .fullScreenAllowsTiling, ] hasShadow = true + + // Set contentView after basic configuration contentView = NSHostingView( rootView: ChatWindowView( store: store, @@ -56,8 +62,11 @@ final class ChatPanelWindow: NSWindow { ) .environment(\.chatTabPool, chatTabPool) ) - setIsVisible(true) + + // Initialize as invisible first + alphaValue = 0 isPanelDisplayed = false + setIsVisible(true) storeObserver.observe { [weak self] in guard let self else { return } @@ -70,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) { @@ -103,5 +119,25 @@ final class ChatPanelWindow: NSWindow { override func miniaturize(_: Any?) { minimizeWindow() } -} + 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 new file mode 100644 index 00000000..bb3747c6 --- /dev/null +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift @@ -0,0 +1,292 @@ +import ActiveApplicationMonitor +import ConversationTab +import AppKit +import ComposableArchitecture +import SwiftUI +import ChatTab +import SharedUIComponents +import PersistMiddleware + + +struct ChatHistoryView: View { + let store: StoreOf + @Environment(\.chatTabPool) var chatTabPool + @Binding var isChatHistoryVisible: Bool + @State private var searchText = "" + + var body: some View { + WithPerceptionTracking { + + VStack(alignment: .center, spacing: 0) { + Header(isChatHistoryVisible: $isChatHistoryVisible) + .scaledFrame(height: 32) + .scaledPadding(.leading, 12) + .scaledPadding(.trailing, 8) + + Divider() + + ChatHistorySearchBarView(searchText: $searchText) + .scaledPadding(.leading, 12) + .scaledPadding(.trailing, 8) + .scaledPadding(.vertical, 8) + + ItemView(store: store, searchText: $searchText, isChatHistoryVisible: $isChatHistoryVisible) + .scaledPadding(.leading, 12) + .scaledPadding(.trailing, 8) + } + } + } + + struct Header: View { + @Binding var isChatHistoryVisible: Bool + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + HStack { + Text("Chat History") + .scaledFont(size: 13, weight: .bold) + .scaledPadding(.leading, 4) + .scaledFrame(maxWidth: 192, alignment: .leading) + + Spacer() + + Button(action: { + isChatHistoryVisible = false + }) { + Image(systemName: "xmark") + .scaledFont(.body) + } + .buttonStyle(HoverButtonStyle()) + .help("Close") + } + } + } + + struct ItemView: View { + let store: StoreOf + @Binding var searchText: String + @Binding var isChatHistoryVisible: Bool + @State private var storedChatTabPreviewInfos: [ChatTabPreviewInfo] = [] + + @Environment(\.chatTabPool) var chatTabPool + + var body: some View { + WithPerceptionTracking { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(filteredTabInfo, id: \.id) { previewInfo in + ChatHistoryItemView( + store: store, + previewInfo: previewInfo, + isChatHistoryVisible: $isChatHistoryVisible + ) { + refreshStoredChatTabInfos() + } + .id(previewInfo.id) + .scaledFrame(height: 61) + } + } + } + .onAppear { refreshStoredChatTabInfos() } + } + } + + 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: []) + } + + guard !searchText.isEmpty else { return IdentifiedArray(uniqueElements: storedChatTabPreviewInfos) } + + let result = storedChatTabPreviewInfos.filter { info in + return (info.title ?? "New Chat").localizedCaseInsensitiveContains(searchText) + } + + return IdentifiedArray(uniqueElements: result) + } + } +} + + +struct ChatHistorySearchBarView: View { + @Binding var searchText: String + @FocusState private var isSearchBarFocused: Bool + + var body: some View { + HStack(spacing: 5) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .scaledFont(.body) + + TextField("Search", text: $searchText) + .scaledFont(.body) + .textFieldStyle(PlainTextFieldStyle()) + .focused($isSearchBarFocused) + .foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor)) + } + .cornerRadius(10) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.1)) + ) + .onAppear { + isSearchBarFocused = true + } + } +} + +struct ChatHistoryItemView: View { + let store: StoreOf + let 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 == previewInfo.id + } + + func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM d, yyyy, h:mm a" + return formatter.string(from: date) + } + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + HStack(alignment: .center, spacing: 0) { + VStack(spacing: 4) { + HStack(spacing: 8) { + // Do not use the `ChatConversationItemView` any more + // directly get title from chat tab info + Text(previewInfo.title ?? "New Chat") + .frame(alignment: .leading) + .scaledFont(size: 14, weight: .semibold) + .foregroundColor(.primary) + .lineLimit(1) + + if isTabSelected() { + Text("Current") + .scaledFont(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + } + + HStack(spacing: 0) { + Text(formatDate(previewInfo.updatedAt)) + .frame(alignment: .leading) + .scaledFont(size: 13, weight: .regular) + .foregroundColor(.secondary) + .lineLimit(1) + + Spacer() + } + } + + Spacer() + + if !isTabSelected() { + Button(action: { + Task { @MainActor in + await store.send(.chatHistoryDeleteButtonClicked(id: previewInfo.id)).finish() + onDelete() + } + }) { + Image(systemName: "trash") + .foregroundColor(.primary) + .scaledFont(.body) + .opacity(isHovered ? 1 : 0) + } + .buttonStyle(HoverButtonStyle()) + .help("Delete") + .allowsHitTesting(isHovered) + } + } + .padding(.horizontal, 12) + } + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .onHover(perform: { + isHovered = $0 + }) + .hoverRadiusBackground( + isHovered: isHovered, + hoverColor: Color( + nsColor: .controlColor + .withAlphaComponent(colorScheme == .dark ? 0.1 : 0.55) + ), + cornerRadius: 8, + showBorder: isHovered, + borderColor: Color(nsColor: .separatorColor) + ) + .onTapGesture { + Task { @MainActor in + await store.send(.chatHistoryItemClicked(id: previewInfo.id)).finish() + isChatHistoryVisible = false + } + } + } + } +} + +struct ChatHistoryView_Previews: PreviewProvider { + static let pool = ChatTabPool([ + "2": EmptyChatTab(id: "2"), + "3": EmptyChatTab(id: "3"), + "4": EmptyChatTab(id: "4"), + "5": EmptyChatTab(id: "5"), + "6": EmptyChatTab(id: "6") + ]) + + static func createStore() -> StoreOf { + StoreOf( + initialState: .init( + chatHistory: .init( + workspaces: [.init( + id: .init(path: "p", username: "u"), + tabInfo: [ + .init(id: "2", title: "Empty-2", workspacePath: "path", username: "username"), + .init(id: "3", title: "Empty-3", workspacePath: "path", username: "username"), + .init(id: "4", title: "Empty-4", workspacePath: "path", username: "username"), + .init(id: "5", title: "Empty-5", workspacePath: "path", username: "username"), + .init(id: "6", title: "Empty-6", workspacePath: "path", username: "username") + ] as IdentifiedArray, + selectedTabId: "2" + ) { _ in }] as IdentifiedArray, + selectedWorkspacePath: "activeWorkspacePath", + selectedWorkspaceName: "activeWorkspacePath" + ), + isPanelDisplayed: true + ), + reducer: { ChatPanelFeature() } + ) + } + + static var previews: some View { + ChatHistoryView( + store: createStore(), + isChatHistoryVisible: .constant(true) + ) + .xcodeStyleFrame() + .padding() + .environment(\.chatTabPool, pool) + } +} diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift new file mode 100644 index 00000000..0a70e8ba --- /dev/null +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift @@ -0,0 +1,98 @@ +import SwiftUI +import Perception +import GitHubCopilotViewModel +import SharedUIComponents + +struct ChatLoginView: View { + @StateObject var viewModel: GitHubCopilotViewModel + @Environment(\.openURL) private var openURL + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0){ + VStack(spacing: 24) { + Spacer() + VStack(spacing: 8) { + Image("CopilotLogo") + .resizable() + .renderingMode(.template) + .scaledToFill() + .scaledFrame(width: 60.0, height: 60.0) + .foregroundColor(.secondary) + + Text("Welcome to Copilot") + .scaledFont(.largeTitle) + .multilineTextAlignment(.center) + + Text("Your AI-powered coding assistant") + .scaledFont(.body) + .multilineTextAlignment(.center) + } + + CopilotIntroView() + + VStack(spacing: 8) { + Button("Sign Up for Copilot Free") { + if let url = URL(string: "https://github.com/features/copilot/plans") { + 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")) + + if viewModel.isRunningAction || viewModel.waitingForSignIn { + ProgressView() + .controlSize(.small) + } + } + } + .scaledPadding(.top, 16) + + Spacer() + Text("Copilot Free and Copilot Pro may show [public code](https://aka.ms/github-copilot-match-public-code) suggestions and collect telemetry. You can change these [GitHub settings](https://aka.ms/github-copilot-settings) at any time. By continuing, you agree to our [terms](https://github.com/customer-terms/github-copilot-product-specific-terms) and [privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).") + .scaledFont(.system(size: 12)) + } + .padding() + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + } + .xcodeStyleFrame() + .ignoresSafeArea(edges: .top) + .alert( + viewModel.signInResponse?.userCode ?? "", + isPresented: $viewModel.isSignInAlertPresented, + presenting: viewModel.signInResponse + ) { _ in + Button("Cancel", role: .cancel, action: {}) + .scaledFont(.body) + + Button("Copy Code and Open", action: { viewModel.copyAndOpen(fromHostApp: false) }) + .scaledFont(.body) + } message: { response in + Text(""" + Please enter the above code in the GitHub website \ + to authorize your GitHub account with Copilot for Xcode. + + \(response?.verificationURL.absoluteString ?? "") + """) + } + } + } +} + +struct ChatLoginView_Previews: PreviewProvider { + static var previews: some View { + ChatLoginView(viewModel: GitHubCopilotViewModel.shared) + } +} diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift new file mode 100644 index 00000000..f6674e4a --- /dev/null +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift @@ -0,0 +1,56 @@ +import SwiftUI +import Perception +import SharedUIComponents + +struct ChatNoAXPermissionView: View { + @Environment(\.openURL) private var openURL + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + VStack(alignment: .center, spacing: 20) { + Spacer() + Image("CopilotError") + .resizable() + .renderingMode(.template) + .scaledToFill() + .scaledFrame(width: 64.0, height: 64.0) + .foregroundColor(.primary) + + Text("Accessibility Permission Required") + .scaledFont(.largeTitle) + .multilineTextAlignment(.center) + + Text("Please grant accessibility permission for Github Copilot to work with Xcode.") + .scaledFont(.body) + .multilineTextAlignment(.center) + + HStack{ + Button("Open Permission Settings") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + openURL(url) + } + } + .scaledFont(.body) + .buttonStyle(.borderedProminent) + } + + Spacer() + } + .padding() + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + } + .xcodeStyleFrame() + .ignoresSafeArea(edges: .top) + } + } +} + +struct ChatNoAXPermission_Previews: PreviewProvider { + static var previews: some View { + ChatNoAXPermissionView() + } +} diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift new file mode 100644 index 00000000..1ecbfc90 --- /dev/null +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift @@ -0,0 +1,70 @@ +import SwiftUI +import Perception +import GitHubCopilotViewModel +import SharedUIComponents + +struct ChatNoSubscriptionView: View { + @StateObject var viewModel: GitHubCopilotViewModel + @Environment(\.openURL) private var openURL + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + VStack(alignment: .center, spacing: 20) { + Spacer() + Image("CopilotIssue") + .resizable() + .renderingMode(.template) + .scaledToFill() + .scaledFrame(width: 60.0, height: 60.0) + .foregroundColor(.primary) + + Text("No Copilot Subscription Found") + .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") + .scaledFont(.system(size: 12)) + .multilineTextAlignment(.center) + + HStack{ + Button("Check Subscription Plans") { + if let url = URL(string: "https://github.com/settings/copilot") { + openURL(url) + } + } + .scaledFont(.body) + .buttonStyle(.borderedProminent) + + Button("Retry") { viewModel.checkStatus() } + .scaledFont(.body) + .buttonStyle(.bordered) + + if viewModel.isRunningAction || viewModel.waitingForSignIn { + ProgressView() + .controlSize(.small) + } + } + + Spacer() + + Text("Copilot Free and Copilot Pro may show [public code](https://aka.ms/github-copilot-match-public-code) suggestions and collect telemetry. You can change these [GitHub settings](https://aka.ms/github-copilot-settings) at any time. By continuing, you agree to our [terms](https://github.com/customer-terms/github-copilot-product-specific-terms) and [privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).") + .scaledFont(.system(size: 12)) + } + .padding() + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + } + .xcodeStyleFrame() + .ignoresSafeArea(edges: .top) + } + } +} + +struct ChatNoSubcription_Previews: PreviewProvider { + static var previews: some View { + ChatNoSubscriptionView(viewModel: GitHubCopilotViewModel.shared) + } +} diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift new file mode 100644 index 00000000..9e342bca --- /dev/null +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift @@ -0,0 +1,48 @@ +import SwiftUI +import Perception +import SharedUIComponents + +struct ChatNoWorkspaceView: View { + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + VStack(alignment: .center, spacing: 32) { + Spacer() + VStack (alignment: .center, spacing: 8) { + Image("CopilotLogo") + .resizable() + .renderingMode(.template) + .scaledToFill() + .scaledFrame(width: 64.0, height: 64.0) + .foregroundColor(.secondary) + + Text("No Active Xcode Workspace") + .scaledFont(.largeTitle) + .multilineTextAlignment(.center) + + Text("To use Copilot, open Xcode with an active workspace in focus") + .scaledFont(.body) + .multilineTextAlignment(.center) + } + + CopilotIntroView() + + Spacer() + } + .padding() + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + } + .xcodeStyleFrame() + .ignoresSafeArea(edges: .top) + } + } +} + +struct ChatNoWorkspace_Previews: PreviewProvider { + static var previews: some View { + ChatNoWorkspaceView() + } +} diff --git a/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift b/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift new file mode 100644 index 00000000..a7fdcec7 --- /dev/null +++ b/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift @@ -0,0 +1,110 @@ +import SwiftUI +import Perception +import SharedUIComponents + +struct CopilotIntroView: View { + var body: some View { + WithPerceptionTracking { + VStack(alignment: .center, spacing: 8) { + CopilotIntroItemView( + imageName: "CopilotLogo", + title: "Agent Mode", + description: "Activate Agent Mode to handle multi-step coding tasks with Copilot." + ) + + CopilotIntroItemView( + systemImage: "wrench.and.screwdriver", + title: "MCP Support", + description: "Connect to MCP to extend your Copilot with custom tools and services for advanced workflows." + ) + + CopilotIntroItemView( + imageName: "ChatIcon", + title: "Ask Mode", + description: "Use Ask Mode to chat with Copilot to understand, debug, or improve your code." + ) + + CopilotIntroItemView( + systemImage: "option", + title: "Code Suggestions", + description: "Get smart code suggestions in Xcode. Press Tab ⇥ to accept a code suggestion, or Option ⌥ to see more alternatives." + ) + } + .padding(0) + .frame(maxWidth: .infinity, alignment: .center) + } + } +} + +struct CopilotIntroItemView: View { + let image: Image + let title: String + let description: String + + public init(imageName: String, title: String, description: String) { + self.init( + imageObject: Image(imageName), + title: title, + description: description + ) + } + + public init(systemImage: String, title: String, description: String) { + self.init( + imageObject: Image(systemName: systemImage), + title: title, + description: description + ) + } + + public init(imageObject: Image, title: String, description: String) { + self.image = imageObject + self.title = title + self.description = description + } + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 0){ + HStack(alignment: .center, spacing: 8) { + image + .resizable() + .renderingMode(.template) + .scaledToFill() + .frame(width: 12, height: 12) + .foregroundColor(.primary) + .padding(.leading, 8) + + Text(title) + .kerning(0.096) + .scaledFont(.body) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Text(description) + .scaledFont(.body) + .foregroundColor(.secondary) + .padding(.leading, 28) + .padding(.top, 4) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .padding(8) + .frame(maxWidth: 360, alignment: .top) + .background(.primary.opacity(0.1)) + .cornerRadius(2) + .overlay( + RoundedRectangle(cornerRadius: 2) + .inset(by: 0.5) + .stroke(lineWidth: 0) + ) + } + } +} + +struct CopilotIntroView_Previews: PreviewProvider { + static var previews: some View { + CopilotIntroView() + } +} diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index f9f68a41..665ccd70 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -1,44 +1,148 @@ import ActiveApplicationMonitor +import ConversationTab import AppKit import ChatTab import ComposableArchitecture import SwiftUI +import SharedUIComponents +import GitHubCopilotViewModel +import Status +import ChatService +import Workspace private let r: Double = 8 struct ChatWindowView: View { let store: StoreOf let toggleVisibility: (Bool) -> Void + @State private var isChatHistoryVisible: Bool = false + @ObservedObject private var statusObserver = StatusObserver.shared var body: some View { WithPerceptionTracking { - let _ = store.chatTabGroup.selectedTabId // force re-evaluation - VStack(spacing: 0) { - Rectangle().fill(.regularMaterial).frame(height: 28) - - Divider() + // Force re-evaluation when workspace state changes + let currentWorkspace = store.currentChatWorkspace + let _ = currentWorkspace?.selectedTabId + ZStack { + if statusObserver.observedAXStatus == .notGranted { + ChatNoAXPermissionView() + } else { + switch statusObserver.authStatus.status { + case .loggedIn: + if currentWorkspace == nil || (currentWorkspace?.tabInfo.isEmpty ?? true) { + ChatNoWorkspaceView() + } else if isChatHistoryVisible { + ChatHistoryViewWrapper(store: store, isChatHistoryVisible: $isChatHistoryVisible) + } else { + ChatView(store: store, isChatHistoryVisible: $isChatHistoryVisible) + } + case .notLoggedIn: + ChatLoginView(viewModel: GitHubCopilotViewModel.shared) + case .notAuthorized: + ChatNoSubscriptionView(viewModel: GitHubCopilotViewModel.shared) + case .unknown: + ChatLoginView(viewModel: GitHubCopilotViewModel.shared) + } + } + } + .onChange(of: store.isPanelDisplayed) { isDisplayed in + toggleVisibility(isDisplayed) + } + .preferredColorScheme(store.colorScheme) + } + } +} - ChatTabBar(store: store) - .frame(height: 26) +struct ChatView: View { + let store: StoreOf + @Binding var isChatHistoryVisible: Bool + + var body: some View { + VStack(spacing: 0) { + Rectangle() + .fill(Color.chatWindowBackgroundColor) + .scaledFrame(height: 28) + VStack(spacing: 0) { + ChatBar(store: store, isChatHistoryVisible: $isChatHistoryVisible) + .scaledFrame(height: 32) + .scaledPadding(.leading, 16) + .scaledPadding(.trailing, 8) + Divider() - + ChatTabContainer(store: store) .frame(maxWidth: .infinity, maxHeight: .infinity) } - .xcodeStyleFrame(cornerRadius: 10) - .ignoresSafeArea(edges: .top) - .onChange(of: store.isPanelDisplayed) { isDisplayed in - toggleVisibility(isDisplayed) + } + .xcodeStyleFrame() + .ignoresSafeArea(edges: .top) + } +} + +struct ChatHistoryViewWrapper: View { + let store: StoreOf + @Binding var isChatHistoryVisible: Bool + + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + Rectangle() + .fill(Color.chatWindowBackgroundColor) + .scaledFrame(height: 28) + + ChatHistoryView( + store: store, + isChatHistoryVisible: $isChatHistoryVisible + ) + .background(Color.chatWindowBackgroundColor) + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) } + .xcodeStyleFrame() + .ignoresSafeArea(edges: .top) .preferredColorScheme(store.colorScheme) + .focusable() + .onExitCommand(perform: { + isChatHistoryVisible = false + }) + } + } +} + +struct ChatLoadingView: View { + var body: some View { + VStack(alignment: .center) { + + Spacer() + + VStack(spacing: 24) { + Instruction(isAgentMode: .constant(false)) + + ProgressView("Loading...") + + } + .frame(maxWidth: .infinity, alignment: .center) + // keep same as chat view + .padding(.top, 20) // chat bar + + Spacer() + } + .xcodeStyleFrame() + .ignoresSafeArea(edges: .top) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.ultraThinMaterial) } } struct ChatTitleBar: View { let store: StoreOf @State var isHovering = false + @AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode var body: some View { WithPerceptionTracking { @@ -58,25 +162,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) @@ -106,7 +212,7 @@ struct ChatTitleBar: View { ? color : Color(nsColor: .separatorColor) ) - .frame( + .scaledFrame( width: Style.trafficLightButtonSize, height: Style.trafficLightButtonSize ) @@ -134,8 +240,9 @@ private extension View { } } -struct ChatTabBar: View { +struct ChatBar: View { let store: StoreOf + @Binding var isChatHistoryVisible: Bool struct TabBarState: Equatable { var tabInfo: IdentifiedArray @@ -143,36 +250,37 @@ struct ChatTabBar: View { } var body: some View { - HStack(spacing: 0) { - Divider() - Tabs(store: store) - CreateButton(store: store) - } - .background { - Button(action: { store.send(.switchToNextTab) }) { EmptyView() } - .opacity(0) - .keyboardShortcut("]", modifiers: [.command, .shift]) - Button(action: { store.send(.switchToPreviousTab) }) { EmptyView() } - .opacity(0) - .keyboardShortcut("[", modifiers: [.command, .shift]) + WithPerceptionTracking { + HStack(spacing: 8) { + if store.chatHistory.selectedWorkspaceName != nil { + ChatWindowHeader(store: store) + } + + Spacer() + + CreateButton(store: store) + + ChatHistoryButton(store: store, isChatHistoryVisible: $isChatHistoryVisible) + + SettingsButton(store: store) + } } } struct Tabs: View { let store: StoreOf - @State var draggingTabId: String? @Environment(\.chatTabPool) var chatTabPool var body: some View { WithPerceptionTracking { - let tabInfo = store.chatTabGroup.tabInfo - let selectedTabId = store.chatTabGroup.selectedTabId - ?? store.chatTabGroup.tabInfo.first?.id + let tabInfo = store.currentChatWorkspace?.tabInfo + let selectedTabId = store.currentChatWorkspace?.selectedTabId + ?? store.currentChatWorkspace?.tabInfo.first?.id ?? "" ScrollViewReader { proxy in ScrollView(.horizontal) { HStack(spacing: 0) { - ForEach(tabInfo, id: \.id) { info in + ForEach(tabInfo!, id: \.id) { info in if let tab = chatTabPool.getTab(of: info.id) { ChatTabBarButton( store: store, @@ -185,20 +293,6 @@ struct ChatTabBar: View { tab.menu } .id(info.id) - .onDrag { - draggingTabId = info.id - return NSItemProvider(object: info.id as NSString) - } - .onDrop( - of: [.text], - delegate: ChatTabBarDropDelegate( - store: store, - tabs: tabInfo, - itemId: info.id, - draggingTabId: $draggingTabId - ) - ) - } else { EmptyView() } @@ -216,77 +310,87 @@ struct ChatTabBar: View { } } - struct CreateButton: View { + struct ChatWindowHeader: View { let store: StoreOf var body: some View { WithPerceptionTracking { - let collection = store.chatTabGroup.tabCollection - Menu { - ForEach(0.. - let tabs: IdentifiedArray - let itemId: String - @Binding var draggingTabId: String? + struct CreateButton: View { + let store: StoreOf - func dropUpdated(info: DropInfo) -> DropProposal? { - return DropProposal(operation: .move) + var body: some View { + WithPerceptionTracking { + Button(action: { + store.send(.createNewTapButtonClicked(kind: nil)) + }) { + Image(systemName: "plus.bubble") + .scaledFont(.body) + } + .buttonStyle(HoverButtonStyle()) + .help("New Chat") + .accessibilityLabel("New Chat") + } + } } - - func performDrop(info: DropInfo) -> Bool { - draggingTabId = nil - return true + + struct ChatHistoryButton: View { + let store: StoreOf + @Binding var isChatHistoryVisible: Bool + + var body: some View { + WithPerceptionTracking { + Button(action: { + isChatHistoryVisible = true + }) { + 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()) + .help("Show Chats...") + .accessibilityLabel("Show Chats...") + } + } } + + struct SettingsButton: View { + let store: StoreOf - func dropEntered(info: DropInfo) { - guard itemId != draggingTabId else { return } - let from = tabs.firstIndex { $0.id == draggingTabId } - let to = tabs.firstIndex { $0.id == itemId } - guard let from, let to, from != to else { return } - store.send(.moveChatTab(from: from, to: to)) + var body: some View { + WithPerceptionTracking { + Button(action: { + store.send(.openSettings) + }) { + Image(systemName: "gearshape") + .scaledFont(.body) + } + .buttonStyle(HoverButtonStyle()) + .help("Open Settings") + .accessibilityLabel("Open Settings") + } + } } } @@ -299,77 +403,101 @@ struct ChatTabBarButton: View { @State var isHovered: Bool = false var body: some View { - HStack(spacing: 0) { - HStack(spacing: 4) { - icon().foregroundColor(.secondary) - content() - } - .font(.callout) - .lineLimit(1) - .frame(maxWidth: 120) - .padding(.horizontal, 28) - .contentShape(Rectangle()) - .onTapGesture { - store.send(.tabClicked(id: info.id)) - } - .overlay(alignment: .leading) { - Button(action: { - store.send(.closeTabButtonClicked(id: info.id)) - }) { - Image(systemName: "xmark") - .foregroundColor(.secondary) + if self.isSelected { + HStack(spacing: 0) { + HStack(spacing: 0) { + icon() + .buttonStyle(.plain) } - .buttonStyle(.plain) - .padding(2) - .padding(.leading, 8) - .opacity(isHovered ? 1 : 0) + .font(.callout) + .lineLimit(1) } - .onHover { isHovered = $0 } - .animation(.linear(duration: 0.1), value: isHovered) - .animation(.linear(duration: 0.1), value: isSelected) - - Divider().padding(.vertical, 6) + .frame(maxHeight: .infinity) } - .background(isSelected ? Color(nsColor: .selectedControlColor) : Color.clear) - .frame(maxHeight: .infinity) } } struct ChatTabContainer: View { let store: StoreOf @Environment(\.chatTabPool) var chatTabPool + @State private var pasteMonitor: Any? var body: some View { WithPerceptionTracking { - let tabInfo = store.chatTabGroup.tabInfo - let selectedTabId = store.chatTabGroup.selectedTabId - ?? store.chatTabGroup.tabInfo.first?.id + let tabInfoArray = store.currentChatWorkspace?.tabInfo + let selectedTabId = store.currentChatWorkspace?.selectedTabId + ?? store.currentChatWorkspace?.tabInfo.first?.id ?? "" - ZStack { - if tabInfo.isEmpty { - Text("Empty") - } else { - ForEach(tabInfo) { 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) - // move it out of window - .rotationEffect( - isActive ? .zero : .degrees(90), - anchor: .topLeading - ) - } else { - EmptyView() - } - } + if let tabInfoArray = tabInfoArray, !tabInfoArray.isEmpty { + activeTabsView( + tabInfoArray: tabInfoArray, + selectedTabId: selectedTabId + ) + } else { + // Fallback view for empty state (rarely seen in practice) + EmptyView().frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .onAppear { + setupPasteMonitor() + } + .onDisappear { + removePasteMonitor() + } + } + + // View displayed when there are active tabs + private func activeTabsView( + tabInfoArray: IdentifiedArray, + selectedTabId: String + ) -> some View { + 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 } } @@ -377,7 +505,7 @@ struct CreateOtherChatTabMenuStyle: MenuStyle { func makeBody(configuration: Configuration) -> some View { Image(systemName: "chevron.down") .resizable() - .frame(width: 7, height: 4) + .scaledFrame(width: 7, height: 4) .frame(maxHeight: .infinity) .padding(.leading, 4) .padding(.trailing, 8) @@ -398,16 +526,23 @@ struct ChatWindowView_Previews: PreviewProvider { static func createStore() -> StoreOf { StoreOf( initialState: .init( - chatTabGroup: .init( - tabInfo: [ - .init(id: "2", title: "Empty-2"), - .init(id: "3", title: "Empty-3"), - .init(id: "4", title: "Empty-4"), - .init(id: "5", title: "Empty-5"), - .init(id: "6", title: "Empty-6"), - .init(id: "7", title: "Empty-7"), - ] as IdentifiedArray, - selectedTabId: "2" + chatHistory: .init( + workspaces: [ + .init( + id: .init(path: "p", username: "u"), + tabInfo: [ + .init(id: "2", title: "Empty-2", workspacePath: "path", username: "username"), + .init(id: "3", title: "Empty-3", workspacePath: "path", username: "username"), + .init(id: "4", title: "Empty-4", workspacePath: "path", username: "username"), + .init(id: "5", title: "Empty-5", workspacePath: "path", username: "username"), + .init(id: "6", title: "Empty-6", workspacePath: "path", username: "username"), + .init(id: "7", title: "Empty-7", workspacePath: "path", username: "username"), + ] as IdentifiedArray, + selectedTabId: "2" + ) { _ in } + ] as IdentifiedArray, + selectedWorkspacePath: "activeWorkspacePath", + selectedWorkspaceName: "activeWorkspacePath" ), isPanelDisplayed: true ), @@ -423,3 +558,8 @@ struct ChatWindowView_Previews: PreviewProvider { } } +struct ChatLoadingView_Previews: PreviewProvider { + static var previews: some View { + ChatLoadingView() + } +} diff --git a/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift new file mode 100644 index 00000000..f7359baa --- /dev/null +++ b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift @@ -0,0 +1,447 @@ +import SwiftUI +import Combine +import XcodeInspector +import ComposableArchitecture +import ConversationServiceProvider +import LanguageServerProtocol +import ChatService +import SharedUIComponents +import ConversationTab + +private typealias CodeReviewPanelViewStore = ViewStore + +private struct ViewState: Equatable { + let reviewComments: [ReviewComment] + let currentSelectedComment: ReviewComment? + let currentIndex: Int + let operatedCommentIds: Set + var hasNextComment: Bool + var hasPreviousComment: Bool + + var commentsCount: Int { reviewComments.count } + + init(state: CodeReviewPanelFeature.State) { + self.reviewComments = state.currentDocumentReview?.comments ?? [] + self.currentSelectedComment = state.currentSelectedComment + self.currentIndex = state.currentIndex + self.operatedCommentIds = state.operatedCommentIds + self.hasNextComment = state.hasNextComment + self.hasPreviousComment = state.hasPreviousComment + } +} + +struct CodeReviewPanelView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: ViewState.init) { viewStore in + WithPerceptionTracking { + VStack(spacing: 0) { + VStack(spacing: 0) { + HeaderView(viewStore: viewStore) + .padding(.bottom, 4) + + Divider() + + ContentView( + comment: viewStore.currentSelectedComment, + viewStore: viewStore + ) + .padding(.top, 16) + } + .padding(.vertical, 10) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: Style.codeReviewPanelHeight, alignment: .top) + .fixedSize(horizontal: false, vertical: true) + .xcodeStyleFrame() + .onAppear { viewStore.send(.appear) } + + Spacer() + } + } + } + } +} + +// MARK: - Header View +private struct HeaderView: View { + let viewStore: CodeReviewPanelViewStore + + var body: some View { + HStack(alignment: .center, spacing: 8) { + ZStack { + Circle() + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + .frame(width: 24, height: 24) + + Image("CopilotLogo") + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: 12, height: 12) + } + + Text("Code Review Comment") + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + + if viewStore.commentsCount > 0 { + Text("(\(viewStore.currentIndex + 1) of \(viewStore.commentsCount))") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + + Spacer() + + NavigationControls(viewStore: viewStore) + } + .fixedSize(horizontal: false, vertical: true) + } +} + +// MARK: - Navigation Controls +private struct NavigationControls: View { + let viewStore: CodeReviewPanelViewStore + + var body: some View { + HStack(spacing: 4) { + if viewStore.hasPreviousComment { + Button(action: { + viewStore.send(.previous) + }) { + Image(systemName: "arrow.up") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 13, height: 13) + } + .buttonStyle(HoverButtonStyle()) + .buttonStyle(PlainButtonStyle()) + .help("Previous") + } + + if viewStore.hasNextComment { + Button(action: { + viewStore.send(.next) + }) { + Image(systemName: "arrow.down") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 13, height: 13) + } + .buttonStyle(HoverButtonStyle()) + .buttonStyle(PlainButtonStyle()) + .help("Next") + } + + Button(action: { + if let id = viewStore.currentSelectedComment?.id { + viewStore.send(.close(commentId: id)) + } + }) { + Image(systemName: "xmark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 13, height: 13) + } + .buttonStyle(HoverButtonStyle()) + .buttonStyle(PlainButtonStyle()) + .help("Close") + } + } +} + +// MARK: - Content View +private struct ContentView: View { + let comment: ReviewComment? + let viewStore: CodeReviewPanelViewStore + + var body: some View { + if let comment = comment { + CommentDetailView(comment: comment, viewStore: viewStore) + } else { + EmptyView() + } + } +} + +// MARK: - Comment Detail View +private struct CommentDetailView: View { + let comment: ReviewComment + let viewStore: CodeReviewPanelViewStore + @AppStorage(\.chatFontSize) var chatFontSize + + var lineInfoContent: String { + let displayStartLine = comment.range.start.line + 1 + let displayEndLine = comment.range.end.line + 1 + + if displayStartLine == displayEndLine { + return "Line \(displayStartLine)" + } else { + return "Line \(displayStartLine)-\(displayEndLine)" + } + } + + var lineInfoView: some View { + Text(lineInfoContent) + .font(.system(size: chatFontSize)) + } + + var kindView: some View { + Text(comment.kind) + .font(.system(size: chatFontSize)) + .padding(.horizontal, 6) + .frame(maxHeight: 20) + .background( + RoundedRectangle(cornerRadius: 4) + .foregroundColor(.hoverColor) + ) + } + + var messageView: some View { + ScrollView { + ThemedMarkdownText( + text: comment.message, + context: .init(supportInsert: false) + ) + } + } + + var dismissButton: some View { + Button(action: { + viewStore.send(.dismiss(commentId: comment.id)) + }) { + Text("Dismiss") + } + .buttonStyle(.bordered) + .foregroundColor(.primary) + .help("Dismiss") + } + + var acceptButton: some View { + Button(action: { + viewStore.send(.accept(commentId: comment.id)) + }) { + Text("Accept") + } + .buttonStyle(.borderedProminent) + .help("Accept") + } + + private var fileURL: URL? { + URL(string: comment.uri) + } + + var fileNameView: some View { + HStack(alignment: .center, spacing: 8) { + drawFileIcon(fileURL) + .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..b59276f7 --- /dev/null +++ b/Core/Sources/SuggestionWidget/Extensions/Helper.swift @@ -0,0 +1,52 @@ +import AppKit + +struct LocationStrategyHelper { + + /// `lineNumber` is 0-based + /// + /// - Parameters: + /// - length: If specified, use this length instead of the actual line length. Useful when you want to get the exact line height and y that ignores the unwrappded lines. + static func getLineFrame( + _ lineNumber: Int, + in editor: AXUIElement, + with lines: [String], + length: Int? = nil + ) -> CGRect? { + guard editor.isNonNavigatorSourceEditor, + lineNumber < lines.count && lineNumber >= 0 + else { + return nil + } + + var characterPosition = 0 + for i in 0.. 0 && fittingSize.height > 0 { + return fittingSize + } + + let intrinsicSize = contentView.intrinsicContentSize + if intrinsicSize.width > 0 && intrinsicSize.height > 0 { + return intrinsicSize + } + + return nil + }() + + guard let contentSize = effectiveSize, + contentSize.width.isFinite, + contentSize.height.isFinite, + let frame = location.calcDiffViewFrame(contentSize: contentSize) + else { + return + } + + windows.nesDiffWindow.setFrame( + frame, + display: false, + animate: animated + ) + } + + @MainActor + func updateNESNotificationWindowFrame( + _ location: WidgetLocation.NESPanelLocation, + animated: Bool + ) async { + var notificationWindowFrame = windows.nesNotificationWindow.frame + let scrollViewFrame = location.scrollViewFrame + let screenFrame = location.screenFrame + + notificationWindowFrame.origin.x = scrollViewFrame.minX + scrollViewFrame.width / 2 - notificationWindowFrame.width / 2 + notificationWindowFrame.origin.y = screenFrame.height - scrollViewFrame.maxY + Style.nesSuggestionMenuLeadingPadding * 2 + + windows.nesNotificationWindow.setFrame( + notificationWindowFrame, + display: false, + animate: animated + ) + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/AgentConfigurationWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/AgentConfigurationWidgetFeature.swift new file mode 100644 index 00000000..ef5ac74d --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/AgentConfigurationWidgetFeature.swift @@ -0,0 +1,65 @@ +import ComposableArchitecture +import Foundation +import SuggestionBasic +import XcodeInspector +import ChatTab +import ConversationTab +import ChatService +import ConversationServiceProvider + +@Reducer +public struct AgentConfigurationWidgetFeature { + @ObservableState + public struct State: Equatable { + public var focusedEditor: SourceEditor? = nil + public var isPanelDisplayed: Bool = false + public var currentMode: ConversationMode? = nil + + public var lineHeight: Double = 16.0 + } + + public enum Action: Equatable { + case setCurrentMode(ConversationMode?) + case onFocusedEditorChanged(SourceEditor?) + } + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onFocusedEditorChanged(let editor): + state.focusedEditor = editor + return .run { send in + let currentMode = await getCurrentMode(for: editor) + await send(.setCurrentMode(currentMode)) + } + case .setCurrentMode(let mode): + state.currentMode = mode + return .none + } + } + } +} + +private func getCurrentMode(for focusedEditor: SourceEditor?) async -> ConversationMode? { + guard let documentURL = focusedEditor?.realtimeDocumentURL, + documentURL.pathExtension == "md", + documentURL.lastPathComponent.hasSuffix(".agent.md") else { + return nil + } + + // Load all conversation modes + guard let modes = await SharedChatService.shared.loadConversationModes() else { + return nil + } + + // Find the mode that matches the current document URL + let documentURLString = documentURL.absoluteString + let mode = modes.first { mode in + guard let modeURI = mode.uri else { return false } + return modeURI == documentURLString || URL(string: modeURI)?.path == documentURL.path + } + + return mode +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index 990b557e..c28f6a66 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -1,8 +1,12 @@ import ActiveApplicationMonitor import AppKit +import ChatService import ChatTab import ComposableArchitecture +import ConversationTab import GitHubCopilotService +import HostAppActivator +import PersistMiddleware import SwiftUI public enum ChatTabBuilderCollection: Equatable { @@ -23,32 +27,121 @@ public struct ChatTabKind: Equatable { } } -@Reducer -public struct ChatPanelFeature { - public struct ChatTabGroup: Equatable { - public var tabInfo: IdentifiedArray - public var tabCollection: [ChatTabBuilderCollection] - public var selectedTabId: String? - - public var selectedTabInfo: ChatTabInfo? { - guard let id = selectedTabId else { return tabInfo.first } - return tabInfo[id: id] +public struct WorkspaceIdentifier: Hashable, Codable { + public let path: String + public let username: String + + public init(path: String, username: String) { + self.path = path + self.username = username + } +} + +@ObservableState +public struct ChatHistory: Equatable { + public var workspaces: IdentifiedArray + public var selectedWorkspacePath: String? + public var selectedWorkspaceName: String? + public var currentUsername: String? + + public var currentChatWorkspace: ChatWorkspace? { + guard let id = selectedWorkspacePath, + let username = currentUsername + else { return workspaces.first } + let identifier = WorkspaceIdentifier(path: id, username: username) + return workspaces[id: identifier] + } + + init(workspaces: IdentifiedArray = [], + selectedWorkspacePath: String? = nil, + selectedWorkspaceName: String? = nil, + currentUsername: String? = nil) { + self.workspaces = workspaces + self.selectedWorkspacePath = selectedWorkspacePath + self.selectedWorkspaceName = selectedWorkspaceName + self.currentUsername = currentUsername + } + + mutating func updateHistory(_ workspace: ChatWorkspace) { + if let index = workspaces.firstIndex(where: { $0.id == workspace.id }) { + workspaces[index] = workspace } + } + + mutating func addWorkspace(_ workspace: ChatWorkspace) { + guard !workspaces.contains(where: { $0.id == workspace.id }) else { return } + workspaces[id: workspace.id] = workspace + } +} + +@ObservableState +public struct ChatWorkspace: Identifiable, Equatable { + public var id: WorkspaceIdentifier + public var tabInfo: IdentifiedArray + public var tabCollection: [ChatTabBuilderCollection] + public var selectedTabId: String? + + public var selectedTabInfo: ChatTabInfo? { + guard let tabId = selectedTabId else { return tabInfo.first } + return tabInfo[id: tabId] + } - init( - tabInfo: IdentifiedArray = [], - tabCollection: [ChatTabBuilderCollection] = [], - selectedTabId: String? = nil - ) { - self.tabInfo = tabInfo - self.tabCollection = tabCollection - self.selectedTabId = selectedTabId + public var workspacePath: String { id.path } + public var username: String { id.username } + + private var onTabInfoDeleted: (String) -> Void + + public init( + id: WorkspaceIdentifier, + tabInfo: IdentifiedArray = [], + tabCollection: [ChatTabBuilderCollection] = [], + 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) } } +} +@Reducer +public struct ChatPanelFeature { @ObservableState public struct State: Equatable { - public var chatTabGroup = ChatTabGroup() + public var chatHistory = ChatHistory() + public var currentChatWorkspace: ChatWorkspace? { + return chatHistory.currentChatWorkspace + } + var colorScheme: ColorScheme = .light public internal(set) var isPanelDisplayed = false var isDetached = false @@ -65,20 +158,40 @@ public struct ChatPanelFeature { case enterFullScreen case exitFullScreen case presentChatPanel(forceDetach: Bool) + case switchWorkspace(String, String, String) + case openSettings // Tabs - case updateChatTabInfo(IdentifiedArray) - case createNewTapButtonHovered + case updateChatHistory(ChatWorkspace) +// case updateChatTabInfo(IdentifiedArray) +// 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 switchToNextTab - case switchToPreviousTab - case moveChatTab(from: Int, to: Int) + case appendTabToWorkspace(ChatTabInfo, ChatWorkspace) +// case switchToNextTab +// case switchToPreviousTab +// case moveChatTab(from: Int, to: Int) case focusActiveChatTab + // Chat History + case chatHistoryItemClicked(id: String) + case 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 @@ -86,6 +199,7 @@ public struct ChatPanelFeature { @Dependency(\.activatePreviousActiveXcode) var activatePreviouslyActiveXcode @Dependency(\.activateThisApp) var activateExtensionService @Dependency(\.chatTabBuilderCollection) var chatTabBuilderCollection + @Dependency(\.chatTabPool) var chatTabPool @MainActor func toggleFullScreen() { let window = suggestionWidgetControllerDependency.windowsController?.windows @@ -94,7 +208,8 @@ public struct ChatPanelFeature { } public var body: some ReducerOf { - Reduce { state, action in + Reduce { + state, action in switch action { case .hideButtonClicked: state.isPanelDisplayed = false @@ -111,7 +226,7 @@ public struct ChatPanelFeature { } case .closeActiveTabClicked: - if let id = state.chatTabGroup.selectedTabId { + if let id = state.currentChatWorkspace?.selectedTabId { return .run { send in await send(.closeTabButtonClicked(id: id)) } @@ -121,12 +236,13 @@ public struct ChatPanelFeature { return .none case .toggleChatPanelDetachedButtonClicked: - if state.isFullScreen, state.isDetached { + if state.isFullScreen, + state.isDetached { return .run { send in await send(.attachChatPanel) } } - + state.isDetached.toggle() return .none @@ -138,7 +254,7 @@ public struct ChatPanelFeature { if state.isFullScreen { return .run { send in await MainActor.run { toggleFullScreen() } - try await Task.sleep(nanoseconds: 1_000_000_000) + try await Task.sleep(nanoseconds: 1000000000) await send(.attachChatPanel) } } @@ -165,133 +281,418 @@ public struct ChatPanelFeature { activateExtensionService() await send(.focusActiveChatTab) } - - case let .updateChatTabInfo(chatTabInfo): - let previousSelectedIndex = state.chatTabGroup.tabInfo - .firstIndex(where: { $0.id == state.chatTabGroup.selectedTabId }) - state.chatTabGroup.tabInfo = chatTabInfo - if !chatTabInfo.contains(where: { $0.id == state.chatTabGroup.selectedTabId }) { - if let previousSelectedIndex { - let proposedSelectedIndex = previousSelectedIndex - 1 - if proposedSelectedIndex >= 0, - proposedSelectedIndex < chatTabInfo.endIndex - { - state.chatTabGroup.selectedTabId = chatTabInfo[proposedSelectedIndex].id - } else { - state.chatTabGroup.selectedTabId = chatTabInfo.first?.id - } - } else { - state.chatTabGroup.selectedTabId = nil - } + case let .switchWorkspace(path, name, username): + state.chatHistory.selectedWorkspacePath = path + state.chatHistory.selectedWorkspaceName = name + state.chatHistory.currentUsername = username + if state.chatHistory.currentChatWorkspace == nil { + let identifier = WorkspaceIdentifier(path: path, username: username) + state.chatHistory.addWorkspace( + ChatWorkspace(id: identifier) { chatTabPool.removeTab(of: $0) } + ) } return .none + case .openSettings: + try? launchHostAppSettings() + return .none + case let .updateChatHistory(chatWorkspace): + state.chatHistory.updateHistory(chatWorkspace) + return .none +// case let .updateChatTabInfo(chatTabInfo): +// let previousSelectedIndex = state.chatTabGroup.tabInfo +// .firstIndex(where: { $0.id == state.chatTabGroup.selectedTabId }) +// state.chatTabGroup.tabInfo = chatTabInfo +// if !chatTabInfo.contains(where: { $0.id == state.chatTabGroup.selectedTabId }) { +// if let previousSelectedIndex { +// let proposedSelectedIndex = previousSelectedIndex - 1 +// if proposedSelectedIndex >= 0, +// proposedSelectedIndex < chatTabInfo.endIndex +// { +// state.chatTabGroup.selectedTabId = chatTabInfo[proposedSelectedIndex].id +// } else { +// state.chatTabGroup.selectedTabId = chatTabInfo.first?.id +// } +// } else { +// state.chatTabGroup.selectedTabId = nil +// } +// } +// return .none case let .closeTabButtonClicked(id): - let firstIndex = state.chatTabGroup.tabInfo.firstIndex { $0.id == id } + guard var currentChatWorkspace = state.currentChatWorkspace else { + return .none + } + let firstIndex = currentChatWorkspace.tabInfo.firstIndex { $0.id == id } let nextIndex = { guard let firstIndex else { return 0 } let nextIndex = firstIndex - 1 return max(nextIndex, 0) }() - state.chatTabGroup.tabInfo.removeAll { $0.id == id } - if state.chatTabGroup.tabInfo.isEmpty { + currentChatWorkspace.tabInfo.removeAll { $0.id == id } + if currentChatWorkspace.tabInfo.isEmpty { state.isPanelDisplayed = false } - if nextIndex < state.chatTabGroup.tabInfo.count { - state.chatTabGroup.selectedTabId = state.chatTabGroup.tabInfo[nextIndex].id + if nextIndex < currentChatWorkspace.tabInfo.count { + currentChatWorkspace.selectedTabId = currentChatWorkspace.tabInfo[nextIndex].id } else { - state.chatTabGroup.selectedTabId = nil + currentChatWorkspace.selectedTabId = nil } + state.chatHistory.updateHistory(currentChatWorkspace) return .none - case .createNewTapButtonHovered: - state.chatTabGroup.tabCollection = chatTabBuilderCollection() - return .none + case let .chatHistoryDeleteButtonClicked(id): + // the current chat should not be deleted + guard var currentChatWorkspace = state.currentChatWorkspace, + id != currentChatWorkspace.selectedTabId else { + return .none + } + let CLSConversationID = currentChatWorkspace.tabInfo.first { + $0.id == id + }?.CLSConversationID + currentChatWorkspace.tabInfo.removeAll { $0.id == id } + state.chatHistory.updateHistory(currentChatWorkspace) + + let chatWorkspace = currentChatWorkspace + return .run { send in + await send(.deleteChatTabInfo(id: id, chatWorkspace)) + await ToolAutoApprovalManager.shared.clearConversationData(conversationId: CLSConversationID) + } + +// case .createNewTapButtonHovered: +// state.chatTabGroup.tabCollection = chatTabBuilderCollection() +// 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 state.chatTabGroup.tabInfo.contains(where: { $0.id == id }) else { - state.chatTabGroup.selectedTabId = nil + guard var currentChatWorkspace = state.currentChatWorkspace, + var chatTabInfo = currentChatWorkspace.tabInfo.first(where: { $0.id == id }) else { +// chatTabGroup.selectedTabId = nil return .none } - state.chatTabGroup.selectedTabId = id - return .run { send in - await send(.focusActiveChatTab) - } - case let .appendAndSelectTab(tab): - guard !state.chatTabGroup.tabInfo.contains(where: { $0.id == tab.id }) - else { return .none } - state.chatTabGroup.tabInfo.append(tab) - state.chatTabGroup.selectedTabId = tab.id + let (originalTab, currentTab) = currentChatWorkspace.switchTab(to: &chatTabInfo) + state.chatHistory.updateHistory(currentChatWorkspace) + + let workspace = currentChatWorkspace return .run { send in await send(.focusActiveChatTab) + await send(.saveChatTabInfo([originalTab, currentTab], workspace)) + await send(.syncChatTabInfo([originalTab, currentTab])) } - case .switchToNextTab: - let selectedId = state.chatTabGroup.selectedTabId - guard let index = state.chatTabGroup.tabInfo - .firstIndex(where: { $0.id == selectedId }) + case let .chatHistoryItemClicked(id): + guard var chatWorkspace = state.currentChatWorkspace, + // No Need to swicth selected Tab when already selected + id != chatWorkspace.selectedTabId else { return .none } - let nextIndex = index + 1 - if nextIndex >= state.chatTabGroup.tabInfo.endIndex { - 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 targetId = state.chatTabGroup.tabInfo[nextIndex].id - state.chatTabGroup.selectedTabId = targetId + + // 3. Tab not found - create a new one return .run { send in - await send(.focusActiveChatTab) + await send(.createNewTabByID(id: id)) } - case .switchToPreviousTab: - let selectedId = state.chatTabGroup.selectedTabId - guard let index = state.chatTabGroup.tabInfo - .firstIndex(where: { $0.id == selectedId }) + case var .appendAndSelectTab(tab): + guard var chatWorkspace = state.currentChatWorkspace, + !chatWorkspace.tabInfo.contains(where: { $0.id == tab.id }) else { return .none } - let previousIndex = index - 1 - if previousIndex < 0 || previousIndex >= state.chatTabGroup.tabInfo.endIndex { - return .none - } - let targetId = state.chatTabGroup.tabInfo[previousIndex].id - state.chatTabGroup.selectedTabId = targetId + + chatWorkspace.tabInfo.append(tab) + let (originalTab, currentTab) = chatWorkspace.switchTab(to: &tab) + state.chatHistory.updateHistory(chatWorkspace) + + let currentChatWorkspace = chatWorkspace 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 }) + else { return .none } + var targetWorkspace = chatWorkspace + targetWorkspace.tabInfo.append(tab) + let (originalTab, currentTab) = targetWorkspace.switchTab(to: &tab) + state.chatHistory.updateHistory(targetWorkspace) - case let .moveChatTab(from, to): - guard from >= 0, from < state.chatTabGroup.tabInfo.endIndex, to >= 0, - to <= state.chatTabGroup.tabInfo.endIndex - else { - return .none + let currentChatWorkspace = targetWorkspace + return .run { send in + await send(.saveChatTabInfo([originalTab, currentTab], currentChatWorkspace)) + await send(.scheduleLRUCleanup(currentChatWorkspace)) + await send(.syncChatTabInfo([originalTab, currentTab])) } - let tab = state.chatTabGroup.tabInfo[from] - state.chatTabGroup.tabInfo.remove(at: from) - state.chatTabGroup.tabInfo.insert(tab, at: to) - return .none + +// case .switchToNextTab: +// let selectedId = state.chatTabGroup.selectedTabId +// guard let index = state.chatTabGroup.tabInfo +// .firstIndex(where: { $0.id == selectedId }) +// else { return .none } +// let nextIndex = index + 1 +// if nextIndex >= state.chatTabGroup.tabInfo.endIndex { +// return .none +// } +// let targetId = state.chatTabGroup.tabInfo[nextIndex].id +// state.chatTabGroup.selectedTabId = targetId +// return .run { send in +// await send(.focusActiveChatTab) +// } + +// case .switchToPreviousTab: +// let selectedId = state.chatTabGroup.selectedTabId +// guard let index = state.chatTabGroup.tabInfo +// .firstIndex(where: { $0.id == selectedId }) +// else { return .none } +// let previousIndex = index - 1 +// if previousIndex < 0 || previousIndex >= state.chatTabGroup.tabInfo.endIndex { +// return .none +// } +// let targetId = state.chatTabGroup.tabInfo[previousIndex].id +// state.chatTabGroup.selectedTabId = targetId +// return .run { send in +// await send(.focusActiveChatTab) +// } + +// case let .moveChatTab(from, to): +// guard from >= 0, from < state.chatTabGroup.tabInfo.endIndex, to >= 0, +// to <= state.chatTabGroup.tabInfo.endIndex +// else { +// return .none +// } +// let tab = state.chatTabGroup.tabInfo[from] +// state.chatTabGroup.tabInfo.remove(at: from) +// state.chatTabGroup.tabInfo.insert(tab, at: to) +// return .none case .focusActiveChatTab: guard FeatureFlagNotifierImpl.shared.featureFlags.chat else { return .none } - let id = state.chatTabGroup.selectedTabInfo?.id + let id = state.currentChatWorkspace?.selectedTabInfo?.id guard let id else { return .none } return .run { send in await send(.chatTab(id: id, action: .focus)) } - case let .chatTab(id, .close): +// case let .chatTab(id, .close): +// return .run { send in +// await send(.closeTabButtonClicked(id: id)) +// } + + // MARK: - ChatTabItem action + + case let .chatTab(id, .tabContentUpdated): + guard var currentChatWorkspace = state.currentChatWorkspace, + var info = state.currentChatWorkspace?.tabInfo[id: id] + else { return .none } + + info.updatedAt = .now + currentChatWorkspace.tabInfo[id: id] = info + state.chatHistory.updateHistory(currentChatWorkspace) + + let chatTabInfo = info + let chatWorkspace = currentChatWorkspace + return .run { send in + await send(.saveChatTabInfo([chatTabInfo], chatWorkspace)) + } + + case let .chatTab(id, .setCLSConversationID(CID)): + guard var currentChatWorkspace = state.currentChatWorkspace, + var info = state.currentChatWorkspace?.tabInfo[id: id] + else { return .none } + + info.CLSConversationID = CID + currentChatWorkspace.tabInfo[id: id] = info + state.chatHistory.updateHistory(currentChatWorkspace) + + let chatTabInfo = info + let chatWorkspace = currentChatWorkspace return .run { send in - await send(.closeTabButtonClicked(id: id)) + await send(.saveChatTabInfo([chatTabInfo], chatWorkspace)) + } + + case let .chatTab(id, .updateTitle(title)): + guard var currentChatWorkspace = state.currentChatWorkspace, + var info = state.currentChatWorkspace?.tabInfo[id: id], + !info.isTitleSet + else { return .none } + + info.title = title + info.updatedAt = .now + currentChatWorkspace.tabInfo[id: id] = info + state.chatHistory.updateHistory(currentChatWorkspace) + + let chatTabInfo = info + let chatWorkspace = currentChatWorkspace + return .run { send in + await send(.saveChatTabInfo([chatTabInfo], chatWorkspace)) } case .chatTab: return .none + + // MARK: - Persist + + case let .saveChatTabInfo(chatTabInfos, chatWorkspace): + let toSaveInfo = chatTabInfos.compactMap { $0 } + guard toSaveInfo.count > 0 else { return .none } + let workspacePath = chatWorkspace.workspacePath + let username = chatWorkspace.username + + return .run { _ in + Task(priority: .background) { + ChatTabInfoStore.saveAll(toSaveInfo, with: .init(workspacePath: workspacePath, username: username)) + } + } + + case let .deleteChatTabInfo(id, chatWorkspace): + let workspacePath = chatWorkspace.workspacePath + let username = chatWorkspace.username + + ChatTabInfoStore.delete(by: id, with: .init(workspacePath: workspacePath, username: username)) + return .none + case var .restoreWorkspace(chatWorkspace): + // chat opened before finishing restoration + if var existChatWorkspace = state.chatHistory.workspaces[id: chatWorkspace.id] { + if var selectedChatTabInfo = chatWorkspace.tabInfo.first(where: { $0.id == chatWorkspace.selectedTabId }) { + // Keep the selection state when restoring + selectedChatTabInfo.isSelected = true + chatWorkspace.tabInfo[id: selectedChatTabInfo.id] = selectedChatTabInfo + + // Update the existing workspace's selected tab to match + existChatWorkspace.selectedTabId = selectedChatTabInfo.id + + // merge tab info + existChatWorkspace.tabInfo.append(contentsOf: chatWorkspace.tabInfo) + state.chatHistory.updateHistory(existChatWorkspace) + + let chatTabInfo = selectedChatTabInfo + 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) + + let workspace = existChatWorkspace + return .run { send in + await send(.scheduleLRUCleanup(workspace)) + } + } + + state.chatHistory.addWorkspace(chatWorkspace) + return .none + + case let .syncChatTabInfo(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 let .scheduleLRUCleanup(chatWorkspace): + return .run { send in + await send(.performLRUCleanup(chatWorkspace)) + }.cancellable(id: "lru-cleanup-\(chatWorkspace.id)", cancelInFlight: true) // apply built-in race condition prevention + + case var .performLRUCleanup(chatWorkspace): + chatWorkspace.applyLRULimit() + state.chatHistory.updateHistory(chatWorkspace) + return .none } - }.forEach(\.chatTabGroup.tabInfo, action: /Action.chatTab) { - ChatTabItem() } +// .forEach(\.chatGroupCollection.selectedChatGroup?.tabInfo, action: /Action.chatTab) { +// ChatTabItem() +// } } } +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 selectedTabId != chatTabInfo.id else { return (nil, chatTabInfo) } + + // get original selected tab info to update its isSelected + var originalTabInfo: ChatTabInfo? + if selectedTabId != nil { + originalTabInfo = tabInfo[id: selectedTabId!] + } + + // fresh selected info in chatWorksapce and tabInfo + selectedTabId = chatTabInfo.id + originalTabInfo?.isSelected = false + chatTabInfo.isSelected = true + + // update tab back to chatWorkspace + let isNewTab = tabInfo[id: chatTabInfo.id] == nil + tabInfo[id: chatTabInfo.id] = chatTabInfo + if isNewTab { + applyLRULimit() + } + + if let originalTabInfo { + tabInfo[id: originalTabInfo.id] = originalTabInfo + } + + return (originalTabInfo, chatTabInfo) + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift new file mode 100644 index 00000000..ed7b4375 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift @@ -0,0 +1,356 @@ +import ChatService +import ComposableArchitecture +import AppKit +import AXHelper +import ConversationServiceProvider +import Foundation +import LanguageServerProtocol +import Logger +import Terminal +import XcodeInspector +import SuggestionBasic +import ConversationTab + +@Reducer +public struct CodeReviewPanelFeature { + @ObservableState + public struct State: Equatable { + public fileprivate(set) var documentReviews: DocumentReviewsByUri = [:] + public var operatedCommentIds: Set = [] + public var currentIndex: Int = 0 + public var activeDocumentURL: URL? = nil + public var isPanelDisplayed: Bool = false + public var closedByUser: Bool = false + + public var currentDocumentReview: DocumentReview? { + if let url = activeDocumentURL, + let result = documentReviews[url.absoluteString] + { + return result + } + return nil + } + + public var currentSelectedComment: ReviewComment? { + guard let currentDocumentReview = currentDocumentReview else { return nil } + guard currentIndex >= 0 && currentIndex < currentDocumentReview.comments.count + else { return nil } + + return currentDocumentReview.comments[currentIndex] + } + + public var originalContent: String? { currentDocumentReview?.originalContent } + + public var documentUris: [DocumentUri] { Array(documentReviews.keys) } + + public var pendingNavigation: PendingNavigation? = nil + + public func getCommentById(id: String) -> ReviewComment? { + // Check current selected comment first for efficiency + if let currentSelectedComment = currentSelectedComment, + currentSelectedComment.id == id { + return currentSelectedComment + } + + // Search through all document reviews + for documentReview in documentReviews.values { + for comment in documentReview.comments { + if comment.id == id { + return comment + } + } + } + + return nil + } + + public func getOriginalContentByUri(_ uri: DocumentUri) -> String? { + documentReviews[uri]?.originalContent + } + + public var hasNextComment: Bool { hasComment(of: .next) } + public var hasPreviousComment: Bool { hasComment(of: .previous) } + + public init() {} + } + + public struct PendingNavigation: Equatable { + public let url: URL + public let index: Int + + public init(url: URL, index: Int) { + self.url = url + self.index = index + } + } + + public enum Action: Equatable { + case next + case previous + case close(commentId: String) + case dismiss(commentId: String) + case accept(commentId: String) + + case onActiveDocumentURLChanged(URL?) + + case appear + case onCodeReviewResultsChanged(DocumentReviewsByUri) + case observeDocumentReviews + case observeReviewedFileClicked + + case checkDisplay + case reviewedfileClicked + } + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .next: + let nextIndex = state.currentIndex + 1 + if let reviewComments = state.currentDocumentReview?.comments, + reviewComments.count > nextIndex { + state.currentIndex = nextIndex + return .none + } + + if let result = state.getDocumentNavigation(.next) { + state.navigateToDocument(uri: result.documentUri, index: result.commentIndex) + } + + return .none + + case .previous: + let previousIndex = state.currentIndex - 1 + if let reviewComments = state.currentDocumentReview?.comments, + reviewComments.count > previousIndex && previousIndex >= 0 { + state.currentIndex = previousIndex + return .none + } + + if let result = state.getDocumentNavigation(.previous) { + state.navigateToDocument(uri: result.documentUri, index: result.commentIndex) + } + + return .none + + case let .close(id): + state.isPanelDisplayed = false + state.closedByUser = true + + return .none + + case let .dismiss(id): + state.operatedCommentIds.insert(id) + return .run { send in + await send(.checkDisplay) + await send(.next) + } + + case let .accept(id): + guard !state.operatedCommentIds.contains(id), + let comment = state.getCommentById(id: id), + let suggestion = comment.suggestion, + let url = URL(string: comment.uri), + let currentContent = try? String(contentsOf: url), + let originalContent = state.getOriginalContentByUri(comment.uri) + else { return .none } + + let currentLines = currentContent.components(separatedBy: .newlines) + + let currentEndLineNumber = CodeReviewLocationStrategy.calculateCurrentLineNumber( + for: comment.range.end.line, + originalLines: originalContent.components(separatedBy: .newlines), + currentLines: currentLines + ) + + let range: CursorRange = .init( + start: .init( + line: currentEndLineNumber - (comment.range.end.line - comment.range.start.line), + character: comment.range.start.character + ), + end: .init(line: currentEndLineNumber, character: comment.range.end.character) + ) + + ChatInjector.insertSuggestion( + suggestion: suggestion, + range: range, + lines: currentLines + ) + + state.operatedCommentIds.insert(id) + + return .none + + case let .onActiveDocumentURLChanged(url): + if url != state.activeDocumentURL { + if let pendingNavigation = state.pendingNavigation, + pendingNavigation.url == url { + state.activeDocumentURL = url + state.currentIndex = pendingNavigation.index + } else { + state.activeDocumentURL = url + state.currentIndex = 0 + } + } + return .run { send in await send(.checkDisplay) } + + case .appear: + return .run { send in + await send(.observeDocumentReviews) + await send(.observeReviewedFileClicked) + } + + case .observeDocumentReviews: + return .run { send in + for await documentReviews in await CodeReviewService.shared.$documentReviews.values { + await send(.onCodeReviewResultsChanged(documentReviews)) + } + } + + case .observeReviewedFileClicked: + return .run { send in + for await _ in await CodeReviewStateService.shared.fileClickedEvent.values { + await send(.reviewedfileClicked) + } + } + + case let .onCodeReviewResultsChanged(newCodeReviewResults): + state.documentReviews = newCodeReviewResults + + return .run { send in await send(.checkDisplay) } + + case .checkDisplay: + guard !state.closedByUser else { + state.isPanelDisplayed = false + return .none + } + + if let currentDocumentReview = state.currentDocumentReview, + currentDocumentReview.comments.count > 0 { + state.isPanelDisplayed = true + } else { + state.isPanelDisplayed = false + } + + return .none + + case .reviewedfileClicked: + state.isPanelDisplayed = true + state.closedByUser = false + + return .none + } + } + } +} + +enum NavigationDirection { + case previous, next +} + +extension CodeReviewPanelFeature.State { + func getDocumentNavigation(_ direction: NavigationDirection) -> (documentUri: String, commentIndex: Int)? { + let documentUris = documentUris + let documentUrisCount = documentUris.count + + guard documentUrisCount > 1, + let activeDocumentURL = activeDocumentURL, + let documentIndex = documentUris.firstIndex(where: { $0 == activeDocumentURL.absoluteString }) + else { return nil } + + var offSet = 1 + // Iter documentUris to find valid next/previous document and comment + while offSet < documentUrisCount { + let targetDocumentIndex: Int = { + switch direction { + case .previous: (documentIndex - offSet + documentUrisCount) % documentUrisCount + case .next: (documentIndex + offSet) % documentUrisCount + } + }() + + let targetDocumentUri = documentUris[targetDocumentIndex] + if let targetComments = documentReviews[targetDocumentUri]?.comments, + !targetComments.isEmpty { + let targetCommentIndex: Int = { + switch direction { + case .previous: targetComments.count - 1 + case .next: 0 + } + }() + + return (targetDocumentUri, targetCommentIndex) + } + + offSet += 1 + } + + return nil + } + + mutating func navigateToDocument(uri: String, index: Int) { + let url = URL(fileURLWithPath: uri) + let originalContent = documentReviews[uri]!.originalContent + let comment = documentReviews[uri]!.comments[index] + + openFileInXcode(fileURL: url, originalContent: originalContent, range: comment.range) + + pendingNavigation = .init(url: url, index: index) + } + + func hasComment(of direction: NavigationDirection) -> Bool { + // Has next comment against current document + switch direction { + case .next: + if currentDocumentReview?.comments.count ?? 0 > currentIndex + 1 { + return true + } + case .previous: + if currentIndex > 0 { + return true + } + } + + // Has next comment against next document + if getDocumentNavigation(direction) != nil { + return true + } + + return false + } +} + +private func openFileInXcode( + fileURL: URL, + originalContent: String, + range: LSPRange +) { + NSWorkspace.openFileInXcode(fileURL: fileURL) { app, error in + guard error == nil else { + Logger.client.error("Failed to open file in xcode: \(error!.localizedDescription)") + return + } + + guard let app = app else { return } + + let appInstanceInspector = AppInstanceInspector(runningApplication: app) + guard appInstanceInspector.isXcode, + let focusedElement = appInstanceInspector.appElement.focusedElement, + let content = try? String(contentsOf: fileURL) + else { return } + + let currentLineNumber = CodeReviewLocationStrategy.calculateCurrentLineNumber( + for: range.end.line, + originalLines: originalContent.components(separatedBy: .newlines), + currentLines: content.components(separatedBy: .newlines) + ) + + + AXHelper.scrollSourceEditorToLine( + currentLineNumber, + content: content, + focusedElement: focusedElement + ) + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/FixErrorPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/FixErrorPanelFeature.swift new file mode 100644 index 00000000..71850fa5 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/FixErrorPanelFeature.swift @@ -0,0 +1,266 @@ +import ComposableArchitecture +import Foundation +import SuggestionBasic +import XcodeInspector +import ChatTab +import ConversationTab + +@Reducer +public struct FixErrorPanelFeature { + @ObservableState + public struct State: Equatable { + public var focusedEditor: SourceEditor? = nil + public var editorContent: EditorInformation.SourceEditorContent? = nil + public var fixId: String? = nil + public var fixFailure: FixEditorErrorIssueFailure? = nil + public var cursorPosition: CursorPosition? { + editorContent?.cursorPosition + } + public var isPanelDisplayed: Bool = false + public var shouldCheckingAnnotations: Bool = false { + didSet { + if shouldCheckingAnnotations { + annotationCheckStartTime = Date() + } + } + } + public var maxCheckDuration: TimeInterval = 30.0 + public var annotationCheckStartTime: Date? = nil + + public var editorContentLines: [String] { + editorContent?.lines ?? [] + } + + public var errorAnnotationsAtCursorPosition: [EditorInformation.LineAnnotation] { + guard let editorContent = editorContent else { + return [] + } + + return getErrorAnnotationsAtCursor(from: editorContent) + } + + public func getErrorAnnotationsAtCursor(from editorContent: EditorInformation.SourceEditorContent) -> [EditorInformation.LineAnnotation] { + return editorContent.lineAnnotations + .filter { $0.isError } + .filter { $0.line == editorContent.cursorPosition.line + 1 } + } + + public mutating func resetFailure() { + fixFailure = nil + fixId = nil + } + } + + public enum Action: Equatable { + case onFocusedEditorChanged(SourceEditor?) + case onEditorContentChanged + case onScrollPositionChanged + case onCursorPositionChanged + + case fixErrorIssue([EditorInformation.LineAnnotation]) + case scheduleFixFailureReset + case observeErrorNotification + + case appear + case onFailure(FixEditorErrorIssueFailure) + case checkDisplay + case resetFixFailure + + // Annotation checking + case startAnnotationCheck + case onAnnotationCheckTimerFired + case stopCheckingAnnotation + } + + let id = UUID() + + enum CancelID: Hashable { + case observeErrorNotification(UUID) + case annotationCheck(UUID) + case scheduleFixFailureReset(UUID) + } + + public init() {} + + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.observeErrorNotification) + await send(.startAnnotationCheck) + } + + case .observeErrorNotification: + return .run { send in + let stream = AsyncStream { continuation in + let observer = NotificationCenter.default.addObserver( + forName: .fixEditorErrorIssueError, + object: nil, + queue: .main + ) { notification in + guard let error = notification.userInfo?["error"] as? FixEditorErrorIssueFailure + else { + return + } + + Task { + await send(.onFailure(error)) + } + } + + continuation.onTermination = { _ in + NotificationCenter.default.removeObserver(observer) + } + } + + for await _ in stream { + // Stream continues until cancelled + } + }.cancellable( + id: CancelID.observeErrorNotification(id), + cancelInFlight: true + ) + case .onFocusedEditorChanged(let editor): + state.focusedEditor = editor + state.editorContent = nil + state.shouldCheckingAnnotations = true + return .none + + case .onEditorContentChanged: + state.shouldCheckingAnnotations = true + return .none + + case .onScrollPositionChanged: + if state.shouldCheckingAnnotations { + state.shouldCheckingAnnotations = false + } + if state.editorContent != nil { + state.editorContent = nil + } + return .none + + case .onCursorPositionChanged: + state.shouldCheckingAnnotations = true + return .none + + case .fixErrorIssue(let annotations): + guard let fileURL = state.focusedEditor?.realtimeDocumentURL ?? nil, + let workspaceURL = state.focusedEditor?.realtimeWorkspaceURL ?? nil + else { + return .none + } + + let fixId = UUID().uuidString + state.fixId = fixId + state.fixFailure = nil + + let editorErrorIssue: EditorErrorIssue = .init( + lineAnnotations: annotations, + fileURL: fileURL, + workspaceURL: workspaceURL, + id: fixId + ) + + let userInfo = [ + "editorErrorIssue": editorErrorIssue + ] + + return .run { _ in + await MainActor.run { + suggestionWidgetControllerDependency.onOpenChatClicked() + + NotificationCenter.default.post( + name: .fixEditorErrorIssue, + object: nil, + userInfo: userInfo + ) + } + } + + case .scheduleFixFailureReset: + return .run { send in + try await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds + await send(.resetFixFailure) + } + .cancellable(id: CancelID.scheduleFixFailureReset(id), cancelInFlight: true) + + case .resetFixFailure: + state.resetFailure() + return .cancel(id: CancelID.scheduleFixFailureReset(id)) + + case .onFailure(let failure): + guard case let .isReceivingMessage(fixId) = failure, + fixId == state.fixId + else { + return .none + } + + state.fixFailure = failure + + return .run { send in await send(.scheduleFixFailureReset)} + + case .checkDisplay: + state.isPanelDisplayed = !state.editorContentLines.isEmpty + && !state.errorAnnotationsAtCursorPosition.isEmpty + return .none + + // MARK: - Annotation Check + + case .startAnnotationCheck: + return .run { send in + let interval: TimeInterval = 2 + + while !Task.isCancelled { + try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) + + await send(.onAnnotationCheckTimerFired) + } + }.cancellable(id: CancelID.annotationCheck(id), cancelInFlight: true) + + case .onAnnotationCheckTimerFired: + // Check if max duration exceeded + if let startTime = state.annotationCheckStartTime, + Date().timeIntervalSince(startTime) > state.maxCheckDuration { + return .run { send in + await send(.stopCheckingAnnotation) + await send(.checkDisplay) + } + } + + guard state.shouldCheckingAnnotations, + let editor = state.focusedEditor + else { + return .run { send in + await send(.checkDisplay) + } + } + + let newEditorContent = editor.getContent() + let newErrorAnnotationsAtCursorPosition = state.getErrorAnnotationsAtCursor(from: newEditorContent) + let errorAnnotationsAtCursorPosition = state.errorAnnotationsAtCursorPosition + + if state.editorContent != newEditorContent { + state.editorContent = newEditorContent + } + + if Set(errorAnnotationsAtCursorPosition) != Set(newErrorAnnotationsAtCursorPosition) { + // Keep checking annotations as Xcode may update them asynchronously after content changes + return .merge( + .run { send in + await send(.checkDisplay) + } + ) + } else { + return .none + } + + case .stopCheckingAnnotation: + state.shouldCheckingAnnotations = false + return .none + } + } + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/NESSuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/NESSuggestionPanelFeature.swift new file mode 100644 index 00000000..1bb7dc47 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/NESSuggestionPanelFeature.swift @@ -0,0 +1,62 @@ +import ComposableArchitecture +import Foundation +import SwiftUI + +@Reducer +public struct NESSuggestionPanelFeature { + @ObservableState + public struct State: Equatable { + static let baseFontSize: CGFloat = 13 + static let defaultLineHeight: Double = 18 + + var nesContent: NESCodeSuggestionProvider? { + didSet { closeNotificationByUser = false } + } + var colorScheme: ColorScheme = .light + var firstLineIndent: Double = 0 + var lineHeight: Double = Self.defaultLineHeight + var lineFontSize: Double { + Self.baseFontSize * fontSizeScale + } + var isPanelDisplayed: Bool = false + public var isPanelOutOfFrame: Bool = false + var closeNotificationByUser: Bool = false + // TODO: handle warnings + // var warningMessage: String? + // var warningURL: String? + var opacity: Double { + guard isPanelDisplayed else { return 0 } + if isPanelOutOfFrame { return 0 } + guard nesContent != nil else { return 0 } + return 1 + } + var menuViewOpacity: Double { + guard nesContent != nil else { return 0 } + guard isPanelDisplayed else { return 0 } + return isPanelOutOfFrame ? 0 : 1 + } + var diffViewOpacity: Double { menuViewOpacity } + var notificationViewOpacity: Double { + guard nesContent != nil else { return 0 } + guard isPanelDisplayed else { return 0 } + return isPanelOutOfFrame ? 1 : 0 + } + var fontSizeScale: Double { + (lineHeight / Self.defaultLineHeight * 100).rounded() / 100 + } + } + + public enum Action: Equatable { + case onUserCloseNotification + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onUserCloseNotification: + state.closeNotificationByUser = true + return .none + } + } + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift index 1e3f3dc8..525affb4 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -4,6 +4,10 @@ import Foundation @Reducer public struct PanelFeature { + public enum PanelType { + case suggestion, nes, agentConfiguration + } + @ObservableState public struct State: Equatable { public var content: SharedPanelFeature.Content { @@ -11,6 +15,13 @@ public struct PanelFeature { set { sharedPanelState.content = newValue suggestionPanelState.content = newValue.suggestion + } + } + + public var nesContent: NESCodeSuggestionProvider? { + get { nesSuggestionPanelState.nesContent } + set { + nesSuggestionPanelState.nesContent = newValue } } @@ -21,23 +32,44 @@ public struct PanelFeature { // MARK: SuggestionPanel var suggestionPanelState = SuggestionPanelFeature.State() + + // MARK: NESSuggestionPanel + + public var nesSuggestionPanelState = NESSuggestionPanelFeature.State() + + // MARK: SubAgent + + public var agentConfigurationWidgetState = AgentConfigurationWidgetFeature.State() + + var warningMessage: String? + var warningURL: String? } public enum Action: Equatable { case presentSuggestion + case presentNESSuggestion case presentSuggestionProvider(CodeSuggestionProvider, displayContent: Bool) + case presentNESSuggestionProvider(NESCodeSuggestionProvider, displayContent: Bool) case presentError(String) case presentPromptToCode(PromptToCodeGroup.PromptToCodeInitialState) case displayPanelContent + case displayNESPanelContent case expandSuggestion case discardSuggestion + case discardNESSuggestion case removeDisplayedContent case switchToAnotherEditorAndUpdateContent - case hidePanel - case showPanel + case hidePanel(PanelType) + case showPanel(PanelType) + case onRealtimeNESToggleChanged(Bool) case sharedPanel(SharedPanelFeature.Action) case suggestionPanel(SuggestionPanelFeature.Action) + case nesSuggestionPanel(NESSuggestionPanelFeature.Action) + case agentConfigurationWidget(AgentConfigurationWidgetFeature.Action) + + case presentWarning(message: String, url: String?) + case dismissWarning } @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @@ -53,6 +85,14 @@ public struct PanelFeature { Scope(state: \.sharedPanelState, action: \.sharedPanel) { SharedPanelFeature() } + + Scope(state: \.nesSuggestionPanelState, action: \.nesSuggestionPanel) { + NESSuggestionPanelFeature() + } + + Scope(state: \.agentConfigurationWidgetState, action: \.agentConfigurationWidget) { + AgentConfigurationWidgetFeature() + } Reduce { state, action in switch action { @@ -63,6 +103,14 @@ public struct PanelFeature { else { return } await send(.presentSuggestionProvider(provider, displayContent: true)) } + + case .presentNESSuggestion: + return .run { send in + guard let fileURL = await xcodeInspector.safe.activeDocumentURL, + let provider = await fetchNESSuggestionProvider(fileURL: fileURL) + else { return } + await send(.presentNESSuggestionProvider(provider, displayContent: true)) + } case let .presentSuggestionProvider(provider, displayContent): state.content.suggestion = provider @@ -72,6 +120,15 @@ public struct PanelFeature { }.animation(.easeInOut(duration: 0.2)) } return .none + + case let .presentNESSuggestionProvider(provider, displayContent): + state.nesContent = provider + if displayContent { + return .run { send in + await send(.displayNESPanelContent) + }.animation(.easeInOut(duration: 0.2)) + } + return .none case let .presentError(errorDescription): state.content.error = errorDescription @@ -92,12 +149,22 @@ public struct PanelFeature { if state.suggestionPanelState.content != nil { state.suggestionPanelState.isPanelDisplayed = true } - + return .none + + case .displayNESPanelContent: + if state.nesSuggestionPanelState.nesContent != nil { + state.nesSuggestionPanelState.isPanelDisplayed = true + } return .none case .discardSuggestion: state.content.suggestion = nil return .none + + case .discardNESSuggestion: + state.nesContent = nil + return .none + case .expandSuggestion: state.content.isExpanded = true return .none @@ -112,15 +179,39 @@ public struct PanelFeature { ) )) } - case .hidePanel: - state.suggestionPanelState.isPanelDisplayed = false + case .hidePanel(let panelType): + switch panelType { + case .suggestion: + state.suggestionPanelState.isPanelDisplayed = false + case .nes: + state.nesSuggestionPanelState.isPanelDisplayed = false + case .agentConfiguration: + state.agentConfigurationWidgetState.isPanelDisplayed = false + } return .none - case .showPanel: - state.suggestionPanelState.isPanelDisplayed = true + case .showPanel(let panelType): + switch panelType { + case .suggestion: + state.suggestionPanelState.isPanelDisplayed = true + case .nes: + state.nesSuggestionPanelState.isPanelDisplayed = true + case .agentConfiguration: + state.agentConfigurationWidgetState.isPanelDisplayed = true + } return .none + case let .onRealtimeNESToggleChanged(isOn): + if !isOn { + return .run { send in + await send(.hidePanel(.nes)) + await send(.discardNESSuggestion) + } + } + return .none + case .removeDisplayedContent: state.content.error = nil state.content.suggestion = nil + state.nesContent = nil return .none case .sharedPanel(.promptToCodeGroup(.activateOrCreatePromptToCode)), @@ -142,6 +233,26 @@ public struct PanelFeature { case .suggestionPanel: return .none + + case .nesSuggestionPanel: + return .none + + case .agentConfigurationWidget: + return .none + + case .presentWarning(let message, let url): + state.warningMessage = message + state.warningURL = url + state.suggestionPanelState.warningMessage = message + state.suggestionPanelState.warningURL = url + return .none + + case .dismissWarning: + state.warningMessage = nil + state.warningURL = nil + state.suggestionPanelState.warningMessage = nil + state.suggestionPanelState.warningURL = nil + return .none } } } @@ -152,5 +263,12 @@ public struct PanelFeature { .suggestionForFile(at: fileURL) else { return nil } return provider } + + func fetchNESSuggestionProvider(fileURL: URL) async -> NESCodeSuggestionProvider? { + guard let provider = await suggestionWidgetControllerDependency + .suggestionWidgetDataSource? + .nesSuggestionForFile(at: fileURL) else { return nil } + return provider + } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift index 82010dfe..028ae777 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift @@ -14,6 +14,8 @@ public struct SuggestionPanelFeature { var lineHeight: Double = 17 var isPanelDisplayed: Bool = false var isPanelOutOfFrame: Bool = false + var warningMessage: String? + var warningURL: String? var opacity: Double { guard isPanelDisplayed else { return 0 } if isPanelOutOfFrame { return 0 } @@ -24,9 +26,19 @@ public struct SuggestionPanelFeature { public enum Action: Equatable { case noAction + case dismissWarning } public var body: some ReducerOf { - Reduce { _, _ in .none } + Reduce { state, action in + switch action { + case .dismissWarning: + state.warningMessage = nil + state.warningURL = nil + return .none + default: + return .none + } + } } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index be6c2e68..0bbda7e5 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -36,6 +36,14 @@ public struct WidgetFeature { // MARK: ChatPanel public var chatPanelState = ChatPanelFeature.State() + + // MARK: CodeReview + + public var codeReviewPanelState = CodeReviewPanelFeature.State() + + // MARK: FixError + + public var fixErrorPanelState = FixErrorPanelFeature.State() // MARK: CircularWidget @@ -66,8 +74,9 @@ public struct WidgetFeature { } return false }(), - isContentEmpty: chatPanelState.chatTabGroup.tabInfo.isEmpty - && panelState.sharedPanelState.isEmpty, + isContentEmpty: chatPanelState.currentChatWorkspace == nil + || (chatPanelState.currentChatWorkspace!.tabInfo.isEmpty + && panelState.sharedPanelState.isEmpty), isChatPanelDetached: chatPanelState.isDetached, isChatOpen: chatPanelState.isPanelDisplayed ) @@ -102,6 +111,8 @@ public struct WidgetFeature { case updateColorScheme case updatePanelStateToMatch(WidgetLocation) + case updateNESSuggestionPanelStateToMatch(WidgetLocation) + case updateAgentConfigurationWidgetStateToMatch(WidgetLocation) case updateFocusingDocumentURL case setFocusingDocumentURL(to: URL?) case updateKeyWindow(WindowCanBecomeKey) @@ -110,6 +121,8 @@ public struct WidgetFeature { case panel(PanelFeature.Action) case chatPanel(ChatPanelFeature.Action) case circularWidget(CircularWidgetFeature.Action) + case codeReviewPanel(CodeReviewPanelFeature.Action) + case fixErrorPanel(FixErrorPanelFeature.Action) } var windowsController: WidgetWindowsController? { @@ -137,6 +150,14 @@ public struct WidgetFeature { Scope(state: \._internalCircularWidgetState, action: \.circularWidget) { CircularWidgetFeature() } + + Scope(state: \.codeReviewPanelState, action: \.codeReviewPanel) { + CodeReviewPanelFeature() + } + + Scope(state: \.fixErrorPanelState, action: \.fixErrorPanel) { + FixErrorPanelFeature() + } Reduce { state, action in switch action { @@ -162,7 +183,7 @@ public struct WidgetFeature { } let isDisplayingContent = state._internalCircularWidgetState.isDisplayingContent - let hasChat = state.chatPanelState.chatTabGroup.selectedTabInfo != nil + let hasChat = state.chatPanelState.currentChatWorkspace?.selectedTabInfo != nil let hasPromptToCode = state.panelState.sharedPanelState.content .promptToCodeGroup.activePromptToCode != nil @@ -372,6 +393,36 @@ public struct WidgetFeature { .alignPanelTop return .none + + case let .updateNESSuggestionPanelStateToMatch(widgetLocation): + + guard let nesSuggestionPanelLocation = widgetLocation.nesSuggestionPanelLocation else { + state.panelState.nesSuggestionPanelState.isPanelDisplayed = false + state.panelState.nesSuggestionPanelState.isPanelOutOfFrame = false + return .none + } + + let lineFirstCharacterFrame = nesSuggestionPanelLocation.lineFirstCharacterFrame + let scrollViewFrame = nesSuggestionPanelLocation.scrollViewFrame + if scrollViewFrame.contains(lineFirstCharacterFrame) { + state.panelState.nesSuggestionPanelState.isPanelOutOfFrame = false + } else { + state.panelState.nesSuggestionPanelState.isPanelOutOfFrame = true + } + state.panelState.nesSuggestionPanelState.lineHeight = nesSuggestionPanelLocation.lineHeight + + return .none + + case let .updateAgentConfigurationWidgetStateToMatch(widgetLocation): + guard let agentConfigurationWidgetLocation = widgetLocation.agentConfigurationWidgetLocation else { + state.panelState.agentConfigurationWidgetState.isPanelDisplayed = false + return .none + } + + state.panelState.agentConfigurationWidgetState.isPanelDisplayed = true + state.panelState.agentConfigurationWidgetState.lineHeight = agentConfigurationWidgetLocation.lineHeight + + return .none case let .updateKeyWindow(window): return .run { _ in @@ -398,6 +449,12 @@ public struct WidgetFeature { case .chatPanel: return .none + + case .codeReviewPanel: + return .none + + case .fixErrorPanel: + return .none } } } diff --git a/Core/Sources/SuggestionWidget/FixErrorPanelView.swift b/Core/Sources/SuggestionWidget/FixErrorPanelView.swift new file mode 100644 index 00000000..0799b7a0 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FixErrorPanelView.swift @@ -0,0 +1,96 @@ +import SwiftUI +import ComposableArchitecture +import SuggestionBasic +import ConversationTab + +private typealias FixErrorViewStore = ViewStore + +private struct ViewState: Equatable { + let errorAnnotationsAtCursorPosition: [EditorInformation.LineAnnotation] + let fixFailure: FixEditorErrorIssueFailure? + let isPanelDisplayed: Bool + + init(state: FixErrorPanelFeature.State) { + self.errorAnnotationsAtCursorPosition = state.errorAnnotationsAtCursorPosition + self.fixFailure = state.fixFailure + self.isPanelDisplayed = state.isPanelDisplayed + } +} + +struct FixErrorPanelView: View { + let store: StoreOf + + @State private var showFailurePopover = false + @Environment(\.colorScheme) var colorScheme + + var body: some View { + WithViewStore(self.store, observe: ViewState.init) { viewStore in + WithPerceptionTracking { + + VStack { + buildFixErrorButton(viewStore: viewStore) + .popover(isPresented: $showFailurePopover) { + if let fixFailure = viewStore.fixFailure { + buildFailureView(failure: fixFailure) + .padding(.horizontal, 4) + } + } + } + .onAppear { viewStore.send(.appear) } + .onChange(of: viewStore.fixFailure) { + showFailurePopover = $0 != nil + } + .animation(.easeInOut(duration: 0.2), value: viewStore.isPanelDisplayed) + } + } + } + + @ViewBuilder + private func buildFixErrorButton(viewStore: FixErrorViewStore) -> some View { + let annotations = viewStore.errorAnnotationsAtCursorPosition + let rect = annotations.first(where: { $0.rect != nil })?.rect ?? nil + let annotationHeight = rect?.height ?? 16 + let iconSize = annotationHeight * 0.7 + + Group { + if !annotations.isEmpty { + ZStack { + Button(action: { + store.send(.fixErrorIssue(annotations)) + }) { + Image("FixError") + .resizable() + .scaledToFit() + .frame(width: iconSize, height: iconSize) + .padding((annotationHeight - iconSize) / 2) + .foregroundColor(.white) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color("FixErrorBackgroundColor").opacity(0.8)) + ) + } + } else { + Color.clear + .frame(width: 0, height: 0) + } + } + } + + @ViewBuilder + private func buildFailureView(failure: FixEditorErrorIssueFailure) -> some View { + let message: String = { + switch failure { + case .isReceivingMessage: "Copilot is still processing the last message. Please wait…" + } + }() + + Text(message) + .font(.system(size: 14)) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .cornerRadius(4) + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESDiffView.swift b/Core/Sources/SuggestionWidget/NES/NESDiffView.swift new file mode 100644 index 00000000..5d650596 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESDiffView.swift @@ -0,0 +1,150 @@ +import SwiftUI +import ComposableArchitecture +import SuggestionBasic + +struct NESDiffView: View { + var store: StoreOf + + var body: some View { + WithPerceptionTracking { + if store.isPanelDisplayed, + !store.isPanelOutOfFrame, + let nesContent = store.nesContent, + let originalCodeSnippet = nesContent.getOriginalCodeSnippet() + { + let nesCode = nesContent.code + + ScrollView(showsIndicators: true) { + Group { + if nesContent.range.isOneLine && nesCode.components(separatedBy: .newlines).count <= 1 { + InlineDiffView( + store: store, + segments: DiffBuilder.inlineSegments( + oldLine: originalCodeSnippet, + newLine: nesCode + ) + ) + } else { + LineDiffView( + store: store, + segments: DiffBuilder.lineSegments( + oldContent: originalCodeSnippet, + newContent: nesCode + ) + ) + } + } + } + .padding(.leading, 12 * store.fontSizeScale) + .padding(.trailing, 10 * store.fontSizeScale) + .padding(.vertical, 4 * store.fontSizeScale) + .xcodeStyleFrame() + .opacity(store.diffViewOpacity) + } + } + } +} + + +private struct AccentStrip: View { + let store: StoreOf + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill(.blue) + .frame(width: 4 * store.fontSizeScale) + } +} + +struct InlineDiffView: View { + let store: StoreOf + let segments: [DiffSegment] + + var body: some View { + HStack(spacing: 0) { + AccentStrip(store: store) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in + buildSegmentView(segment) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + @ViewBuilder + func buildSegmentView(_ segment: DiffSegment) -> some View { + Text(verbatim: segment.text.diffDisplayEscaped()) + .lineLimit(1) + .font(.system(size: store.lineFontSize, weight: .medium)) + .padding(.vertical, 4 * store.fontSizeScale) + .background( + Rectangle() + .fill(segment.backgroundColor) + ) + .alignmentGuide(.firstTextBaseline) { d in + d[.firstTextBaseline] + } + } +} + + +struct LineDiffView: View { + let store: StoreOf + let segments: [DiffSegment] + + var body: some View { + HStack(spacing: 0) { + AccentStrip(store: store) + + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in + buildSegmentView(segment) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + @ViewBuilder + func buildSegmentView(_ segment: DiffSegment) -> some View { + Text(segment.text.diffDisplayEscaped()) + .font(.system(size: store.lineFontSize, weight: .medium)) + .multilineTextAlignment(.leading) + .padding(.vertical, 4 * store.fontSizeScale) + .background( + Rectangle() + .fill(segment.backgroundColor) + ) + } +} + + +extension DiffSegment { + var backgroundColor: Color { + switch change { + case .added: return Color("editor.focusedStackFrameHighlightBackground") + case .removed: return Color("editorOverviewRuler.inlineChatRemoved") + case .unchanged: return .clear + } + } +} + +private extension String { + func diffDisplayEscaped() -> String { + var escaped = "" + for scalar in unicodeScalars { + switch scalar { + case "\n": escaped.append("\\n") + case "\r": escaped.append("\\r") + case "\t": escaped.append("\\t") + default: escaped.append(Character(scalar)) + } + } + return escaped + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESDiffView/NESDiffBuilder.swift b/Core/Sources/SuggestionWidget/NES/NESDiffView/NESDiffBuilder.swift new file mode 100644 index 00000000..54b9c6d6 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESDiffView/NESDiffBuilder.swift @@ -0,0 +1,136 @@ +import Foundation + +struct DiffSegment { + enum Change { + case unchanged + case added + case removed + } + let text: String + let change: Change +} + +enum DiffBuilder { + static func inlineSegments(oldLine: String, newLine: String) -> [DiffSegment] { + let oldTokens = tokenizePreservingWhitespace(oldLine) + let newTokens = tokenizePreservingWhitespace(newLine) + let condensed = condensedSegments(oldTokens: oldTokens, newTokens: newTokens) + return mergeInlineWhitespaceSegments(condensed) + } + + static func lineSegments(oldContent: String, newContent: String) -> [DiffSegment] { + let oldLines = oldContent.components(separatedBy: .newlines) + let newLines = newContent.components(separatedBy: .newlines) + return diff(tokensInOld: oldLines, tokensInNew: newLines) + } + + private static func tokenizePreservingWhitespace(_ text: String) -> [String] { + guard !text.isEmpty else { return [] } + // This pattern matches either: + // - a sequence of non-whitespace characters (\\S+) followed by optional whitespace (\\s*), or + // - a sequence of whitespace characters (\\s+) + // This ensures that tokens preserve trailing whitespace, or capture standalone whitespace sequences. + let pattern = "\\S+\\s*|\\s+" + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return [text] + } + let nsText = text as NSString + let fullRange = NSRange(location: 0, length: nsText.length) + let matches = regex.matches(in: text, range: fullRange) + if matches.isEmpty { + return [text] + } + return matches.map { nsText.substring(with: $0.range) } + } + + private static func condensedSegments(oldTokens: [String], newTokens: [String]) -> [DiffSegment] { + let raw = diff(tokensInOld: oldTokens, tokensInNew: newTokens) + guard var last = raw.first else { return [] } + var condensed: [DiffSegment] = [] + for segment in raw.dropFirst() { + if segment.change == last.change { + last = DiffSegment(text: last.text + segment.text, change: last.change) + } else { + condensed.append(last) + last = segment + } + } + condensed.append(last) + return condensed + } + + private static func diff(tokensInOld oldTokens: [String], tokensInNew newTokens: [String]) -> [DiffSegment] { + let m = oldTokens.count + let n = newTokens.count + if m == 0 { return newTokens.map { DiffSegment(text: $0, change: .added) } } + if n == 0 { return oldTokens.map { DiffSegment(text: $0, change: .removed) } } + var lcs = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1) + for i in 1...m { + for j in 1...n { + if oldTokens[i - 1] == newTokens[j - 1] { + lcs[i][j] = lcs[i - 1][j - 1] + 1 + } else { + lcs[i][j] = max(lcs[i - 1][j], lcs[i][j - 1]) + } + } + } + var i = m + var j = n + var result: [DiffSegment] = [] + while i > 0 && j > 0 { + if oldTokens[i - 1] == newTokens[j - 1] { + result.append(DiffSegment(text: oldTokens[i - 1], change: .unchanged)) + i -= 1 + j -= 1 + } else if lcs[i - 1][j] > lcs[i][j - 1] { + result.append(DiffSegment(text: oldTokens[i - 1], change: .removed)) + i -= 1 + } else { + result.append(DiffSegment(text: newTokens[j - 1], change: .added)) + j -= 1 + } + } + while i > 0 { + result.append(DiffSegment(text: oldTokens[i - 1], change: .removed)) + i -= 1 + } + while j > 0 { + result.append(DiffSegment(text: newTokens[j - 1], change: .added)) + j -= 1 + } + return result.reversed() + } + + private static func mergeInlineWhitespaceSegments(_ segments: [DiffSegment]) -> [DiffSegment] { + guard !segments.isEmpty else { return segments } + var merged: [DiffSegment] = [] + var index = 0 + while index < segments.count { + let current = segments[index] + switch current.change { + case .added, .removed: + var combinedText = current.text + var lookahead = index + 1 + while lookahead + 1 < segments.count, + segments[lookahead].change == .unchanged, + segments[lookahead].text.isWhitespaceOnly, + segments[lookahead + 1].change == current.change { + combinedText += segments[lookahead].text + segments[lookahead + 1].text + lookahead += 2 + } + merged.append(DiffSegment(text: combinedText, change: current.change)) + index = lookahead + case .unchanged: + merged.append(current) + index += 1 + } + } + return merged + } +} + +private extension String { + var isWhitespaceOnly: Bool { + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESCustomMenu.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESCustomMenu.swift new file mode 100644 index 00000000..e20ddbf8 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESCustomMenu.swift @@ -0,0 +1,24 @@ +import Cocoa +import CGEventOverride +import Logger + +class NESCustomMenu: NSMenu { + weak var menuController: NESMenuController? + + override func awakeFromNib() { + super.awakeFromNib() + } + + override init(title: String) { + super.init(title: title) + } + + required init(coder: NSCoder) { + super.init(coder: coder) + } + + private func setupMenuAppearance() { + self.showsStateColumn = false + self.allowsContextMenuPlugIns = false + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuButtonView.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuButtonView.swift new file mode 100644 index 00000000..c9dd8c59 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuButtonView.swift @@ -0,0 +1,94 @@ +import SwiftUI +import Cocoa +import Logger + +struct NESMenuButtonView: NSViewRepresentable { + let menuController: NESMenuController + var fontSize: CGFloat + + var buttonImage: NSImage? { + NSImage( + systemSymbolName: "arrow.right.to.line", + accessibilityDescription: "Next Edit Suggestion Menu" + ) + } + + var buttonFont: NSFont { + NSFont.systemFont(ofSize: fontSize, weight: .medium) + } + + func makeNSView(context: Context) -> NSButton { + let button = NSButton(frame: .zero) + button.title = "" + button.bezelStyle = .shadowlessSquare + button.isBordered = false + button.imageScaling = .scaleProportionallyDown + button.contentTintColor = .white + button.imagePosition = .imageOnly + button.focusRingType = .none + button.target = context.coordinator + button.action = #selector(Coordinator.buttonClicked) + button.font = buttonFont + + let baseConfig = NSImage.SymbolConfiguration(pointSize: fontSize, weight: .regular) + let colorConfig = NSImage.SymbolConfiguration(hierarchicalColor: NSColor.white) + button.image = buttonImage? + .withSymbolConfiguration(baseConfig)? + .withSymbolConfiguration(colorConfig) + + context.coordinator.setupMenu(for: button) + + return button + } + + func updateNSView(_ nsView: NSButton, context: Context) { + nsView.font = buttonFont + if let image = buttonImage { + let base = NSImage.SymbolConfiguration(pointSize: fontSize, weight: .regular) + let tinted = NSImage.SymbolConfiguration(hierarchicalColor: .white) + nsView.image = image.withSymbolConfiguration(base)?.withSymbolConfiguration(tinted) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(menuController: menuController) + } + + class Coordinator: NSObject { + let menuController: NESMenuController + private weak var button: NSButton? + + init(menuController: NESMenuController) { + self.menuController = menuController + super.init() + } + + func setupMenu(for button: NSButton) { + self.button = button + } + + @objc func buttonClicked(_ sender: NSButton) { + let menu = menuController.createMenu() + showMenu(menu, for: sender) + } + + private func showMenu(_ menu: NSMenu, for button: NSButton) { + // Ensure the button is still in a window before showing the menu + guard let window = button.window else { + return + } + + // Ensure menu is properly positioned and shown + let location = NSPoint(x: 0, y: button.bounds.height + 5) + let originalLevel = window.level + window.level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue + 1) + defer { window.level = originalLevel } + + menu.popUp(positioning: nil, at: location, in: button) + } + + @objc func menuDidClose(_ menu: NSMenu) { } + + @objc func menuWillOpen(_ menu: NSMenu) { } + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift new file mode 100644 index 00000000..071b1dd2 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift @@ -0,0 +1,230 @@ +import Cocoa +import ComposableArchitecture +import SwiftUI +import HostAppActivator + +class NESMenuController: ObservableObject { + private static let defaultParagraphTabStopLocation: CGFloat = 180.0 + private static let titleColor: NSColor = NSColor(Color.secondary) + private static let shortcutIconColor: NSColor = NSColor.tertiaryLabelColor + static let baseFontSize: CGFloat = 13 + + private var menu: NSMenu? + var fontSize: CGFloat { + didSet { menu = nil } + } + var fontSizeScale: Double { + didSet { menu = nil } + } + var store: StoreOf + + private var imageSize: NSSize { + NSSize(width: self.fontSize, height: self.fontSize) + } + private var paragraphStyle: NSMutableParagraphStyle { + let style = NSMutableParagraphStyle() + style.tabStops = [ + NSTextTab( + textAlignment: .right, + location: Self.defaultParagraphTabStopLocation * fontSizeScale + ) + ] + return style + } + + init(fontSize: CGFloat, fontSizeScale: Double, store: StoreOf) { + self.fontSize = fontSize + self.fontSizeScale = fontSizeScale + self.store = store + } + + func createMenu() -> NSMenu { + let menu = NESCustomMenu(title: "") + menu.menuController = self + + menu.font = NSFont.systemFont(ofSize: fontSize, weight: .regular) + + let titleItem = createTitleItem() + let settingsItem = createSettingItem() + let goToAcceptItem = createGoToAcceptItem() + let rejectItem = createRejectItem() + let moreInfoItem = createGetMoreInfoItem() + + menu.addItem(titleItem) + menu.addItem(NSMenuItem.separator()) + menu.addItem(settingsItem) + menu.addItem(NSMenuItem.separator()) + menu.addItem(goToAcceptItem) + menu.addItem(rejectItem) +// menu.addItem(NSMenuItem.separator()) +// menu.addItem(moreInfoItem) + + self.menu = menu + return menu + } + + private func createImage(_ name: String, description accessibilityDescription: String) -> NSImage? { + guard let image = NSImage( + systemSymbolName: name, accessibilityDescription: accessibilityDescription + ) else { return nil } + + image.size = self.imageSize + return image + } + + private func createParagraphAttributedTitle(_ text: String, helpText: String) -> NSAttributedString { + let attributedTitle = NSMutableAttributedString(string: text) + attributedTitle.append(NSAttributedString( + string: "\t\(helpText)", + attributes: [ + .foregroundColor: Self.shortcutIconColor, + .font: NSFont.systemFont(ofSize: fontSize - 1, weight: .regular), + .paragraphStyle: paragraphStyle + ] + )) + + attributedTitle.addAttribute( + .paragraphStyle, + value: paragraphStyle, + range: NSRange(location: 0, length: attributedTitle.length) + ) + + return attributedTitle + + } + + private func createParagraphAttributedTitle(_ text: String, systemSymbolName: String) -> NSAttributedString { + let attributedTitle = NSMutableAttributedString(string: text) + attributedTitle.append(NSAttributedString(string: "\t")) + + if let image = createImage(systemSymbolName, description: "\(systemSymbolName) key") { + let attachment = NSTextAttachment() + attachment.image = image + + let attachmentString = NSMutableAttributedString(attachment: attachment) + attachmentString.addAttributes([ + .foregroundColor: Self.shortcutIconColor, + .font: NSFont.systemFont(ofSize: fontSize - 1, weight: .regular), + .paragraphStyle: paragraphStyle + ], range: NSRange(location: 0, length: attachmentString.length)) + + attributedTitle.append(attachmentString) + } + + attributedTitle.addAttribute( + .paragraphStyle, + value: paragraphStyle, + range: NSRange(location: 0, length: attributedTitle.length) + ) + + return attributedTitle + + } + + @objc func handleSettingsAction() { + try? launchHostAppAdvancedSettings() + } + + @objc func handleGoToAcceptAction() { + let state = store.withState { $0 } + state.nesContent?.acceptNESSuggestion() + } + + @objc func handleRejectAction() { + let state = store.withState { $0 } + state.nesContent?.rejectNESSuggestion() + } + + @objc func handleGetMoreInfoAction() { } + + private func createTitleItem() -> NSMenuItem { + let titleItem = NSMenuItem() + + titleItem.isEnabled = false + + let attributedTitle = NSMutableAttributedString(string: "Copilot Next Edit Suggestion") + attributedTitle.addAttributes([ + .foregroundColor: Self.titleColor, + .font: NSFont.systemFont(ofSize: fontSize - 1, weight: .medium) + ], range: NSRange(location: 0, length: attributedTitle.length)) + + titleItem.attributedTitle = attributedTitle + return titleItem + } + + private func createSettingItem() -> NSMenuItem { + let settingsItem = NSMenuItem( + title: "Settings", + action: #selector(handleSettingsAction), + keyEquivalent: "" + ) + settingsItem.target = self + + if let gearImage = NSImage( + systemSymbolName: "gearshape", + accessibilityDescription: "Settings" + ) { + gearImage.size = self.imageSize + settingsItem.image = gearImage + } + + return settingsItem + } + + private func createGoToAcceptItem() -> NSMenuItem { + let goToAcceptItem = NSMenuItem( + title: "Go To / Accept", + action: #selector(handleGoToAcceptAction), + keyEquivalent: "" + ) + goToAcceptItem.target = self + + let imageSymbolName = "arrow.right.to.line" + + if let arrowImage = createImage(imageSymbolName, description: "Go To or Accept") { + goToAcceptItem.image = arrowImage + } + + let attributedTitle = createParagraphAttributedTitle("Go To / Accept", systemSymbolName: imageSymbolName) + goToAcceptItem.attributedTitle = attributedTitle + + return goToAcceptItem + } + + private func createRejectItem() -> NSMenuItem { + let rejectItem = NSMenuItem( + title: "Reject", + action: #selector(handleRejectAction), + keyEquivalent: "" + ) + rejectItem.target = self + + if let xImage = createImage("xmark", description: "Reject") { + rejectItem.image = xImage + } + + let attributedTitle = createParagraphAttributedTitle("Reject", helpText: "Esc") + rejectItem.attributedTitle = attributedTitle + + return rejectItem + } + + private func createGetMoreInfoItem() -> NSMenuItem { + let moreInfoItem = NSMenuItem( + title: "Get More Info", + action: #selector(handleGetMoreInfoAction), + keyEquivalent: "" + ) + moreInfoItem.target = self + + let attributedTitle = NSMutableAttributedString(string: "Get More Info") + attributedTitle.addAttributes([ + .foregroundColor: NSColor.linkColor, + .font: NSFont.systemFont(ofSize: fontSize, weight: .medium) + ], range: NSRange(location: 0, length: attributedTitle.length)) + + moreInfoItem.attributedTitle = attributedTitle + + return moreInfoItem + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESMenuView.swift b/Core/Sources/SuggestionWidget/NES/NESMenuView.swift new file mode 100644 index 00000000..b6ca96f7 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESMenuView.swift @@ -0,0 +1,57 @@ +import ComposableArchitecture +import SwiftUI +import Foundation +import SharedUIComponents +import XcodeInspector +import Logger + +struct NESMenuView: View { + let store: StoreOf + + @State private var menuController: NESMenuController + + init(store: StoreOf) { + self.store = store + self._menuController = State( + initialValue: NESMenuController( + fontSize: store.lineFontSize, + fontSizeScale: store.fontSizeScale, + store: store + ) + ) + } + + var body: some View { + WithPerceptionTracking { + let lineHeight = store.lineHeight + let fontSizeScale = store.fontSizeScale + let fontSize = store.lineFontSize + if store.isPanelDisplayed && !store.isPanelOutOfFrame && store.nesContent != nil { + NESMenuButtonView( + menuController: menuController, + fontSize: fontSize + ) + .id("nes-menu-button") + .frame(width: lineHeight, height: calcMenuHeight(by: lineHeight)) + .padding(.horizontal, 3 * fontSizeScale) + .padding(.leading, 1 * fontSizeScale) + .padding(.vertical, 3 * fontSizeScale) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color("LightBluePrimary")) + ) + .opacity(store.menuViewOpacity) + .onChange(of: store.lineFontSize) { + menuController.fontSize = $0 + } + .onChange(of: store.fontSizeScale) { + menuController.fontSizeScale = $0 + } + } + } + } + + private func calcMenuHeight(by lineHeight: Double) -> Double { + return (lineHeight * 2 / 3 * 100).rounded() / 100 + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESNotificationView.swift b/Core/Sources/SuggestionWidget/NES/NESNotificationView.swift new file mode 100644 index 00000000..a73ebaae --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESNotificationView.swift @@ -0,0 +1,66 @@ +import SwiftUI +import ComposableArchitecture +import Logger + +struct NESNotificationView: View { + let store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + WithPerceptionTracking { + if store.isPanelOutOfFrame, + !store.closeNotificationByUser, + store.nesContent != nil { + + let fontSize = store.lineFontSize + let scale = store.fontSizeScale + + HStack(spacing: 8) { + Image("EditSparkle") + .resizable() + .scaledToFit() + .font(.system(size: calcImageFontSize(fontSize, scale), weight: .medium)) + + HStack(spacing: 4 * scale) { + Text("Press") + + Text("Tab") + .foregroundStyle(.secondary) + + Text("to jump to Next Edit Suggestion") + } + .font(.system(size: fontSize, weight: .medium)) + + Button(action: { + store.send(.onUserCloseNotification) + }) { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .font(.system(size: calcImageFontSize(fontSize, scale), weight: .medium)) + } + .foregroundStyle(Color(NSColor.controlBackgroundColor)) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(.primary) + ) + .shadow( + color: Color("NESShadowColor"), + radius: 12, + x: 0, + y: 3 + ) + .opacity(store.notificationViewOpacity) + } + } + .frame(maxWidth: .infinity, alignment: .center) + } + + func calcImageFontSize(_ baseFontSize: CGFloat, _ scale: Double) -> CGFloat { + return baseFontSize + 2 * scale + } +} diff --git a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift index dd50233f..0ec9bb1f 100644 --- a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift +++ b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift @@ -4,6 +4,8 @@ import Perception import SharedUIComponents import SwiftUI import XcodeInspector +import SuggestionBasic +import WorkspaceSuggestionService @Perceptible public final class CodeSuggestionProvider: Equatable { @@ -58,3 +60,95 @@ public final class CodeSuggestionProvider: Equatable { } +@Perceptible +public final class NESCodeSuggestionProvider: Equatable { + public static func == (lhs: NESCodeSuggestionProvider, rhs: NESCodeSuggestionProvider) -> Bool { + lhs.code == rhs.code && lhs.language == rhs.language + } + + public let fileURL: URL + public let code: String + public let sourceSnapshot: FilespaceSuggestionSnapshot + public let range: CursorRange + public let language: String + + @PerceptionIgnored public var onRejectSuggestionTapped: () -> Void + @PerceptionIgnored public var onAcceptNESSuggestionTapped: () -> Void + @PerceptionIgnored public var onDismissNESSuggestionTapped: () -> Void + + public init( + fileURL: URL, + code: String, + sourceSnapshot: FilespaceSuggestionSnapshot, + range: CursorRange, + language: String = "", + onRejectSuggestionTapped: @escaping () -> Void = {}, + onAcceptNESSuggestionTapped: @escaping () -> Void = {}, + onDismissNESSuggestionTapped: @escaping () -> Void = {} + ) { + self.fileURL = fileURL + self.code = code + self.sourceSnapshot = sourceSnapshot + self.range = range + self.language = language + self.onRejectSuggestionTapped = onRejectSuggestionTapped + self.onAcceptNESSuggestionTapped = onAcceptNESSuggestionTapped + self.onDismissNESSuggestionTapped = onDismissNESSuggestionTapped + } + + func rejectNESSuggestion() { onRejectSuggestionTapped() } + func acceptNESSuggestion() { onAcceptNESSuggestionTapped() } + func dismissNESSuggestion() { onDismissNESSuggestionTapped() } + + func getOriginalCodeSnippet() -> String? { + /// The lines is from `EditorContent`, the "\n" is kept there. + let lines = sourceSnapshot.lines.joined(separator: "").components(separatedBy: .newlines) + guard range.start.line >= 0, + range.end.line >= range.start.line, + range.end.line < lines.count + else { return nil } + + // Single line case + if range.start.line == range.end.line { + let line = lines[range.start.line] + let startIndex = calcStartIndex(of: line, by: range) + let endIndex = calcEndIndex(of: line, by: range) + return String(line[startIndex.. 0) + let endIndex = calcEndIndex(of: line, by: range) + result.append(String(line[.. String.Index { + return line.index(line.startIndex, offsetBy: range.start.character, limitedBy: line.endIndex) ?? line.endIndex + } + + private func calcEndIndex(of line: String, by range: CursorRange) -> String.Index { + return line.index(line.startIndex, offsetBy: range.end.character, limitedBy: line.endIndex) ?? line.endIndex + } +} + diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index d8be66dc..6f063016 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -5,7 +5,8 @@ import SwiftUI enum Style { static let panelHeight: Double = 560 - static let panelWidth: Double = 454 + 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,11 @@ enum Style { static let widgetPadding: Double = 4 static let chatWindowTitleBarHeight: Double = 24 static let trafficLightButtonSize: Double = 12 + static let codeReviewPanelWidth: Double = 550 + static let codeReviewPanelHeight: Double = 450 + static let fixPanelToAnnotationSpacing: Double = 1 + static let nesSuggestionMenuLeadingPadding: Double = 4 + static let agentConfigurationWidgetLeadingSpacing: Double = 4 } extension Color { @@ -54,7 +60,7 @@ struct XcodeLikeFrame: View { content.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .background( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Material.bar) + .fill(Color.chatWindowBackgroundColor) ) .overlay( RoundedRectangle(cornerRadius: max(0, cornerRadius), style: .continuous) @@ -69,8 +75,9 @@ struct XcodeLikeFrame: View { } extension View { - func xcodeStyleFrame(cornerRadius: Double? = nil) -> some View { - XcodeLikeFrame(content: self, cornerRadius: cornerRadius ?? 10) + var xcodeStyleCornerRadius: Double { 16 } + func xcodeStyleFrame() -> some View { + XcodeLikeFrame(content: self, cornerRadius: xcodeStyleCornerRadius) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift index b5791d17..2ef813dc 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedUIComponents struct ErrorPanel: View { var description: String @@ -16,6 +17,7 @@ struct ErrorPanel: View { // close button Button(action: onCloseButtonTap) { Image(systemName: "xmark") + .scaledFont(.body) .padding([.leading, .bottom], 16) .padding([.top, .trailing], 8) .foregroundColor(.white) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift index 6e9ffab9..ee648ef3 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift @@ -4,42 +4,48 @@ import Foundation import SwiftUI import Toast +private struct HitTestConfiguration: ViewModifier { + let hitTestPredicate: () -> Bool + + func body(content: Content) -> some View { + WithPerceptionTracking { + content.allowsHitTesting(hitTestPredicate()) + } + } +} + struct ToastPanelView: View { let store: StoreOf + @Dependency(\.toastController) var toastController var body: some View { WithPerceptionTracking { VStack(spacing: 4) { if !store.alignTopToAnchor { Spacer() + .allowsHitTesting(false) } ForEach(store.toast.messages) { message in - message.content - .foregroundColor(.white) - .padding(8) - .frame(maxWidth: .infinity) - .background({ - switch message.type { - case .info: return Color.accentColor - case .error: return Color(nsColor: .systemRed) - case .warning: return Color(nsColor: .systemOrange) - } - }() as Color, in: RoundedRectangle(cornerRadius: 8)) - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color.black.opacity(0.3), lineWidth: 1) - } + NotificationView( + message: message, + onDismiss: { toastController.dismissMessage(withId: message.id) } + ) + .frame(maxWidth: 450) + // Allow hit testing for notification views + .allowsHitTesting(true) } if store.alignTopToAnchor { Spacer() + .allowsHitTesting(false) } } .colorScheme(store.colorScheme) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .allowsHitTesting(false) + .background(Color.clear) + // Only allow hit testing when there are messages + // to prevent the view from blocking the mouse events + .modifier(HitTestConfiguration(hitTestPredicate: { !store.toast.messages.isEmpty })) } } } - diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift new file mode 100644 index 00000000..d5a1719e --- /dev/null +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift @@ -0,0 +1,81 @@ +import SwiftUI +import SharedUIComponents +import XcodeInspector +import ComposableArchitecture + +struct WarningPanel: View { + let message: String + let url: String? + let firstLineIndent: Double + let onDismiss: () -> Void + + @Environment(\.colorScheme) var colorScheme + @Environment(CursorPositionTracker.self) var cursorPositionTracker + @AppStorage(\.clsWarningDismissedUntilRelaunch) var isDismissedUntilRelaunch + + var foregroundColor: Color { + return colorScheme == .light ? .black.opacity(0.85) : .white.opacity(0.85) + } + + var body: some View { + 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() + + HStack(spacing: 8) { + if let url = url { + Button("Upgrade Now") { + NSWorkspace.shared.open(URL(string: url)!) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color(nsColor: .controlAccentColor)) + .foregroundColor(Color(nsColor: .white)) + .cornerRadius(6) + .font(.system(size: 12)) + .fixedSize() + } + + Button("Dismiss") { + isDismissedUntilRelaunch = true + onDismiss() + } + .buttonStyle(.bordered) + .font(.system(size: 12)) + .keyboardShortcut(.escape, modifiers: []) + .fixedSize() + } + } + .padding(.top, 24) + .padding( + .leading, + firstLineIndent + 20 + CGFloat( + cursorPositionTracker.cursorPosition.character + ) + ) + .background(.clear) + } + } + } +} diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift index 2f2306d2..a0469859 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift @@ -7,30 +7,42 @@ struct SuggestionPanelView: View { var body: some View { WithPerceptionTracking { - VStack(spacing: 0) { - Content(store: store) - .allowsHitTesting( - store.isPanelDisplayed && !store.isPanelOutOfFrame + Group { + if let message = store.warningMessage { + WarningPanel( + message: message, + url: store.warningURL, + firstLineIndent: store.firstLineIndent + ) { + store.send(.dismissWarning) + } + } else { + VStack(spacing: 0) { + Content(store: store) + .allowsHitTesting( + store.isPanelDisplayed && !store.isPanelOutOfFrame + ) + .frame(maxWidth: .infinity) + } + .preferredColorScheme(store.colorScheme) + .opacity(store.opacity) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: store.isPanelDisplayed ) - .frame(maxWidth: .infinity) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: store.isPanelOutOfFrame + ) + .frame( + maxWidth: .infinity, + maxHeight: Style.inlineSuggestionMaxHeight, + alignment: .top + ) + } } - .preferredColorScheme(store.colorScheme) - .opacity(store.opacity) - .animation( - featureFlag: \.animationBCrashSuggestion, - .easeInOut(duration: 0.2), - value: store.isPanelDisplayed - ) - .animation( - featureFlag: \.animationBCrashSuggestion, - .easeInOut(duration: 0.2), - value: store.isPanelOutOfFrame - ) - .frame( - maxWidth: .infinity, - maxHeight: Style.inlineSuggestionMaxHeight, - alignment: .top - ) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 1d804529..366d98d3 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -8,6 +8,7 @@ import Preferences import SwiftUI import UserDefaultsObserver import XcodeInspector +import SuggestionBasic @MainActor public final class SuggestionWidgetController: NSObject { @@ -48,6 +49,11 @@ public extension SuggestionWidgetController { store.send(.panel(.presentSuggestion)) } + + func suggestNESCode() { + store.send(.panel(.presentNESSuggestion)) + } + func expandSuggestion() { store.withState { state in if state.panelState.content.suggestion != nil { @@ -63,6 +69,14 @@ public extension SuggestionWidgetController { } } } + + func discardNESSuggestion() { + store.withState { state in + if state.panelState.nesContent != nil { + store.send(.panel(.discardNESSuggestion)) + } + } + } #warning("TODO: Make a progress controller that doesn't use TCA.") func markAsProcessing(_ isProcessing: Bool) { @@ -92,3 +106,13 @@ public extension SuggestionWidgetController { } } +extension SuggestionWidgetController { + public func presentWarning(message: String, url: String?) { + store.send(.panel(.presentWarning(message: message, url: url))) + } + + public func dismissWarning() { + store.send(.panel(.dismissWarning)) + } +} + diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift index f7ad662a..9c691e14 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift @@ -2,6 +2,7 @@ import Foundation public protocol SuggestionWidgetDataSource { func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? + func nesSuggestionForFile(at url: URL) async -> NESCodeSuggestionProvider? } struct MockWidgetDataSource: SuggestionWidgetDataSource { @@ -20,5 +21,24 @@ struct MockWidgetDataSource: SuggestionWidgetDataSource { currentSuggestionIndex: 0 ) } + + func nesSuggestionForFile(at url: URL) async -> NESCodeSuggestionProvider? { + return NESCodeSuggestionProvider( + fileURL: URL(fileURLWithPath: "the/file/path.swift"), + code: """ + func test() { + let x = 1 + let y = 2 + let z = x + y + } + """, + sourceSnapshot: .init( + lines: [""], + cursorPosition: .init(line: 0, character: 0) + ), + range: .init(startPair: (1, 0), endPair: (2, 0)), + language: "swift" + ) + } } diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 3cb6298d..6f681218 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -1,18 +1,126 @@ import AppKit import Foundation +import XcodeInspector +import ConversationServiceProvider public struct WidgetLocation: Equatable { + // Indicates from where the widget location generation was triggered + enum LocationTrigger { + case sourceEditor, xcodeWorkspaceWindow, unknown, otherApp + + var isSourceEditor: Bool { self == .sourceEditor } + var isOtherApp: Bool { self == .otherApp } + var isFromXcode: Bool { self == .sourceEditor || self == .xcodeWorkspaceWindow} + } + + struct NESPanelLocation: Equatable { + struct DiffViewConstraints: Equatable { + var maxX: CGFloat + var y: CGFloat + var maxWidth: CGFloat + var maxHeight: CGFloat + } + + var scrollViewFrame: CGRect + var screenFrame: CGRect + var lineFirstCharacterFrame: CGRect + + var lineHeight: Double { + lineFirstCharacterFrame.height + } + var menuFrame: CGRect { + .init( + x: scrollViewFrame.minX + Style.nesSuggestionMenuLeadingPadding, + y: screenFrame.height - lineFirstCharacterFrame.maxY, + width: lineFirstCharacterFrame.width, + height: lineHeight + ) + } + + var availableHeight: CGFloat? { + guard scrollViewFrame.contains(lineFirstCharacterFrame) else { + return nil + } + return scrollViewFrame.maxY - lineFirstCharacterFrame.minY + } + + var availableWidth: CGFloat { + return scrollViewFrame.width / 2 + } + + func calcDiffViewFrame(contentSize: CGSize) -> CGRect? { + guard scrollViewFrame.contains(lineFirstCharacterFrame) else { + return nil + } + + let availableWidth = max(0, scrollViewFrame.width / 2) + let availableHeight = max(0, scrollViewFrame.maxY - lineFirstCharacterFrame.minY) + let preferredWidth = max(contentSize.width, 1) + let preferredHeight = max(contentSize.height, lineHeight) + + let width = availableWidth > 0 ? min(preferredWidth, availableWidth) : preferredWidth + let height = availableHeight > 0 ? min(preferredHeight, availableHeight) : preferredHeight + + return .init( + x: scrollViewFrame.maxX - width - Style.nesSuggestionMenuLeadingPadding, + y: screenFrame.height - lineFirstCharacterFrame.minY - height, + width: width, + height: height + ) + } + } + + struct AgentConfigurationWidgetLocation: Equatable { + var firstLineFrame: CGRect + var scrollViewRect: CGRect + var screenFrame: CGRect + var textEndX: CGFloat + + var lineHeight: CGFloat { + firstLineFrame.height + } + + func getWidgetFrame(_ originalFrame: NSRect) -> NSRect { + let width = originalFrame.width + let height = originalFrame.height + let lineCenter = firstLineFrame.minY + firstLineFrame.height / 2 + let panelHalfHeight = originalFrame.height / 2 + + return .init( + x: textEndX + Style.agentConfigurationWidgetLeadingSpacing, + y: screenFrame.maxY - lineCenter - panelHalfHeight + screenFrame.minY, + width: width, + height: height + ) + } + } + struct PanelLocation: Equatable { var frame: CGRect var alignPanelTop: Bool var firstLineIndent: Double? var lineHeight: Double? } - + var widgetFrame: CGRect var tabFrame: CGRect var defaultPanelLocation: PanelLocation var suggestionPanelLocation: PanelLocation? + var nesSuggestionPanelLocation: NESPanelLocation? + var locationTrigger: LocationTrigger = .unknown + var agentConfigurationWidgetLocation: AgentConfigurationWidgetLocation? + + mutating func setNESSuggestionPanelLocation(_ location: NESPanelLocation?) { + self.nesSuggestionPanelLocation = location + } + + mutating func setLocationTrigger(_ trigger: LocationTrigger) { + self.locationTrigger = trigger + } + + mutating func setAgentConfigurationWidgetLocation(_ location: AgentConfigurationWidgetLocation?) { + self.agentConfigurationWidgetLocation = location + } } enum UpdateLocationStrategy { @@ -28,10 +136,10 @@ enum UpdateLocationStrategy { ) -> WidgetLocation { guard let selectedRange: AXValue = try? editor .copyValue(key: kAXSelectedTextRangeAttribute), - let rect: AXValue = try? editor.copyParameterizedValue( + let rect: AXValue = try? editor.copyParameterizedValue( key: kAXBoundsForRangeParameterizedAttribute, parameters: selectedRange - ) + ) else { return FixedToBottom().framesForWindows( editorFrame: editorFrame, @@ -61,7 +169,7 @@ enum UpdateLocationStrategy { ) } } - + struct FixedToBottom { func framesForWindows( editorFrame: CGRect, @@ -84,7 +192,7 @@ enum UpdateLocationStrategy { ) } } - + struct HorizontalMovable { func framesForWindows( y: CGFloat, @@ -107,58 +215,52 @@ enum UpdateLocationStrategy { mainScreen.frame.height - editorFrame.minY - Style.widgetHeight - Style .widgetPadding ) - + var proposedAnchorFrameOnTheRightSide = CGRect( x: editorFrame.maxX - Style.widgetPadding, y: y, width: 0, height: 0 ) - + let widgetFrameOnTheRightSide = CGRect( x: editorFrame.maxX - Style.widgetPadding - Style.widgetWidth, y: y, width: Style.widgetWidth, height: Style.widgetHeight ) - + if !hideCircularWidget { proposedAnchorFrameOnTheRightSide = widgetFrameOnTheRightSide } - + let proposedPanelX = proposedAnchorFrameOnTheRightSide.maxX - + Style.widgetPadding * 2 - - editorFrameExpendedSize.width + + Style.widgetPadding * 2 + - editorFrameExpendedSize.width let putPanelToTheRight = { if editorFrame.size.width >= preferredInsideEditorMinWidth { return false } return activeScreen.frame.maxX > proposedPanelX + Style.panelWidth }() let alignPanelTopToAnchor = fixedAlignment ?? (y > activeScreen.frame.midY) - + + let chatPanelFrame = getChatPanelFrame(mainScreen) + if putPanelToTheRight { let anchorFrame = proposedAnchorFrameOnTheRightSide - let panelFrame = CGRect( - x: proposedPanelX, - y: alignPanelTopToAnchor - ? anchorFrame.maxY - Style.panelHeight - : anchorFrame.minY - editorFrameExpendedSize.height, - width: Style.panelWidth, - height: Style.panelHeight - ) let tabFrame = CGRect( x: anchorFrame.origin.x, y: alignPanelTopToAnchor - ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding - : anchorFrame.maxY + Style.widgetPadding, + ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding + : anchorFrame.maxY + Style.widgetPadding, width: Style.widgetWidth, height: Style.widgetHeight ) - + return .init( widgetFrame: widgetFrameOnTheRightSide, tabFrame: tabFrame, defaultPanelLocation: .init( - frame: panelFrame, + frame: chatPanelFrame, alignPanelTop: alignPanelTopToAnchor ), suggestionPanelLocation: nil @@ -170,22 +272,22 @@ enum UpdateLocationStrategy { width: 0, height: 0 ) - + let widgetFrameOnTheLeftSide = CGRect( x: editorFrame.minX + Style.widgetPadding, y: proposedAnchorFrameOnTheRightSide.origin.y, width: Style.widgetWidth, height: Style.widgetHeight ) - + if !hideCircularWidget { proposedAnchorFrameOnTheLeftSide = widgetFrameOnTheLeftSide } - + let proposedPanelX = proposedAnchorFrameOnTheLeftSide.minX - - Style.widgetPadding * 2 - - Style.panelWidth - + editorFrameExpendedSize.width + - Style.widgetPadding * 2 + - Style.panelWidth + + editorFrameExpendedSize.width let putAnchorToTheLeft = { if editorFrame.size.width >= preferredInsideEditorMinWidth { if editorFrame.maxX <= activeScreen.frame.maxX { @@ -194,22 +296,14 @@ enum UpdateLocationStrategy { } return proposedPanelX > activeScreen.frame.minX }() - + if putAnchorToTheLeft { let anchorFrame = proposedAnchorFrameOnTheLeftSide - let panelFrame = CGRect( - x: proposedPanelX, - y: alignPanelTopToAnchor - ? anchorFrame.maxY - Style.panelHeight - : anchorFrame.minY - editorFrameExpendedSize.height, - width: Style.panelWidth, - height: Style.panelHeight - ) let tabFrame = CGRect( x: anchorFrame.origin.x, y: alignPanelTopToAnchor - ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding - : anchorFrame.maxY + Style.widgetPadding, + ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding + : anchorFrame.maxY + Style.widgetPadding, width: Style.widgetWidth, height: Style.widgetHeight ) @@ -217,23 +311,13 @@ enum UpdateLocationStrategy { widgetFrame: widgetFrameOnTheLeftSide, tabFrame: tabFrame, defaultPanelLocation: .init( - frame: panelFrame, + frame: chatPanelFrame, alignPanelTop: alignPanelTopToAnchor ), suggestionPanelLocation: nil ) } else { let anchorFrame = proposedAnchorFrameOnTheRightSide - let panelFrame = CGRect( - x: anchorFrame.maxX - Style.panelWidth, - y: alignPanelTopToAnchor - ? anchorFrame.maxY - Style.panelHeight - Style.widgetHeight - - Style.widgetPadding - : anchorFrame.maxY + Style.widgetPadding - - editorFrameExpendedSize.height, - width: Style.panelWidth, - height: Style.panelHeight - ) let tabFrame = CGRect( x: anchorFrame.minX - Style.widgetPadding - Style.widgetWidth, y: anchorFrame.origin.y, @@ -244,7 +328,7 @@ enum UpdateLocationStrategy { widgetFrame: widgetFrameOnTheRightSide, tabFrame: tabFrame, defaultPanelLocation: .init( - frame: panelFrame, + frame: chatPanelFrame, alignPanelTop: alignPanelTopToAnchor ), suggestionPanelLocation: nil @@ -253,7 +337,7 @@ enum UpdateLocationStrategy { } } } - + struct NearbyTextCursor { func framesForSuggestionWindow( editorFrame: CGRect, @@ -264,35 +348,37 @@ enum UpdateLocationStrategy { ) -> WidgetLocation.PanelLocation? { guard let selectionFrame = UpdateLocationStrategy .getSelectionFirstLineFrame(editor: editor) else { return nil } - + // hide it when the line of code is outside of the editor visible rect if selectionFrame.maxY < editorFrame.minY || selectionFrame.minY > editorFrame.maxY { return nil } - + + let lineHeight: Double = selectionFrame.height + let selectionMinY = selectionFrame.minY // Always place suggestion window at cursor position. return .init( frame: .init( x: editorFrame.minX, - y: mainScreen.frame.height - selectionFrame.minY - Style.inlineSuggestionMaxHeight + Style.inlineSuggestionPadding, + y: mainScreen.frame.height - selectionMinY - Style.inlineSuggestionMaxHeight + Style.inlineSuggestionPadding, width: editorFrame.width, height: Style.inlineSuggestionMaxHeight ), alignPanelTop: true, firstLineIndent: selectionFrame.maxX - editorFrame.minX - Style.inlineSuggestionPadding, - lineHeight: selectionFrame.height + lineHeight: lineHeight ) } } - + /// Get the frame of the selection. static func getSelectionFrame(editor: AXUIElement) -> CGRect? { guard let selectedRange: AXValue = try? editor .copyValue(key: kAXSelectedTextRangeAttribute), - let rect: AXValue = try? editor.copyParameterizedValue( + let rect: AXValue = try? editor.copyParameterizedValue( key: kAXBoundsForRangeParameterizedAttribute, parameters: selectedRange - ) + ) else { return nil } @@ -301,36 +387,36 @@ enum UpdateLocationStrategy { guard found else { return nil } return selectionFrame } - + /// Get the frame of the first line of the selection. static func getSelectionFirstLineFrame(editor: AXUIElement) -> CGRect? { // Find selection range rect guard let selectedRange: AXValue = try? editor .copyValue(key: kAXSelectedTextRangeAttribute), - let rect: AXValue = try? editor.copyParameterizedValue( + let rect: AXValue = try? editor.copyParameterizedValue( key: kAXBoundsForRangeParameterizedAttribute, parameters: selectedRange - ) + ) else { return nil } var selectionFrame: CGRect = .zero let found = AXValueGetValue(rect, .cgRect, &selectionFrame) guard found else { return nil } - + var firstLineRange: CFRange = .init() let foundFirstLine = AXValueGetValue(selectedRange, .cfRange, &firstLineRange) firstLineRange.length = 0 - - #warning( - "FIXME: When selection is too low and out of the screen, the selection range becomes something else." + +#warning( + "FIXME: When selection is too low and out of the screen, the selection range becomes something else." ) - + if foundFirstLine, let firstLineSelectionRange = AXValueCreate(.cfRange, &firstLineRange), let firstLineRect: AXValue = try? editor.copyParameterizedValue( - key: kAXBoundsForRangeParameterizedAttribute, - parameters: firstLineSelectionRange + key: kAXBoundsForRangeParameterizedAttribute, + parameters: firstLineSelectionRange ) { var firstLineFrame: CGRect = .zero @@ -339,8 +425,201 @@ enum UpdateLocationStrategy { selectionFrame = firstLineFrame } } - + return selectionFrame } + + static func getChatPanelFrame(_ screen: NSScreen? = nil) -> CGRect { + let screen = screen ?? NSScreen.main ?? NSScreen.screens.first! + + let visibleScreenFrame = screen.visibleFrame + + // Default Frame + let width = min(Style.panelWidth, visibleScreenFrame.width * 0.3) + let height = visibleScreenFrame.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) + } } +public struct NESPanelLocationStrategy { + static func getNESPanelLocation( + maybeEditor: AXUIElement, + state: WidgetFeature.State + ) -> WidgetLocation.NESPanelLocation? { + guard let sourceEditor = maybeEditor.findSourceEditorElement(shouldRetry: false), + let editorContent: String = try? sourceEditor.copyValue(key: kAXValueAttribute), + let nesContent = state.panelState.nesContent, + let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }) + else { + return nil + } + + let startLine = nesContent.range.start.line + guard let lineFirstCharacterFrame = LocationStrategyHelper.getLineFrame( + startLine, + in: sourceEditor, + with: editorContent.components(separatedBy: .newlines), + length: 1 + ) else { + return nil + } + + guard let scrollViewFrame = sourceEditor.parent?.rect else { + return nil + } + + return .init( + scrollViewFrame: scrollViewFrame, + screenFrame: screen.frame, + lineFirstCharacterFrame: lineFirstCharacterFrame + ) + } +} + +public struct AgentConfigurationWidgetLocationStrategy { + static func getAgentConfigurationWidgetLocation( + maybeEditor: AXUIElement, + screen: NSScreen + ) -> WidgetLocation.AgentConfigurationWidgetLocation? { + guard let sourceEditorElement = maybeEditor.findSourceEditorElement(shouldRetry: false), + let editorContent: String = try? sourceEditorElement.copyValue(key: kAXValueAttribute), + let scrollViewRect = sourceEditorElement.parent?.rect + else { + return nil + } + + // Get the editor content to access lines + let lines = editorContent.components(separatedBy: .newlines) + guard !lines.isEmpty else { + return nil + } + + // Get the frame of the first line (line 0) + guard let firstLineFrame = LocationStrategyHelper.getLineFrame( + 0, + in: sourceEditorElement, + with: [lines[0]] + ) else { + return nil + } + + // Check if the first line is visible within the scroll view + guard firstLineFrame.width > 0, firstLineFrame.height > 0, + scrollViewRect.contains(firstLineFrame) + else { + return nil + } + + // Get the actual text content width (excluding trailing whitespace) + let firstLineText = lines[0].trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + let textEndX: CGFloat + + if !firstLineText.isEmpty { + // Calculate character position for the end of the trimmed text + let textLength = firstLineText.count + var range = CFRange(location: 0, length: textLength) + + if let rangeValue = AXValueCreate(AXValueType.cfRange, &range), + let boundsValue: AXValue = try? sourceEditorElement.copyParameterizedValue( + key: kAXBoundsForRangeParameterizedAttribute, + parameters: rangeValue + ) { + var textRect = CGRect.zero + if AXValueGetValue(boundsValue, .cgRect, &textRect) { + textEndX = textRect.maxX + } else { + textEndX = firstLineFrame.minX + } + } else { + textEndX = firstLineFrame.minX + } + } else { + textEndX = firstLineFrame.minX + } + + return .init( + firstLineFrame: firstLineFrame, + scrollViewRect: scrollViewRect, + screenFrame: screen.frame, + textEndX: textEndX + ) + } +} diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 72ad0576..e790da75 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -7,16 +7,20 @@ import Dependencies import Foundation import SwiftUI import XcodeInspector +import AXHelper actor WidgetWindowsController: NSObject { let userDefaultsObservers = WidgetUserDefaultsObservers() var xcodeInspector: XcodeInspector { .shared } - let windows: WidgetWindows - let store: StoreOf - let chatTabPool: ChatTabPool + nonisolated let windows: WidgetWindows + nonisolated let store: StoreOf + 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,11 @@ actor WidgetWindowsController: NSObject { }.store(in: &cancellable) xcodeInspector.$focusedEditor.sink { [weak self] editor in + Task { @MainActor [weak self] in + self?.store.send(.fixErrorPanel(.onFocusedEditorChanged(editor))) + self?.store.send(.panel(.agentConfigurationWidget(.onFocusedEditorChanged(editor)))) + } + guard let editor else { return } Task { [weak self] in await self?.observe(toEditor: editor) } }.store(in: &cancellable) @@ -67,12 +76,63 @@ actor WidgetWindowsController: NSObject { } }.store(in: &cancellable) + xcodeInspector.$activeDocumentURL.sink { [weak self] url in + Task { [weak self] in + await self?.updateCodeReviewWindowLocation(.onActiveDocumentURLChanged) + _ = await MainActor.run { [weak self] in + self?.store.send(.codeReviewPanel(.onActiveDocumentURLChanged(url))) + } + } + }.store(in: &cancellable) + userDefaultsObservers.presentationModeChangeObserver.onChange = { [weak self] in Task { [weak self] in await self?.updateWindowLocation(animated: false, immediately: false) await self?.send(.updateColorScheme) } } + + // Observe state change of code review + setupCodeReviewPanelObservers() + + // Observe state change of fix error + setupFixErrorPanelObservers() + + // Observer state change for NES + setupNESSuggestionPanelObservers() + + // Observe feature flags + setupFeatureFlagObservers() + } + + private func setupCodeReviewPanelObservers() { + Task { @MainActor in + let currentIndexPublisher = store.publisher + .map(\.codeReviewPanelState.currentIndex) + .removeDuplicates() + .sink { [weak self] _ in + Task { [weak self] in + await self?.updateCodeReviewWindowLocation(.onCurrentReviewIndexChanged) + } + } + + let isPanelDisplayedPublisher = store.publisher + .map(\.codeReviewPanelState.isPanelDisplayed) + .removeDuplicates() + .sink { [weak self] isPanelDisplayed in + Task { [weak self] in + await self?.updateCodeReviewWindowLocation(.onIsPanelDisplayedChanged(isPanelDisplayed)) + } + } + + await self.storeCancellables([currentIndexPublisher, isPanelDisplayedPublisher]) + } + } + + func storeCancellables(_ newCancellables: [AnyCancellable]) { + for cancellable in newCancellables { + self.cancellable.insert(cancellable) + } } } @@ -84,12 +144,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 +210,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 +237,14 @@ private extension WidgetWindowsController { .filter { $0.kind == .selectedTextChanged } let scroll = await editor.axNotifications.notifications() .filter { $0.kind == .scrollPositionChanged } + let valueChange = await editor.axNotifications.notifications() + .filter { $0.kind == .valueChanged } if #available(macOS 13.0, *) { for await notification in merge( scroll, - selectionRangeChange.debounce(for: Duration.milliseconds(0)) + selectionRangeChange.debounce(for: Duration.milliseconds(0)), + valueChange.debounce(for: Duration.milliseconds(100)) ) { guard await xcodeInspector.safe.latestActiveXcode != nil else { return } try Task.checkCancellation() @@ -182,9 +256,12 @@ private extension WidgetWindowsController { updateWindowLocation(animated: false, immediately: false) updateWindowOpacity(immediately: false) + await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) + + await handleFixErrorEditorNotification(notification: notification) } } else { - for await notification in merge(selectionRangeChange, scroll) { + for await notification in merge(selectionRangeChange, scroll, valueChange) { guard await xcodeInspector.safe.latestActiveXcode != nil else { return } try Task.checkCancellation() @@ -195,6 +272,9 @@ private extension WidgetWindowsController { updateWindowLocation(animated: false, immediately: false) updateWindowOpacity(immediately: false) + await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) + + await handleFixErrorEditorNotification(notification: notification) } } } @@ -229,10 +309,26 @@ extension WidgetWindowsController { @MainActor func hideSuggestionPanelWindow() { windows.suggestionPanelWindow.alphaValue = 0 - send(.panel(.hidePanel)) + send(.panel(.hidePanel(.suggestion))) + } + + @MainActor + func hideCodeReviewWindow() { + windows.codeReviewPanelWindow.alphaValue = 0 + windows.codeReviewPanelWindow.setIsVisible(false) + } + + @MainActor + func displayCodeReviewWindow() { + windows.codeReviewPanelWindow.setIsVisible(true) + windows.codeReviewPanelWindow.alphaValue = 1 + windows.codeReviewPanelWindow.orderFrontRegardless() } - func generateWidgetLocation() -> WidgetLocation? { + func generateWidgetLocation(_ state: WidgetFeature.State) -> WidgetLocation { + // Default location when no active application/window + var defaultLocation = generateDefaultLocation() + if let application = xcodeInspector.latestActiveXcode?.appElement { if let focusElement = xcodeInspector.focusedEditor?.element, let parent = focusElement.parent, @@ -244,6 +340,12 @@ extension WidgetWindowsController { .value(for: \.suggestionWidgetPositionMode) let suggestionMode = UserDefaults.shared .value(for: \.suggestionPresentationMode) + + let nesPanelLocation: WidgetLocation.NESPanelLocation? = NESPanelLocationStrategy.getNESPanelLocation(maybeEditor: parent, state: state) + let locationTrigger: WidgetLocation.LocationTrigger = .sourceEditor + let agentConfigurationWidgetLocation = AgentConfigurationWidgetLocationStrategy.getAgentConfigurationWidgetLocation( + maybeEditor: parent, screen: screen + ) switch positionMode { case .fixedToBottom: @@ -252,6 +354,9 @@ extension WidgetWindowsController { mainScreen: screen, activeScreen: firstScreen ) + result.setNESSuggestionPanelLocation(nesPanelLocation) + result.setLocationTrigger(locationTrigger) + result.setAgentConfigurationWidgetLocation(agentConfigurationWidgetLocation) switch suggestionMode { case .nearbyTextCursor: result.suggestionPanelLocation = UpdateLocationStrategy @@ -273,6 +378,9 @@ extension WidgetWindowsController { activeScreen: firstScreen, editor: focusElement ) + result.setNESSuggestionPanelLocation(nesPanelLocation) + result.setLocationTrigger(locationTrigger) + result.setAgentConfigurationWidgetLocation(agentConfigurationWidgetLocation) switch suggestionMode { case .nearbyTextCursor: result.suggestionPanelLocation = UpdateLocationStrategy @@ -290,24 +398,21 @@ extension WidgetWindowsController { } } else if var window = application.focusedWindow, var frame = application.focusedWindow?.rect, - !["menu bar", "menu bar item"].contains(window.description), + !window.isXcodeMenuBar, frame.size.height > 300, let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), let firstScreen = NSScreen.main { - if ["open_quickly"].contains(window.identifier) - || ["alert"].contains(window.label) + if window.isXcodeOpenQuickly + || window.isXcodeAlert { // fallback to use workspace window guard let workspaceWindow = application.windows - .first(where: { $0.identifier == "Xcode.WorkspaceWindow" }), + .first(where: { $0.isXcodeWorkspaceWindow }), let rect = workspaceWindow.rect else { - return WidgetLocation( - widgetFrame: .zero, - tabFrame: .zero, - defaultPanelLocation: .init(frame: .zero, alignPanelTop: false) - ) + defaultLocation.setLocationTrigger(.otherApp) + return defaultLocation } window = workspaceWindow @@ -315,7 +420,7 @@ extension WidgetWindowsController { } var expendedSize = CGSize.zero - if ["Xcode.WorkspaceWindow"].contains(window.identifier) { + if window.isXcodeWorkspaceWindow { // extra padding to bottom so buttons won't be covered frame.size.height -= 40 } else { @@ -326,20 +431,41 @@ extension WidgetWindowsController { expendedSize.height += Style.widgetPadding } - return UpdateLocationStrategy.FixedToBottom().framesForWindows( + var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( editorFrame: frame, mainScreen: screen, activeScreen: firstScreen, preferredInsideEditorMinWidth: 9_999_999_999, // never editorFrameExpendedSize: expendedSize ) + result.setLocationTrigger(.xcodeWorkspaceWindow) + + return result } } - return nil + return defaultLocation + } + + // Generate a default location when no workspace is opened + private func generateDefaultLocation() -> WidgetLocation { + let chatPanelFrame = UpdateLocationStrategy.getChatPanelFrame() + + return WidgetLocation( + widgetFrame: .zero, + tabFrame: .zero, + defaultPanelLocation: .init( + frame: chatPanelFrame, + alignPanelTop: false + ), + suggestionPanelLocation: nil, + nesSuggestionPanelLocation: nil + ) } func updatePanelState(_ location: WidgetLocation) async { await send(.updatePanelStateToMatch(location)) + await send(.updateNESSuggestionPanelStateToMatch(location)) + await send(.updateAgentConfigurationWidgetStateToMatch(location)) } func updateWindowOpacity(immediately: Bool) { @@ -360,20 +486,33 @@ extension WidgetWindowsController { await MainActor.run { let state = store.withState { $0 } let isChatPanelDetached = state.chatPanelState.isDetached - let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty + // Check if the user has requested to display the panel, regardless of workspace state + let isPanelDisplayed = state.chatPanelState.isPanelDisplayed + + // Keep the chat panel visible even when there's no workspace/tabs if it's explicitly displayed + // This ensures the login screen remains visible + let shouldShowChatPanel = isPanelDisplayed || ( + state.chatPanelState.currentChatWorkspace != nil && + !state.chatPanelState.currentChatWorkspace!.tabInfo.isEmpty + ) if let activeApp, activeApp.isXcode { let application = activeApp.appElement /// We need this to hide the windows when Xcode is minimized. let noFocus = application.focusedWindow == nil windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 - send(.panel(noFocus ? .hidePanel : .showPanel)) + send(.panel(noFocus ? .hidePanel(.suggestion) : .showPanel(.suggestion))) windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 + send(.panel(noFocus ? .hidePanel(.nes) : .showPanel(.nes))) + applyOpacityForNESWindows(by: noFocus) + send(.panel(noFocus ? .hidePanel(.agentConfiguration) : .showPanel(.agentConfiguration))) + applyOpacityForAgentConfigurationWidget(by: noFocus) + windows.nesNotificationWindow.alphaValue = noFocus ? 0 : 1 windows.widgetWindow.alphaValue = noFocus ? 0 : 1 windows.toastWindow.alphaValue = noFocus ? 0 : 1 if isChatPanelDetached { - windows.chatPanelWindow.isWindowHidden = !hasChat + windows.chatPanelWindow.isWindowHidden = !shouldShowChatPanel } else { windows.chatPanelWindow.isWindowHidden = noFocus } @@ -390,8 +529,13 @@ extension WidgetWindowsController { let previousAppIsXcode = previousActiveApplication?.isXcode ?? false - send(.panel(noFocus ? .hidePanel : .showPanel)) + send(.panel(noFocus ? .hidePanel(.suggestion) : .showPanel(.suggestion))) windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 + send(.panel(noFocus ? .hidePanel(.nes) : .showPanel(.nes))) + applyOpacityForNESWindows(by: noFocus) + send(.panel(noFocus ? .hidePanel(.agentConfiguration) : .showPanel(.agentConfiguration))) + applyOpacityForAgentConfigurationWidget(by: noFocus) + windows.nesNotificationWindow.alphaValue = noFocus ? 0 : 1 windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 windows.widgetWindow.alphaValue = if noFocus { 0 @@ -402,7 +546,7 @@ extension WidgetWindowsController { } windows.toastWindow.alphaValue = noFocus ? 0 : 1 if isChatPanelDetached { - windows.chatPanelWindow.isWindowHidden = !hasChat + windows.chatPanelWindow.isWindowHidden = !shouldShowChatPanel } else { windows.chatPanelWindow.isWindowHidden = noFocus && !windows .chatPanelWindow.isKeyWindow @@ -410,6 +554,10 @@ extension WidgetWindowsController { } else { windows.sharedPanelWindow.alphaValue = 0 windows.suggestionPanelWindow.alphaValue = 0 + windows.nesMenuWindow.alphaValue = 0 + windows.nesDiffWindow.alphaValue = 0 + applyOpacityForAgentConfigurationWidget() + windows.nesNotificationWindow.alphaValue = 0 windows.widgetWindow.alphaValue = 0 windows.toastWindow.alphaValue = 0 if !isChatPanelDetached { @@ -421,6 +569,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, @@ -432,7 +635,7 @@ extension WidgetWindowsController { func update() async { let state = store.withState { $0 } let isChatPanelDetached = state.chatPanelState.isDetached - guard let widgetLocation = await generateWidgetLocation() else { return } + let widgetLocation = await generateWidgetLocation(state) await updatePanelState(widgetLocation) windows.widgetWindow.setFrame( @@ -458,8 +661,34 @@ extension WidgetWindowsController { animate: animated ) } - - if isChatPanelDetached { + + if let nesPanelLocation = widgetLocation.nesSuggestionPanelLocation { + windows.nesMenuWindow.setFrame( + nesPanelLocation.menuFrame, + display: false, + animate: animated + ) + await updateNESDiffWindowFrame( + nesPanelLocation, + animated: animated, + trigger: widgetLocation.locationTrigger + ) + + await updateNESNotificationWindowFrame(nesPanelLocation, animated: animated) + } + + if let agentConfigurationWidgetLocation = widgetLocation.agentConfigurationWidgetLocation { + windows.agentConfigurationWidgetWindow.setFrame( + agentConfigurationWidgetLocation.getWidgetFrame(windows.agentConfigurationWidgetWindow.frame), + display: false, + animate: animated + ) + } + + let isAttachedToXcodeEnabled = UserDefaults.shared.value(for: \.autoAttachChatToXcode) + if isAttachedToXcodeEnabled { + // update in `updateAttachedChatWindowLocation` + } else if isChatPanelDetached { // don't update it! } else { windows.chatPanelWindow.setFrame( @@ -470,6 +699,8 @@ extension WidgetWindowsController { } await adjustChatPanelWindowLevel() + + await updateFixErrorPanelWindowLocation() } let now = Date() @@ -500,10 +731,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 @@ -526,7 +757,7 @@ extension WidgetWindowsController { } else { false } - + if !floatOnTopWhenOverlapsXcode || !latestAppIsXcodeOrExtension { window.setFloatOnTop(false) } else { @@ -550,6 +781,135 @@ extension WidgetWindowsController { } } +// MARK: - Code Review +extension WidgetWindowsController { + + enum CodeReviewLocationTrigger { + case onXcodeAppNotification(XcodeAppInstanceInspector.AXNotification) // resized, moved + case onSourceEditorNotification(SourceEditor.AXNotification) // scroll, valueChange + case onActiveDocumentURLChanged + case onCurrentReviewIndexChanged + case onIsPanelDisplayedChanged(Bool) + + static let relevantXcodeAppNotificationKind: [XcodeAppInstanceInspector.AXNotificationKind] = + [ + .windowMiniaturized, + .windowDeminiaturized, + .resized, + .moved, + .windowMoved, + .windowResized + ] + + static let relevantSourceEditorNotificationKind: [SourceEditor.AXNotificationKind] = + [.scrollPositionChanged, .valueChanged] + + var isRelevant: Bool { + switch self { + case .onActiveDocumentURLChanged, .onCurrentReviewIndexChanged, .onIsPanelDisplayedChanged: return true + case let .onSourceEditorNotification(notif): + return Self.relevantSourceEditorNotificationKind.contains(where: { $0 == notif.kind }) + case let .onXcodeAppNotification(notif): + return Self.relevantXcodeAppNotificationKind.contains(where: { $0 == notif.kind }) + } + } + + var shouldScroll: Bool { + switch self { + case .onCurrentReviewIndexChanged: return true + default: return false + } + } + } + + @MainActor + func updateCodeReviewWindowLocation(_ trigger: CodeReviewLocationTrigger) async { + guard trigger.isRelevant else { return } + if case .onIsPanelDisplayedChanged(let isPanelDisplayed) = trigger, !isPanelDisplayed { + hideCodeReviewWindow() + return + } + + var sourceEditorElement: AXUIElement? + + switch trigger { + case .onXcodeAppNotification(let notif): + sourceEditorElement = notif.element.retrieveSourceEditor() + case .onSourceEditorNotification(_), + .onActiveDocumentURLChanged, + .onCurrentReviewIndexChanged, + .onIsPanelDisplayedChanged: + sourceEditorElement = await xcodeInspector.safe.focusedEditor?.element + } + + guard let sourceEditorElement = sourceEditorElement + else { + hideCodeReviewWindow() + return + } + + await _updateCodeReviewWindowLocation( + sourceEditorElement, + shouldScroll: trigger.shouldScroll + ) + } + + @MainActor + func _updateCodeReviewWindowLocation(_ sourceEditorElement: AXUIElement, shouldScroll: Bool = false) async { + // Get the current index and comment from the store state + let state = store.withState { $0.codeReviewPanelState } + + guard state.isPanelDisplayed, + let comment = state.currentSelectedComment, + await currentXcodeApp?.realtimeDocumentURL?.absoluteString == comment.uri, + let reviewWindowFittingSize = windows.codeReviewPanelWindow.contentView?.fittingSize + else { + hideCodeReviewWindow() + return + } + + guard let originalContent = state.originalContent, + let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), + let scrollViewRect = sourceEditorElement.parent?.rect, + let scrollScreenFrame = sourceEditorElement.parent?.maxIntersectionScreen?.frame, + let currentContent: String = try? sourceEditorElement.copyValue(key: kAXValueAttribute) + else { return } + + let result = CodeReviewLocationStrategy.getCurrentLineFrame( + editor: sourceEditorElement, + currentContent: currentContent, + comment: comment, + originalContent: originalContent) + guard let lineNumber = result.lineNumber, let lineFrame = result.lineFrame + else { return } + + // The line should be visible + guard lineFrame.width > 0, lineFrame.height > 0, + scrollViewRect.contains(lineFrame) + else { + if shouldScroll { + AXHelper + .scrollSourceEditorToLine( + lineNumber, + content: currentContent, + focusedElement: sourceEditorElement + ) + } else { + hideCodeReviewWindow() + } + return + } + + // Position the code review window near the target line + var reviewWindowFrame = windows.codeReviewPanelWindow.frame + reviewWindowFrame.origin.x = scrollViewRect.maxX - reviewWindowFrame.width + reviewWindowFrame.origin.y = screen.frame.maxY - lineFrame.maxY + screen.frame.minY - reviewWindowFrame.height + + windows.codeReviewPanelWindow.setFrame(reviewWindowFrame, display: true, animate: true) + displayCodeReviewWindow() + } +} + // MARK: - NSWindowDelegate extension WidgetWindowsController: NSWindowDelegate { @@ -712,7 +1072,195 @@ public final class WidgetWindows { it.setIsVisible(true) return it }() + + @MainActor + lazy var nesMenuWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isOpaque = false + it.backgroundColor = .clear + it.level = widgetLevel(2) + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.hasShadow = false + it.contentView = NSHostingView( + rootView: NESMenuView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.nesSuggestionPanelState, + action: \.nesSuggestionPanel + ) + ) + ) + it.canBecomeKeyChecker = { false } + it.setIsVisible(true) + return it + }() + + @MainActor + lazy var nesDiffWindow = { + let it = CanBecomeKeyWindow( + contentRect: .zero, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isOpaque = false + it.backgroundColor = .clear + it.level = widgetLevel(2) + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.contentView = NSHostingView( + rootView: NESDiffView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.nesSuggestionPanelState, + action: \.nesSuggestionPanel + ) + ) + ) + it.canBecomeKeyChecker = { false } + it.setIsVisible(true) + it.hasShadow = true + return it + }() + + @MainActor + lazy var nesNotificationWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isOpaque = false + it.backgroundColor = .clear + it.level = widgetLevel(2) + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.contentView = NSHostingView( + rootView: NESNotificationView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.nesSuggestionPanelState, + action: \.nesSuggestionPanel + ) + ) + ) + it.canBecomeKeyChecker = { false } + it.setIsVisible(true) + return it + }() + @MainActor + lazy var codeReviewPanelWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init( + x: 0, + y: 0, + width: Style.codeReviewPanelWidth, + height: Style.codeReviewPanelHeight + ), + styleMask: .borderless, + backing: .buffered, + defer: true + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.hasShadow = true + it.level = widgetLevel(2) + it.contentView = NSHostingView( + rootView: CodeReviewPanelView( + store: store.scope( + state: \.codeReviewPanelState, + action: \.codeReviewPanel + ) + ) + ) + it.canBecomeKeyChecker = { true } + it.alphaValue = 0 + it.setIsVisible(false) + return it + }() + + @MainActor + lazy var fixErrorPanelWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init( + x: 0, + y: 0, + width: Style.panelWidth, + height: Style.panelHeight + ), + styleMask: .borderless, + backing: .buffered, + defer: true + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.hasShadow = false + it.level = widgetLevel(2) + it.contentView = NSHostingView( + rootView: FixErrorPanelView( + store: store.scope( + state: \.fixErrorPanelState, + action: \.fixErrorPanel + ) + ).environment(cursorPositionTracker) + ) + it.canBecomeKeyChecker = { false } + it.alphaValue = 0 + it.setIsVisible(false) + return it + }() + + @MainActor + lazy var agentConfigurationWidgetWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init( + x: 0, + y: 0, + width: Style.panelWidth, + height: Style.panelHeight + ), + styleMask: .borderless, + backing: .buffered, + defer: true + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.hasShadow = false + it.level = widgetLevel(2) + it.contentView = NSHostingView( + rootView: AgentConfigurationWidgetView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.agentConfigurationWidgetState, + action: \.agentConfigurationWidget + ) + ).environment(cursorPositionTracker) + ) + it.canBecomeKeyChecker = { true } + it.alphaValue = 0 + it.setIsVisible(false) + return it + }() + @MainActor lazy var chatPanelWindow = { let it = ChatPanelWindow( @@ -731,6 +1279,8 @@ public final class WidgetWindows { }() @MainActor + // The toast window area is now capturing mouse events + // Even in the transparent parts where there's no visible content. lazy var toastWindow = { let it = CanBecomeKeyWindow( contentRect: .zero, @@ -739,9 +1289,9 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = true + it.isOpaque = false it.backgroundColor = .clear - it.level = widgetLevel(0) + it.level = widgetLevel(2) it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] it.hasShadow = false it.contentView = NSHostingView( @@ -751,7 +1301,6 @@ public final class WidgetWindows { )) ) it.setIsVisible(true) - it.ignoresMouseEvents = true it.canBecomeKeyChecker = { false } return it }() @@ -770,6 +1319,11 @@ public final class WidgetWindows { toastWindow.orderFrontRegardless() sharedPanelWindow.orderFrontRegardless() suggestionPanelWindow.orderFrontRegardless() + nesMenuWindow.orderFrontRegardless() + fixErrorPanelWindow.orderFrontRegardless() + nesDiffWindow.orderFrontRegardless() + nesNotificationWindow.orderFrontRegardless() + agentConfigurationWidgetWindow.orderFrontRegardless() if chatPanelWindow.level.rawValue > NSWindow.Level.normal.rawValue { chatPanelWindow.orderFrontRegardless() } @@ -789,4 +1343,3 @@ func widgetLevel(_ addition: Int) -> NSWindow.Level { minimumWidgetLevel = NSWindow.Level.floating.rawValue return .init(minimumWidgetLevel + addition) } - diff --git a/Core/Tests/ChatServiceTests/ChatServiceTests.swift b/Core/Tests/ChatServiceTests/ChatServiceTests.swift new file mode 100644 index 00000000..f8b5ec26 --- /dev/null +++ b/Core/Tests/ChatServiceTests/ChatServiceTests.swift @@ -0,0 +1,22 @@ +import XCTest + +@testable import ChatService + +final class ReplaceFirstWordTests: XCTestCase { + func test_replace_first_word() { + let cases: [(String, String)] = [ + ("", ""), + ("workspace 001", "workspace 001"), + ("workspace001", "workspace001"), + ("@workspace", "@project"), + ("@workspace001", "@workspace001"), + ("@workspace 001", "@project 001"), + ] + + for (input, expected) in cases { + let result = replaceFirstWord(in: input, from: "@workspace", to: "@project") + XCTAssertEqual(result, expected, "Input: \(input), Expected: \(expected), Result: \(result)") + } + } +} + diff --git a/Core/Tests/ChatServiceTests/ToolAutoApprovalParsingHelpersTests.swift b/Core/Tests/ChatServiceTests/ToolAutoApprovalParsingHelpersTests.swift new file mode 100644 index 00000000..486b018f --- /dev/null +++ b/Core/Tests/ChatServiceTests/ToolAutoApprovalParsingHelpersTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import ChatService + +class ToolAutoApprovalParsingHelpersTests: XCTestCase { + func testExtractSubCommandsWithTreeSitter() { + // Simple command + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("git status"), ["git status"]) + + // Chained commands + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("cd Core && swift test"), ["cd Core", "swift test"]) + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("make build; make install"), ["make build", "make install"]) + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("make build || echo 'fail'"), ["make build", "echo 'fail'"]) + + // Pipes + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("ls -la | grep swift"), ["ls -la", "grep swift"]) + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("ls &> out.txt"), ["ls &> out.txt"]) + + // Complex with quotes (content inside quotes shouldn't be split) + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("echo 'hello && world'"), ["echo 'hello && world'"]) + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("echo $(date +%Y) && ls"), ["echo $", "date +%Y", "ls"]) + + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("git commit -m \"fix: update && clean\""), ["git commit -m \"fix: update && clean\""]) + + // Nested / Subshells (might depend on how detailed the query is) + // (command) query usually picks up the command nodes. + // For `(cd Core; ls)`, the inner commands are commands too. + XCTAssertEqual(Set(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("(cd Core; ls)")), Set(["cd Core", "ls"])) + + // Empty or whitespace + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter(" "), []) + } + + func testExtractTerminalCommandNames() { + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "git status"), ["git"]) + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "run_tests.sh --verbose"), ["run_tests.sh"]) + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "sudo apt-get install"), ["apt-get"]) + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "env VAR=1 command run"), ["command"]) + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "cd Core && swift test"), ["cd", "swift"]) + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "ls | grep match"), ["ls", "grep"]) + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "ls &> out.txt"), ["ls"]) + } +} diff --git a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift index 70469700..966f047f 100644 --- a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift +++ b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift @@ -1,10 +1,12 @@ import Foundation import XCTest +import SuggestionBasic @testable import Workspace @testable import KeyBindingManager class TabToAcceptSuggestionTests: XCTestCase { + @WorkspaceActor func test_should_accept() { let fileURL = URL(string: "file:///test")! @@ -20,7 +22,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (true, nil) + ), (true, nil, .codeCompletion) ) } @@ -39,7 +41,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No suggestion") + ), (false, "No suggestion", nil) ) } @@ -57,7 +59,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No filespace") + ), (false, "No filespace", nil) ) } @@ -76,7 +78,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No focused editor") + ), (false, "No focused editor", nil) ) } @@ -95,7 +97,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No active Xcode") + ), (false, "No active Xcode", nil) ) } @@ -114,7 +116,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No active document") + ), (false, "No active document", nil) ) } @@ -133,7 +135,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48, flags: .maskShift), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, nil) + ), (false, nil, nil) ) } @@ -152,7 +154,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48, flags: .maskCommand), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, nil) + ), (false, nil, nil) ) } @@ -171,7 +173,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48, flags: .maskControl), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, nil) + ), (false, nil, nil) ) } @@ -190,33 +192,14 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48, flags: .maskHelp), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, nil) - ) - } - - @WorkspaceActor - func test_should_not_accept_without_tab() { - let fileURL = URL(string: "file:///test")! - let workspacePool = FakeWorkspacePool() - workspacePool.setTestFile(fileURL: fileURL) - let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( - activeDocumentURL: fileURL, - hasActiveXcode: true, - hasFocusedEditor: true - ) - assertEqual( - TabToAcceptSuggestion.shouldAcceptSuggestion( - event: createEvent(50), - workspacePool: workspacePool, - xcodeInspector: xcodeInspector - ), (false, nil) + ), (false, nil, nil) ) } } private func assertEqual( - _ result: (Bool, String?), - _ expected: (Bool, String?) + _ result: (Bool, String?, CodeSuggestionType?), + _ expected: (Bool, String?, CodeSuggestionType?), ) { if result != expected { XCTFail("Expected \(expected), got \(result)") @@ -242,7 +225,7 @@ private class FakeWorkspacePool: WorkspacePool { @WorkspaceActor func setTestFile(fileURL: URL, skipSuggestion: Bool = false) { self.fileURL = fileURL - self.filespace = Filespace(fileURL: fileURL, onSave: {_ in }, onClose: {_ in }) + self.filespace = Filespace(fileURL: fileURL, content: "", onSave: {_ in }, onClose: {_ in }) if skipSuggestion { return } guard let filespace = self.filespace else { return } filespace.setSuggestions([.init(id: "id", text: "test", position: .zero, range: .zero)]) diff --git a/Core/Tests/ServiceTests/Environment.swift b/Core/Tests/ServiceTests/Environment.swift index 191bf6aa..a018b294 100644 --- a/Core/Tests/ServiceTests/Environment.swift +++ b/Core/Tests/ServiceTests/Environment.swift @@ -6,6 +6,7 @@ import SuggestionBasic import Workspace import XCTest import XPCShared +import LanguageServerProtocol @testable import Service @@ -26,7 +27,7 @@ class MockSuggestionService: GitHubCopilotSuggestionServiceType { fatalError() } - func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws { + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, contentChanges: [LanguageServerProtocol.TextDocumentContentChangeEvent]?) async throws { fatalError() } @@ -59,13 +60,29 @@ class MockSuggestionService: GitHubCopilotSuggestionServiceType { completions } + func getCopilotInlineEdit( + fileURL: URL, + content: String, + cursorPosition: SuggestionBasic.CursorPosition + ) async throws -> [SuggestionBasic.CodeSuggestion] { + completions + } + func notifyShown(_ completion: SuggestionBasic.CodeSuggestion) async { shown = completion.id } + + func notifyCopilotInlineEditShown(_ completion: SuggestionBasic.CodeSuggestion) async { + shown = completion.id + } func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int? = nil) async { accepted = completion.id } + + func notifyCopilotInlineEditAccepted(_ completion: CodeSuggestion) async { + accepted = completion.id + } func notifyRejected(_ completions: [CodeSuggestion]) async { rejected = completions.map(\.id) diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift index 65fb2426..83990303 100644 --- a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift +++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift @@ -19,8 +19,12 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { range: CursorRange = .init(startPair: (1, 0), endPair: (1, 0)) ) async throws -> (Filespace, FilespaceSuggestionSnapshot) { let pool = WorkspacePool() - let (_, filespace) = try await pool - .fetchOrCreateWorkspaceAndFilespace(fileURL: URL(fileURLWithPath: "file/path/to.swift")) + let filespace = Filespace( + fileURL: URL(fileURLWithPath: "file/path/to.swift"), + content: "", + onSave: { _ in }, + onClose: { _ in } + ) filespace.suggestions = [ .init( id: "", diff --git a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift index dfdb4b3e..653492fc 100644 --- a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift +++ b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift @@ -856,6 +856,57 @@ final class AcceptSuggestionTests: XCTestCase { """) } + + func test_accept_multi_lines_suggestion_with_overlay() async throws { + let content = """ + struct Cat { + var name: String + var age: String + } + """ + let text = """ + newName: String + var newAge + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 1, character: 12), + range: .init( + start: .init(line: 1, character: 8), + end: .init(line: 2, character: 11) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 1, character: 12) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo, + isNES: true + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + lines, + content.breakIntoEditorStyleLines().applying(extraInfo.modifications) + ) + XCTAssertEqual(cursor, .init(line: 2, character: 22)) + XCTAssertEqual( + lines.joined(separator: ""), + """ + struct Cat { + var newName: String + var newAge: String + } + + """ + ) + } } extension String { diff --git a/Core/Tests/SuggestionWidgetTests/NES/NESDiffBuilderTests.swift b/Core/Tests/SuggestionWidgetTests/NES/NESDiffBuilderTests.swift new file mode 100644 index 00000000..fb52105b --- /dev/null +++ b/Core/Tests/SuggestionWidgetTests/NES/NESDiffBuilderTests.swift @@ -0,0 +1,55 @@ +import XCTest + +@testable import SuggestionWidget + +final class NESDiffBuilderTests: XCTestCase { + func testInlineSegmentsIdentifiesChangesWithinLine() { + let oldLine = " let foo = 1" + let newLine = " let bar = 2" + + let segments = DiffBuilder.inlineSegments(oldLine: oldLine, newLine: newLine) + + XCTAssertEqual(segments.count, 6) + XCTAssertEqual( + segments.map(\.change), + [.unchanged, .removed, .added, .unchanged, .removed, .added] + ) + XCTAssertEqual( + segments.map(\.text), + [" let ", "foo ", "bar ", "= ", "1", "2"] + ) + } + + func testInlineSegmentsWhenOldLineIsEmptyTreatsNewContentAsAdded() { + let segments = DiffBuilder.inlineSegments(oldLine: "", newLine: "value") + + XCTAssertEqual(segments.count, 1) + XCTAssertEqual(segments.first?.change, .added) + XCTAssertEqual(segments.first?.text, "value") + } + + func testLineSegmentsReturnsDiffAcrossLineBoundaries() { + let oldContent = [ + "line1", + "line2", + "line3" + ].joined(separator: "\n") + let newContent = [ + "line1", + "line3" + ].joined(separator: "\n") + + let segments = DiffBuilder.lineSegments(oldContent: oldContent, newContent: newContent) + + XCTAssertEqual(segments.count, 3) + XCTAssertEqual( + segments.map(\.change), + [.unchanged, .removed, .unchanged] + ) + XCTAssertEqual( + segments.map(\.text), + ["line1", "line2", "line3"] + ) + } +} + diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 877cdd92..5e7a287b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -47,6 +47,21 @@ Most of the logics are implemented inside the package `Core` and `Tool`. Just run both the `ExtensionService`, `CommunicationBridge` and the `EditorExtension` Target. Read [Testing Your Source Editor Extension](https://developer.apple.com/documentation/xcodekit/testing_your_source_editor_extension) for more details. +## Local Build + +To build the application locally, follow these steps: + +1. Navigate to the Script directory and run the build scripts: + + ```sh + cd ./Script + sh ./uninstall-app.sh # Remove any previous installation + rm -rf ../build # Clean the build directory + sh ./localbuild-app.sh # Build a fresh copy of the app + ``` + +2. After successful build, the application will be available in the build directory. Copy `GitHub Copilot for Xcode.app` to your Applications folder to test it locally. + ## SwiftUI Previews Looks like SwiftUI Previews are not very happy with Objective-C packages when running with app targets. To use previews, please switch schemes to the package product targets. 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/Images/background-permission-required.png b/Docs/Images/background-permission-required.png new file mode 100644 index 00000000..fb35d34b Binary files /dev/null and b/Docs/Images/background-permission-required.png differ diff --git a/Docs/Images/chat_agent.gif b/Docs/Images/chat_agent.gif new file mode 100644 index 00000000..a6a684d1 Binary files /dev/null and b/Docs/Images/chat_agent.gif differ diff --git a/Docs/Images/connect-comm-bridge-failed.png b/Docs/Images/connect-comm-bridge-failed.png new file mode 100644 index 00000000..4e8d2587 Binary files /dev/null and b/Docs/Images/connect-comm-bridge-failed.png differ diff --git a/Docs/Images/copilot-menu_dark.png b/Docs/Images/copilot-menu_dark.png new file mode 100644 index 00000000..35b36e7b Binary files /dev/null and b/Docs/Images/copilot-menu_dark.png differ diff --git a/Docs/demo.gif b/Docs/Images/demo.gif similarity index 100% rename from Docs/demo.gif rename to Docs/Images/demo.gif diff --git a/Docs/device-code.png b/Docs/Images/device-code.png similarity index 100% rename from Docs/device-code.png rename to Docs/Images/device-code.png diff --git a/Docs/dmg-open.png b/Docs/Images/dmg-open.png similarity index 100% rename from Docs/dmg-open.png rename to Docs/Images/dmg-open.png diff --git a/Docs/Images/document-folder-permission-request.png b/Docs/Images/document-folder-permission-request.png new file mode 100644 index 00000000..1d512ae4 Binary files /dev/null and b/Docs/Images/document-folder-permission-request.png differ diff --git a/Docs/extension-permission.png b/Docs/Images/extension-permission.png similarity index 100% rename from Docs/extension-permission.png rename to Docs/Images/extension-permission.png diff --git a/Docs/macos-download-open-confirm.png b/Docs/Images/macos-download-open-confirm.png similarity index 100% rename from Docs/macos-download-open-confirm.png rename to Docs/Images/macos-download-open-confirm.png diff --git a/Docs/Images/screen-record-permission-request.png b/Docs/Images/screen-record-permission-request.png new file mode 100644 index 00000000..0b3eeb25 Binary files /dev/null and b/Docs/Images/screen-record-permission-request.png differ diff --git a/Docs/signin-button.png b/Docs/Images/signin-button.png similarity index 100% rename from Docs/signin-button.png rename to Docs/Images/signin-button.png diff --git a/Docs/update-message.png b/Docs/Images/update-message.png similarity index 100% rename from Docs/update-message.png rename to Docs/Images/update-message.png diff --git a/Docs/Images/xcode-menu.png b/Docs/Images/xcode-menu.png new file mode 100644 index 00000000..c30e539c Binary files /dev/null and b/Docs/Images/xcode-menu.png differ diff --git a/Docs/Images/xcode-menu_dark.png b/Docs/Images/xcode-menu_dark.png new file mode 100644 index 00000000..28b957b7 Binary files /dev/null and b/Docs/Images/xcode-menu_dark.png differ 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 c40d0180..00000000 Binary files a/Docs/welcome.png and /dev/null differ diff --git a/Docs/xcode-menu.png b/Docs/xcode-menu.png deleted file mode 100644 index a3dc21a5..00000000 Binary files a/Docs/xcode-menu.png and /dev/null differ diff --git a/EditorExtension/AcceptNESSuggestionCommand.swift b/EditorExtension/AcceptNESSuggestionCommand.swift new file mode 100644 index 00000000..6c015030 --- /dev/null +++ b/EditorExtension/AcceptNESSuggestionCommand.swift @@ -0,0 +1,32 @@ +import Client +import SuggestionBasic +import Foundation +import XcodeKit +import XPCShared + +class AcceptNESSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Accept Next Edit Suggestion" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + Task { + do { + try await (Task(timeout: 7) { + let service = try getService() + if let content = try await service.getNESSuggestionAcceptedCode( + editorContent: .init(invocation) + ) { + invocation.accept(content) + } + completionHandler(nil) + }.value) + } catch is CancellationError { + completionHandler(nil) + } catch { + completionHandler(error) + } + } + } +} diff --git a/EditorExtension/Info.plist b/EditorExtension/Info.plist index 12cf1cca..b6fa3b60 100644 --- a/EditorExtension/Info.plist +++ b/EditorExtension/Info.plist @@ -42,5 +42,9 @@ TEAM_ID_PREFIX $(TeamIdentifierPrefix) + STANDARD_TELEMETRY_CHANNEL_KEY + $(STANDARD_TELEMETRY_CHANNEL_KEY) + GITHUB_APP_ID + $(GITHUB_APP_ID) diff --git a/EditorExtension/OpenChat.swift b/EditorExtension/OpenChat.swift index fccdc3fe..7ee1d945 100644 --- a/EditorExtension/OpenChat.swift +++ b/EditorExtension/OpenChat.swift @@ -10,10 +10,16 @@ class OpenChatCommand: NSObject, XCSourceEditorCommand, CommandType { with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) { - completionHandler(nil) Task { - let service = try getService() - _ = try await service.openChat(editorContent: .init(invocation)) + do { + let service = try getService() + try await service.openChat() + completionHandler(nil) + } catch is CancellationError { + completionHandler(nil) + } catch { + completionHandler(error) + } } } } diff --git a/EditorExtension/OpenSettingsCommand.swift b/EditorExtension/OpenSettingsCommand.swift index 2350171c..b1262c4b 100644 --- a/EditorExtension/OpenSettingsCommand.swift +++ b/EditorExtension/OpenSettingsCommand.swift @@ -7,20 +7,9 @@ import Foundation import XcodeKit +import HostAppActivator -enum GitHubCopilotForXcodeSettingsLaunchError: Error, LocalizedError { - case appNotFound - case openFailed(exitCode: Int32) - var errorDescription: String? { - switch self { - case .appNotFound: - return "\(hostAppName()) settings application not found" - case let .openFailed(exitCode): - return "Failed to launch \(hostAppName()) settings (exit code \(exitCode))" - } - } -} class OpenSettingsCommand: NSObject, XCSourceEditorCommand, CommandType { var name: String { "Open \(hostAppName()) Settings" } @@ -30,35 +19,18 @@ class OpenSettingsCommand: NSObject, XCSourceEditorCommand, CommandType { completionHandler: @escaping (Error?) -> Void ) { Task { - if let appPath = locateHostBundleURL(url: Bundle.main.bundleURL)?.absoluteString { - let task = Process() - task.launchPath = "/usr/bin/open" - task.arguments = [appPath] - task.launch() - task.waitUntilExit() - if task.terminationStatus == 0 { - completionHandler(nil) - } else { - completionHandler(GitHubCopilotForXcodeSettingsLaunchError.openFailed(exitCode: task.terminationStatus)) - } - } else { - completionHandler(GitHubCopilotForXcodeSettingsLaunchError.appNotFound) - } - } - } - - func locateHostBundleURL(url: URL) -> URL? { - var nextURL = url - while nextURL.path != "/" { - nextURL = nextURL.deletingLastPathComponent() - if nextURL.lastPathComponent.hasSuffix(".app") { - return nextURL + do { + try launchHostAppSettings() + completionHandler(nil) + } catch { + completionHandler( + GitHubCopilotForXcodeSettingsLaunchError + .openFailed( + errorDescription: error.localizedDescription + ) + ) } } - let devAppURL = url - .deletingLastPathComponent() - .appendingPathComponent("GitHub Copilot for Xcode Dev.app") - return devAppURL } } diff --git a/EditorExtension/RejectNESSuggestionCommand.swift b/EditorExtension/RejectNESSuggestionCommand.swift new file mode 100644 index 00000000..43183779 --- /dev/null +++ b/EditorExtension/RejectNESSuggestionCommand.swift @@ -0,0 +1,20 @@ +import Client +import Foundation +import SuggestionBasic +import XcodeKit + +class RejectNESSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Decline Next Edit Suggestion" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.getNESSuggestionRejectedCode(editorContent: .init(invocation)) + } + } +} + diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift index a9d252f9..a0ca6579 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -12,7 +12,9 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { var builtin: [[XCSourceEditorCommandDefinitionKey: Any]] { [ AcceptSuggestionCommand(), + AcceptNESSuggestionCommand(), RejectSuggestionCommand(), + RejectNESSuggestionCommand(), GetSuggestionsCommand(), NextSuggestionCommand(), PreviousSuggestionCommand(), diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 9453d310..a40b2137 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -5,6 +5,8 @@ import Status import SuggestionBasic import XcodeInspector import Logger +import StatusBarItemView +import GitHubCopilotViewModel extension AppDelegate { fileprivate var statusBarMenuIdentifier: NSUserInterfaceItemIdentifier { @@ -19,14 +21,6 @@ extension AppDelegate { .init("sourceEditorDebugMenu") } - fileprivate var toggleCompletionsMenuItemIdentifier: NSUserInterfaceItemIdentifier { - .init("toggleCompletionsMenuItem") - } - - fileprivate var toggleIgnoreLanguageMenuItemIdentifier: NSUserInterfaceItemIdentifier { - .init("toggleIgnoreLanguageMenuItem") - } - @MainActor @objc func buildStatusBarMenu() { let statusBar = NSStatusBar.system @@ -38,19 +32,16 @@ extension AppDelegate { let statusBarMenu = NSMenu(title: "Status Bar Menu") statusBarMenu.identifier = statusBarMenuIdentifier statusBarItem.menu = statusBarMenu - - let hostAppName = Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String - ?? "GitHub Copilot for Xcode" - + let checkForUpdate = NSMenuItem( title: "Check for Updates", action: #selector(checkForUpdate), keyEquivalent: "" ) - let openCopilotForXcodeItem = NSMenuItem( - title: "Open \(hostAppName) Settings", - action: #selector(openCopilotForXcode), + openCopilotForXcodeItem = NSMenuItem( + title: "Settings", + action: #selector(openCopilotForXcodeSettings), keyEquivalent: "" ) @@ -65,12 +56,19 @@ extension AppDelegate { xcodeInspectorDebug.submenu = xcodeInspectorDebugMenu xcodeInspectorDebug.isHidden = false - statusMenuItem = NSMenuItem( + axStatusItem = NSMenuItem( title: "", - action: #selector(openStatusLink), + action: #selector(openAXStatusLink), keyEquivalent: "" ) - statusMenuItem.isHidden = true + axStatusItem.isHidden = true + + extensionStatusItem = NSMenuItem( + title: "", + action: #selector(openExtensionStatusLink), + keyEquivalent: "" + ) + extensionStatusItem.isHidden = true let quitItem = NSMenuItem( title: "Quit", @@ -79,50 +77,106 @@ extension AppDelegate { ) quitItem.target = self - let toggleCompletions = NSMenuItem( + toggleCompletions = NSMenuItem( title: "Enable/Disable Completions", action: #selector(toggleCompletionsEnabled), keyEquivalent: "" ) - toggleCompletions.identifier = toggleCompletionsMenuItemIdentifier; - - let toggleIgnoreLanguage = NSMenuItem( + + toggleIgnoreLanguage = NSMenuItem( title: "No Active Document", action: nil, keyEquivalent: "" ) - toggleIgnoreLanguage.identifier = toggleIgnoreLanguageMenuItemIdentifier; + + toggleNES = NSMenuItem( + title: "Enable/Disable Next Edit Suggestions (NES)", + action: #selector(toggleNESEnabled), + keyEquivalent: "" + ) + + // Auth menu item with custom view + accountItem = NSMenuItem() + accountItem.view = AccountItemView( + target: self, + action: #selector(signIntoGitHub) + ) - authMenuItem = NSMenuItem( - title: "Copilot Connection: Checking...", - action: #selector(openCopilotForXcode), + authStatusItem = NSMenuItem( + title: "", + action: nil, keyEquivalent: "" ) + authStatusItem.isHidden = true + + 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: "" + ) + quotaItem.isHidden = true let openDocs = NSMenuItem( - title: "View Copilot Documentation...", + title: "View Documentation", action: #selector(openCopilotDocs), keyEquivalent: "" ) let openForum = NSMenuItem( - title: "View Copilot Feedback Forum...", + title: "Feedback Forum", action: #selector(openCopilotForum), keyEquivalent: "" ) - statusBarMenu.addItem(openCopilotForXcodeItem) + openChat = NSMenuItem( + title: "Open Chat", + action: #selector(openGlobalChat), + keyEquivalent: "" + ) + + signOutItem = NSMenuItem( + title: "Sign Out", + action: #selector(signOutGitHub), + keyEquivalent: "" + ) + + statusBarMenu.addItem(accountItem) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(authStatusItem) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(quotaItem) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(axStatusItem) + statusBarMenu.addItem(extensionStatusItem) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(checkForUpdate) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(openChat) statusBarMenu.addItem(toggleCompletions) statusBarMenu.addItem(toggleIgnoreLanguage) + statusBarMenu.addItem(toggleNES) statusBarMenu.addItem(.separator()) - statusBarMenu.addItem(authMenuItem) - statusBarMenu.addItem(statusMenuItem) - statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(openCopilotForXcodeItem) statusBarMenu.addItem(openDocs) statusBarMenu.addItem(openForum) statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(signOutItem) + statusBarMenu.addItem(.separator()) statusBarMenu.addItem(xcodeInspectorDebug) statusBarMenu.addItem(quitItem) @@ -142,23 +196,30 @@ extension AppDelegate: NSMenuDelegate { .value(for: \.enableXcodeInspectorDebugMenu) } - if let toggleCompletions = menu.items.first(where: { item in - item.identifier == toggleCompletionsMenuItemIdentifier - }) { + if toggleCompletions != nil { toggleCompletions.title = "\(UserDefaults.shared.value(for: \.realtimeSuggestionToggle) ? "Disable" : "Enable") Completions" } - - if let toggleLanguage = menu.items.first(where: { item in - item.identifier == toggleIgnoreLanguageMenuItemIdentifier - }) { + + if toggleIgnoreLanguage != nil { if let lang = DisabledLanguageList.shared.activeDocumentLanguage { - toggleLanguage.title = "\(DisabledLanguageList.shared.isEnabled(lang) ? "Disable" : "Enable") Completions for \(lang.rawValue)" - toggleLanguage.action = #selector(toggleIgnoreLanguage) + toggleIgnoreLanguage.title = "\(DisabledLanguageList.shared.isEnabled(lang) ? "Disable" : "Enable") Completions for \(lang.rawValue)" + toggleIgnoreLanguage.action = #selector( + toggleIgnoreLanguageEnabled + ) } else { - toggleLanguage.title = "No Active Document" - toggleLanguage.action = nil + toggleIgnoreLanguage.title = "No Active Document" + toggleIgnoreLanguage.action = nil } } + + if toggleNES != nil { + toggleNES.title = "\(UserDefaults.shared.value(for: \.realtimeNESToggle) ? "Disable" : "Enable") Next Edit Suggestions (NES)" + } + + Task { + await forceAuthStatusCheck() + updateStatusBarItem() + } case xcodeInspectorDebugMenuIdentifier: let inspector = XcodeInspector.shared @@ -271,8 +332,21 @@ private extension AppDelegate { } } } - - @objc func toggleIgnoreLanguage() { + + @objc func toggleNESEnabled() { + Task { + let initialSetting = UserDefaults.shared.value(for: \.realtimeNESToggle) + do { + let service = getXPCExtensionService() + try await service.toggleRealtimeNES() + } catch { + Logger.service.error("Failed to toggle NES enabled via XPC: \(error)") + UserDefaults.shared.set(!initialSetting, for: \.realtimeNESToggle) + } + } + } + + @objc func toggleIgnoreLanguageEnabled() { guard let lang = DisabledLanguageList.shared.activeDocumentLanguage else { return } if DisabledLanguageList.shared.isEnabled(lang) { @@ -298,10 +372,30 @@ private extension AppDelegate { } } - @objc func openStatusLink() { + @objc func openAXStatusLink() { + Task { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } + } + } + + @objc func openExtensionStatusLink() { Task { - let status = await Status.shared.getStatus() - if let s = status.url, let url = URL(string: s) { + let status = await Status.shared.getExtensionStatus() + if status == .notGranted { + if let url = URL(string: "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.dt.Xcode.extension.source-editor") { + NSWorkspace.shared.open(url) + } + } else { + NSWorkspace.restartXcode() + } + } + } + + @objc func openUpSellLink() { + Task { + if let url = URL(string: "https://aka.ms/github-copilot-settings") { NSWorkspace.shared.open(url) } } @@ -319,4 +413,3 @@ private extension NSMenuItem { return item } } - diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index dd4e8889..4995aa31 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -13,6 +13,9 @@ import UserDefaultsObserver import UserNotifications import XcodeInspector import XPCShared +import GitHubCopilotViewModel +import StatusBarItemView +import HostAppActivator let bundleIdentifierBase = Bundle.main .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String @@ -31,12 +34,21 @@ class ExtensionUpdateCheckerDelegate: UpdateCheckerDelegate { class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let service = Service.shared var statusBarItem: NSStatusItem! - var statusMenuItem: NSMenuItem! - var authMenuItem: NSMenuItem! + var axStatusItem: NSMenuItem! + var extensionStatusItem: NSMenuItem! + var openCopilotForXcodeItem: NSMenuItem! + var accountItem: NSMenuItem! + var authStatusItem: NSMenuItem! + var quotaItem: NSMenuItem! + var toggleCompletions: NSMenuItem! + var toggleIgnoreLanguage: NSMenuItem! + var toggleNES: NSMenuItem! + var openChat: NSMenuItem! + var signOutItem: NSMenuItem! var xpcController: XPCController? let updateChecker = UpdateChecker( - hostBundle: Bundle(url: locateHostBundleURL(url: Bundle.main.bundleURL)), + hostBundle: Bundle(url: HostAppURL!), checkerDelegate: ExtensionUpdateCheckerDelegate() ) var xpcExtensionService: XPCExtensionService? @@ -56,13 +68,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { Logger.service.info("XPC Service started.") NSApp.setActivationPolicy(.accessory) buildStatusBarMenu() + _ = FeatureFlagNotifierImpl.shared + observeFeatureFlags() watchServiceStatus() watchAXStatus() watchAuthStatus() setInitialStatusBarStatus() + UserDefaults.shared.set(false, for: \.clsWarningDismissedUntilRelaunch) } @objc func quit() { + if let hostApp = getRunningHostApp() { + hostApp.terminate() + } + + // Start shutdown process in a task Task { @MainActor in await service.prepareForExit() await xpcController?.quit() @@ -70,13 +90,46 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } - @objc func openCopilotForXcode() { - let task = Process() - let appPath = locateHostBundleURL(url: Bundle.main.bundleURL) - task.launchPath = "/usr/bin/open" - task.arguments = [appPath.absoluteString] - task.launch() - task.waitUntilExit() + @objc func openCopilotForXcodeSettings() { + try? launchHostAppSettings() + } + + @objc func signIntoGitHub() { + Task { @MainActor in + let viewModel = GitHubCopilotViewModel.shared + // Don't trigger the shared viewModel's alert + do { + guard let signInResponse = try await viewModel.preSignIn() else { + return + } + + NSApp.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.messageText = signInResponse.userCode + alert.informativeText = """ + Please enter the above code in the GitHub website to authorize your \ + GitHub account with Copilot for Xcode. + \(signInResponse.verificationURL.absoluteString) + """ + alert.addButton(withTitle: "Copy Code and Open") + alert.addButton(withTitle: "Cancel") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + viewModel.signInResponse = signInResponse + viewModel.copyAndOpen() + } + } catch { + Logger.service.error("GitHub copilot view model Sign in fails: \(error)") + } + } + } + + @objc func signOutGitHub() { + Task { @MainActor in + let viewModel = GitHubCopilotViewModel.shared + viewModel.signOut() + } } @objc func openGlobalChat() { @@ -130,10 +183,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, app.isUserOfService else { continue } - if NSWorkspace.shared.runningApplications.contains(where: \.isUserOfService) { - continue + + // Check if Xcode is running + let isXcodeRunning = NSWorkspace.shared.runningApplications.contains { + $0.bundleIdentifier == "com.apple.dt.Xcode" + } + + if !isXcodeRunning { + Logger.client.info("No Xcode instances running, preparing to quit") + quit() } - quit() } } } @@ -178,13 +237,31 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } } + + + func observeFeatureFlags() { + Task { @MainActor in + FeatureFlagNotifierImpl.shared.featureFlagsDidChange + .sink(receiveValue: { [weak self] featureFlags in + self?.toggleNES.isHidden = !featureFlags.editorPreviewFeatures + }) + } + } func watchAuthStatus() { let notifications = DistributedNotificationCenter.default().notifications(named: .authStatusDidChange) 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)") + } } } } @@ -192,7 +269,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() } @@ -202,28 +279,207 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func forceAuthStatusCheck() async { do { - let service = try GitHubCopilotService() - _ = try await service.checkStatus() - try await service.shutdown() - try await service.exit() + let service = try await GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + 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)") } } + + private func configureNotLoggedIn() { + self.accountItem.view = AccountItemView( + target: self, + action: #selector(signIntoGitHub) + ) + self.authStatusItem.isHidden = true + self.quotaItem.isHidden = true + self.toggleCompletions.isHidden = true + self.toggleIgnoreLanguage.isHidden = true + self.toggleNES.isHidden = true + self.signOutItem.isHidden = true + } + + private func configureLoggedIn(status: StatusResponse) { + self.accountItem.view = AccountItemView( + target: self, + action: nil, + userName: status.userName ?? "" + ) + if !status.clsMessage.isEmpty { + let CLSMessageSummary = getCLSMessageSummary(status.clsMessage) + // 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 { + configureCLSAuthStatusItem( + summary: CLSMessageSummary, + actionTitle: "View Details on GitHub", + action: #selector(openGitHubDetailsLink) + ) + } else if CLSMessageSummary.summary == CLSMessageType.byokLimitedReached.summary { + configureCLSAuthStatusItem( + summary: CLSMessageSummary, + actionTitle: "Dismiss", + action: #selector(dismissBYOKMessage) + ) + } else { + // Explicitly hide to avoid leaving stale content if another CLS state was previously shown. + self.authStatusItem.isHidden = true + } + } else { + self.authStatusItem.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.toggleNES.isHidden = false + self.signOutItem.isHidden = false + } + + func configureCLSAuthStatusItem( + summary: CLSMessage, + actionTitle: String, + action: Selector + ) { + self.authStatusItem.isHidden = false + self.authStatusItem.title = summary.summary + let submenu = NSMenu() + + let attributedCLSErrorItem = NSMenuItem() + attributedCLSErrorItem.view = ErrorMessageView( + errorMessage: summary.detail + ) + submenu.addItem(attributedCLSErrorItem) + submenu.addItem(.separator()) + submenu.addItem( + NSMenuItem( + title: actionTitle, + action: action, + keyEquivalent: "" + ) + ) + self.authStatusItem.submenu = submenu + self.authStatusItem.isEnabled = true + } + + private func configureNotAuthorized(status: StatusResponse) { + self.accountItem.view = AccountItemView( + target: self, + action: nil, + userName: status.userName ?? "" + ) + self.authStatusItem.isHidden = false + self.authStatusItem.title = "No Subscription" + + let submenu = NSMenu() + let attributedNotAuthorizedItem = NSMenuItem() + attributedNotAuthorizedItem.view = ErrorMessageView( + errorMessage: "GitHub Copilot features are disabled. Check your subscription to enable them." + ) + attributedNotAuthorizedItem.isEnabled = true + submenu.addItem(attributedNotAuthorizedItem) + + self.authStatusItem.submenu = submenu + self.authStatusItem.isEnabled = true + + self.quotaItem.isHidden = true + self.toggleCompletions.isHidden = true + self.toggleIgnoreLanguage.isHidden = true + self.toggleNES.isHidden = true + self.signOutItem.isHidden = false + } + + private func configureUnknown() { + self.accountItem.view = AccountItemView( + target: self, + action: nil, + userName: "Unknown User" + ) + self.authStatusItem.isHidden = true + self.quotaItem.isHidden = true + self.toggleCompletions.isHidden = false + self.toggleIgnoreLanguage.isHidden = false + self.toggleNES.isHidden = false + self.signOutItem.isHidden = false + } func updateStatusBarItem() { Task { @MainActor in let status = await Status.shared.getStatus() - let image = status.icon.nsImage - self.statusBarItem.button?.image = image - self.authMenuItem.title = status.authMessage + /// Update status bar icon + self.statusBarItem.button?.image = status.icon.nsImage + + /// Update auth status related status bar items + switch status.authStatus { + case .notLoggedIn: configureNotLoggedIn() + case .loggedIn: configureLoggedIn(status: status) + case .notAuthorized: configureNotAuthorized(status: status) + case .unknown: configureUnknown() + } + + /// Update accessibility permission status bar item + let exclamationmarkImage = NSImage( + systemSymbolName: "exclamationmark.circle.fill", + accessibilityDescription: "Permission not granted" + ) + exclamationmarkImage?.isTemplate = false + exclamationmarkImage?.withSymbolConfiguration(.init(paletteColors: [.red])) + if let message = status.message { - // TODO switch to attributedTitle to enable line breaks and color. - self.statusMenuItem.title = message - self.statusMenuItem.isHidden = false - self.statusMenuItem.isEnabled = status.url != nil + self.axStatusItem.title = message + if let image = exclamationmarkImage { + self.axStatusItem.image = image + } + self.axStatusItem.isHidden = false + self.axStatusItem.isEnabled = status.url != nil + } else { + self.axStatusItem.isHidden = true + } + + /// Update settings status bar item + if status.extensionStatus == .disabled || status.extensionStatus == .notGranted { + if let image = exclamationmarkImage{ + if #available(macOS 15.0, *){ + self.extensionStatusItem.image = image + self.extensionStatusItem.title = status.extensionStatus == .notGranted ? "Enable extension for full-featured completion" : "Quit and restart Xcode to enable extension" + self.extensionStatusItem.isHidden = false + self.extensionStatusItem.isEnabled = status.extensionStatus == .notGranted + } else { + self.extensionStatusItem.isHidden = true + self.openCopilotForXcodeItem.image = image + } + } } else { - self.statusMenuItem.isHidden = true + self.openCopilotForXcodeItem.image = nil + self.extensionStatusItem.isHidden = true } self.markAsProcessing(status.inProgress) } @@ -250,6 +506,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { statusBarItem.button?.image = nil progressView = progress } + + @objc func openGitHubDetailsLink() { + Task { + if let url = URL(string: "https://github.com/copilot") { + NSWorkspace.shared.open(url) + } + } + } + + @objc func dismissBYOKMessage() { + Task { + await Status.shared.updateCLSStatus(.normal, busy: false, message: "") + } + } } extension NSRunningApplication { @@ -261,17 +531,59 @@ extension NSRunningApplication { } } -func locateHostBundleURL(url: URL) -> URL { - var nextURL = url - while nextURL.path != "/" { - nextURL = nextURL.deletingLastPathComponent() - if nextURL.lastPathComponent.hasSuffix(".app") { - return nextURL +enum CLSMessageType { + case chatLimitReached + case completionLimitReached + case byokLimitedReached + case other + + var summary: String { + switch self { + case .chatLimitReached: + return "Monthly Chat Limit Reached" + case .completionLimitReached: + return "Monthly Completion Limit Reached" + case .byokLimitedReached: + return "BYOK Limit Reached" + case .other: + return "CLS Error" } } - let devAppURL = url - .deletingLastPathComponent() - .appendingPathComponent("GitHub Copilot for Xcode Dev.app") - return devAppURL } +struct CLSMessage { + let summary: String + let detail: String +} + +func extractDateFromCLSMessage(_ message: String) -> String? { + let pattern = #"until (\d{1,2}/\d{1,2}/\d{4}, \d{1,2}:\d{2}:\d{2} [AP]M)"# + if let range = message.range(of: pattern, options: .regularExpression) { + return String(message[range].dropFirst(6)) + } + return nil +} + +func getCLSMessageSummary(_ message: String) -> CLSMessage { + let 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") { + messageType = .completionLimitReached + } else if message.contains("BYOK") { + messageType = .byokLimitedReached + } else { + messageType = .other + } + + let detail: String + if let date = extractDateFromCLSMessage(message) { + detail = "Visit GitHub to check your usage and upgrade to Copilot Pro or wait until \(date) for your limit to reset." + } else { + detail = message + } + + return CLSMessage(summary: messageType.summary, detail: detail) +} diff --git a/ExtensionService/Assets.xcassets/Agent.imageset/Agent.svg b/ExtensionService/Assets.xcassets/Agent.imageset/Agent.svg new file mode 100644 index 00000000..0d699645 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Agent.imageset/Agent.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/Agent.imageset/Contents.json b/ExtensionService/Assets.xcassets/Agent.imageset/Contents.json new file mode 100644 index 00000000..7154a326 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Agent.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Agent.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/ChatWindowBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ChatWindowBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..1ff6f10e --- /dev/null +++ b/ExtensionService/Assets.xcassets/ChatWindowBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFB", + "green" : "0xFB", + "red" : "0xFB" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x24", + "green" : "0x24", + "red" : "0x24" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/ChatWindowEditorBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ChatWindowEditorBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..a68c9465 --- /dev/null +++ b/ExtensionService/Assets.xcassets/ChatWindowEditorBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x30", + "green" : "0x2A", + "red" : "0x29" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/Contents.json new file mode 100644 index 00000000..c48d2889 --- /dev/null +++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "light1x.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "dark1x.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/dark1x.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/dark1x.svg new file mode 100644 index 00000000..b0e60fbf --- /dev/null +++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/dark1x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/light1x.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/light1x.svg new file mode 100644 index 00000000..1f52da33 --- /dev/null +++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIcon.imageset/light1x.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ExtensionService/Assets.xcassets/Colors/AgentToolStatusDividerColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusDividerColor.colorset/Contents.json new file mode 100644 index 00000000..8ea26959 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusDividerColor.colorset/Contents.json @@ -0,0 +1,33 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF0", + "green" : "0xEC", + "red" : "0xEB" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "universal", + "reference" : "secondaryLabelColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/Colors/AgentToolStatusOutlineColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusOutlineColor.colorset/Contents.json new file mode 100644 index 00000000..58029746 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusOutlineColor.colorset/Contents.json @@ -0,0 +1,33 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE5", + "green" : "0xE1", + "red" : "0xDF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "universal", + "reference" : "secondaryLabelColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/Colors/Contents.json b/ExtensionService/Assets.xcassets/Colors/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Colors/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/DarkBlue.colorset/Contents.json b/ExtensionService/Assets.xcassets/DarkBlue.colorset/Contents.json new file mode 100644 index 00000000..9658cfff --- /dev/null +++ b/ExtensionService/Assets.xcassets/DarkBlue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "248", + "green" : "189", + "red" : "160" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "85", + "green" : "45", + "red" : "25" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/DiffEditor.imageset/Contents.json b/ExtensionService/Assets.xcassets/DiffEditor.imageset/Contents.json new file mode 100644 index 00000000..b0971b3c --- /dev/null +++ b/ExtensionService/Assets.xcassets/DiffEditor.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Editor.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ExtensionService/Assets.xcassets/DiffEditor.imageset/Editor.svg b/ExtensionService/Assets.xcassets/DiffEditor.imageset/Editor.svg new file mode 100644 index 00000000..ad643fcf --- /dev/null +++ b/ExtensionService/Assets.xcassets/DiffEditor.imageset/Editor.svg @@ -0,0 +1,3 @@ + + + diff --git a/ExtensionService/Assets.xcassets/Discard.imageset/Contents.json b/ExtensionService/Assets.xcassets/Discard.imageset/Contents.json new file mode 100644 index 00000000..0a27c3ef --- /dev/null +++ b/ExtensionService/Assets.xcassets/Discard.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "discard.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ExtensionService/Assets.xcassets/Discard.imageset/discard.svg b/ExtensionService/Assets.xcassets/Discard.imageset/discard.svg new file mode 100644 index 00000000..a22942fe --- /dev/null +++ b/ExtensionService/Assets.xcassets/Discard.imageset/discard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json b/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json new file mode 100644 index 00000000..c66c7fae --- /dev/null +++ b/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "edit-sparkle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg b/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg new file mode 100644 index 00000000..9cff22ff --- /dev/null +++ b/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/Eye.imageset/Contents.json b/ExtensionService/Assets.xcassets/Eye.imageset/Contents.json new file mode 100644 index 00000000..107bc195 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Eye.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "eye.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/Eye.imageset/eye.svg b/ExtensionService/Assets.xcassets/Eye.imageset/eye.svg new file mode 100644 index 00000000..4b83cd92 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Eye.imageset/eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/ExtensionService/Assets.xcassets/EyeClosed.imageset/Contents.json b/ExtensionService/Assets.xcassets/EyeClosed.imageset/Contents.json new file mode 100644 index 00000000..e874ab47 --- /dev/null +++ b/ExtensionService/Assets.xcassets/EyeClosed.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "eye-closed.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/EyeClosed.imageset/eye-closed.svg b/ExtensionService/Assets.xcassets/EyeClosed.imageset/eye-closed.svg new file mode 100644 index 00000000..76407a31 --- /dev/null +++ b/ExtensionService/Assets.xcassets/EyeClosed.imageset/eye-closed.svg @@ -0,0 +1,3 @@ + + + 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/IconStrokeColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/IconStrokeColor.colorset/Contents.json new file mode 100644 index 00000000..dbc3a4e3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/IconStrokeColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x7E", + "green" : "0x70", + "red" : "0x6C" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xD6", + "green" : "0xD0", + "red" : "0xCE" + } + }, + "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/ItemSelectedColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ItemSelectedColor.colorset/Contents.json new file mode 100644 index 00000000..955c4738 --- /dev/null +++ b/ExtensionService/Assets.xcassets/ItemSelectedColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "248", + "green" : "154", + "red" : "98" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "194", + "green" : "108", + "red" : "55" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json new file mode 100644 index 00000000..552c7769 --- /dev/null +++ b/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xE2", + "red" : "0xD4" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "110", + "green" : "67", + "red" : "46" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json new file mode 100644 index 00000000..1c8b1e91 --- /dev/null +++ b/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF0", + "green" : "0x74", + "red" : "0x35" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/LightGreen.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightGreen.colorset/Contents.json new file mode 100644 index 00000000..ef4b486e --- /dev/null +++ b/ExtensionService/Assets.xcassets/LightGreen.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "60", + "green" : "138", + "red" : "32" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3C", + "green" : "0x8A", + "red" : "0x20" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/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/MenuBarErrorIcon.imageset/Status=error, Mode=dark.svg b/ExtensionService/Assets.xcassets/MenuBarErrorIcon.imageset/Status=error, Mode=dark.svg new file mode 100644 index 00000000..d3263f54 --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarErrorIcon.imageset/Status=error, Mode=dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json index 60c5e84e..4ab2faba 100644 --- a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json @@ -1,19 +1,18 @@ { "images" : [ { - "filename" : "copilot-16.png", - "idiom" : "universal", - "scale" : "1x" + "filename" : "Status=active, Mode=dark.svg", + "idiom" : "universal" }, { - "filename" : "copilot-32.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "copilot-48.png", - "idiom" : "universal", - "scale" : "3x" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Status=active, Mode=white.svg", + "idiom" : "universal" } ], "info" : { @@ -21,6 +20,6 @@ "version" : 1 }, "properties" : { - "template-rendering-intent" : "template" + "preserves-vector-representation" : true } } diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Status=active, Mode=dark.svg b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Status=active, Mode=dark.svg new file mode 100644 index 00000000..7e472bde --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Status=active, Mode=dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Status=active, Mode=white.svg b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Status=active, Mode=white.svg new file mode 100644 index 00000000..22dd8c1a --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Status=active, Mode=white.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png deleted file mode 100644 index 6add79db..00000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png deleted file mode 100644 index f6cf6542..00000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png deleted file mode 100644 index 9c76a880..00000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json index 60c5e84e..4829284b 100644 --- a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json @@ -1,19 +1,8 @@ { "images" : [ { - "filename" : "copilot-16.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "copilot-32.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "copilot-48.png", - "idiom" : "universal", - "scale" : "3x" + "filename" : "Status=inactive, Mode=dark.svg", + "idiom" : "universal" } ], "info" : { @@ -21,6 +10,7 @@ "version" : 1 }, "properties" : { + "preserves-vector-representation" : true, "template-rendering-intent" : "template" } } diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Status=inactive, Mode=dark.svg b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Status=inactive, Mode=dark.svg new file mode 100644 index 00000000..58b44f03 --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Status=inactive, Mode=dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-16.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-16.png deleted file mode 100644 index 0884ac6e..00000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-16.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-32.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-32.png deleted file mode 100644 index 74aa2b4c..00000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-32.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-48.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-48.png deleted file mode 100644 index f7e21eef..00000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-48.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json index 60c5e84e..c9b66241 100644 --- a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json @@ -1,19 +1,8 @@ { "images" : [ { - "filename" : "copilot-16.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "copilot-32.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "copilot-48.png", - "idiom" : "universal", - "scale" : "3x" + "filename" : "Status=warning, Mode=dark.svg", + "idiom" : "universal" } ], "info" : { @@ -21,6 +10,7 @@ "version" : 1 }, "properties" : { + "preserves-vector-representation" : true, "template-rendering-intent" : "template" } } diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Status=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/MenuBarWarningIcon.imageset/copilot-16.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-16.png deleted file mode 100644 index 6497d37a..00000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-16.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-32.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-32.png deleted file mode 100644 index c0073a7e..00000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-32.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-48.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-48.png deleted file mode 100644 index 0daf8ca3..00000000 Binary files a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-48.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json new file mode 100644 index 00000000..f606b54c --- /dev/null +++ b/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.180", + "blue" : "0x26", + "green" : "0x1F", + "red" : "0x1B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json b/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json new file mode 100644 index 00000000..2f1e961f --- /dev/null +++ b/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "sparkle.svg", + "idiom" : "mac" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "sparkle_dark.svg", + "idiom" : "mac" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle.svg b/ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle.svg new file mode 100644 index 00000000..442e6cc3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle_dark.svg b/ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle_dark.svg new file mode 100644 index 00000000..2102024b --- /dev/null +++ b/ExtensionService/Assets.xcassets/Sparkle.imageset/sparkle_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/ExtensionService/Assets.xcassets/SubagentTurnBackground.colorset/Contents.json b/ExtensionService/Assets.xcassets/SubagentTurnBackground.colorset/Contents.json new file mode 100644 index 00000000..3bd5adce --- /dev/null +++ b/ExtensionService/Assets.xcassets/SubagentTurnBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.940", + "green" : "0.930", + "red" : "0.920" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.260", + "green" : "0.230", + "red" : "0.220" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/Terminal.imageset/Contents.json b/ExtensionService/Assets.xcassets/Terminal.imageset/Contents.json new file mode 100644 index 00000000..0f6b450f --- /dev/null +++ b/ExtensionService/Assets.xcassets/Terminal.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "terminal.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/Terminal.imageset/terminal.svg b/ExtensionService/Assets.xcassets/Terminal.imageset/terminal.svg new file mode 100644 index 00000000..d5c43adc --- /dev/null +++ b/ExtensionService/Assets.xcassets/Terminal.imageset/terminal.svg @@ -0,0 +1,3 @@ + + + diff --git a/ExtensionService/Assets.xcassets/ToastActionButtonColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ToastActionButtonColor.colorset/Contents.json new file mode 100644 index 00000000..41903f4d --- /dev/null +++ b/ExtensionService/Assets.xcassets/ToastActionButtonColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.080", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.800", + "blue" : "0x3C", + "green" : "0x3C", + "red" : "0x3C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/ToastBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ToastBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..ee9f736a --- /dev/null +++ b/ExtensionService/Assets.xcassets/ToastBackgroundColor.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" : "0x23", + "green" : "0x23", + "red" : "0x23" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/ToastDismissButtonColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ToastDismissButtonColor.colorset/Contents.json new file mode 100644 index 00000000..ab8dfaf8 --- /dev/null +++ b/ExtensionService/Assets.xcassets/ToastDismissButtonColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.500", + "green" : "0.500", + "red" : "0.500" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.800", + "green" : "0.800", + "red" : "0.800" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/ToastStrokeColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ToastStrokeColor.colorset/Contents.json new file mode 100644 index 00000000..2a52454e --- /dev/null +++ b/ExtensionService/Assets.xcassets/ToastStrokeColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.550", + "blue" : "0xC0", + "green" : "0xC0", + "red" : "0xC0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2B", + "green" : "0x2B", + "red" : "0x2B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/WorkingSetHeaderKeepButtonColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/WorkingSetHeaderKeepButtonColor.colorset/Contents.json new file mode 100644 index 00000000..bce38459 --- /dev/null +++ b/ExtensionService/Assets.xcassets/WorkingSetHeaderKeepButtonColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "extended-srgb", + "components" : { + "alpha" : "1.000", + "blue" : "212", + "green" : "120", + "red" : "0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "extended-srgb", + "components" : { + "alpha" : "1.000", + "blue" : "212", + "green" : "120", + "red" : "0" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/WorkingSetHeaderUndoButtonColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/WorkingSetHeaderUndoButtonColor.colorset/Contents.json new file mode 100644 index 00000000..0bdd57d7 --- /dev/null +++ b/ExtensionService/Assets.xcassets/WorkingSetHeaderUndoButtonColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "extended-srgb", + "components" : { + "alpha" : "1.000", + "blue" : "204", + "green" : "204", + "red" : "204" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "extended-srgb", + "components" : { + "alpha" : "1.000", + "blue" : "49", + "green" : "49", + "red" : "49" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/WorkingSetItemColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/WorkingSetItemColor.colorset/Contents.json new file mode 100644 index 00000000..4de580b8 --- /dev/null +++ b/ExtensionService/Assets.xcassets/WorkingSetItemColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "extended-srgb", + "components" : { + "alpha" : "0.850", + "blue" : "0", + "green" : "0", + "red" : "0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "extended-srgb", + "components" : { + "alpha" : "0.850", + "blue" : "255", + "green" : "255", + "red" : "255" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Contents.json new file mode 100644 index 00000000..c4b93a1c --- /dev/null +++ b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Xcode_16x16.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_16x16.svg b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_16x16.svg new file mode 100644 index 00000000..0e118ea5 --- /dev/null +++ b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_16x16.svg @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json b/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json new file mode 100644 index 00000000..a1548aa0 --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "code-review-light.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "code-review-dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-dark.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-dark.svg new file mode 100644 index 00000000..39eea110 --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-light.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-light.svg new file mode 100644 index 00000000..b60a0982 --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/editor.focusedStackFrameHighlightBackground.colorset/Contents.json b/ExtensionService/Assets.xcassets/editor.focusedStackFrameHighlightBackground.colorset/Contents.json new file mode 100644 index 00000000..e475d8e3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/editor.focusedStackFrameHighlightBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "202", + "green" : "223", + "red" : "203" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "57", + "green" : "77", + "red" : "57" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/editorOverviewRuler.inlineChatRemoved.colorset/Contents.json b/ExtensionService/Assets.xcassets/editorOverviewRuler.inlineChatRemoved.colorset/Contents.json new file mode 100644 index 00000000..abd021c3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/editorOverviewRuler.inlineChatRemoved.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "211", + "green" : "214", + "red" : "242" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "25", + "green" : "25", + "red" : "55" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/gitDecoration.addedResourceForeground.colorset/Contents.json b/ExtensionService/Assets.xcassets/gitDecoration.addedResourceForeground.colorset/Contents.json new file mode 100644 index 00000000..a19edf2b --- /dev/null +++ b/ExtensionService/Assets.xcassets/gitDecoration.addedResourceForeground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "52", + "green" : "138", + "red" : "56" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "52", + "green" : "138", + "red" : "56" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/gitDecoration.deletedResourceForeground.colorset/Contents.json b/ExtensionService/Assets.xcassets/gitDecoration.deletedResourceForeground.colorset/Contents.json new file mode 100644 index 00000000..f8b5d709 --- /dev/null +++ b/ExtensionService/Assets.xcassets/gitDecoration.deletedResourceForeground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "57", + "green" : "78", + "red" : "199" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "57", + "green" : "78", + "red" : "199" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Info.plist b/ExtensionService/Info.plist index 2ad095c8..f7f84340 100644 --- a/ExtensionService/Info.plist +++ b/ExtensionService/Info.plist @@ -27,5 +27,9 @@ $(COPILOT_DOCS_URL) COPILOT_FORUM_URL $(COPILOT_FORUM_URL) + STANDARD_TELEMETRY_CHANNEL_KEY + $(STANDARD_TELEMETRY_CHANNEL_KEY) + GITHUB_APP_ID + $(GITHUB_APP_ID) diff --git a/ExtensionService/XPCController.swift b/ExtensionService/XPCController.swift index 5fdd4445..02656f85 100644 --- a/ExtensionService/XPCController.swift +++ b/ExtensionService/XPCController.swift @@ -39,19 +39,38 @@ final class XPCController: XPCServiceDelegate { func createPingTask() { pingTask?.cancel() pingTask = Task { [weak self] in + var consecutiveFailures = 0 + var backoffDelay = 1_000_000_000 // Start with 1 second + while !Task.isCancelled { guard let self else { return } do { try await self.bridge.updateServiceEndpoint(self.xpcListener.endpoint) - try await Task.sleep(nanoseconds: 60_000_000_000) + // Reset on success + consecutiveFailures = 0 + backoffDelay = 1_000_000_000 + try await Task.sleep(nanoseconds: 60_000_000_000) // 60 seconds between successful pings } catch { - try await Task.sleep(nanoseconds: 1_000_000_000) + consecutiveFailures += 1 + // Log only on 1st, 5th (31 sec), 10th failures, etc. to avoid flooding + let shouldLog = consecutiveFailures == 1 || consecutiveFailures % 5 == 0 + #if DEBUG // No log, but you should run CommunicationBridge, too. #else - Logger.service - .error("Failed to connect to bridge: \(error.localizedDescription)") + if consecutiveFailures == 5 { + if #available(macOS 13.0, *) { + showBackgroundPermissionAlert() + } + } + if shouldLog { + Logger.service.error("Failed to connect to bridge (\(consecutiveFailures) consecutive failures): \(error.localizedDescription)") + } #endif + + // Exponential backoff with a cap + backoffDelay = min(backoffDelay * 2, 120_000_000_000) // Cap at 120 seconds + try await Task.sleep(nanoseconds: UInt64(backoffDelay)) } } } diff --git a/README.md b/README.md index 8eabfdbe..eea0b39a 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,34 @@ -# GitHub Copilot for Xcode +# GitHub Copilot for Xcode -Demo of GitHub Copilot for Xcode +[GitHub Copilot](https://github.com/features/copilot) for Xcode is the leading AI coding assistant for Swift, Objective-C and iOS/macOS development. It delivers intelligent Completions, Chat, and Code Review—plus advanced features like Agent Mode, Next Edit Suggestions, MCP Registry, and Copilot Vision to make Xcode development faster and smarter. -[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. +## Chat -## Beta Preview Policy +GitHub Copilot Chat provides suggestions to your specific coding tasks via chat. +Chat of GitHub Copilot for Xcode -Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Terms](https://docs.github.com/en/site-policy/github-terms/github-pre-release-license-terms). We want to remind you that: +## Agent Mode -> Beta Previews may not be supported or may change at any time. You may receive confidential information through those programs that must remain confidential while the program is private. We'd love your feedback to make our Beta Previews better. +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 ## Requirements - macOS 12+ - Xcode 8+ -- A GitHub Copilot subscription. To learn more, visit [https://github.com/features/copilot](https://github.com/features/copilot). +- A GitHub account ## Getting Started @@ -32,30 +43,28 @@ Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Te 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 Copilot to start when `GitHub Copilot for Xcode` is opened. +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. Two permissions are required: `Accessibility` and `Xcode Source Editor - Extension`. For more on why these permissions are required see - [TROUBLESHOOTING.md](./TROUBLESHOOTING.md). +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). 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 @@ -64,7 +73,7 @@ Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Te and enable `GitHub Copilot`:

- Screenshot of extension permission + Screenshot of extension permission

1. After granting the extension permission, open Xcode. Verify that the @@ -72,7 +81,7 @@ Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Te 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` @@ -80,7 +89,7 @@ Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Te 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 @@ -100,9 +109,24 @@ Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Te 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 +

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

+ Screenshot of GitHub Copilot menu item +

+ +## How to use Code Completion + + Press `tab` to accept the first line of a suggestion, hold `option` to view + the full suggestion, and press `option` + `tab` to accept the full suggestion. ## License @@ -121,7 +145,7 @@ Copilot for Xcode. We’d love to get your help in making GitHub Copilot better! If you have feedback or encounter any problems, please reach out on our [Feedback -forum](https://github.com/orgs/community/discussions/categories/copilot). +forum](https://github.com/github/CopilotForXcode/discussions). ## Acknowledgements diff --git a/ReleaseNotes.md b/ReleaseNotes.md new file mode 100644 index 00000000..54e04ee5 --- /dev/null +++ b/ReleaseNotes.md @@ -0,0 +1,18 @@ +### GitHub Copilot for Xcode 0.47.0 + +**🚀 Highlights** + +- **Toolcall Auto Approval**: Streamlined workflow with auto-approval support for MCP tools, sensitive files, and terminal commands. +- **MCP Registry**: The MCP registry and allowlist features are now available (requires editor preview feature flag). + +**💪 Improvements** + +- Refined the working set header. +- Improved the details view for MCP tool calls. + +**🛠️ Bug Fixes** + +- Fixed layout issues with tool calls. +- Resolved display issues for Next Edit Suggestions (NES). +- Improved error messaging for SSL certificate failures. +- Addressed various performance issues. diff --git a/SUPPORT.md b/SUPPORT.md index 33762051..fe426a0a 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -4,7 +4,7 @@ We’d love to get your help in making GitHub Copilot better! If you have feedback or encounter any problems, please reach out on our [Feedback -forum](https://github.com/orgs/community/discussions/categories/copilot). +forum](https://github.com/github/CopilotForXcode/discussions). GitHub Copilot for Xcode is under active development and maintained by GitHub staff. We will do our best to respond to support, feature requests, and diff --git a/Script/export-options-local.plist b/Script/export-options-local.plist new file mode 100644 index 00000000..9c4fb9f7 --- /dev/null +++ b/Script/export-options-local.plist @@ -0,0 +1,10 @@ + + + + + method + debugging + signingStyle + automatic + + \ No newline at end of file diff --git a/Script/localbuild-app.sh b/Script/localbuild-app.sh new file mode 100644 index 00000000..177c20fe --- /dev/null +++ b/Script/localbuild-app.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Determine paths relative to script location +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$( cd "${SCRIPT_DIR}/.." && pwd )" +PROJECT_NAME=$(basename "${PROJECT_ROOT}") + +# Define build directory +BUILD_DIR="${PROJECT_ROOT}/build" +mkdir -p "${BUILD_DIR}" + +# Set variables +APP_NAME="CopiloForXcode" +SCHEME_NAME="Copilot for Xcode" +CONFIGURATION="Release" +ARCHIVE_PATH="${BUILD_DIR}/Archives/${APP_NAME}.xcarchive" +XCWORKSPACE_PATH="${PROJECT_ROOT}/Copilot for Xcode.xcworkspace" +EXPORT_PATH="${BUILD_DIR}/Export" +EXPORT_OPTIONS_PLIST="${PROJECT_ROOT}/Script/export-options-local.plist" + +# Clean and build archive +xcodebuild \ + -scheme "${SCHEME_NAME}" \ + -quiet \ + -archivePath "${ARCHIVE_PATH}" \ + -configuration "${CONFIGURATION}" \ + -skipMacroValidation \ + -showBuildTimingSummary \ + -disableAutomaticPackageResolution \ + -workspace "${XCWORKSPACE_PATH}" -verbose -arch arm64 \ + archive \ + APP_VERSION='0.0.0' + +# Export archive to .app +xcodebuild -exportArchive \ + -archivePath "${ARCHIVE_PATH}" \ + -exportOptionsPlist "${EXPORT_OPTIONS_PLIST}" \ + -exportPath "${EXPORT_PATH}" + +echo "App packaged successfully at ${EXPORT_PATH}/${APP_NAME}.app" + +open "${EXPORT_PATH}" \ No newline at end of file diff --git a/Server/package-lock.json b/Server/package-lock.json index b3eefd78..4ff704de 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,20 +8,1986 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.263.0" + "@github/copilot-language-server": "1.411.0", + "@github/copilot-language-server-darwin-arm64": "1.411.0", + "@github/copilot-language-server-darwin-x64": "1.411.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "monaco-editor": "0.52.2" + }, + "devDependencies": { + "@types/node": "^22.15.17", + "copy-webpack-plugin": "^13.0.1", + "css-loader": "^7.1.2", + "style-loader": "^4.0.0", + "terser-webpack-plugin": "^5.3.14", + "ts-loader": "^9.5.4", + "typescript": "^5.8.3", + "webpack": "^5.99.9", + "webpack-cli": "^6.0.1" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", + "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@github/copilot-language-server": { + "version": "1.411.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.411.0.tgz", + "integrity": "sha512-KxuvWq3DT4qTujxtgDQTHmynWawDiwqsRC9BmuBVi5PyzdyejJEj6rgcuwH7WcOMgNQJlHVQmSxN6uYurqp26w==", + "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.411.0", + "@github/copilot-language-server-darwin-x64": "1.411.0", + "@github/copilot-language-server-linux-arm64": "1.411.0", + "@github/copilot-language-server-linux-x64": "1.411.0", + "@github/copilot-language-server-win32-x64": "1.411.0" + } + }, + "node_modules/@github/copilot-language-server-darwin-arm64": { + "version": "1.411.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.411.0.tgz", + "integrity": "sha512-fTRodMIdHRgsLDhfhlpOT6OvyR3rLD4JwkbjlRCa+KDHAQd/kFN8+G5KnzqMckIFtGAvQ1zY7d8oKiT7Z11ayg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "os": [ + "darwin" + ] + }, + "node_modules/@github/copilot-language-server-darwin-x64": { + "version": "1.411.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.411.0.tgz", + "integrity": "sha512-CA9l1MvMmfgDgaKmzP4inEx6P8sG1x+pF12HY9nwwH01XmeJre+obQM8M3Nm5BUIklmpS07Vk5fbu9X3fOpWkg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "os": [ + "darwin" + ] + }, + "node_modules/@github/copilot-language-server-linux-arm64": { + "version": "1.411.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.411.0.tgz", + "integrity": "sha512-6T0SVveZlfVTcUS98vqTPhJSFA3Ia3FCPubOeYHF3SqHdLTokJtKrYGzD6gux+0ik1/9pPmx4bj8cMfHkhp1SA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@github/copilot-language-server-linux-x64": { + "version": "1.411.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.411.0.tgz", + "integrity": "sha512-OKKZqCH2x7OL71pzDQQP4lZfsVnqiOlpcnz9UXoP4QFnkGunx5PhAmbYvZwTQiCNAwippkSaUjDnj9YneNkytw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@github/copilot-language-server-win32-x64": { + "version": "1.411.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.411.0.tgz", + "integrity": "sha512-kJK/qoiMeydpy1K/uBgVwTqXjeLZmkMwJupbvZFXWjYFbHU2iCe6fHiUsROYPoVbqX52tjX5C+Rw/plhARDuRA==", + "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", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.15.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", + "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-3.0.1.tgz", + "integrity": "sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-3.0.1.tgz", + "integrity": "sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-3.0.1.tgz", + "integrity": "sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001715", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz", + "integrity": "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-webpack-plugin": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz", + "integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-parent": "^6.0.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2", + "tinyglobby": "^0.2.12" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", + "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.142", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.142.tgz", + "integrity": "sha512-Ah2HgkTu/9RhTDNThBtzu2Wirdy4DC9b0sMT1pUhbkZQ5U/iwmE+PHZX1MpjD5IkJCc2wSghgGG/B04szAx07w==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz", + "integrity": "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@github/copilot-language-server": { - "version": "1.263.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.263.0.tgz", - "integrity": "sha512-kf8M5kN1gYp+8yjk+yaH6iR9c8pSamWVpD7M6S2ZRkd3E4NqFGJe90Jgk6nub19tv5/qC7h7fHgeWxuhMRAGLQ==", + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", "dependencies": { - "vscode-languageserver-protocol": "^3.17.5" + "mime-db": "1.52.0" }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "bin": { - "copilot-language-server": "dist/language-server.js" + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.39.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", + "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", + "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-loader": { + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", + "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", @@ -43,6 +2009,169 @@ "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" + }, + "node_modules/watchpack": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "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": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.2", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-6.0.1.tgz", + "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^0.6.1", + "@webpack-cli/configtest": "^3.0.1", + "@webpack-cli/info": "^3.0.1", + "@webpack-cli/serve": "^3.0.1", + "colorette": "^2.0.14", + "commander": "^12.1.0", + "cross-spawn": "^7.0.3", + "envinfo": "^7.14.0", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.82.0" + }, + "peerDependenciesMeta": { + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" } } } diff --git a/Server/package.json b/Server/package.json index b82186e4..62a20e1d 100644 --- a/Server/package.json +++ b/Server/package.json @@ -3,7 +3,26 @@ "version": "0.0.1", "description": "Package for downloading @github/copilot-language-server", "private": true, + "scripts": { + "build": "webpack" + }, "dependencies": { - "@github/copilot-language-server": "^1.263.0" + "@github/copilot-language-server": "1.411.0", + "@github/copilot-language-server-darwin-arm64": "1.411.0", + "@github/copilot-language-server-darwin-x64": "1.411.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "monaco-editor": "0.52.2" + }, + "devDependencies": { + "@types/node": "^22.15.17", + "copy-webpack-plugin": "^13.0.1", + "css-loader": "^7.1.2", + "style-loader": "^4.0.0", + "terser-webpack-plugin": "^5.3.14", + "ts-loader": "^9.5.4", + "typescript": "^5.8.3", + "webpack": "^5.99.9", + "webpack-cli": "^6.0.1" } } diff --git a/Server/src/diffView/css/style.css b/Server/src/diffView/css/style.css new file mode 100644 index 00000000..2e430145 --- /dev/null +++ b/Server/src/diffView/css/style.css @@ -0,0 +1,192 @@ +/* Diff Viewer Styles */ +:root { + /* Light theme variables */ + --bg-color: #ffffff; + --text-color: #333333; + --border-color: #dddddd; + --button-bg: #007acc; + --button-text: white; + --secondary-button-bg: #f0f0f0; + --secondary-button-text: #333333; + --secondary-button-border: #dddddd; + --secondary-button-hover: #e0e0e0; + --additions-foreground-color: #2EA043; + --deletions-foreground-color: #F85149; +} + +@media (prefers-color-scheme: dark) { + :root { + /* Dark theme variables */ + --bg-color: #1e1e1e; + --text-color: #cccccc; + --border-color: #444444; + --button-bg: #0e639c; + --button-text: white; + --secondary-button-bg: #6E6D70; + --secondary-button-text: #DFDEDF; + --secondary-button-border: #555555; + --secondary-button-hover: #505050; + --additions-foreground-color: #2EA043; + --deletions-foreground-color: #F85149; + } +} + +html, body { + margin: 0; + padding: 0; + height: 100%; + width: 100%; + overflow: hidden; + background-color: var(--bg-color); + color: var(--text-color); +} + +#container { + width: calc(100% - 40px); /* 20px padding on each side */ + height: calc(100vh - 84px); /* 40px header + 4px top padding + 40px bottom padding */ + border: 1px solid var(--border-color); + margin: 0 20px 40px 20px; + padding: 0; + margin-top: 44px; /* 40px header + 4px top padding */ + box-sizing: border-box; +} + +.loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + color: var(--text-color); +} + +.header { + position: absolute; + top: 4px; + left: 10px; + right: 10px; + height: 40px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 10px; + background-color: var(--bg-color); + box-sizing: border-box; +} + +.action-button { + margin-left: 2px; + padding: 4px 14px; + background-color: var(--button-bg); + color: var(--button-text); + border: none; + border-radius: 4px; + cursor: pointer; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 14px; + font-weight: 500; +} + +.action-button:hover { + background-color: #0062a3; +} + +.action-button.secondary { + background-color: var(--secondary-button-bg); + color: var(--secondary-button-text); + border: 1px solid var(--secondary-button-border); +} + +.action-button.secondary:hover { + background-color: var(--secondary-button-hover); +} + +.hidden { + display: none; +} + +.header-left { + display: flex; + align-items: center; + overflow: hidden; + gap: 4px; +} + +/* file path */ +.file-path { + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 14px; + font-weight: 600; + color: var(--text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Diff stats */ +.diff-stats { + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 12px; + font-weight: 500; + display: flex; + gap: 4px; +} + +.additions-count { + color: var(--additions-foreground-color); + font-weight: 600; +} + +.deletions-count { + color: var(--deletions-foreground-color); + font-weight: 600; +} + +/* Style for gutter indicators using data attributes */ +.monaco-editor .codicon.codicon-diff-insert:before { + content: "+" !important; + font-family: inherit !important; + font-size: inherit !important; + font-weight: bold; + color: var(--additions-foreground-color) !important; + padding: 0 2px; +} + +.monaco-editor .codicon.codicon-diff-remove:before { + content: "-" !important; + font-family: inherit !important; + font-size: inherit !important; + font-weight: bold; + color: var(--deletions-foreground-color) !important; + padding: 0 2px; +} + +/* Force show for Monaco Editor 0.52.2 */ +.monaco-editor .diff-side-insert .margin-view-zone .codicon, +.monaco-editor .diff-side-delete .margin-view-zone .codicon { + display: inline-block !important; + visibility: visible !important; + opacity: 1 !important; +} + +/* Hide the diff overview bar completely */ +.monaco-diff-editor .diffOverview { + display: none !important; +} + +/* Hide all lightbulb icons (Copy Changed Line buttons) */ +.monaco-editor .codicon-lightbulb, +.monaco-editor .codicon-lightbulb-autofix, +.monaco-editor .lightbulb-glyph { + display: none !important; + visibility: hidden !important; + pointer-events: none !important; +} + +/* Unfold icon */ +.monaco-editor .codicon.codicon-unfold:before { + content:"···" !important; + font-family: inherit !important; + font-size: inherit !important; + font-weight: bold; +} \ No newline at end of file diff --git a/Server/src/diffView/diffView.html b/Server/src/diffView/diffView.html new file mode 100644 index 00000000..de32b013 --- /dev/null +++ b/Server/src/diffView/diffView.html @@ -0,0 +1,31 @@ + + + + + + Diff Viewer + + + +
Loading diff viewer...
+ +
+
+
+
+ +0 + -0 +
+
+ +
+ + +
+
+ +
+ + + + diff --git a/Server/src/diffView/index.ts b/Server/src/diffView/index.ts new file mode 100644 index 00000000..05eb6fdf --- /dev/null +++ b/Server/src/diffView/index.ts @@ -0,0 +1,38 @@ +// index.ts - Main entry point for the Monaco Editor diff view +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; +import { initDiffEditor } from './js/monaco-diff-editor'; +import { setupUI } from './js/ui-controller'; +import DiffViewer from './js/api'; + +// Initialize everything when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + // Hide loading indicator as Monaco is directly imported + const loadingElement = document.getElementById('loading'); + if (loadingElement) { + loadingElement.style.display = 'none'; + } + + // Set up UI elements and event handlers + setupUI(); + + // Make sure the editor follows the system theme + DiffViewer.followSystemTheme(); + + // Handle window resize events + window.addEventListener('resize', () => { + DiffViewer.handleResize(); + }); +}); + +// Define DiffViewer on the window object +declare global { + interface Window { + DiffViewer: typeof DiffViewer; + } +} + +// Expose the MonacoDiffViewer API to the global scope +window.DiffViewer = DiffViewer; + +// Export the MonacoDiffViewer for webpack +export default DiffViewer; diff --git a/Server/src/diffView/js/api.ts b/Server/src/diffView/js/api.ts new file mode 100644 index 00000000..2774e0c9 --- /dev/null +++ b/Server/src/diffView/js/api.ts @@ -0,0 +1,121 @@ +// api.ts - Public API for external use +import { initDiffEditor, updateDiffContent, getEditor, setEditorTheme, updateDiffStats } from './monaco-diff-editor'; +import { updateFileMetadata } from './ui-controller'; +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; + +/** + * Interface for the DiffViewer API + */ +interface DiffViewerAPI { + init: ( + originalContent: string, + modifiedContent: string, + path: string | null, + status: string | null, + options?: monaco.editor.IDiffEditorConstructionOptions + ) => void; + update: ( + originalContent: string, + modifiedContent: string, + path: string | null, + status: string | null + ) => void; + handleResize: () => void; + setTheme: (theme: 'light' | 'dark') => void; + followSystemTheme: () => void; +} + +/** + * The public API that will be exposed to the global scope + */ +const DiffViewer: DiffViewerAPI = { + /** + * Initialize the diff editor with content + * @param {string} originalContent - Content for the original side + * @param {string} modifiedContent - Content for the modified side + * @param {string} path - File path + * @param {string} status - File edit status + * @param {Object} options - Optional configuration for the diff editor + */ + init: function( + originalContent: string, + modifiedContent: string, + path: string | null, + status: string | null, + options?: monaco.editor.IDiffEditorConstructionOptions + ): void { + // Initialize editor + initDiffEditor(originalContent, modifiedContent, options || {}); + + // Update file metadata and UI + updateFileMetadata(path, status); + }, + + /** + * Update the diff editor with new content + * @param {string} originalContent - Content for the original side + * @param {string} modifiedContent - Content for the modified side + * @param {string} path - File path + * @param {string} status - File edit status + */ + update: function( + originalContent: string, + modifiedContent: string, + path: string | null, + status: string | null + ): void { + // Update editor content + updateDiffContent(originalContent, modifiedContent); + + // Update file metadata and UI + updateFileMetadata(path, status); + + // Update diff stats + updateDiffStats(); + }, + + /** + * Handle resize events + */ + handleResize: function(): void { + const editor = getEditor(); + if (editor) { + const container = document.getElementById('container'); + if (container) { + const headerHeight = 40; + const topPadding = 4; + const bottomPadding = 40; + + const availableHeight = window.innerHeight - headerHeight - topPadding - bottomPadding; + container.style.height = `${availableHeight}px`; + } + + editor.layout(); + } + }, + + /** + * Set the theme for the editor + */ + setTheme: function(theme: 'light' | 'dark'): void { + setEditorTheme(theme); + }, + + /** + * Follow the system theme + */ + followSystemTheme: function(): void { + // Set initial theme based on system preference + const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + setEditorTheme(isDarkMode ? 'dark' : 'light'); + + // Add listener for theme changes + if (window.matchMedia) { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => { + setEditorTheme(event.matches ? 'dark' : 'light'); + }); + } + } +}; + +export default DiffViewer; diff --git a/Server/src/diffView/js/monaco-diff-editor.ts b/Server/src/diffView/js/monaco-diff-editor.ts new file mode 100644 index 00000000..0a87ac4c --- /dev/null +++ b/Server/src/diffView/js/monaco-diff-editor.ts @@ -0,0 +1,346 @@ +// monaco-diff-editor.ts - Monaco Editor diff view core functionality +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; + +// Editor state +let diffEditor: monaco.editor.IStandaloneDiffEditor | null = null; +let originalModel: monaco.editor.ITextModel | null = null; +let modifiedModel: monaco.editor.ITextModel | null = null; +let resizeObserver: ResizeObserver | null = null; +const DEFAULT_EDITOR_OPTIONS: monaco.editor.IDiffEditorConstructionOptions = { + renderSideBySide: false, + readOnly: true, + // Enable automatic layout adjustments + automaticLayout: true, + glyphMargin: false, + // Collapse unchanged regions + folding: true, + hideUnchangedRegions: { + enabled: true, + revealLineCount: 20, + minimumLineCount: 2, + contextLineCount: 2 + + }, + // Disable overview ruler and related features + renderOverviewRuler: false, + overviewRulerBorder: false, + overviewRulerLanes: 0, + scrollBeyondLastLine: false, + scrollbar: { + vertical: 'auto', + horizontal: 'auto', + useShadows: false, + verticalHasArrows: false, + horizontalHasArrows: false, + alwaysConsumeMouseWheel: false, + }, + lineHeight: 24, +} + +/** + * Initialize the Monaco diff editor + * @param {string} originalContent - Content for the original side + * @param {string} modifiedContent - Content for the modified side + * @param {Object} options - Optional configuration for the diff editor + * @returns {Object} The diff editor instance + */ +function initDiffEditor( + originalContent: string, + modifiedContent: string, + options: monaco.editor.IDiffEditorConstructionOptions = {} +): monaco.editor.IStandaloneDiffEditor | null { + try { + // Default options + const editorOptions: monaco.editor.IDiffEditorConstructionOptions = { + ...DEFAULT_EDITOR_OPTIONS, + lineNumbersMinChars: calculateLineNumbersMinChars(originalContent, modifiedContent), + ...options + }; + + // Create the diff editor if it doesn't exist yet + if (!diffEditor) { + const container = document.getElementById("container"); + if (!container) { + throw new Error("Container element not found"); + } + + // Set initial container size to viewport height + // const headerHeight = 40; + // container.style.height = `${window.innerHeight - headerHeight}px`; + // Set initial container size to viewport height with precise calculations + const visibleHeight = window.innerHeight; + const headerHeight = 40; + const topPadding = 4; + const bottomPadding = 40; + const availableHeight = visibleHeight - headerHeight - topPadding - bottomPadding; + container.style.height = `${Math.floor(availableHeight)}px`; + container.style.overflow = "hidden"; // Ensure container doesn't have scrollbars + + diffEditor = monaco.editor.createDiffEditor( + container, + editorOptions + ); + + // Add resize handling + setupResizeHandling(); + + // Initialize theme + initializeTheme(); + } else { + // Apply any new options + diffEditor.updateOptions(editorOptions); + } + + // Create and set models + updateModels(originalContent, modifiedContent); + + return diffEditor; + } catch (error) { + console.error("Error initializing diff editor:", error); + return null; + } +} + +/** + * Setup proper resize handling for the editor + */ +function setupResizeHandling(): void { + window.addEventListener('resize', () => { + if (diffEditor) { + diffEditor.layout(); + } + }); + + if (window.ResizeObserver && !resizeObserver) { + const container = document.getElementById('container'); + + if (container) { + resizeObserver = new ResizeObserver(() => { + if (diffEditor) { + diffEditor.layout() + } + }); + resizeObserver.observe(container); + } + } +} + +/** + * Create or update the models for the diff editor + * @param {string} originalContent - Content for the original side + * @param {string} modifiedContent - Content for the modified side + */ +function updateModels(originalContent: string, modifiedContent: string): void { + try { + // Clean up existing models if they exist + if (originalModel) { + originalModel.dispose(); + } + if (modifiedModel) { + modifiedModel.dispose(); + } + + // Create new models with the content + originalModel = monaco.editor.createModel(originalContent || "", "plaintext"); + modifiedModel = monaco.editor.createModel(modifiedContent || "", "plaintext"); + + // Set the models to show the diff + if (diffEditor) { + diffEditor.setModel({ + original: originalModel, + modified: modifiedModel, + }); + + // Add timeout to give Monaco time to calculate diffs + setTimeout(() => { + updateDiffStats(); + adjustContainerHeight(); + }, 100); // 100ms delay allows diff calculation to complete + } + } catch (error) { + console.error("Error updating models:", error); + } +} + +/** + * Update the diff view with new content + * @param {string} originalContent - Content for the original side + * @param {string} modifiedContent - Content for the modified side + */ +function updateDiffContent(originalContent: string, modifiedContent: string): void { + // If editor exists, update it + if (diffEditor && diffEditor.getModel()) { + const model = diffEditor.getModel(); + + // Update model values + if (model) { + model.original.setValue(originalContent || ""); + model.modified.setValue(modifiedContent || ""); + } + } else { + // Initialize if not already done + initDiffEditor(originalContent, modifiedContent); + } +} + +/** + * Get the current diff editor instance + * @returns {Object|null} The diff editor instance or null + */ +function getEditor(): monaco.editor.IStandaloneDiffEditor | null { + return diffEditor; +} + +/** + * Calculate the number of line differences + * @returns {Object} The number of additions and deletions + */ +function calculateLineDifferences(): { additions: number, deletions: number } { + if (!diffEditor || !diffEditor.getModel()) { + return { additions: 0, deletions: 0 }; + } + + let additions = 0; + let deletions = 0; + const lineChanges = diffEditor.getLineChanges(); + console.log(">>> Line Changes:", lineChanges); + if (lineChanges) { + for (const change of lineChanges) { + console.log(change); + if (change.originalEndLineNumber >= change.originalStartLineNumber) { + deletions += change.originalEndLineNumber - change.originalStartLineNumber + 1; + } + if (change.modifiedEndLineNumber >= change.modifiedStartLineNumber) { + additions += change.modifiedEndLineNumber - change.modifiedStartLineNumber + 1; + } + } + } + + return { additions, deletions }; +} + +/** + * Update the diff statistics displayed in the UI + */ +function updateDiffStats(): void { + const { additions, deletions } = calculateLineDifferences(); + + const additionsElement = document.getElementById('additions-count'); + const deletionsElement = document.getElementById('deletions-count'); + + if (additionsElement) { + additionsElement.textContent = `+${additions}`; + } + + if (deletionsElement) { + deletionsElement.textContent = `-${deletions}`; + } +} + +/** + * Dynamically adjust container height based on content + */ +function adjustContainerHeight(): void { + const container = document.getElementById('container'); + if (!container || !diffEditor) return; + + // Always use the full viewport height + const visibleHeight = window.innerHeight; + const headerHeight = 40; // Height of the header + const topPadding = 4; // Top padding + const bottomPadding = 40; // Bottom padding + const availableHeight = visibleHeight - headerHeight - topPadding - bottomPadding; + + container.style.height = `${Math.floor(availableHeight)}px`; + + diffEditor.layout(); +} + +/** + * Set the editor theme + * @param {string} theme - The theme to set ('light' or 'dark') + */ +function setEditorTheme(theme: 'light' | 'dark'): void { + if (!diffEditor) return; + + monaco.editor.setTheme(theme === 'dark' ? 'vs-dark' : 'vs'); +} + +/** + * Detect the system theme preference + * @returns {string} The detected theme ('light' or 'dark') + */ +function detectSystemTheme(): 'light' | 'dark' { + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +/** + * Initialize the theme based on system preference + * and set up a listener for changes + */ +function initializeTheme(): void { + const theme = detectSystemTheme(); + setEditorTheme(theme); + + // Listen for changes in system theme preference + if (window.matchMedia) { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => { + setEditorTheme(event.matches ? 'dark' : 'light'); + }); + } +} + +/** + * Calculate the optimal number of characters for line numbers + * @param {string} originalContent - Content for the original side + * @param {string} modifiedContent - Content for the modified side + * @returns {number} The minimum number of characters needed for line numbers + */ +function calculateLineNumbersMinChars(originalContent: string, modifiedContent: string): number { + // Count the number of lines in both contents + const originalLineCount = originalContent ? originalContent.split('\n').length : 0; + const modifiedLineCount = modifiedContent ? modifiedContent.split('\n').length : 0; + + // Get the maximum line count + const maxLineCount = Math.max(originalLineCount, modifiedLineCount); + + // Calculate the number of digits in the max line count + // Use Math.log10 and Math.ceil to get the number of digits + // Add 1 to ensure some extra padding + const digits = maxLineCount > 0 ? Math.floor(Math.log10(maxLineCount) + 1) + 1 : 2; + + // Return a minimum of 2 characters, maximum of 5 + return Math.min(Math.max(digits, 2), 5); +} + +/** + * Dispose of the editor and models to clean up resources + */ +function dispose(): void { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + + if (originalModel) { + originalModel.dispose(); + originalModel = null; + } + if (modifiedModel) { + modifiedModel.dispose(); + modifiedModel = null; + } + if (diffEditor) { + diffEditor.dispose(); + diffEditor = null; + } +} + +export { + initDiffEditor, + updateDiffContent, + getEditor, + dispose, + setEditorTheme, + updateDiffStats +}; diff --git a/Server/src/diffView/js/ui-controller.ts b/Server/src/diffView/js/ui-controller.ts new file mode 100644 index 00000000..6e8579ea --- /dev/null +++ b/Server/src/diffView/js/ui-controller.ts @@ -0,0 +1,162 @@ +// ui-controller.ts - UI event handlers and state management +import { DiffViewMessageHandler } from '../../shared/webkit'; +/** + * UI state and file metadata + */ +let filePath: string | null = null; +let fileEditStatus: string | null = null; + +/** + * Interface for messages sent to Swift handlers + */ +interface SwiftMessage { + event: string; + data: { + filePath: string | null; + [key: string]: any; + }; +} + +/** + * Initialize and set up UI elements and their event handlers + * @param {string} initialPath - The initial file path + * @param {string} initialStatus - The initial file edit status + */ +function setupUI(initialPath: string | null = null, initialStatus: string | null = null): void { + filePath = initialPath; + fileEditStatus = initialStatus; + + if (filePath) { + showFilePath(filePath); + } + + const keepButton = document.getElementById('keep-button'); + const undoButton = document.getElementById('undo-button'); + const choiceButtons = document.getElementById('choice-buttons'); + + if (!keepButton || !undoButton || !choiceButtons) { + console.error("Could not find UI elements"); + return; + } + + // Set initial UI state + updateUIStatus(initialStatus); + + // Setup event listeners + keepButton.addEventListener('click', handleKeepButtonClick); + undoButton.addEventListener('click', handleUndoButtonClick); +} + +/** + * Update the UI based on file edit status + * @param {string} status - The current file edit status + */ +function updateUIStatus(status: string | null): void { + fileEditStatus = status; + const choiceButtons = document.getElementById('choice-buttons'); + + if (!choiceButtons) return; + + // Hide buttons if file has been modified + if (status && status !== "none") { + choiceButtons.classList.add('hidden'); + } else { + choiceButtons.classList.remove('hidden'); + } +} + +/** + * Update the file metadata + * @param {string} path - The file path + * @param {string} status - The file edit status + */ +function updateFileMetadata(path: string | null, status: string | null): void { + filePath = path; + updateUIStatus(status); + if (filePath) { + showFilePath(filePath) + } +} + +/** + * Handle the "Keep" button click + */ +function handleKeepButtonClick(): void { + // Send message to Swift handler + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.swiftHandler) { + const message: SwiftMessage = { + event: 'keepButtonClicked', + data: { + filePath: filePath + } + }; + window.webkit.messageHandlers.swiftHandler.postMessage(message); + } else { + console.log('Keep button clicked, but no message handler found'); + } + + // Hide the choice buttons + const choiceButtons = document.getElementById('choice-buttons'); + if (choiceButtons) { + choiceButtons.classList.add('hidden'); + } +} + +/** + * Handle the "Undo" button click + */ +function handleUndoButtonClick(): void { + // Send message to Swift handler + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.swiftHandler) { + const message: SwiftMessage = { + event: 'undoButtonClicked', + data: { + filePath: filePath + } + }; + window.webkit.messageHandlers.swiftHandler.postMessage(message); + } else { + console.log('Undo button clicked, but no message handler found'); + } + + // Hide the choice buttons + const choiceButtons = document.getElementById('choice-buttons'); + if (choiceButtons) { + choiceButtons.classList.add('hidden'); + } +} + +/** + * Get the current file path + * @returns {string} The current file path + */ +function getFilePath(): string | null { + return filePath; +} + +/** + * Show the current file path + */ +function showFilePath(path: string): void { + const filePathElement = document.getElementById('file-path'); + const fileName = path.split('/').pop() ?? ''; + if (filePathElement) { + filePathElement.textContent = fileName + } +} + +/** + * Get the current file edit status + * @returns {string} The current file edit status + */ +function getFileEditStatus(): string | null { + return fileEditStatus; +} + +export { + setupUI, + updateUIStatus, + updateFileMetadata, + getFilePath, + getFileEditStatus +}; \ No newline at end of file diff --git a/Server/src/shared/webkit.ts b/Server/src/shared/webkit.ts new file mode 100644 index 00000000..3b6948fe --- /dev/null +++ b/Server/src/shared/webkit.ts @@ -0,0 +1,49 @@ +/** + * Type definitions for WebKit message handlers used in WebView communication + */ + +/** + * Base WebKit message handler interface + */ +export interface WebkitMessageHandler { + postMessage(message: any): void; +} + +/** + * Terminal-specific message handler + */ +export interface TerminalMessageHandler extends WebkitMessageHandler { + postMessage(message: string): void; +} + +/** + * DiffView-specific message handler + */ +export interface DiffViewMessageHandler extends WebkitMessageHandler { + postMessage(message: object): void; +} + +/** + * WebKit message handlers container interface + */ +export interface WebkitMessageHandlers { + terminalInput: TerminalMessageHandler; + swiftHandler: DiffViewMessageHandler; + [key: string]: WebkitMessageHandler | undefined; +} + +/** + * Main WebKit interface exposed by WebViews + */ +export interface WebkitHandler { + messageHandlers: WebkitMessageHandlers; +} + +/** + * Add webkit to the global Window interface + */ +declare global { + interface Window { + webkit: WebkitHandler; + } +} \ No newline at end of file diff --git a/Server/src/terminal/index.ts b/Server/src/terminal/index.ts new file mode 100644 index 00000000..e97ee33c --- /dev/null +++ b/Server/src/terminal/index.ts @@ -0,0 +1,52 @@ +import '@xterm/xterm/css/xterm.css'; +import { Terminal } from '@xterm/xterm'; +import { TerminalAddon } from './terminalAddon'; + +declare global { + interface Window { + initializeTerminal: () => Terminal; + writeToTerminal: (text: string) => void; + clearTerminal: () => void; + } +} + +window.initializeTerminal = function (): Terminal { + const term = new Terminal({ + cursorBlink: true, + theme: { + background: '#1e1e1e', + foreground: '#cccccc', + cursor: '#ffffff', + selectionBackground: 'rgba(128, 128, 128, 0.4)' + }, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontSize: 13 + }); + + const terminalAddon = new TerminalAddon(); + term.loadAddon(terminalAddon); + + const terminalElement = document.getElementById('terminal'); + if (!terminalElement) { + throw new Error('Terminal element not found'); + } + term.open(terminalElement); + terminalAddon.fit(); + + // Handle window resize + window.addEventListener('resize', () => { + terminalAddon.fit(); + }); + + // Expose terminal API methods + window.writeToTerminal = function (text: string): void { + term.write(text); + terminalAddon.processTerminalOutput(text); + }; + + window.clearTerminal = function (): void { + term.clear(); + }; + + return term; +} diff --git a/Server/src/terminal/terminal.html b/Server/src/terminal/terminal.html new file mode 100644 index 00000000..a35ac6fb --- /dev/null +++ b/Server/src/terminal/terminal.html @@ -0,0 +1,27 @@ + + + + + + + + +
+ + + + diff --git a/Server/src/terminal/terminalAddon.ts b/Server/src/terminal/terminalAddon.ts new file mode 100644 index 00000000..bf78dfe5 --- /dev/null +++ b/Server/src/terminal/terminalAddon.ts @@ -0,0 +1,326 @@ +import { FitAddon } from '@xterm/addon-fit'; +import { Terminal, ITerminalAddon } from '@xterm/xterm'; +import { TerminalMessageHandler } from '../shared/webkit'; + +interface TermSize { + cols: number; + rows: number; +} + +interface TerminalPosition { + row: number; + col: number; +} + +// https://xtermjs.org/docs/api/vtfeatures/ +// https://en.wikipedia.org/wiki/ANSI_escape_code +const VT = { + ESC: '\x1b', + CSI: '\x1b[', + UP_ARROW: '\x1b[A', + DOWN_ARROW: '\x1b[B', + RIGHT_ARROW: '\x1b[C', + LEFT_ARROW: '\x1b[D', + HOME_KEY: ['\x1b[H', '\x1bOH'], + END_KEY: ['\x1b[F', '\x1bOF'], + DELETE_REST_OF_LINE: '\x1b[K', + CursorUp: (n = 1) => `\x1b[${n}A`, + CursorDown: (n = 1) => `\x1b[${n}B`, + CursorForward: (n = 1) => `\x1b[${n}C`, + CursorBack: (n = 1) => `\x1b[${n}D` +}; + +/** + * Key code constants + */ +const KeyCodes = { + CONTROL_C: 3, + CONTROL_D: 4, + ENTER: 13, + BACKSPACE: 8, + DELETE: 127 +}; + +export class TerminalAddon implements ITerminalAddon { + private term: Terminal | null; + private fitAddon: FitAddon; + private inputBuffer: string; + private cursor: number; + private promptInLastLine: string; + private termSize: TermSize; + + constructor() { + this.term = null; + this.fitAddon = new FitAddon(); + this.inputBuffer = ''; + this.cursor = 0; + this.promptInLastLine = ''; + this.termSize = { + cols: 0, + rows: 0, + }; + } + + dispose(): void { + this.fitAddon.dispose(); + } + + activate(terminal: Terminal): void { + this.term = terminal; + this.termSize = { + cols: terminal.cols, + rows: terminal.rows, + }; + this.fitAddon.activate(terminal); + this.term.onData(this.handleData.bind(this)); + this.term.onResize(this.handleResize.bind(this)); + } + + fit(): void { + this.fitAddon.fit(); + } + + private handleData(data: string): void { + // If the input is a longer string (e.g., from paste), and it contains newlines + if (data.length > 1 && !data.startsWith(VT.ESC)) { + const lines = data.split(/(\r\n|\n|\r)/g); + + let lineIndex = 0; + const processLine = () => { + if (lineIndex >= lines.length) return; + + const line = lines[lineIndex]; + if (line === '\n' || line === '\r' || line === '\r\n') { + if (this.cursor > 0) { + this.clearInputLine(); + this.cursor = 0; + this.renderInputLine(this.inputBuffer); + } + window.webkit.messageHandlers.terminalInput.postMessage(this.inputBuffer + '\n'); + this.inputBuffer = ''; + this.cursor = 0; + lineIndex++; + setTimeout(processLine, 100); + return; + } + + this.handleSingleLine(line); + lineIndex++; + processLine(); + }; + + processLine(); + return; + } + + // Handle escape sequences for special keys + if (data.startsWith(VT.ESC)) { + this.handleEscSequences(data); + return; + } + + this.handleSingleLine(data); + } + + private handleSingleLine(data: string): void { + if (data.length === 0) return; + + const char = data.charCodeAt(0); + // Handle control characters + if (char < 32 || char === 127) { + // Handle Enter key (carriage return) + if (char === KeyCodes.ENTER) { + if (this.cursor > 0) { + this.clearInputLine(); + this.cursor = 0; + this.renderInputLine(this.inputBuffer); + } + window.webkit.messageHandlers.terminalInput.postMessage(this.inputBuffer + '\n'); + this.inputBuffer = ''; + this.cursor = 0; + } + else if (char === KeyCodes.CONTROL_C || char === KeyCodes.CONTROL_D) { + if (this.cursor > 0) { + this.clearInputLine(); + this.cursor = 0; + this.renderInputLine(this.inputBuffer); + } + window.webkit.messageHandlers.terminalInput.postMessage(this.inputBuffer + data); + this.inputBuffer = ''; + this.cursor = 0; + } + // Handle backspace or delete + else if (char === KeyCodes.BACKSPACE || char === KeyCodes.DELETE) { + if (this.cursor > 0) { + this.clearInputLine(); + + // Delete character at cursor position - 1 + const beforeCursor = this.inputBuffer.substring(0, this.cursor - 1); + const afterCursor = this.inputBuffer.substring(this.cursor); + const newInput = beforeCursor + afterCursor; + this.cursor--; + this.renderInputLine(newInput); + } + } + return; + } + + this.clearInputLine(); + + // Insert character at cursor position + const beforeCursor = this.inputBuffer.substring(0, this.cursor); + const afterCursor = this.inputBuffer.substring(this.cursor); + const newInput = beforeCursor + data + afterCursor; + this.cursor += data.length; + this.renderInputLine(newInput); + } + + private handleResize(data: { cols: number; rows: number }): void { + this.clearInputLine(); + this.termSize = { + cols: data.cols, + rows: data.rows, + }; + this.renderInputLine(this.inputBuffer); + } + + private clearInputLine(): void { + if (!this.term) return; + // Move to beginning of the current line + this.term.write('\r'); + const cursorPosition = this.calcCursorPosition(); + const inputEndPosition = this.calcLineWrapPosition(this.promptInLastLine.length + this.inputBuffer.length); + // If cursor is not at the end of input, move to the end + if (cursorPosition.row < inputEndPosition.row) { + this.term.write(VT.CursorDown(inputEndPosition.row - cursorPosition.row)); + } else if (cursorPosition.row > inputEndPosition.row) { + this.term.write(VT.CursorUp(cursorPosition.row - inputEndPosition.row)); + } + + // Clear from the last line upwards + this.term.write('\r' + VT.DELETE_REST_OF_LINE); + for (let i = inputEndPosition.row - 1; i >= 0; i--) { + this.term.write(VT.CursorUp(1)); + this.term.write('\r' + VT.DELETE_REST_OF_LINE); + } + }; + + // Function to render the input line considering line wrapping + private renderInputLine(newInput: string): void { + if (!this.term) return; + this.inputBuffer = newInput; + // Write prompt and input + this.term.write(this.promptInLastLine + this.inputBuffer); + const cursorPosition = this.calcCursorPosition(); + const inputEndPosition = this.calcLineWrapPosition(this.promptInLastLine.length + this.inputBuffer.length); + // If the last input char is at the end of the terminal width, + // need to print an extra empty line to display the cursor. + if (inputEndPosition.col == 0) { + this.term.write(' '); + this.term.write(VT.CursorBack(1)); + this.term.write(VT.DELETE_REST_OF_LINE); + } + + if (this.inputBuffer.length === this.cursor) { + return; + } + + // Move the cursor from the input end to the expected cursor row + if (cursorPosition.row < inputEndPosition.row) { + this.term.write(VT.CursorUp(inputEndPosition.row - cursorPosition.row)); + } + this.term.write('\r'); + if (cursorPosition.col > 0) { + this.term.write(VT.CursorForward(cursorPosition.col)); + } + }; + + private calcCursorPosition(): TerminalPosition { + return this.calcLineWrapPosition(this.promptInLastLine.length + this.cursor); + } + + private calcLineWrapPosition(textLength: number): TerminalPosition { + if (!this.term) { + return { row: 0, col: 0 }; + } + const row = Math.floor(textLength / this.termSize.cols); + const col = textLength % this.termSize.cols; + + return { row, col }; + } + + /** + * Handle ESC sequences + */ + private handleEscSequences(data: string): void { + if (!this.term) return; + switch (data) { + case VT.UP_ARROW: + // TODO: Could implement command history here + break; + + case VT.DOWN_ARROW: + // TODO: Could implement command history here + break; + + case VT.RIGHT_ARROW: + if (this.cursor < this.inputBuffer.length) { + this.clearInputLine(); + this.cursor++; + this.renderInputLine(this.inputBuffer); + } + break; + + case VT.LEFT_ARROW: + if (this.cursor > 0) { + this.clearInputLine(); + this.cursor--; + this.renderInputLine(this.inputBuffer); + } + break; + } + + // Handle Home key variations + if (VT.HOME_KEY.includes(data)) { + this.clearInputLine(); + this.cursor = 0; + this.renderInputLine(this.inputBuffer); + } + + // Handle End key variations + if (VT.END_KEY.includes(data)) { + this.clearInputLine(); + this.cursor = this.inputBuffer.length; + this.renderInputLine(this.inputBuffer); + } + }; + + /** + * Remove OSC escape sequences from text + */ + private removeOscSequences(text: string): string { + // Remove basic OSC sequences + let filteredText = text.replace(/\u001b\]\d+;[^\u0007\u001b]*[\u0007\u001b\\]/g, ''); + + // More comprehensive approach for nested sequences + return filteredText.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, ''); + }; + + /** + * Process terminal output and update prompt tracking + */ + processTerminalOutput(text: string): void { + if (typeof text !== 'string') return; + + const lastNewline = text.lastIndexOf('\n'); + const lastCarriageReturn = text.lastIndexOf('\r'); + const lastControlChar = Math.max(lastNewline, lastCarriageReturn); + let newPromptText = lastControlChar !== -1 ? text.substring(lastControlChar + 1) : text; + + // Filter out OSC sequences + newPromptText = this.removeOscSequences(newPromptText); + + this.promptInLastLine = lastControlChar !== -1 ? + newPromptText : this.promptInLastLine + newPromptText; + }; +} diff --git a/Server/tsconfig.json b/Server/tsconfig.json new file mode 100644 index 00000000..71eb52f9 --- /dev/null +++ b/Server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "sourceMap": true, + "allowJs": true, + "checkJs": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/Server/webpack.config.js b/Server/webpack.config.js new file mode 100644 index 00000000..2ace244b --- /dev/null +++ b/Server/webpack.config.js @@ -0,0 +1,77 @@ +const path = require('path'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); +const webpack = require('webpack'); +const TerserPlugin = require('terser-webpack-plugin'); + +/* + * The folder structure of `dist` would be: + * dist/ + * ├── terminal/ + * │ ├── terminal.js + * │ └── terminal.html + * └── diffView/ + * ├── diffView.js + * ├── diffView.html + * └── css/ + * └── style.css +*/ +module.exports = { + mode: 'production', + entry: { + // Add more entry points here + terminal: './src/terminal/index.ts', + diffView: './src/diffView/index.ts' + }, + resolve: { + extensions: ['.ts', '.js'] + }, + output: { + filename: '[name]/[name].js', + path: path.resolve(__dirname, 'dist'), + }, + module: { + rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/ + }, + { + test: /\.css$/, + use: ['style-loader', 'css-loader'] + } + ] + }, + plugins: [ + new CopyWebpackPlugin({ + patterns: [ + /// MARK: - Terminal component files + { + from: 'src/terminal/terminal.html', + to: 'terminal/terminal.html' + }, + + /// MARK: - DiffView component files + { + from: 'src/diffView/diffView.html', + to: 'diffView/diffView.html' + }, + { + from: 'src/diffView/css', + to: 'diffView/css' + } + ] + }), + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1 + }) + ], + optimization: { + minimizer: [ + new TerserPlugin({ + // Prevent extracting license comments to a separate file + extractComments: false + }) + ] + } +}; diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 98fdca78..eee16478 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -5,14 +5,17 @@ common issues: 1. Check for updates and restart Xcode. Ensure that Copilot for Xcode has the [latest release](https://github.com/github/CopilotForXcode/releases/latest) - by click `Check for Updates` in the settings or under the status menu. After + by clicking `Check for Updates` in the settings or under the status menu. After updating, restart Xcode. -2. Ensure that the Copilot for Xcode extension is enabled. Open Xcode and go to - the top menu bar and open the `Editor` menu. If there is no `GitHub Copilot` - menu is under `Editor` then [extension permission](#extension-permission) - needs to be enabled. If the `GitHub Copilot` menu is shown but grayed out, - then Xcode needs to be restarted to enable the extension. +2. Ensure that all required permissions are granted. GitHub Copilot for Xcode app requires these permissions to function properly: + - [Extension Permission](#extension-permission) - Allows GitHub Copilot to integrate with Xcode + - [Accessibility Permission](#accessibility-permission) - Enables real-time code suggestions + - [Background Permission](#background-permission) - Allows extension to connect with host app + - [Files & Folders Permission](#files--folders-permission) - Allows GitHub Copilot for Xcode to access files and folders + - [Screen & System Audio Recording Permission](#screen--system-audio-recording-permission-optional) (Optional) - Allow GitHub Copilot for Xcode to capture screen when using Copilot Vision + + Please note that GitHub Copilot for Xcode may not work properly if any necessary permissions are missing. 3. Need more help? If these steps don't resolve the issue, please [open an issue](https://github.com/github/CopilotForXcode/issues/new/choose). Make @@ -29,7 +32,8 @@ Or you can navigate to the permission manually depending on your OS version: | macOS | Location | | :--- | :--- | -| 15 | System Settings > General > Login Items > Extensions > Xcode Source Editor | +| 26 | System Settings > General > Login Items & Extensions > Extensions > By Category > Xcode Source Editor | +| 15 | System Settings > General > Login Items & Extensions > Extensions > Xcode Source Editor | | 13 & 14 | System Settings > Privacy & Security > Extensions > Xcode Source Editor | | 12 | System Preferences > Extensions | @@ -40,7 +44,7 @@ real-time updates from the active Xcode editor. [The XcodeKit API](https://developer.apple.com/documentation/xcodekit) enabled by the Xcode Source Editor extension permission only provides information when manually triggered by the user. In order to generate -suggestions as you type, the accessibility permission is used read the +suggestions as you type, the accessibility permission is used to read the Xcode editor content in real-time. The accessibility permission is also used to accept suggestions when `tab` is @@ -53,9 +57,77 @@ but you can audit the usage in this repository: search for `CGEvent` and `AX`*. Enable in System Settings under `Privacy & Security` > `Accessibility` > `GitHub Copilot for Xcode Extension` and turn on the toggle. +## Background Permission + +GitHub Copilot for Xcode requires background permission to connect with the host app. This permission ensures proper communication between the components of GitHub Copilot for Xcode, which is essential for its functionality in Xcode. + + +

+ 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 +

+ +Please ensure that this permission is enabled. You can manually navigate to the background permission setting based on your macOS version: + +| macOS | Location | +| :--- | :--- | +| 26 | System Settings > General > Login Items & Extensions > App Background Activity | +| 15 | System Settings > General > Login Items & Extensions > Allow in the Background | +| 13 & 14 | System Settings > General > Login Items > Allow in the Background | + +Ensure that "GitHub Copilot for Xcode" is enabled in the list of allowed background items. Without this permission, the extension may not be able to properly communicate with the host app, which can result in inconsistent behavior or reduced functionality. + +## Files & Folders Permission + +GitHub Copilot for Xcode needs permission to read your project’s files so it can: + +- Use actual file contents as contextual grounding when you ask questions in Ask and Agent mode (instead of generic language-only answers) +- Safely apply or preview multi-file edits in Agent modes (e.g. refactors, adding tests, updating related types) +- Improve precision by leveraging nearby code, patterns, and naming conventions + +

+ Files & Folders Permission +

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

+ Screen & System Audio Recording Permission +

+ +This permission is typically granted automatically when you first use Copilot Vision and try to capture screen in GitHub Copilot for Xcode. You can also manually navigate to the background permission setting based on your macOS version: + +| macOS | Location | +| :--- | :--- | +| 14 & 15 & 26 | System Settings > Privacy & Security > Screen & System Audio Recording | +| 13 | System Settings > Privacy & Security > Screen Recording | +| 12 | System Preferences > Security & Privacy > Privacy > Screen Recording | + +Check `GitHub Copilot for Xcode` and restart the app. + ## Logs -Logs can be found in `~/Library/Logs/GitHubCopilot/` the most recent log file +Logs can be found in `~/Library/Logs/GitHubCopilot/`. The most recent log file is: ``` diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index cd25c465..a46ddf32 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -91,6 +91,27 @@ "identifier" : "WorkspaceSuggestionServiceTests", "name" : "WorkspaceSuggestionServiceTests" } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "WorkspaceTests", + "name" : "WorkspaceTests" + } + }, + { + "target" : { + "containerPath" : "container:Core", + "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 7b97c58b..b54bd789 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -11,6 +11,7 @@ let package = Package( .library(name: "Terminal", targets: ["Terminal"]), .library(name: "Preferences", targets: ["Preferences", "Configs"]), .library(name: "Logger", targets: ["Logger"]), + .library(name: "SystemUtils", targets: ["SystemUtils"]), .library(name: "ChatAPIService", targets: ["ChatAPIService"]), .library(name: "ChatTab", targets: ["ChatTab"]), .library(name: "FileSystem", targets: ["FileSystem"]), @@ -18,8 +19,11 @@ let package = Package( .library(name: "Toast", targets: ["Toast"]), .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), .library(name: "Status", targets: ["Status"]), + .library(name: "Persist", targets: ["Persist"]), .library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]), .library(name: "Workspace", targets: ["Workspace", "WorkspaceSuggestionService"]), + .library(name: "WorkspaceSuggestionService", targets: ["WorkspaceSuggestionService"]), + .library(name: "WebContentExtractor", targets: ["WebContentExtractor"]), .library( name: "SuggestionProvider", targets: ["SuggestionProvider"] @@ -28,6 +32,14 @@ let package = Package( name: "ConversationServiceProvider", targets: ["ConversationServiceProvider"] ), + .library( + name: "TelemetryServiceProvider", + targets: ["TelemetryServiceProvider"] + ), + .library( + name: "TelemetryService", + targets: ["TelemetryService"] + ), .library( name: "GitHubCopilotService", targets: ["GitHubCopilotService"] @@ -49,14 +61,20 @@ let package = Package( .library(name: "DebounceFunction", targets: ["DebounceFunction"]), .library(name: "AsyncPassthroughSubject", targets: ["AsyncPassthroughSubject"]), .library(name: "CustomAsyncAlgorithms", targets: ["CustomAsyncAlgorithms"]), + .library(name: "AXHelper", targets: ["AXHelper"]), + .library(name: "Cache", targets: ["Cache"]), + .library(name: "StatusBarItemView", targets: ["StatusBarItemView"]), + .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", @@ -64,18 +82,22 @@ 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/devm33/CopilotForXcodeKit", branch: "main"), + .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"]), + .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger", "Status", "HostAppActivator", "GitHubCopilotService"]), .target(name: "Configs"), .target(name: "Preferences", dependencies: ["Configs"]), - .target(name: "Terminal"), + .target(name: "Terminal", dependencies: ["Logger", "SystemUtils"]), + + .target(name: "WebContentExtractor", dependencies: ["Logger", "SwiftSoup", "Preferences"]), .target(name: "Logger"), @@ -90,10 +112,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"), @@ -107,11 +129,19 @@ let package = Package( ), .target(name: "ActiveApplicationMonitor"), + + .target( + name: "HostAppActivator", + dependencies: [ + "Logger", + ] + ), .target( name: "SuggestionBasic", dependencies: [ "LanguageClient", + "AXExtension", .product(name: "Parsing", package: "swift-parsing"), .product(name: "CodableWrappers", package: "CodableWrappers"), ] @@ -167,6 +197,7 @@ let package = Package( .target( name: "SharedUIComponents", dependencies: [ + "AppKitExtension", "Highlightr", "Preferences", "SuggestionBasic", @@ -185,8 +216,10 @@ let package = Package( "Logger", "Preferences", "XcodeInspector", + "ConversationServiceProvider" ] ), + .testTarget(name: "WorkspaceTests", dependencies: ["Workspace"]), .target( name: "WorkspaceSuggestionService", @@ -198,6 +231,20 @@ let package = Package( "GitHubCopilotService", ] ), + + .target( + name: "AXHelper", + dependencies: [ + "XPCShared", + "XcodeInspector" + ] + ), + + .target(name: "StatusBarItemView", dependencies: ["Cache"]), + + .target( + name: "Cache" + ), .testTarget( name: "WorkspaceSuggestionServiceTests", @@ -209,7 +256,19 @@ let package = Package( // MARK: - Services - .target(name: "Status"), + .target( + name: "Status", + dependencies: ["Cache", "Preferences"] + ), + + .target( + name: "Persist", + dependencies: [ + "Logger", + "Status", + .product(name: "SQLite", package: "SQLite.Swift") + ] + ), .target(name: "SuggestionProvider", dependencies: [ "SuggestionBasic", @@ -221,8 +280,26 @@ let package = Package( .testTarget(name: "SuggestionProviderTests", dependencies: ["SuggestionProvider"]), .target(name: "ConversationServiceProvider", dependencies: [ + "GitHelper", + "SuggestionBasic", .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), ]), + + .target(name: "TelemetryServiceProvider", dependencies: [ + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + ]), + + .target( + name: "TelemetryService", + dependencies: [ + "TelemetryServiceProvider", + "GitHubCopilotService", + "BuiltinExtension", + "SystemUtils", + "UserDefaultsObserver", + "Preferences" + ]), // MARK: - GitHub Copilot @@ -237,7 +314,12 @@ let package = Package( "Terminal", "BuiltinExtension", "ConversationServiceProvider", + "TelemetryServiceProvider", "Status", + "SystemUtils", + "Workspace", + "Persist", + "SuggestionProvider", .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] @@ -255,6 +337,7 @@ let package = Package( dependencies: [ "Logger", "Preferences", + "GitHubCopilotService", .product(name: "JSONRPC", package: "JSONRPC"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product( @@ -273,6 +356,28 @@ let package = Package( package: "swift-composable-architecture" )] ), + + // MARK: - 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+Xcode.swift b/Tool/Sources/AXExtension/AXUIElement+Xcode.swift new file mode 100644 index 00000000..ff5948cc --- /dev/null +++ b/Tool/Sources/AXExtension/AXUIElement+Xcode.swift @@ -0,0 +1,43 @@ +import AppKit +import Foundation + +// Extension for xcode specifically +public extension AXUIElement { + private static let XcodeWorkspaceWindowIdentifier = "Xcode.WorkspaceWindow" + + var isSourceEditor: Bool { + description == "Source Editor" + } + + var isEditorArea: Bool { + description == "editor area" + } + + var isXcodeWorkspaceWindow: Bool { + self.description == Self.XcodeWorkspaceWindowIdentifier || self.identifier == Self.XcodeWorkspaceWindowIdentifier + } + + var isXcodeOpenQuickly: Bool { + ["open_quickly"].contains(self.identifier) + } + + var isXcodeAlert: Bool { + ["alert"].contains(self.label) + } + + var isXcodeMenuBar: Bool { + ["menu bar", "menu bar item"].contains(self.description) + } + + var isNavigator: Bool { + description == "navigator" + } + + var isDescendantOfNavigator: Bool { + self.firstParent(where: \.isNavigator) != nil + } + + var isNonNavigatorSourceEditor: Bool { + isSourceEditor && !isDescendantOfNavigator + } +} diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index f32f4d44..677a8264 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -56,10 +56,6 @@ public extension AXUIElement { (try? copyValue(key: kAXLabelValueAttribute)) ?? "" } - var isSourceEditor: Bool { - description == "Source Editor" - } - var selectedTextRange: ClosedRange? { guard let value: AXValue = try? copyValue(key: kAXSelectedTextRangeAttribute) else { return nil } @@ -237,6 +233,19 @@ public extension AXUIElement { var verticalScrollBar: AXUIElement? { try? copyValue(key: kAXVerticalScrollBarAttribute) } + + func retrieveSourceEditor() -> AXUIElement? { + if isNonNavigatorSourceEditor { return self } + + if self.isXcodeWorkspaceWindow { + return self.firstChild(where: \.isNonNavigatorSourceEditor) + } + + guard let xcodeWorkspaceWindowElement = self.firstParent(where: \.isXcodeWorkspaceWindow) + else { return nil } + + return xcodeWorkspaceWindowElement.firstChild(where: \.isNonNavigatorSourceEditor) + } } public extension AXUIElement { @@ -313,6 +322,56 @@ public extension AXUIElement { } } +// MARK: - Xcode Specific +public extension AXUIElement { + func findSourceEditorElement(shouldRetry: Bool = true) -> AXUIElement? { + + // 1. Check if the current element is a source editor + if isNonNavigatorSourceEditor { + return self + } + + // 2. Search for child that is a source editor + if let sourceEditorChild = firstChild(where: \.isNonNavigatorSourceEditor) { + return sourceEditorChild + } + + // 3. Search for parent that is a source editor (XcodeInspector's approach) + if let sourceEditorParent = firstParent(where: \.isNonNavigatorSourceEditor) { + 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: \.isNonNavigatorSourceEditor) { + 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: \.isNonNavigatorSourceEditor) { + 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 new file mode 100644 index 00000000..533a3c1e --- /dev/null +++ b/Tool/Sources/AXHelper/AXHelper.swift @@ -0,0 +1,110 @@ +import XPCShared +import XcodeInspector +import AppKit + +public enum AXHelperError: LocalizedError { + case failedToSetValue(AXError) + + public var errorDescription: String? { + switch self { + case .failedToSetValue(let axError): + return "Failed to set focus element value by AccessibilityAPI: \(axError.rawValue)" + } + } +} + +public struct AXHelper { + public init() {} + + /// When Xcode commands are not available, we can fallback to directly + /// set the value of the editor with Accessibility API. + public func injectUpdatedCodeWithAccessibilityAPI( + _ result: UpdatedContent, + focusElement: AXUIElement, + onSuccess: (() -> Void)? = nil, + onError: (() -> Void)? = nil + ) throws { + let oldPosition = focusElement.selectedTextRange + let oldScrollPosition = focusElement.parent?.verticalScrollBar?.doubleValue + + let error = AXUIElementSetAttributeValue( + focusElement, + kAXValueAttribute as CFString, + result.content as CFTypeRef + ) + + if error != AXError.success { + if let onError = onError { + onError() + } + throw AXHelperError.failedToSetValue(error) + } + + // recover selection range + if let selection = result.newSelection { + var range = SourceEditor.convertCursorRangeToRange(selection, in: result.content) + if let value = AXValueCreate(.cfRange, &range) { + AXUIElementSetAttributeValue( + focusElement, + kAXSelectedTextRangeAttribute as CFString, + value + ) + } + } else if let oldPosition { + var range = CFRange( + location: oldPosition.lowerBound, + length: 0 + ) + if let value = AXValueCreate(.cfRange, &range) { + AXUIElementSetAttributeValue( + focusElement, + kAXSelectedTextRangeAttribute as CFString, + value + ) + } + } + + // recover scroll position + if let oldScrollPosition, + let scrollBar = focusElement.parent?.verticalScrollBar + { + 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.isNonNavigatorSourceEditor, + let scrollBar = focusedElement.parent?.verticalScrollBar, + let linePosition = Self.getScrollPositionForLine(lineNumber, content: content) + else { return } + + Self.setScrollBarValue(scrollBar, value: linePosition) + } +} diff --git a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift index 2a7fa162..f4b3f194 100644 --- a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift +++ b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift @@ -110,6 +110,7 @@ public final class AXNotificationStream: AsyncSequence { ) var pendingRegistrationNames = Set(notificationNames) var retry = 0 + var shouldLogAXDisabledEvent: Bool = true while !pendingRegistrationNames.isEmpty, retry < 100 { guard let self else { return } retry += 1 @@ -125,35 +126,39 @@ public final class AXNotificationStream: AsyncSequence { } switch e { case .success: + shouldLogAXDisabledEvent = true pendingRegistrationNames.remove(name) await Status.shared.updateAXStatus(.granted) case .actionUnsupported: - Logger.service.error("AXObserver: Action unsupported: \(name)") + Logger.service.info("AXObserver: Action unsupported: \(name)") pendingRegistrationNames.remove(name) case .apiDisabled: - Logger.service - .error("AXObserver: Accessibility API disabled, will try again later") + if shouldLogAXDisabledEvent { // Avoid keeping log AX disabled too many times + shouldLogAXDisabledEvent = false + Logger.service + .error("AXObserver: Accessibility API disabled, will try again later") + } retry -= 1 await Status.shared.updateAXStatus(.notGranted) case .invalidUIElement: Logger.service - .error("AXObserver: Invalid UI element, notification name \(name)") + .info("AXObserver: Invalid UI element, notification name \(name)") pendingRegistrationNames.remove(name) case .invalidUIElementObserver: - Logger.service.error("AXObserver: Invalid UI element observer") + Logger.service.info("AXObserver: Invalid UI element observer") pendingRegistrationNames.remove(name) case .cannotComplete: Logger.service - .error("AXObserver: Failed to observe \(name), will try again later") + .info("AXObserver: Failed to observe \(name), will try again later") case .notificationUnsupported: - Logger.service.error("AXObserver: Notification unsupported: \(name)") + Logger.service.info("AXObserver: Notification unsupported: \(name)") pendingRegistrationNames.remove(name) case .notificationAlreadyRegistered: Logger.service.info("AXObserver: Notification already registered: \(name)") pendingRegistrationNames.remove(name) default: Logger.service - .error( + .info( "AXObserver: Unrecognized error \(e) when registering \(name), will try again later" ) } 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/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift index 9e30dd49..c4c9b8c7 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift @@ -2,14 +2,134 @@ import CopilotForXcodeKit import Foundation import Preferences import ConversationServiceProvider +import TelemetryServiceProvider +import LanguageServerProtocol -public typealias CopilotForXcodeCapability = CopilotForXcodeExtensionCapability & CopilotForXcodeChatCapability +// Exported from `CopilotForXcodeKit`, as we need to modify the protocol for document change +public protocol CopilotForXcodeExtensionCapability { + associatedtype TheSuggestionService: SuggestionServiceType + associatedtype TheChatService: ChatServiceType + associatedtype ThePromptToCodeService: PromptToCodeServiceType + + /// The suggestion service. + /// + /// Provide a non nil value if the extension provides a suggestion service, even if + /// the extension is not yet ready to provide suggestions. + /// + /// If you don't have a suggestion service in this extension, simply ignore this property. + var suggestionService: TheSuggestionService? { get } + /// Not implemented yet. + var chatService: TheChatService? { get } + /// Not implemented yet. + var promptToCodeService: ThePromptToCodeService? { get } + + // MARK: Optional Methods + + /// Called when a workspace is opened. + /// + /// A workspace may have already been opened when the extension is activated. + /// Use ``HostServer/getExistedWorkspaces()`` to get all ``WorkspaceInfo`` instead. + func workspaceDidOpen(_ workspace: WorkspaceInfo) + + /// Called when a workspace is closed. + func workspaceDidClose(_ workspace: WorkspaceInfo) + + /// Called when a document is saved. + func workspace(_ workspace: WorkspaceInfo, didSaveDocumentAt documentURL: URL) + + /// Called when a document is closed. + /// + /// - note: Copilot for Xcode doesn't know that a document is closed. It use + /// some mechanism to detect if the document is closed which is inaccurate and could be delayed. + func workspace(_ workspace: WorkspaceInfo, didCloseDocumentAt documentURL: URL) + + /// Called when a document is opened. + /// + /// - note: Copilot for Xcode doesn't know that a document is opened. It use + /// some mechanism to detect if the document is opened which is inaccurate and could be delayed. + func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) async + + /// Called when a document is changed. + /// + /// - attention: `content` could be nil if \ + /// • the document is too large \ + /// • the document is binary \ + /// • the document is git ignored \ + /// • the extension is not considered in-use by the host app \ + /// • the extension has no permission to access the file \ + /// \ + /// If you still want to access the file content in these cases, + /// you will have to access the file by yourself, or call ``HostServer/getDocument(at:)``. + func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String?, + contentChanges: [TextDocumentContentChangeEvent]? + ) async + + /// Called occasionally to inform the extension how it is used in the app. + /// + /// The `usage` contains information like the current user-picked suggestion service, etc. + /// You can use this to determine if you would like to startup or dispose some resources. + /// + /// For example, if you are running a language server to provide suggestions, you may want to + /// kill the process when the suggestion service is no longer in use. + func extensionUsageDidChange(_ usage: ExtensionUsage) +} + +public extension CopilotForXcodeExtensionCapability { + func xcodeDidBecomeActive() {} + + func xcodeDidBecomeInactive() {} + + func xcodeDidSwitchEditor() {} + + func workspaceDidOpen(_: WorkspaceInfo) {} + + func workspaceDidClose(_: WorkspaceInfo) {} + + func workspace(_: WorkspaceInfo, didSaveDocumentAt _: URL) {} + + func workspace(_: WorkspaceInfo, didCloseDocumentAt _: URL) {} + + func workspace(_: WorkspaceInfo, didOpenDocumentAt _: URL) async {} + + func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String?, + contentChanges: [TextDocumentContentChangeEvent]? = nil + ) async {} + + func extensionUsageDidChange(_: ExtensionUsage) {} +} + +public extension CopilotForXcodeExtensionCapability +where TheSuggestionService == NoSuggestionService +{ + var suggestionService: TheSuggestionService? { nil } +} + +public extension CopilotForXcodeExtensionCapability +where ThePromptToCodeService == NoPromptToCodeService +{ + var promptToCodeService: ThePromptToCodeService? { nil } +} + +public extension CopilotForXcodeExtensionCapability where TheChatService == NoChatService { + var chatService: TheChatService? { nil } +} + +public typealias CopilotForXcodeCapability = CopilotForXcodeExtensionCapability & CopilotForXcodeChatCapability & CopilotForXcodeTelemetryCapability public protocol CopilotForXcodeChatCapability { - /// Not implemented yet. var conversationService: ConversationServiceType? { get } } +public protocol CopilotForXcodeTelemetryCapability { + var telemetryService: TelemetryServiceType? { get } +} + public protocol BuiltinExtension: CopilotForXcodeCapability { /// An id that let the extension manager determine whether the extension is in use. var suggestionServiceId: BuiltInSuggestionFeatureProvider { get } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift index b7ab3e33..14625052 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,38 +56,60 @@ public final class BuiltinExtensionConversationServiceProvider< } } - public func createConversation(_ request: ConversationRequest) async throws { + public func createConversation( + _ request: ConversationRequest, workspaceURL: URL? + ) async throws -> ConversationCreateResponse? { guard let conversationService else { Logger.service.error("Builtin chat service not found.") - return + return nil } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") - return + return nil } - try await conversationService.createConversation(request, workspace: workspaceInfo) + return try await conversationService.createConversation(request, workspace: workspaceInfo) } - public func createTurn(with conversationId: String, request: ConversationRequest) async throws { + public func createTurn( + with conversationId: String, request: ConversationRequest, workspaceURL: URL? + ) async throws -> ConversationCreateResponse? { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return nil + } + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { + Logger.service.error("Could not get active workspace info") + return nil + } + + return try await conversationService + .createTurn( + with: conversationId, + request: request, + workspace: workspaceInfo + ) + } + + public func deleteTurn(with conversationId: String, turnId: String, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() 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.deleteTurn(with: conversationId, turnId: turnId, 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,27 +117,101 @@ 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 } try? await conversationService.copyCode(request: request, workspace: workspaceInfo) } + + public func templates() async throws -> [ChatTemplate]? { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return nil + } + guard let workspaceInfo = await activeWorkspace() else { + Logger.service.error("Could not get active workspace info") + return nil + } + + return (try? await conversationService.templates(workspace: workspaceInfo)) + } + + public func modes() async throws -> [ConversationMode]? { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return nil + } + guard let workspaceInfo = await activeWorkspace() else { + Logger.service.error("Could not get active workspace info") + return nil + } + + return (try? await conversationService.modes(workspace: workspaceInfo)) + } + + public func models() async throws -> [CopilotModel]? { + guard let conversationService else { + 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.models(workspace: workspaceInfo)) + } + + public func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return + } + + try? await conversationService.notifyDidChangeWatchedFiles(event, workspace: workspace) + } + + public func agents() async throws -> [ChatAgent]? { + 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.agents(workspace: workspaceInfo)) + } + + public func reviewChanges(_ changes: [ReviewChangesParams.Change]) async throws -> CodeReviewResult? { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return nil + } + guard let workspaceInfo = await activeWorkspace() else { + Logger.service.error("Could not get active workspace info") + return nil + } + + return (try? await conversationService.reviewChanges(workspace: workspaceInfo, changes: changes)) + } } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift index f6234ddf..4b09aeef 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift @@ -29,8 +29,8 @@ public final class BuiltinExtensionSuggestionServiceProvider< self.extensionManager = extensionManager } - var service: CopilotForXcodeKit.SuggestionServiceType? { - extensionManager.extensions.first { $0 is T }?.suggestionService + var service: (SuggestionServiceType & NESSuggestionServiceType)? { + extensionManager.extensions.first { $0 is T }?.suggestionService as? (SuggestionServiceType & NESSuggestionServiceType) } struct BuiltinExtensionSuggestionServiceNotFoundError: Error, LocalizedError { @@ -47,25 +47,22 @@ public final class BuiltinExtensionSuggestionServiceProvider< Logger.service.error("Builtin suggestion service not found.") throw BuiltinExtensionSuggestionServiceNotFoundError() } + return try await service.getSuggestions( - .init( - fileURL: request.fileURL, - relativePath: request.relativePath, - language: .init( - rawValue: languageIdentifierFromFileURL(request.fileURL).rawValue - ) ?? .plaintext, - content: request.content, - originalContent: request.originalContent, - cursorPosition: .init( - line: request.cursorPosition.line, - character: request.cursorPosition.character - ), - tabSize: request.tabSize, - indentSize: request.indentSize, - usesTabsForIndentation: request.usesTabsForIndentation, - relevantCodeSnippets: request.relevantCodeSnippets.map { $0.converted } - ), - workspace: workspaceInfo + request.toCopilotForXcodeKitSuggestionRequest(), workspace: workspaceInfo + ).map { $0.converted } + } + + public func getNESSuggestions( + _ request: SuggestionProvider.SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async throws -> [SuggestionBasic.CodeSuggestion] { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + throw BuiltinExtensionSuggestionServiceNotFoundError() + } + return try await service.getNESSuggestions( + request.toCopilotForXcodeKitSuggestionRequest(), workspace: workspaceInfo ).map { $0.converted } } @@ -121,6 +118,26 @@ extension SuggestionProvider.SuggestionRequest { relevantCodeSnippets: relevantCodeSnippets.map(\.converted) ) } + + func toCopilotForXcodeKitSuggestionRequest() -> CopilotForXcodeKit.SuggestionRequest { + .init( + fileURL: self.fileURL, + relativePath: self.relativePath, + language: .init( + rawValue: languageIdentifierFromFileURL(self.fileURL).rawValue + ) ?? .plaintext, + content: self.content, + originalContent: self.originalContent, + cursorPosition: .init( + line: self.cursorPosition.line, + character: self.cursorPosition.character + ), + tabSize: self.tabSize, + indentSize: self.indentSize, + usesTabsForIndentation: self.usesTabsForIndentation, + relevantCodeSnippets: self.relevantCodeSnippets.map { $0.converted } + ) + } } extension SuggestionBasic.CodeSuggestion { diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionTelemetryServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionTelemetryServiceProvider.swift new file mode 100644 index 00000000..df7a3905 --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionTelemetryServiceProvider.swift @@ -0,0 +1,59 @@ +import TelemetryServiceProvider +import CopilotForXcodeKit +import Foundation +import Logger +import XcodeInspector + +public final class BuiltinExtensionTelemetryServiceProvider< + T: BuiltinExtension +>: TelemetryServiceProvider { + + private let extensionManager: BuiltinExtensionManager + + public init( + extension: T.Type, + extensionManager: BuiltinExtensionManager = .shared + ) { + self.extensionManager = extensionManager + } + + var telemetryService: TelemetryServiceType? { + extensionManager.extensions.first { $0 is T }?.telemetryService + } + + private func activeWorkspace() async -> WorkspaceInfo? { + guard let workspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL, + let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL + else { return nil } + + return WorkspaceInfo(workspaceURL: workspaceURL, projectURL: projectURL) + } + + struct BuiltinExtensionTelemetryServiceNotFoundError: Error, LocalizedError { + var errorDescription: String? { + "Builtin telemetry service not found." + } + } + + struct BuiltinExtensionActiveWorkspaceInfoNotFoundError: Error, LocalizedError { + var errorDescription: String? { + "Builtin active workspace info not found." + } + } + + public func sendError(_ request: TelemetryExceptionRequest) async throws { + guard let telemetryService else { + print("Builtin telemetry service not found.") + throw BuiltinExtensionTelemetryServiceNotFoundError() + } + guard let workspaceInfo = await activeWorkspace() else { + print("Builtin active workspace info not found.") + throw BuiltinExtensionActiveWorkspaceInfoNotFoundError() + } + + try await telemetryService.sendError( + request, + workspace: workspaceInfo + ) + } +} diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift index a03c34d1..4599f3b6 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift @@ -1,5 +1,6 @@ import Foundation import Workspace +import LanguageServerProtocol public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { let extensionManager: BuiltinExtensionManager @@ -9,16 +10,20 @@ public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { super.init(workspace: workspace) } - override public func didOpenFilespace(_ filespace: Filespace) { - notifyOpenFile(filespace: filespace) + override public func didOpenFilespace(_ filespace: Filespace) async { + await notifyOpenFile(filespace: filespace) } override public func didSaveFilespace(_ filespace: Filespace) { notifySaveFile(filespace: filespace) } - override public func didUpdateFilespace(_ filespace: Filespace, content: String) { - notifyUpdateFile(filespace: filespace, content: content) + override public func didUpdateFilespace( + _ filespace: Filespace, + content: String, + contentChanges: [TextDocumentContentChangeEvent]? = nil + ) async { + await notifyUpdateFile(filespace: filespace, content: content, contentChanges: contentChanges) } override public func didCloseFilespace(_ fileURL: URL) { @@ -32,28 +37,29 @@ public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { } } - public func notifyOpenFile(filespace: Filespace) { - Task { - guard filespace.isTextReadable else { return } - for ext in extensionManager.extensions { - ext.workspace( - .init(workspaceURL: workspaceURL, projectURL: projectRootURL), - didOpenDocumentAt: filespace.fileURL - ) - } + public func notifyOpenFile(filespace: Filespace) async { + guard filespace.isTextReadable else { return } + for ext in extensionManager.extensions { + await ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didOpenDocumentAt: filespace.fileURL + ) } } - public func notifyUpdateFile(filespace: Filespace, content: String) { - Task { - guard filespace.isTextReadable else { return } - for ext in extensionManager.extensions { - ext.workspace( - .init(workspaceURL: workspaceURL, projectURL: projectRootURL), - didUpdateDocumentAt: filespace.fileURL, - content: content - ) - } + public func notifyUpdateFile( + filespace: Filespace, + content: String, + contentChanges: [TextDocumentContentChangeEvent]? = nil + ) async { + guard filespace.isTextReadable else { return } + for ext in extensionManager.extensions { + await ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didUpdateDocumentAt: filespace.fileURL, + content: content, + contentChanges: contentChanges + ) } } diff --git a/Tool/Sources/Cache/AvatarCache.swift b/Tool/Sources/Cache/AvatarCache.swift new file mode 100644 index 00000000..a0a91c47 --- /dev/null +++ b/Tool/Sources/Cache/AvatarCache.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftUI +import AppKit + +public final class AvatarCache { + public static let shared = AvatarCache() + private let cache = NSCache() + + private init () {} + + public func set(forUser username: String) async -> Void { + guard let data = await fetchAvatarData(forUser: username) else { return } + cache.setObject(data as NSData, forKey: username as NSString) + } + + public func get(forUser username: String) -> Data? { + return cache.object(forKey: username as NSString) as Data? + } + + public func remove(forUser username: String) { + cache.removeObject(forKey: username as NSString) + } +} + +extension AvatarCache { + // Directly get the avatar from URL like https://avatars.githubusercontent.com/ + // TODO: when the `agent` feature added, the avatarUrl could be obtained from the response of GitHub LSP + func fetchAvatarData(forUser username: String) async -> Data? { + let avatarUrl = "https://avatars.githubusercontent.com/\(username)" + guard let avatarUrl = URL(string: avatarUrl) else { return nil } + + do { + let (data, _) = try await URLSession.shared.data(from: avatarUrl) + return data + } catch { + return nil + } + } + + public func getAvatarImage(forUser username: String) -> Image? { + guard let data = get(forUser: username), + let nsImage = NSImage(data: data) + else { + return nil + } + + return Image(nsImage: nsImage) + } +} diff --git a/Tool/Sources/Cache/AvatarViewModel.swift b/Tool/Sources/Cache/AvatarViewModel.swift new file mode 100644 index 00000000..53dcafc8 --- /dev/null +++ b/Tool/Sources/Cache/AvatarViewModel.swift @@ -0,0 +1,23 @@ +import SwiftUI + +@MainActor +public class AvatarViewModel: ObservableObject { + @Published private(set) public var avatarImage: Image? + public static let shared = AvatarViewModel() + + public init() { } + + public func loadAvatar(forUser userName: String?) { + guard let userName = userName, !userName.isEmpty + else { + avatarImage = nil + return + } + + // Fetch if not in cache + Task { + await AvatarCache.shared.set(forUser: userName) + self.avatarImage = AvatarCache.shared.getAvatarImage(forUser: userName) + } + } +} diff --git a/Tool/Sources/ChatAPIService/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 ca9f06cf..5460fb00 100644 --- a/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift @@ -1,6 +1,7 @@ import Foundation import Logger import Preferences +import ConversationServiceProvider @globalActor public enum AutoManagedChatMemoryActor: GlobalActor { @@ -32,7 +33,7 @@ public actor AutoManagedChatMemory: ChatMemory { public var systemPrompt: String public var contextSystemPrompt: String - public var retrievedContent: [ChatMessage.Reference] = [] + public var retrievedContent: [ConversationReference] = [] var onHistoryChange: () -> Void = {} @@ -62,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) @@ -71,7 +79,7 @@ public actor AutoManagedChatMemory: ChatMemory { contextSystemPrompt = newPrompt } - public func mutateRetrievedContent(_ newContent: [ChatMessage.Reference]) { + public func mutateRetrievedContent(_ newContent: [ConversationReference]) { retrievedContent = newContent } diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift index 19c6d3ba..d988a91e 100644 --- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift @@ -1,4 +1,5 @@ import Foundation +import ConversationServiceProvider public protocol ChatMemory { /// The message history. @@ -8,11 +9,78 @@ public protocol ChatMemory { } public extension ChatMemory { - /// Append a message to the history. func appendMessage(_ message: ChatMessage) async { await mutateHistory { history in - if let index = history.firstIndex(where: { $0.id == message.id }) { - history[index].content = history[index].content + message.content + if let parentTurnId = message.parentTurnId { + history.removeAll { $0.id == message.id } + + guard let parentIndex = history.firstIndex(where: { $0.id == parentTurnId }) else { + return + } + + var parentMessage = history[parentIndex] + + if !message.editAgentRounds.isEmpty { + var parentRounds = parentMessage.editAgentRounds + + if let lastParentRoundIndex = parentRounds.indices.last { + var existingSubRounds = parentRounds[lastParentRoundIndex].subAgentRounds ?? [] + + for messageRound in message.editAgentRounds { + if let subIndex = existingSubRounds.firstIndex(where: { $0.roundId == messageRound.roundId }) { + existingSubRounds[subIndex].reply = existingSubRounds[subIndex].reply + messageRound.reply + + if let messageToolCalls = messageRound.toolCalls, !messageToolCalls.isEmpty { + var mergedToolCalls = existingSubRounds[subIndex].toolCalls ?? [] + for newToolCall in messageToolCalls { + if let toolCallIndex = mergedToolCalls.firstIndex(where: { $0.id == newToolCall.id }) { + mergedToolCalls[toolCallIndex].status = newToolCall.status + if let toolType = newToolCall.toolType { + mergedToolCalls[toolCallIndex].toolType = toolType + } + if let progressMessage = newToolCall.progressMessage, !progressMessage.isEmpty { + mergedToolCalls[toolCallIndex].progressMessage = progressMessage + } + if let input = newToolCall.input, !input.isEmpty { + mergedToolCalls[toolCallIndex].input = input + } + if let inputMessage = newToolCall.inputMessage, !inputMessage.isEmpty { + mergedToolCalls[toolCallIndex].inputMessage = inputMessage + } + if let result = newToolCall.result, !result.isEmpty { + mergedToolCalls[toolCallIndex].result = result + } + if let resultDetails = newToolCall.resultDetails, !resultDetails.isEmpty { + mergedToolCalls[toolCallIndex].resultDetails = resultDetails + } + if let error = newToolCall.error, !error.isEmpty { + mergedToolCalls[toolCallIndex].error = error + } + if let invokeParams = newToolCall.invokeParams { + mergedToolCalls[toolCallIndex].invokeParams = invokeParams + } + if let title = newToolCall.title { + mergedToolCalls[toolCallIndex].title = title + } + } else { + mergedToolCalls.append(newToolCall) + } + } + existingSubRounds[subIndex].toolCalls = mergedToolCalls + } + } else { + existingSubRounds.append(messageRound) + } + } + + parentRounds[lastParentRoundIndex].subAgentRounds = existingSubRounds + parentMessage.editAgentRounds = parentRounds + } + } + + history[parentIndex] = parentMessage + } else if let index = history.firstIndex(where: { $0.id == message.id }) { + history[index].mergeMessage(with: message) } else { history.append(message) } @@ -25,9 +93,176 @@ public extension ChatMemory { $0.removeAll { $0.id == id } } } + + /// Remove multiple messages from the history by their IDs. + func removeMessages(_ ids: [String]) async { + await mutateHistory { history in + history.removeAll { message in + ids.contains(message.id) + } + } + } /// Clear the history. func clearHistory() async { await mutateHistory { $0.removeAll() } } } + +extension ChatMessage { + mutating func mergeMessage(with message: ChatMessage) { + self.content = self.content + message.content + + var seen = Set() + self.references = (self.references + message.references).filter { seen.insert($0).inserted } + + self.followUp = message.followUp ?? self.followUp + + self.suggestedTitle = message.suggestedTitle ?? self.suggestedTitle + + self.errorMessages = self.errorMessages + message.errorMessages + + self.panelMessages = self.panelMessages + message.panelMessages + + if !message.steps.isEmpty { + var mergedSteps = self.steps + + for newStep in message.steps { + if let index = mergedSteps.firstIndex(where: { $0.id == newStep.id }) { + mergedSteps[index] = newStep + } else { + mergedSteps.append(newStep) + } + } + + self.steps = mergedSteps + } + + if !message.editAgentRounds.isEmpty { + let mergedAgentRounds = mergeEditAgentRounds( + oldRounds: self.editAgentRounds, + newRounds: message.editAgentRounds + ) + + self.editAgentRounds = mergedAgentRounds + } + + self.parentTurnId = message.parentTurnId ?? self.parentTurnId + + self.codeReviewRound = message.codeReviewRound + + self.fileEdits = mergeFileEdits(oldEdits: self.fileEdits, newEdits: message.fileEdits) + + self.turnStatus = message.turnStatus ?? self.turnStatus + + // merge modelName and billingMultiplier + self.modelName = message.modelName ?? self.modelName + self.billingMultiplier = message.billingMultiplier ?? self.billingMultiplier + } + + private func mergeEditAgentRounds(oldRounds: [AgentRound], newRounds: [AgentRound]) -> [AgentRound] { + var mergedAgentRounds = oldRounds + + for newRound in newRounds { + if let index = mergedAgentRounds.firstIndex(where: { $0.roundId == newRound.roundId }) { + mergedAgentRounds[index].reply = mergedAgentRounds[index].reply + newRound.reply + + if newRound.toolCalls != nil, !newRound.toolCalls!.isEmpty { + var mergedToolCalls = mergedAgentRounds[index].toolCalls ?? [] + for newToolCall in newRound.toolCalls! { + if let toolCallIndex = mergedToolCalls.firstIndex(where: { $0.id == newToolCall.id }) { + mergedToolCalls[toolCallIndex].status = newToolCall.status + if let toolType = newToolCall.toolType { + mergedToolCalls[toolCallIndex].toolType = toolType + } + if let progressMessage = newToolCall.progressMessage, !progressMessage.isEmpty { + mergedToolCalls[toolCallIndex].progressMessage = newToolCall.progressMessage + } + if let input = newToolCall.input, !input.isEmpty { + mergedToolCalls[toolCallIndex].input = input + } + if let inputMessage = newToolCall.inputMessage, !inputMessage.isEmpty { + mergedToolCalls[toolCallIndex].inputMessage = inputMessage + } + if let result = newToolCall.result, !result.isEmpty { + mergedToolCalls[toolCallIndex].result = result + } + if let resultDetails = newToolCall.resultDetails, !resultDetails.isEmpty { + mergedToolCalls[toolCallIndex].resultDetails = resultDetails + } + if let error = newToolCall.error, !error.isEmpty { + mergedToolCalls[toolCallIndex].error = newToolCall.error + } + if let invokeParams = newToolCall.invokeParams { + mergedToolCalls[toolCallIndex].invokeParams = invokeParams + } + } else { + mergedToolCalls.append(newToolCall) + } + } + mergedAgentRounds[index].toolCalls = mergedToolCalls + } + + if let newSubAgentRounds = newRound.subAgentRounds, !newSubAgentRounds.isEmpty { + var mergedSubRounds = mergedAgentRounds[index].subAgentRounds ?? [] + for newSubRound in newSubAgentRounds { + if let subIndex = mergedSubRounds.firstIndex(where: { $0.roundId == newSubRound.roundId }) { + mergedSubRounds[subIndex].reply = mergedSubRounds[subIndex].reply + newSubRound.reply + + if let subToolCalls = newSubRound.toolCalls, !subToolCalls.isEmpty { + var mergedSubToolCalls = mergedSubRounds[subIndex].toolCalls ?? [] + for newSubToolCall in subToolCalls { + if let toolCallIndex = mergedSubToolCalls.firstIndex(where: { $0.id == newSubToolCall.id }) { + mergedSubToolCalls[toolCallIndex].status = newSubToolCall.status + if let progressMessage = newSubToolCall.progressMessage, !progressMessage.isEmpty { + mergedSubToolCalls[toolCallIndex].progressMessage = newSubToolCall.progressMessage + } + if let error = newSubToolCall.error, !error.isEmpty { + mergedSubToolCalls[toolCallIndex].error = newSubToolCall.error + } + if let result = newSubToolCall.result, !result.isEmpty { + mergedSubToolCalls[toolCallIndex].result = result + } + if let resultDetails = newSubToolCall.resultDetails, !resultDetails.isEmpty { + mergedSubToolCalls[toolCallIndex].resultDetails = resultDetails + } + if let invokeParams = newSubToolCall.invokeParams { + mergedSubToolCalls[toolCallIndex].invokeParams = invokeParams + } + } else { + mergedSubToolCalls.append(newSubToolCall) + } + } + mergedSubRounds[subIndex].toolCalls = mergedSubToolCalls + } + } else { + mergedSubRounds.append(newSubRound) + } + } + mergedAgentRounds[index].subAgentRounds = mergedSubRounds + } + } else { + mergedAgentRounds.append(newRound) + } + } + + return mergedAgentRounds + } + + private func mergeFileEdits(oldEdits: [FileEdit], newEdits: [FileEdit]) -> [FileEdit] { + var edits = oldEdits + + for newEdit in newEdits { + if let index = edits.firstIndex( + where: { $0.fileURL == newEdit.fileURL && $0.toolName == newEdit.toolName } + ) { + edits[index].modifiedContent = newEdit.modifiedContent + edits[index].status = newEdit.status + } else { + edits.append(newEdit) + } + } + + return edits + } +} diff --git a/Tool/Sources/ChatAPIService/Memory/ConversationChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ConversationChatMemory.swift index 0b6fc8e3..8eece555 100644 --- a/Tool/Sources/ChatAPIService/Memory/ConversationChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/ConversationChatMemory.swift @@ -1,14 +1,15 @@ -import Foundation - -public actor ConversationChatMemory: ChatMemory { - public var history: [ChatMessage] = [] - - public init(systemPrompt: String, systemMessageId: String = UUID().uuidString) { - history.append(.init(id: systemMessageId, role: .system, content: systemPrompt)) - } - - public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) { - update(&history) - } -} +//import Foundation + +// Not used actor, commit it avoid chat message init error +//public actor ConversationChatMemory: ChatMemory { +// public var history: [ChatMessage] = [] +// +// public init(systemPrompt: String, systemMessageId: String = UUID().uuidString) { +// history.append(.init(id: systemMessageId, role: .system, content: systemPrompt)) +// } +// +// public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) { +// update(&history) +// } +//} diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index 80fbc39b..82337095 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -1,107 +1,325 @@ import CodableWrappers import Foundation import ConversationServiceProvider +import GitHubCopilotService + +public struct FileEdit: Equatable, Codable { + + public enum Status: String, Codable { + case none = "none" + case kept = "kept" + case undone = "undone" + } + + public let fileURL: URL + public let originalContent: String + public var modifiedContent: String + public var status: Status + + /// Different toolName, the different undo logic. Like `insert_edit_into_file` and `create_file` + public var toolName: ToolName + + public init( + fileURL: URL, + originalContent: String, + modifiedContent: String, + status: Status = .none, + toolName: ToolName + ) { + self.fileURL = fileURL + self.originalContent = originalContent + self.modifiedContent = modifiedContent + self.status = status + self.toolName = toolName + } +} + +// move here avoid circular reference +public struct ConversationReference: Codable, Equatable, Hashable { + public enum Kind: Codable, Equatable, Hashable { + case `class` + case `struct` + case `enum` + case `actor` + case `protocol` + case `extension` + case `case` + case property + case `typealias` + case function + case method + case text + case webpage + case other + // reference for turn - request + case fileReference(ConversationAttachedReference) + // reference from turn - response + 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 ?? "" + } + + public var fileName: String { + return url?.lastPathComponent ?? "" + } + + public var filePath: String { + return url?.path ?? "" + } + + public var url: URL? { + return URL(string: uri) + } + + public var isDirectory: Bool { referenceType == .directory } + + public init( + uri: String, + status: Status?, + kind: Kind, + referenceType: ReferenceType = .file + ) { + self.uri = uri + self.status = status + self.kind = kind + self.referenceType = referenceType + } +} + + +public enum RequestType: String, Equatable, Codable { + case conversation, codeReview +} + +public let HardCodedToolRoundExceedErrorMessage: String = "Oops, maximum tool attempts reached. You can update the max tool requests in settings." +public let SSLCertificateErrorMessage: String = "Unable to verify the SSL certificate. This often happens in enterprise environments with custom certificates. Try disabling **Proxy strict SSL** in the Proxy Settings." public struct ChatMessage: Equatable, Codable { public typealias ID = String public enum Role: String, Codable, Equatable { - case system case user case assistant + case system + + public var isAssistant: Bool { self == .assistant } } - - public struct Reference: Codable, Equatable { - public enum Kind: String, Codable { - case `class` - case `struct` - case `enum` - case `actor` - case `protocol` - case `extension` - case `case` - case property - case `typealias` - case function - case method - case text - case webpage - case other - } - - public var title: String - public var subTitle: String - public var uri: String - public var content: String - public var startLine: Int? - public var endLine: Int? - @FallbackDecoding - public var kind: Kind - - public init( - title: String, - subTitle: String, - content: String, - uri: String, - startLine: Int?, - endLine: Int?, - kind: Kind - ) { - self.title = title - self.subTitle = subTitle - self.content = content - self.uri = uri - self.startLine = startLine - self.endLine = endLine - self.kind = kind - } + + public enum TurnStatus: String, Codable, Equatable { + case inProgress, success, cancelled, error, waitForConfirmation } - + /// The role of a message. - @FallbackDecoding public var role: Role /// The content of the message, either the chat message, or a result of a function call. public var content: String - - /// The summary of a message that is used for display. - public var summary: String? + + /// The attached image content of the message + public var contentImageReferences: [ImageReference] /// The id of the message. public var id: ID - /// The turn id of the message. - public var turnId: ID? + /// The conversation id (not the CLS conversation id) + public var chatTabID: String + + /// The CLS turn id of the message which is from CLS. + public var clsTurnID: ID? /// Rate assistant message - public var rating: ConversationRating = .unrated + public var rating: ConversationRating /// The references of this message. - @FallbackDecoding> - public var references: [Reference] + public var references: [ConversationReference] + + /// The followUp question of this message + public var followUp: ConversationFollowUp? + + public var suggestedTitle: String? + + /// The error occurred during responding chat in server + public var errorMessages: [String] + + /// The steps of conversation progress + public var steps: [ConversationProgressStep] + + public var editAgentRounds: [AgentRound] + + public var parentTurnId: String? + + public var panelMessages: [CopilotShowMessageParams] + + public var codeReviewRound: CodeReviewRound? + + /// File edits performed during the current conversation turn. + /// Used as a checkpoint to track file modifications made by tools. + /// Note: Status changes (kept/undone) are tracked separately and not updated here. + public var fileEdits: [FileEdit] + + public var turnStatus: TurnStatus? + + public let requestType: RequestType + + // The model name used for the turn. + public var modelName: String? + public var billingMultiplier: Float? + + /// The timestamp of the message. + public var createdAt: Date + public var updatedAt: Date public init( id: String = UUID().uuidString, + chatTabID: String, + clsTurnID: String? = nil, role: Role, - turnId: String? = nil, content: String, - summary: String? = nil, - references: [Reference] = [] + contentImageReferences: [ImageReference] = [], + references: [ConversationReference] = [], + followUp: ConversationFollowUp? = nil, + suggestedTitle: String? = nil, + errorMessages: [String] = [], + rating: ConversationRating = .unrated, + steps: [ConversationProgressStep] = [], + editAgentRounds: [AgentRound] = [], + parentTurnId: String? = nil, + panelMessages: [CopilotShowMessageParams] = [], + codeReviewRound: CodeReviewRound? = nil, + fileEdits: [FileEdit] = [], + turnStatus: TurnStatus? = nil, + requestType: RequestType = .conversation, + modelName: String? = nil, + billingMultiplier: Float? = nil, + createdAt: Date? = nil, + updatedAt: Date? = nil ) { self.role = role self.content = content - self.summary = summary + self.contentImageReferences = contentImageReferences self.id = id - self.turnId = turnId + self.chatTabID = chatTabID + self.clsTurnID = clsTurnID self.references = references - } -} + self.followUp = followUp + self.suggestedTitle = suggestedTitle + self.errorMessages = errorMessages + self.rating = rating + self.steps = steps + self.editAgentRounds = editAgentRounds + self.parentTurnId = parentTurnId + self.panelMessages = panelMessages + self.codeReviewRound = codeReviewRound + self.fileEdits = fileEdits + self.turnStatus = turnStatus + self.requestType = requestType + self.modelName = modelName + self.billingMultiplier = billingMultiplier -public struct ReferenceKindFallback: FallbackValueProvider { - public static var defaultValue: ChatMessage.Reference.Kind { .other } + let now = Date.now + self.createdAt = createdAt ?? now + self.updatedAt = updatedAt ?? now + } + + public init( + userMessageWithId id: String, + chatTabId: String, + content: String, + contentImageReferences: [ImageReference] = [], + references: [ConversationReference] = [], + requestType: RequestType = .conversation + ) { + self.init( + id: id, + chatTabID: chatTabId, + role: .user, + content: content, + contentImageReferences: contentImageReferences, + references: references, + requestType: requestType + ) + } + + public init( + assistantMessageWithId id: String, // TurnId + chatTabID: String, + content: String = "", + references: [ConversationReference] = [], + followUp: ConversationFollowUp? = nil, + suggestedTitle: String? = nil, + steps: [ConversationProgressStep] = [], + editAgentRounds: [AgentRound] = [], + parentTurnId: String? = nil, + codeReviewRound: CodeReviewRound? = nil, + fileEdits: [FileEdit] = [], + turnStatus: TurnStatus? = nil, + requestType: RequestType = .conversation, + modelName: String? = nil, + billingMultiplier: Float? = nil + ) { + self.init( + id: id, + chatTabID: chatTabID, + clsTurnID: id, + role: .assistant, + content: content, + references: references, + followUp: followUp, + suggestedTitle: suggestedTitle, + steps: steps, + editAgentRounds: editAgentRounds, + parentTurnId: parentTurnId, + codeReviewRound: codeReviewRound, + fileEdits: fileEdits, + turnStatus: turnStatus, + requestType: requestType, + modelName: modelName, + billingMultiplier: billingMultiplier + ) + } + + public init( + errorMessageWithId id: String, // TurnId + chatTabID: String, + errorMessages: [String] = [], + panelMessages: [CopilotShowMessageParams] = [] + ) { + self.init( + id: id, + chatTabID: chatTabID, + clsTurnID: id, + role: .assistant, + content: "", + errorMessages: errorMessages, + panelMessages: panelMessages + ) + } } -public struct ChatMessageRoleFallback: FallbackValueProvider { - public static var defaultValue: ChatMessage.Role { .user } +extension ConversationReference { + public func getPathRelativeToHome() -> String { + guard !filePath.isEmpty else { return filePath} + + let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path + if !homeDirectory.isEmpty{ + return filePath.replacingOccurrences(of: homeDirectory, with: "~") + } + + return filePath + } } - diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 9396373e..0612cca5 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -2,16 +2,66 @@ 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 { +public struct ChatTabInfo: Identifiable, Equatable, Codable { public var id: String - public var title: String + public var title: String? = nil + public var isTitleSet: Bool { + if let title = title, !title.isEmpty { return true } + return false + } public var focusTrigger: Int = 0 - - public init(id: String, title: String) { + public var isSelected: Bool + public var CLSConversationID: String? + public var createdAt: Date + // used in chat history view + // should be updated when chat tab info changed or chat message of it changed + public var updatedAt: Date + + // The `workspacePath` and `username` won't be save into database + private(set) public var workspacePath: String + private(set) public var username: String + + public init(id: String, title: String? = nil, isSelected: Bool = false, CLSConversationID: String? = nil, workspacePath: String, username: String) { + self.id = id + self.title = title + self.isSelected = isSelected + self.CLSConversationID = CLSConversationID + self.workspacePath = workspacePath + self.username = username + + let now = Date.now + self.createdAt = now + self.updatedAt = now + } + + // for restoring + public init(id: String, title: String? = nil, focusTrigger: Int = 0, isSelected: Bool, CLSConversationID: String? = nil, createdAt: Date, updatedAt: Date, workspacePath: String, username: String) { self.id = id self.title = title + self.focusTrigger = focusTrigger + self.isSelected = isSelected + self.CLSConversationID = CLSConversationID + self.createdAt = createdAt + self.updatedAt = updatedAt + self.workspacePath = workspacePath + self.username = username } } @@ -28,6 +78,9 @@ public protocol ChatTabType { /// Build the tabItem for this chat tab. @ViewBuilder func buildTabItem() -> any View + /// Build the chatConversationItem + @ViewBuilder + func buildChatConversationItem() -> any View /// Build the icon for this chat tab. @ViewBuilder func buildIcon() -> any View @@ -75,7 +128,7 @@ open class BaseChatTab { storeObserver.observe { [weak self] in guard let self else { return } - self.title = store.title + self.title = store.title ?? "" self.id = store.id } } @@ -108,6 +161,16 @@ open class BaseChatTab { } } + @ViewBuilder + public var chatConversationItem: some View { + let id = "ChatTabTab\(id)" + if let tab = self as? (any ChatTabType) { + ContentView(buildView: tab.buildChatConversationItem).id(id) + } else { + EmptyView().id(id) + } + } + /// The icon for this chat tab. @ViewBuilder public var icon: some View { @@ -203,6 +266,10 @@ public class EmptyChatTab: ChatTab { Text("Empty-\(id)") } + public func buildChatConversationItem() -> any View { + Text("Empty-\(id)") + } + public func buildIcon() -> any View { Image(systemName: "square") } @@ -224,7 +291,7 @@ public class EmptyChatTab: ChatTab { public convenience init(id: String) { self.init(store: .init( - initialState: .init(id: id, title: "Empty-\(id)"), + initialState: .init(id: id, title: "Empty-\(id)", workspacePath: "", username: ""), reducer: { ChatTabItem() } )) } diff --git a/Tool/Sources/ChatTab/ChatTabItem.swift b/Tool/Sources/ChatTab/ChatTabItem.swift index abf7aaa2..724cd810 100644 --- a/Tool/Sources/ChatTab/ChatTabItem.swift +++ b/Tool/Sources/ChatTab/ChatTabItem.swift @@ -23,15 +23,16 @@ public struct ChatTabItem { case tabContentUpdated case close case focus + case setCLSConversationID(String) } public init() {} public var body: some ReducerOf { Reduce { state, action in + // the actions will be handled elsewhere in the ChatPanelFeature switch action { - case let .updateTitle(title): - state.title = title + case .updateTitle: return .none case .openNewTab: return .none @@ -42,6 +43,8 @@ public struct ChatTabItem { case .focus: state.focusTrigger += 1 return .none + case .setCLSConversationID: + return .none } } } diff --git a/Tool/Sources/ChatTab/ChatTabPool.swift b/Tool/Sources/ChatTab/ChatTabPool.swift index fafa22cc..116070fd 100644 --- a/Tool/Sources/ChatTab/ChatTabPool.swift +++ b/Tool/Sources/ChatTab/ChatTabPool.swift @@ -5,9 +5,9 @@ import SwiftUI /// A pool that stores all the available tabs. public final class ChatTabPool { - public var createStore: (String) -> StoreOf = { id in + public var createStore: (ChatTabInfo) -> StoreOf = { info in .init( - initialState: .init(id: id, title: ""), + initialState: info, reducer: { ChatTabItem() } ) } @@ -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/Configs/Configurations.swift b/Tool/Sources/Configs/Configurations.swift index 5c6acec3..3cc68a8c 100644 --- a/Tool/Sources/Configs/Configurations.swift +++ b/Tool/Sources/Configs/Configurations.swift @@ -11,3 +11,11 @@ private var bundleIdentifierBase: String { public var userDefaultSuiteName: String { "\(teamIDPrefix)group.\(bundleIdentifierBase).prefs" } + +/// Dedicated preference domain for workspace-level auto-approval. +/// +/// This is intentionally separate from `userDefaultSuiteName` so we can keep +/// auto-approval state isolated from general preferences. +public var autoApprovalUserDefaultSuiteName: String { + "\(teamIDPrefix)group.\(bundleIdentifierBase).autoApproval.prefs" +} diff --git a/Tool/Sources/ConversationServiceProvider/AgentModeToolHelpers.swift b/Tool/Sources/ConversationServiceProvider/AgentModeToolHelpers.swift new file mode 100644 index 00000000..4819cc8f --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/AgentModeToolHelpers.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Helper class for determining tool enabled state and interaction permissions based on agent mode +public final class AgentModeToolHelpers { + public static func makeConfigurationKey(serverName: String, toolName: String) -> String { + return "\(serverName)/\(toolName)" + } + + /// Determines if a tool should be enabled based on the selected agent mode + public static func isToolEnabledInMode( + configurationKey: String, + currentStatus: ToolStatus, + selectedMode: ConversationMode + ) -> Bool { + // For modes other than default agent mode, check if tool is in customTools list + if !selectedMode.isDefaultAgent { + guard let customTools = selectedMode.customTools else { + // If customTools is nil, no tools are enabled + return false + } + + // If customTools is empty, no tools are enabled + if customTools.isEmpty { + return false + } + + return customTools.contains(configurationKey) + } + + // For built-in modes (Agent, Plan, etc.), use tool's current status + return currentStatus == .enabled + } + + /// Determines if users should be allowed to interact with tool checkboxes + public static func isInteractionAllowed(selectedMode: ConversationMode) -> Bool { + // Allow interaction for built-in "Agent" mode and custom modes + if selectedMode.isDefaultAgent || !selectedMode.isBuiltIn { + return true + } + + // Disable interaction for other built-in modes (like Plan) + return false + } + + private init() {} +} diff --git a/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift b/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift new file mode 100644 index 00000000..945733b0 --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift @@ -0,0 +1,163 @@ +import Foundation +import LanguageServerProtocol +import GitHelper +import CopilotForXcodeKit + +extension WorkspaceInfo: @retroactive Equatable { + public static func ==(lhs: WorkspaceInfo, rhs: WorkspaceInfo) -> Bool { + return lhs.projectURL == rhs.projectURL + && lhs.workspaceURL == rhs.workspaceURL + } +} + +public struct CodeReviewRequest: Equatable, Codable { + public struct FileChange: Equatable, Codable { + public let changes: [PRChange] + public var selectedChanges: [PRChange] + + public init(changes: [PRChange]) { + self.changes = changes + self.selectedChanges = changes + } + } + + public var fileChange: FileChange + public var workspaceInfo: WorkspaceInfo? + + public var changedFileUris: [DocumentUri] { fileChange.changes.map { $0.uri } } + public var selectedFileUris: [DocumentUri] { fileChange.selectedChanges.map { $0.uri } } + + public init(fileChange: FileChange) { + self.fileChange = fileChange + } + + public static func from(_ changes: [PRChange]) -> CodeReviewRequest { + return .init(fileChange: .init(changes: changes)) + } + + public mutating func updateSelectedChanges(by fileUris: [DocumentUri]) { + fileChange.selectedChanges = fileChange.selectedChanges.filter { fileUris.contains($0.uri) } + } +} + +public struct CodeReviewResponse: Equatable, Codable { + public struct FileComment: Equatable, Codable, Hashable { + public let uri: DocumentUri + public let originalContent: String + public var comments: [ReviewComment] + + public var url: URL? { URL(string: uri) } + + public init(uri: DocumentUri, originalContent: String, comments: [ReviewComment]) { + self.uri = uri + self.originalContent = originalContent + self.comments = comments + } + } + + public var fileComments: [FileComment] + + public var allComments: [ReviewComment] { + fileComments.flatMap { $0.comments } + } + + public init(fileComments: [FileComment]) { + self.fileComments = fileComments + } + + public func merge(with other: CodeReviewResponse) -> CodeReviewResponse { + var mergedResponse = self + + for newFileComment in other.fileComments { + if let index = mergedResponse.fileComments.firstIndex(where: { $0.uri == newFileComment.uri }) { + // Merge comments for existing URI + var mergedComments = mergedResponse.fileComments[index].comments + newFileComment.comments + mergedComments.sortByEndLine() + mergedResponse.fileComments[index].comments = mergedComments + } else { + // Append new URI with sorted comments + var newReview = newFileComment + newReview.comments.sortByEndLine() + mergedResponse.fileComments.append(newReview) + } + } + + return mergedResponse + } +} + +public struct CodeReviewRound: Equatable, Codable { + public enum Status: Equatable, Codable { + case waitForConfirmation, accepted, running, completed, error, cancelled + + public func canTransitionTo(_ newStatus: Status) -> Bool { + switch (self, newStatus) { + case (.waitForConfirmation, .accepted): return true + case (.waitForConfirmation, .cancelled): return true + case (.accepted, .running): return true + case (.accepted, .cancelled): return true + case (.running, .completed): return true + case (.running, .error): return true + case (.running, .cancelled): return true + default: return false + } + } + } + + public let id: String + public let turnId: String + public var status: Status { + didSet { statusHistory.append(status) } + } + public private(set) var statusHistory: [Status] + public var request: CodeReviewRequest? + public var response: CodeReviewResponse? + public var error: String? + + public init( + id: String = UUID().uuidString, + turnId: String, + status: Status, + request: CodeReviewRequest? = nil, + response: CodeReviewResponse? = nil, + error: String? = nil + ) { + self.id = id + self.turnId = turnId + self.status = status + self.request = request + self.response = response + self.error = error + self.statusHistory = [status] + } + + public static func fromError(turnId: String, error: String) -> CodeReviewRound { + .init(turnId: turnId, status: .error, error: error) + } + + public func withResponse(_ response: CodeReviewResponse) -> CodeReviewRound { + var round = self + round.response = response + return round + } + + public func withStatus(_ status: Status) -> CodeReviewRound { + var round = self + round.status = status + return round + } + + public func withError(_ error: String) -> CodeReviewRound { + var round = self + round.error = error + round.status = .error + return round + } +} + +extension Array where Element == ReviewComment { + // Order in asc + public mutating func sortByEndLine() { + self.sort(by: { $0.range.end.line < $1.range.end.line }) + } +} diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index 22e987fe..d16369a7 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -1,37 +1,387 @@ import CopilotForXcodeKit +import Foundation +import CodableWrappers +import LanguageServerProtocol public protocol ConversationServiceType { - func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws - func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws + func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws -> ConversationCreateResponse? + func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws -> ConversationCreateResponse? + func deleteTurn(with conversationId: String, turnId: String, workspace: WorkspaceInfo) async throws func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws func rateConversation(turnId: String, rating: ConversationRating, workspace: WorkspaceInfo) async throws func copyCode(request: CopyCodeRequest, workspace: WorkspaceInfo) async throws + func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]? + func modes(workspace: WorkspaceInfo) async throws -> [ConversationMode]? + func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]? + func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws + func agents(workspace: WorkspaceInfo) async throws -> [ChatAgent]? + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspace: WorkspaceInfo) async throws + func reviewChanges( + workspace: WorkspaceInfo, + changes: [ReviewChangesParams.Change] + ) async throws -> CodeReviewResult? } public protocol ConversationServiceProvider { - func createConversation(_ request: ConversationRequest) 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 -> ConversationCreateResponse? + func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws -> ConversationCreateResponse? + func deleteTurn(with conversationId: String, turnId: String, workspaceURL: URL?) async throws + func stopReceivingMessage(_ workDoneToken: String, workspaceURL: URL?) async throws + func rateConversation(turnId: String, rating: ConversationRating, workspaceURL: URL?) async throws + func copyCode(_ request: CopyCodeRequest, workspaceURL: URL?) async throws + func templates() async throws -> [ChatTemplate]? + func modes() async throws -> [ConversationMode]? + func models() async throws -> [CopilotModel]? + func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws + func agents() async throws -> [ChatAgent]? + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspaceURL: URL?) async throws + func reviewChanges(_ changes: [ReviewChangesParams.Change]) async throws -> CodeReviewResult? +} + +public struct ConversationFileReference: Hashable, Codable, Equatable { + public let url: URL + public let relativePath: String? + public let fileName: String? + public var 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 + self.selection = selection + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(url) + hasher.combine(isCurrentEditor) + hasher.combine(selection) + } + + public static func == (lhs: ConversationFileReference, rhs: ConversationFileReference) -> Bool { + return lhs.url == rhs.url && lhs.isCurrentEditor == rhs.isCurrentEditor + } +} + +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 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 "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: 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 + self.turnId = turnId + } } 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: [ConversationAttachedReference]? + public var model: String? + public var modelProviderName: String? + public var turns: [TurnSchema] + public var agentMode: Bool = false + public var customChatModeId: String? = nil + public var userLanguage: String? = nil + public var turnId: String? = nil public init( workDoneToken: String, content: String, + contentImages: [ChatCompletionContentPartImage] = [], workspaceFolder: String, - skills: [String] + activeDoc: Doc? = nil, + skills: [String], + ignoredSkills: [String]? = nil, + references: [ConversationAttachedReference]? = nil, + model: String? = nil, + modelProviderName: String? = nil, + turns: [TurnSchema] = [], + agentMode: Bool = false, + customChatModeId: String? = nil, + 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.customChatModeId = customChatModeId + self.userLanguage = userLanguage + self.turnId = turnId } } @@ -63,3 +413,50 @@ public enum CopyKind: Int, Codable { case keyboard = 1 case toolbar = 2 } + + +public struct ConversationFollowUp: Codable, Equatable { + public var message: String + public var id: String + public var type: String + + public init(message: String, id: String, type: String) { + self.message = message + self.id = id + self.type = type + } +} + +public struct ConversationProgressStep: Codable, Equatable, Identifiable { + public enum StepStatus: String, Codable { + case running, completed, failed, cancelled + } + + public struct StepError: Codable, Equatable { + public let message: String + } + + public let id: String + public let title: String + public let description: String? + public var status: StepStatus + public let error: StepError? + + public init(id: String, title: String, description: String?, status: StepStatus, error: StepError?) { + self.id = id + self.title = title + self.description = description + self.status = status + self.error = error + } +} + +public struct DidChangeWatchedFilesEvent: Codable { + public var workspaceUri: String + public var changes: [FileEvent] + + public init(workspaceUri: String, changes: [FileEvent]) { + self.workspaceUri = workspaceUri + self.changes = changes + } +} diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift new file mode 100644 index 00000000..69124626 --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift @@ -0,0 +1,163 @@ +import CopilotForXcodeKit +import Foundation +import LanguageServerProtocol + +public struct AgentRound: Codable, Equatable { + public let roundId: Int + public var reply: String + public var toolCalls: [AgentToolCall]? + public var subAgentRounds: [AgentRound]? + + public init(roundId: Int, reply: String, toolCalls: [AgentToolCall]? = [], subAgentRounds: [AgentRound]? = []) { + self.roundId = roundId + self.reply = reply + self.toolCalls = toolCalls + self.subAgentRounds = subAgentRounds + } +} + +public struct AgentToolCall: Codable, Equatable, Identifiable { + public let id: String + public let name: String + public var toolType: ToolType? + public var progressMessage: String? + public var status: ToolCallStatus + public var input: [String: AnyCodable]? + public var inputMessage: String? + public var error: String? + public var result: [ToolCallResultData]? + public var resultDetails: [ToolResultItem]? + public var invokeParams: InvokeClientToolParams? + public var title: String? + + public enum ToolCallStatus: String, Codable { + case waitForConfirmation, accepted, running, completed, error, cancelled + } + + public init( + id: String, + name: String, + toolType: ToolType? = nil, + progressMessage: String? = nil, + status: ToolCallStatus, + input: [String: AnyCodable]? = nil, + inputMessage: String? = nil, + error: String? = nil, + result: [ToolCallResultData]? = nil, + resultDetails: [ToolResultItem]? = nil, + invokeParams: InvokeClientToolParams? = nil, + title: String? = nil + ) { + self.id = id + self.name = name + self.toolType = toolType + self.progressMessage = progressMessage + self.status = status + self.input = input + self.inputMessage = inputMessage + self.error = error + self.result = result + self.resultDetails = resultDetails + self.invokeParams = invokeParams + self.title = title + } + + public var isToolcallingLoopContinueTool: Bool { + self.name == "internal.tool_calling_loop_continue_confirmation" + } +} + +public enum ToolCallResultData: Codable, Equatable { + case text(String) + case data(mimeType: String, data: String) + + private enum CodingKeys: String, CodingKey { + case type, value + } + + private enum ItemType: String, Codable { + case text, data + } + + private struct DataValue: Codable { + let mimeType: String + let data: String + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(ItemType.self, forKey: .type) + + switch type { + case .text: + let value = try container.decode(String.self, forKey: .value) + self = .text(value) + case .data: + let value = try container.decode(DataValue.self, forKey: .value) + self = .data(mimeType: value.mimeType, data: value.data) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .text(let string): + try container.encode(ItemType.text, forKey: .type) + try container.encode(string, forKey: .value) + case .data(let mimeType, let data): + try container.encode(ItemType.data, forKey: .type) + try container.encode(DataValue(mimeType: mimeType, data: data), forKey: .value) + } + } +} + +public enum ToolResultItem: Codable, Equatable { + case text(String) + case fileLocation(FileLocation) + + public struct FileLocation: Codable, Equatable { + public let uri: String + public let range: LSPRange + + public init(uri: String, range: LSPRange) { + self.uri = uri + self.range = range + } + } + + private enum CodingKeys: String, CodingKey { + case type, value + } + + private enum ItemType: String, Codable { + case text, fileLocation + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(ItemType.self, forKey: .type) + + switch type { + case .text: + let value = try container.decode(String.self, forKey: .value) + self = .text(value) + case .fileLocation: + let value = try container.decode(FileLocation.self, forKey: .value) + self = .fileLocation(value) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .text(let string): + try container.encode(ItemType.text, forKey: .type) + try container.encode(string, forKey: .value) + case .fileLocation(let location): + try container.encode(ItemType.fileLocation, forKey: .type) + try container.encode(location, forKey: .value) + } + } +} diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift new file mode 100644 index 00000000..289fcdbd --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift @@ -0,0 +1,738 @@ +import Foundation +import JSONRPC +import LanguageServerProtocol +import SuggestionBasic + +// MARK: Conversation template +public struct ChatTemplate: Codable, Equatable { + public var id: String + public var description: String + public var shortDescription: String + public var scopes: [PromptTemplateScope] + + public init(id: String, description: String, shortDescription: String, scopes: [PromptTemplateScope]=[]) { + self.id = id + self.description = description + self.shortDescription = shortDescription + self.scopes = scopes + } +} + +public enum PromptTemplateScope: String, Codable, Equatable { + case chatPanel = "chat-panel" + case editPanel = "edit-panel" + case agentPanel = "agent-panel" + case editor = "editor" + case inline = "inline" + case completion = "completion" +} + +public struct CopilotLanguageServerError: Codable { + public var code: Int? + public var message: String + public var responseIsIncomplete: Bool? + public var responseIsFiltered: Bool? +} + +// MARK: Copilot Model +public struct CopilotModel: Codable, Equatable { + public let modelFamily: String + public let modelName: String + public let id: String + public let modelPolicy: CopilotModelPolicy? + public let scopes: [PromptTemplateScope] + public let preview: Bool + public let isChatDefault: Bool + public let isChatFallback: Bool + public let capabilities: CopilotModelCapabilities + public let billing: CopilotModelBilling? +} + +public struct CopilotModelPolicy: Codable, Equatable { + public let state: String + public let terms: String +} + +public struct CopilotModelCapabilities: Codable, Equatable { + public let supports: CopilotModelCapabilitiesSupports +} + +public struct CopilotModelCapabilitiesSupports: Codable, Equatable { + public let vision: Bool +} + +public struct CopilotModelBilling: Codable, Equatable, Hashable { + public let isPremium: Bool + public let multiplier: Float + + public init(isPremium: Bool, multiplier: Float) { + self.isPremium = isPremium + self.multiplier = multiplier + } +} + +// MARK: ChatModes +public enum ChatMode: String, Codable { + case Ask = "Ask" + case Edit = "Edit" + case Agent = "Agent" +} + +public struct ConversationMode: Codable, Equatable { + public let id: String + public let name: String + public let kind: ChatMode + public let isBuiltIn: Bool + public let uri: String? + public let description: String? + public let customTools: [String]? + public let model: String? + public let handOffs: [HandOff]? + + public var isDefaultAgent: Bool { id == "Agent" } + + public static let `defaultAgent` = ConversationMode( + id: "Agent", + name: "Agent", + kind: .Agent, + isBuiltIn: true, + description: "Advanced agent mode with access to tools and capabilities" + ) + + public init( + id: String, + name: String, + kind: ChatMode, + isBuiltIn: Bool, + uri: String? = nil, + description: String? = nil, + customTools: [String]? = nil, + model: String? = nil, + handOffs: [HandOff]? = nil + ) { + self.id = id + self.name = name + self.kind = kind + self.isBuiltIn = isBuiltIn + self.uri = uri + self.description = description + self.customTools = customTools + self.model = model + self.handOffs = handOffs + } +} + +public struct HandOff: Codable, Equatable { + public let agent: String + public let label: String + public let prompt: String + public let send: Bool? + + public init(agent: String, label: String, prompt: String, send: Bool?) { + self.agent = agent + self.label = label + self.prompt = prompt + self.send = send + } +} + +// MARK: Conversation Agents +public struct ChatAgent: Codable, Equatable { + public let slug: String + public let name: String + public let description: String + public let avatarUrl: String? + + public init(slug: String, name: String, description: String, avatarUrl: String?) { + self.slug = slug + self.name = name + self.description = description + self.avatarUrl = avatarUrl + } +} + +// MARK: EditAgent + +public struct RegisterToolsParams: Codable, Equatable { + public let tools: [LanguageModelToolInformation] + + public init(tools: [LanguageModelToolInformation]) { + self.tools = tools + } +} + +public struct UpdateToolsStatusParams: Codable, Equatable { + public let chatModeKind: ChatMode? + public let customChatModeId: String? + public let workspaceFolders: [WorkspaceFolder]? + public let tools: [ToolStatusUpdate] + + public init( + chatmodeKind: ChatMode? = nil, + customChatModeId: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil, + tools: [ToolStatusUpdate] + ) { + self.chatModeKind = chatmodeKind + self.customChatModeId = customChatModeId + self.workspaceFolders = workspaceFolders + self.tools = tools + } +} + +public struct ToolStatusUpdate: Codable, Equatable { + public let name: String + public let status: ToolStatus + + public init(name: String, status: ToolStatus) { + self.name = name + self.status = status + } +} + +public enum ToolStatus: String, Codable, Equatable, Hashable { + case enabled = "enabled" + case disabled = "disabled" +} + +public struct LanguageModelToolInformation: Codable, Equatable { + /// The name of the tool. + public let name: String + + /// A description of this tool that may be used by a language model to select it. + public let description: String + + /// A JSON schema for the input this tool accepts. The input must be an object at the top level. + /// A particular language model may not support all JSON schema features. + public let inputSchema: LanguageModelToolSchema? + + public let confirmationMessages: LanguageModelToolConfirmationMessages? + + public init(name: String, description: String, inputSchema: LanguageModelToolSchema?, confirmationMessages: LanguageModelToolConfirmationMessages? = nil) { + self.name = name + self.description = description + self.inputSchema = inputSchema + self.confirmationMessages = confirmationMessages + } +} + +public struct LanguageModelToolSchema: Codable, Equatable { + public let type: String + public let properties: [String: ToolInputPropertySchema] + public let required: [String] + + public init(type: String, properties: [String : ToolInputPropertySchema], required: [String]) { + self.type = type + self.properties = properties + self.required = required + } +} + +public struct ToolInputPropertySchema: Codable, Equatable { + public struct Items: Codable, Equatable { + public let type: String + + public init(type: String) { + self.type = type + } + } + + public let type: String + public let description: String + public let items: Items? + + public init(type: String, description: String, items: Items? = nil) { + self.type = type + self.description = description + self.items = items + } +} + +public struct LanguageModelToolConfirmationMessages: Codable, Equatable { + public let title: String + public let message: String + + public init(title: String, message: String) { + self.title = title + self.message = message + } +} + +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 + + /// The input to the tool. + public let input: [String: AnyCodable]? + + /// The ID of the conversation this tool invocation belongs to. + public let conversationId: String + + /// The ID of the turn this tool invocation belongs to. + public let turnId: String + + /// The ID of the round this tool invocation belongs to. + public let roundId: Int + + /// The unique ID for this specific tool call. + public let toolCallId: String + + /// The title of the tool confirmation. + public let title: String? + + /// The message of the tool confirmation. + public let message: String? +} + +/// A helper type to encode/decode `Any` values in JSON. +public struct AnyCodable: Codable, Equatable { + public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { + switch (lhs.value, rhs.value) { + case let (lhs as Int, rhs as Int): + return lhs == rhs + case let (lhs as Double, rhs as Double): + return lhs == rhs + case let (lhs as String, rhs as String): + return lhs == rhs + case let (lhs as Bool, rhs as Bool): + return lhs == rhs + case let (lhs as [AnyCodable], rhs as [AnyCodable]): + return lhs == rhs + case let (lhs as [String: AnyCodable], rhs as [String: AnyCodable]): + return lhs == rhs + default: + return false + } + } + + public let value: Any + + public init(_ value: Any) { + self.value = value + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let intValue = try? container.decode(Int.self) { + value = intValue + } else if let doubleValue = try? container.decode(Double.self) { + value = doubleValue + } else if let stringValue = try? container.decode(String.self) { + value = stringValue + } else if let boolValue = try? container.decode(Bool.self) { + value = boolValue + } else if let arrayValue = try? container.decode([AnyCodable].self) { + value = arrayValue.map { $0.value } + } else if let dictionaryValue = try? container.decode([String: AnyCodable].self) { + value = dictionaryValue.mapValues { $0.value } + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + if let intValue = value as? Int { + try container.encode(intValue) + } else if let doubleValue = value as? Double { + try container.encode(doubleValue) + } else if let stringValue = value as? String { + try container.encode(stringValue) + } else if let boolValue = value as? Bool { + try container.encode(boolValue) + } else if let arrayValue = value as? [Any] { + try container.encode(arrayValue.map { AnyCodable($0) }) + } else if let dictionaryValue = value as? [String: Any] { + try container.encode(dictionaryValue.mapValues { AnyCodable($0) }) + } else { + throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: container.codingPath, debugDescription: "Unsupported type")) + } + } +} + +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 + + public init(value: Any) { + self.value = AnyCodable(value) + } + } + + public let status: ToolInvocationStatus + public let content: [Content] + + public init(status: ToolInvocationStatus = .success, content: [Content]) { + self.status = status + self.content = content + } +} + +public struct Doc: Codable { + var uri: String + + public init(uri: String) { + self.uri = uri + } +} + +public enum ToolConfirmationResult: String, Codable { + /// The user accepted the tool invocation. + case Accept = "accept" + /// The user dismissed the tool invocation. + case Dismiss = "dismiss" +} + +public struct LanguageModelToolConfirmationResult: Codable, Equatable { + /// The result of the confirmation. + public let result: ToolConfirmationResult + + public init(result: ToolConfirmationResult) { + self.result = result + } +} + +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 let workspaceFolders: [WorkspaceFolder]? + + public init(changes: [Change], workspaceFolders: [WorkspaceFolder]? = nil) { + self.changes = changes + self.workspaceFolders = workspaceFolders + } +} + +public struct ReviewComment: Codable, Equatable, Hashable { + // Self-defined `id` for using in comment operation. Add an init value to bypass decoding + public let id: String = UUID().uuidString + public let uri: DocumentUri + public let range: LSPRange + public let message: String + // enum: bug, performance, consistency, documentation, naming, readability, style, other + public let kind: String + // enum: low, medium, high + public let severity: String + public let suggestion: String? + + public init( + uri: DocumentUri, + range: LSPRange, + message: String, + kind: String, + severity: String, + suggestion: String? + ) { + self.uri = uri + self.range = range + self.message = message + self.kind = kind + self.severity = severity + self.suggestion = suggestion + } +} + +public struct CodeReviewResult: Codable, Equatable { + public let comments: [ReviewComment] + + public init(comments: [ReviewComment]) { + self.comments = comments + } +} + + +// MARK: - Conversation / Turn + +public enum ConversationSource: String, Codable { + case panel, inline +} + +public struct FileReference: Codable, Equatable, Hashable { + public var type: String = "file" + public let uri: String + public let position: Position? + public let visibleRange: SuggestionBasic.CursorRange? + public let selection: SuggestionBasic.CursorRange? + public let openedAt: String? + public let activeAt: String? +} + +public struct DirectoryReference: Codable, Equatable, Hashable { + public var type: String = "directory" + public let uri: String +} + +public enum Reference: Codable, Equatable, Hashable { + case file(FileReference) + case directory(DirectoryReference) + + public func encode(to encoder: Encoder) throws { + switch self { + case .file(let fileRef): + try fileRef.encode(to: encoder) + case .directory(let directoryRef): + try directoryRef.encode(to: encoder) + } + } + + private enum CodingKeys: String, CodingKey { + case type + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "file": + let fileRef = try FileReference(from: decoder) + self = .file(fileRef) + case "directory": + let directoryRef = try DirectoryReference(from: decoder) + self = .directory(directoryRef) + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown reference type: \(type)" + ) + ) + } + } + + public static func from(_ ref: ConversationAttachedReference) -> Reference { + switch ref { + case .file(let fileRef): + return .file( + .init( + uri: fileRef.url.absoluteString, + position: nil, + visibleRange: nil, + selection: nil, + openedAt: nil, + activeAt: nil + ) + ) + case .directory(let directoryRef): + return .directory(.init(uri: directoryRef.url.absoluteString)) + } + } +} + +public struct ConversationCreateResponse: Codable { + public let conversationId: String + public let turnId: String + public let agentSlug: String? + public let modelName: String? + public let modelProviderName: String? + public let billingMultiplier: Float? +} + +public struct ConversationCreateParams: Codable { + public var workDoneToken: String + public var turns: [TurnSchema] + public var capabilities: Capabilities + public var textDocument: Doc? + public var references: [Reference]? + public var computeSuggestions: Bool? + public var source: ConversationSource? + public var workspaceFolder: String? + public var workspaceFolders: [WorkspaceFolder]? + public var ignoredSkills: [String]? + public var model: String? + public var modelProviderName: String? + public var chatMode: String? + public var customChatModeId: String? + public var needToolCallConfirmation: Bool? + public var userLanguage: String? + + public struct Capabilities: Codable { + public var skills: [String] + public var allSkills: Bool? + + public init(skills: [String], allSkills: Bool? = nil) { + self.skills = skills + self.allSkills = allSkills + } + } + + public init( + workDoneToken: String, + turns: [TurnSchema], + capabilities: Capabilities, + textDocument: Doc? = nil, + references: [Reference]? = nil, + computeSuggestions: Bool? = nil, + source: ConversationSource? = nil, + workspaceFolder: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil, + ignoredSkills: [String]? = nil, + model: String? = nil, + modelProviderName: String? = nil, + chatMode: String? = nil, + customChatModeId: String? = nil, + needToolCallConfirmation: Bool? = nil, + userLanguage: String? = nil + ) { + self.workDoneToken = workDoneToken + self.turns = turns + self.capabilities = capabilities + self.textDocument = textDocument + self.references = references + self.computeSuggestions = computeSuggestions + self.source = source + self.workspaceFolder = workspaceFolder + self.workspaceFolders = workspaceFolders + self.ignoredSkills = ignoredSkills + self.model = model + self.modelProviderName = modelProviderName + self.chatMode = chatMode + self.customChatModeId = customChatModeId + self.needToolCallConfirmation = needToolCallConfirmation + self.userLanguage = userLanguage + } +} + +// MARK: - ConversationErrorCode +public enum ConversationErrorCode: Int { + // -1: Unknown error, used when the error may not be user friendly. + case unknown = -1 + // 0: Default error code, for backward compatibility with Copilot Chat. + case `default` = 0 + case toolRoundExceedError = 10000 +} diff --git a/Tool/Sources/ConversationServiceProvider/PromptType.swift b/Tool/Sources/ConversationServiceProvider/PromptType.swift new file mode 100644 index 00000000..6a896746 --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/PromptType.swift @@ -0,0 +1,123 @@ +import Foundation + +public enum PromptType: String, CaseIterable, Equatable { + case instructions = "instructions" + case prompt = "prompt" + case agent = "agent" + + /// The directory name under .github where files of this type are stored + public var directoryName: String { + switch self { + case .instructions: + return "instructions" + case .prompt: + return "prompts" + case .agent: + return "agents" + } + } + + /// The file extension for this prompt type + public var fileExtension: String { + switch self { + case .instructions: + return ".instructions.md" + case .prompt: + return ".prompt.md" + case .agent: + return ".agent.md" + } + } + + /// Human-readable name for display purposes + public var displayName: String { + switch self { + case .instructions: + return "Instruction File" + case .prompt: + return "Prompt File" + case .agent: + return "Agent File" + } + } + + /// Human-readable name for settings + public var settingTitle: String { + switch self { + case .instructions: + return "Custom Instructions" + case .prompt: + return "Prompt Files" + case .agent: + return "Agent Files" + } + } + + /// Description for the prompt type + public var description: String { + switch self { + case .instructions: + return "Configure `.github/instructions/*.instructions.md` files scoped to specific file patterns or tasks." + case .prompt: + return "Configure `.github/prompts/*.prompt.md` files for reusable prompts. Trigger with '/' commands in the Chat view." + case .agent: + return "Configure `.github/agents/*.agent.md` files for autonomous agent tasks. Agents can perform multi-step operations." + } + } + + /// Default template content for new files + public var defaultTemplate: String { + switch self { + case .instructions: + return """ + --- + applyTo: '**' + --- + Provide project context and coding guidelines that AI should follow when generating code, or answering questions. + + """ + case .prompt: + return """ + --- + description: Prompt Description + --- + Define the task to achieve, including specific requirements, constraints, and success criteria. + + """ + case .agent: + return """ + --- + description: 'Describe what this custom agent does and when to use it.' + tools: [] + --- + Define what this custom agent accomplishes for the user, when to use it, and the edges it won't cross. Specify its ideal inputs/outputs, the tools it may call, and how it reports progress or asks for help. + + """ + } + } + + /// Get the help link for this prompt type. Requires the editor plugin version string. + public func helpLink(editorPluginVersion: String) -> String { + let version = editorPluginVersion == "0.0.0" ? "main" : editorPluginVersion + + switch self { + case .instructions: + return "https://github.com/github/CopilotForXcode/blob/\(version)/Docs/CustomInstructions.md" + case .prompt: + return "https://github.com/github/CopilotForXcode/blob/\(version)/Docs/PromptFiles.md" + case .agent: + return "https://github.com/github/CopilotForXcode/blob/\(version)/Docs/AgentFiles.md" + } + } + + /// Get the full file path for a given name and project URL + public func getFilePath(fileName: String, projectURL: URL) -> URL { + let directory = getDirectoryPath(projectURL: projectURL) + return directory.appendingPathComponent("\(fileName)\(fileExtension)") + } + + /// Get the directory path for this prompt type + public func getDirectoryPath(projectURL: URL) -> URL { + return projectURL.appendingPathComponent(".github/\(directoryName)") + } +} diff --git a/Tool/Sources/ConversationServiceProvider/ToolNames.swift b/Tool/Sources/ConversationServiceProvider/ToolNames.swift new file mode 100644 index 00000000..8ee3e077 --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/ToolNames.swift @@ -0,0 +1,22 @@ + +public enum ToolName: String, Codable { + case runInTerminal = "run_in_terminal" + case getTerminalOutput = "get_terminal_output" + case getErrors = "get_errors" + case insertEditIntoFile = "insert_edit_into_file" + case createFile = "create_file" + case fetchWebPage = "fetch_webpage" +} + +public enum ServerToolName: String, Codable { + case readFile = "read_file" + case findFiles = "file_search" + case findTextInFiles = "grep_search" + case listDir = "list_dir" + case replaceString = "replace_string_in_file" + case codebase = "semantic_search" +} + +public enum CopilotToolName: String, Codable { + case readFile = "copilot.read_file" +} 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/ClientToolHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ClientToolHandler.swift new file mode 100644 index 00000000..46f92ee5 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Conversation/ClientToolHandler.swift @@ -0,0 +1,27 @@ +import JSONRPC +import ConversationServiceProvider +import Combine + +public protocol ClientToolHandler { + var onClientToolInvokeEvent: PassthroughSubject<(InvokeClientToolRequest, (AnyJSONRPCResponse) -> Void), Never> { get } + func invokeClientTool(_ params: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) + + var onClientToolConfirmationEvent: PassthroughSubject<(InvokeClientToolConfirmationRequest, (AnyJSONRPCResponse) -> Void), Never> { get } + func invokeClientToolConfirmation(_ params: InvokeClientToolConfirmationRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) +} + +public final class ClientToolHandlerImpl: ClientToolHandler { + + public static let shared = ClientToolHandlerImpl() + + public let onClientToolInvokeEvent: PassthroughSubject<(InvokeClientToolRequest, (AnyJSONRPCResponse) -> Void), Never> = .init() + public let onClientToolConfirmationEvent: PassthroughSubject<(InvokeClientToolConfirmationRequest, (AnyJSONRPCResponse) -> Void), Never> = .init() + + public func invokeClientTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) { + onClientToolInvokeEvent.send((request, completion)) + } + + public func invokeClientToolConfirmation(_ request: InvokeClientToolConfirmationRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) { + onClientToolConfirmationEvent.send((request, completion)) + } +} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/ConversationContextHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ConversationContextHandler.swift new file mode 100644 index 00000000..bd8ad82d --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Conversation/ConversationContextHandler.swift @@ -0,0 +1,17 @@ +import JSONRPC +import Combine + +public protocol ConversationContextHandler { + var onConversationContext: PassthroughSubject<(ConversationContextRequest, (AnyJSONRPCResponse) -> Void), Never> { get } + func handleConversationContext(_ request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) +} + +public final class ConversationContextHandlerImpl: ConversationContextHandler { + public static let shared = ConversationContextHandlerImpl() + + public var onConversationContext = PassthroughSubject<(ConversationContextRequest, (AnyJSONRPCResponse) -> Void), Never>() + + public func handleConversationContext(_ request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) { + onConversationContext.send((request, completion)) + } +} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift index f88d3a76..4a7c559b 100644 --- a/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift +++ b/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift @@ -2,46 +2,49 @@ import Combine import Foundation import JSONRPC import LanguageServerProtocol +import Logger public enum ProgressKind: String { case begin, report, end } public protocol ConversationProgressHandler { - var onBegin: PassthroughSubject<(String, ConversationProgress), Never> { get } - var onProgress: PassthroughSubject<(String, ConversationProgress), Never> { get } - var onEnd: PassthroughSubject<(String, ConversationProgress), Never> { get } + var onBegin: PassthroughSubject<(String, ConversationProgressBegin), Never> { get } + var onProgress: PassthroughSubject<(String, ConversationProgressReport), Never> { get } + var onEnd: PassthroughSubject<(String, ConversationProgressEnd), Never> { get } func handleConversationProgress(_ progressParams: ProgressParams) } public final class ConversationProgressHandlerImpl: ConversationProgressHandler { public static let shared = ConversationProgressHandlerImpl() - public var onBegin = PassthroughSubject<(String, ConversationProgress), Never>() - public var onProgress = PassthroughSubject<(String, ConversationProgress), Never>() - public var onEnd = PassthroughSubject<(String, ConversationProgress), Never>() + public var onBegin = PassthroughSubject<(String, ConversationProgressBegin), Never>() + public var onProgress = PassthroughSubject<(String, ConversationProgressReport), Never>() + public var onEnd = PassthroughSubject<(String, ConversationProgressEnd), Never>() private var cancellables = Set() public func handleConversationProgress(_ progressParams: ProgressParams) { guard let token = getValueAsString(from: progressParams.token), - let data = try? JSONEncoder().encode(progressParams.value), - let progress = try? JSONDecoder().decode(ConversationProgress.self, from: data) else { + let data = try? JSONEncoder().encode(progressParams.value) else { print("Error encountered while parsing conversation progress params") + Logger.gitHubCopilot.error("Error encountered while parsing conversation progress params") return } - if let kind = ProgressKind(rawValue: progress.kind) { - switch kind { - case .begin: - onBegin.send((token, progress)) - case .report: - onProgress.send((token, progress)) - case .end: - onEnd.send((token, progress)) - } + let progress = try? JSONDecoder().decode(ConversationProgressContainer.self, from: data) + switch progress { + case .begin(let begin): + onBegin.send((token, begin)) + case .report(let report): + onProgress.send((token, report)) + case .end(let end): + onEnd.send((token, end)) + default: + print("Invalid progress kind") + return } - } +} private func getValueAsString(from token: ProgressToken) -> String? { switch token { diff --git a/Tool/Sources/GitHubCopilotService/Conversation/DynamicOAuthRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/DynamicOAuthRequestHandler.swift new file mode 100644 index 00000000..977396c4 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Conversation/DynamicOAuthRequestHandler.swift @@ -0,0 +1,293 @@ +import AppKit +import Combine +import Foundation +import JSONRPC +import LanguageServerProtocol +import Logger + +public protocol DynamicOAuthRequestHandler { + func handleDynamicOAuthRequest( + _ request: DynamicOAuthRequest, + completion: @escaping (AnyJSONRPCResponse) -> Void + ) +} + +public final class DynamicOAuthRequestHandlerImpl: NSObject, DynamicOAuthRequestHandler { + public static let shared = DynamicOAuthRequestHandlerImpl() + + // MARK: - Constants + + private enum LayoutConstants { + static let containerWidth: CGFloat = 450 + static let fieldWidth: CGFloat = 330 + static let labelWidth: CGFloat = 100 + static let labelX: CGFloat = 4 + static let fieldX: CGFloat = 100 + + static let spacing: CGFloat = 8 + static let hintSpacing: CGFloat = 4 + static let labelHeight: CGFloat = 17 + static let fieldHeight: CGFloat = 28 + static let labelVerticalOffset: CGFloat = 6 + + static let hintFontSize: CGFloat = 11 + static let regularFontSize: CGFloat = 13 + } + + private enum Strings { + static let clientIdLabel = "Client ID *" + static let clientSecretLabel = "Client Secret" + static let clientIdPlaceholder = "OAuth client ID (azye39d...)" + static let clientSecretPlaceholder = "OAuth client secret (wer32o50f...) or leave it blank" + static let okButton = "OK" + static let cancelButton = "Cancel" + } + + public func handleDynamicOAuthRequest( + _ request: DynamicOAuthRequest, + completion: @escaping (AnyJSONRPCResponse) -> Void + ) { + guard let params = request.params else { return } + Logger.gitHubCopilot.debug("Received Dynamic OAuth Request: \(params)") + Task { @MainActor in + let response = self.dynamicOAuthRequestAlert(params) + let jsonResult = try? JSONEncoder().encode(response) + let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null + completion(AnyJSONRPCResponse(id: request.id, result: jsonValue)) + } + } + + @MainActor + func dynamicOAuthRequestAlert(_ params: DynamicOAuthParams) -> DynamicOAuthResponse? { + let alert = configureAlert(with: params) + let (clientIdField, clientSecretField) = createAccessoryView(for: alert, params: params) + + let modalResponse = alert.runModal() + + return handleAlertResponse( + modalResponse, + clientIdField: clientIdField, + clientSecretField: clientSecretField + ) + } + + // MARK: - Alert Configuration + + @MainActor + private func configureAlert(with params: DynamicOAuthParams) -> NSAlert { + let alert = NSAlert() + alert.messageText = params.header ?? params.title + alert.informativeText = params.detail + alert.alertStyle = .warning + alert.addButton(withTitle: Strings.okButton) + alert.addButton(withTitle: Strings.cancelButton) + return alert + } + + // MARK: - Accessory View Creation + + @MainActor + private func createAccessoryView( + for alert: NSAlert, + params: DynamicOAuthParams + ) -> (clientIdField: NSTextField, clientSecretField: NSSecureTextField) { + let (clientIdHint, clientIdHintHeight) = createHintLabel( + text: params.inputs.first(where: { $0.value == "clientId" })?.description ?? "" + ) + + let (clientSecretHint, clientSecretHintHeight) = createHintLabel( + text: params.inputs.first(where: { $0.value == "clientSecret" })?.description ?? "" + ) + + let totalHeight = calculateTotalHeight( + clientIdHintHeight: clientIdHintHeight, + clientSecretHintHeight: clientSecretHintHeight + ) + + let containerView = NSView(frame: NSRect( + x: 0, + y: 0, + width: LayoutConstants.containerWidth, + height: totalHeight + )) + + let clientIdField = NSTextField() + let clientSecretField = NSSecureTextField() + + layoutComponents( + in: containerView, + clientIdField: clientIdField, + clientSecretField: clientSecretField, + clientIdHint: clientIdHint, + clientSecretHint: clientSecretHint, + clientIdHintHeight: clientIdHintHeight, + clientSecretHintHeight: clientSecretHintHeight, + params: params + ) + + alert.accessoryView = containerView + + return (clientIdField, clientSecretField) + } + + // MARK: - Component Creation + + @MainActor + private func createHintLabel(text: String) -> (label: NSTextField, height: CGFloat) { + let hint = NSTextField(wrappingLabelWithString: text) + hint.font = NSFont.systemFont(ofSize: LayoutConstants.hintFontSize) + hint.textColor = NSColor.secondaryLabelColor + let height = hint.sizeThatFits(NSSize( + width: LayoutConstants.fieldWidth, + height: CGFloat.greatestFiniteMagnitude + )).height + return (hint, height) + } + + @MainActor + private func createInputField(placeholder: String) -> NSTextField { + let field = NSTextField() + field.placeholderString = placeholder + field.font = NSFont.systemFont(ofSize: LayoutConstants.regularFontSize) + field.isEditable = true + return field + } + + @MainActor + private func createSecureField(placeholder: String) -> NSSecureTextField { + let field = NSSecureTextField() + field.placeholderString = placeholder + field.font = NSFont.systemFont(ofSize: LayoutConstants.regularFontSize) + field.isEditable = true + return field + } + + @MainActor + private func createLabel(text: String) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = NSFont.systemFont(ofSize: LayoutConstants.regularFontSize) + label.alignment = .left + return label + } + + // MARK: - Layout + + private func calculateTotalHeight( + clientIdHintHeight: CGFloat, + clientSecretHintHeight: CGFloat + ) -> CGFloat { + return clientSecretHintHeight + LayoutConstants.hintSpacing + LayoutConstants.fieldHeight + + LayoutConstants.spacing + clientIdHintHeight + LayoutConstants.hintSpacing + + LayoutConstants.fieldHeight + } + + @MainActor + private func layoutComponents( + in containerView: NSView, + clientIdField: NSTextField, + clientSecretField: NSSecureTextField, + clientIdHint: NSTextField, + clientSecretHint: NSTextField, + clientIdHintHeight: CGFloat, + clientSecretHintHeight: CGFloat, + params: DynamicOAuthParams + ) { + var currentY: CGFloat = 0 + + // Client Secret section (bottom) + layoutFieldSection( + in: containerView, + field: clientSecretField, + label: createLabel(text: Strings.clientSecretLabel), + hint: clientSecretHint, + hintHeight: clientSecretHintHeight, + placeholder: params.inputs.first(where: { $0.value == "clientSecret" })?.placeholder ?? Strings.clientSecretPlaceholder, + currentY: ¤tY, + isLastSection: false + ) + + // Client ID section (top) + layoutFieldSection( + in: containerView, + field: clientIdField, + label: createLabel(text: Strings.clientIdLabel), + hint: clientIdHint, + hintHeight: clientIdHintHeight, + placeholder: params.inputs.first(where: { $0.value == "clientId" })?.placeholder ?? Strings.clientIdPlaceholder, + currentY: ¤tY, + isLastSection: true + ) + } + + @MainActor + private func layoutFieldSection( + in containerView: NSView, + field: NSTextField, + label: NSTextField, + hint: NSTextField, + hintHeight: CGFloat, + placeholder: String, + currentY: inout CGFloat, + isLastSection: Bool + ) { + // Position hint + hint.frame = NSRect( + x: LayoutConstants.fieldX, + y: currentY, + width: LayoutConstants.fieldWidth, + height: hintHeight + ) + currentY += hintHeight + LayoutConstants.hintSpacing + + // Position field + field.frame = NSRect( + x: LayoutConstants.fieldX, + y: currentY, + width: LayoutConstants.fieldWidth, + height: LayoutConstants.fieldHeight + ) + field.placeholderString = placeholder + + // Position label + label.frame = NSRect( + x: LayoutConstants.labelX, + y: currentY + LayoutConstants.labelVerticalOffset, + width: LayoutConstants.labelWidth, + height: LayoutConstants.labelHeight + ) + + // Add to container + containerView.addSubview(label) + containerView.addSubview(field) + containerView.addSubview(hint) + + if !isLastSection { + currentY += LayoutConstants.fieldHeight + LayoutConstants.spacing + } + } + + // MARK: - Response Handling + + private func handleAlertResponse( + _ response: NSApplication.ModalResponse, + clientIdField: NSTextField, + clientSecretField: NSSecureTextField + ) -> DynamicOAuthResponse? { + guard response == .alertFirstButtonReturn else { + return nil + } + + let clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !clientId.isEmpty else { + Logger.gitHubCopilot.info("Client ID is required but was not provided") + return nil + } + + let clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + + return DynamicOAuthResponse( + clientId: clientId, + clientSecret: clientSecret + ) + } +} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift new file mode 100644 index 00000000..ad2de6a7 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift @@ -0,0 +1,118 @@ +import JSONRPC +import Foundation +import Combine +import Logger +import AppKit +import LanguageServerProtocol +import UserNotifications + +public protocol ShowMessageRequestHandler { + func handleShowMessageRequest( + _ request: ShowMessageRequest, + callback: @escaping @Sendable (Result>) async -> Void + ) +} + +public final class ShowMessageRequestHandlerImpl: NSObject, ShowMessageRequestHandler, UNUserNotificationCenterDelegate { + public static let shared = ShowMessageRequestHandlerImpl() + + private var isNotificationSetup = false + + private override init() { + super.init() + } + + @MainActor + private func setupNotificationCenterIfNeeded() async { + guard !isNotificationSetup else { return } + guard Bundle.main.bundleIdentifier != nil else { + // Skip notification setup in test environment + return + } + + isNotificationSetup = true + UNUserNotificationCenter.current().delegate = self + _ = try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound]) + } + + public func handleShowMessageRequest( + _ request: ShowMessageRequest, + callback: @escaping @Sendable (Result>) async -> Void + ) { + guard let params = request.params else { return } + Logger.gitHubCopilot.debug("Received Show Message Request: \(params)") + Task { @MainActor in + await setupNotificationCenterIfNeeded() + + let actionCount = params.actions?.count ?? 0 + + // Use notification for messages with no action, alert for messages with actions + if actionCount == 0 { + await showMessageRequestNotification(params) + await callback(.success(nil)) + } else { + let selectedAction = showMessageRequestAlert(params) + await callback(.success(selectedAction)) + } + } + } + + @MainActor + func showMessageRequestNotification(_ params: ShowMessageRequestParams) async { + let content = UNMutableNotificationContent() + content.title = "GitHub Copilot for Xcode" + content.body = params.message + content.sound = .default + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + Logger.gitHubCopilot.error("Failed to show notification: \(error)") + } + } + + @MainActor + func showMessageRequestAlert(_ params: ShowMessageRequestParams) -> MessageActionItem? { + let alert = NSAlert() + + alert.messageText = "GitHub Copilot" + alert.informativeText = params.message + alert.alertStyle = params.type == .info ? .informational : .warning + + let actions = params.actions ?? [] + for item in actions { + alert.addButton(withTitle: item.title) + } + + let response = alert.runModal() + + // Map the button response to the corresponding action + // .alertFirstButtonReturn = 1000, .alertSecondButtonReturn = 1001, etc. + let buttonIndex = response.rawValue - NSApplication.ModalResponse.alertFirstButtonReturn.rawValue + + guard buttonIndex >= 0 && buttonIndex < actions.count else { + return nil + } + + return actions[buttonIndex] + } + + // MARK: - UNUserNotificationCenterDelegate + + // This method is called when a notification is delivered while the app is in the foreground + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Show the notification banner even when app is in foreground + completionHandler([.banner, .list, .badge, .sound]) + } +} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift new file mode 100644 index 00000000..73069562 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift @@ -0,0 +1,146 @@ +import JSONRPC +import Combine +import Workspace +import XcodeInspector +import Foundation +import ConversationServiceProvider +import LanguageServerProtocol + +public protocol WatchedFilesHandler { + func handleWatchedFiles(_ request: WatchedFilesRequest, workspaceURL: URL, completion: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?) +} + +public final class WatchedFilesHandlerImpl: WatchedFilesHandler { + public static let shared = WatchedFilesHandlerImpl() + + public func handleWatchedFiles(_ request: WatchedFilesRequest, workspaceURL: URL, completion: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?) { + guard let params = request.params, params.workspaceFolder.uri != "/" else { return } + + let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(workspaceURL: workspaceURL, documentURL: nil) ?? workspaceURL + + let files = WorkspaceFile.getWatchedFiles( + workspaceURL: workspaceURL, + projectURL: projectURL, + 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 + + Task { + var sentCount = 0 + if params.partialResultToken != nil && fileUris.count > batchSize { + for startIndex in stride(from: 0, to: fileUris.count, by: batchSize) { + let endIndex = min(startIndex + batchSize, fileUris.count) + let partialResult = Array(fileUris[startIndex.. ProgressParams? { + let copilotProgress = CopilotProgressParams(token: token, value: value) + + if let jsonData = try? JSONEncoder().encode(copilotProgress), + let progressParams = try? JSONDecoder().decode(ProgressParams.self, from: jsonData) { + return progressParams + } + return nil + } +} diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index b99cf7d1..b1f89d95 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -1,6 +1,7 @@ import BuiltinExtension import CopilotForXcodeKit import ConversationServiceProvider +import TelemetryServiceProvider import Foundation import LanguageServerProtocol import Logger @@ -11,8 +12,8 @@ public final class GitHubCopilotExtension: BuiltinExtension { public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .gitHubCopilot } public let suggestionService: GitHubCopilotSuggestionService? - public let conversationService: ConversationServiceType? + public let telemetryService: TelemetryServiceType? private var extensionUsage = ExtensionUsage( isSuggestionServiceInUse: false, @@ -33,13 +34,15 @@ public final class GitHubCopilotExtension: BuiltinExtension { self.suggestionService = suggestionService let conversationService = GitHubCopilotConversationService.init(serviceLocator: serviceLocator) self.conversationService = conversationService + let telemetryService = GitHubCopilotTelemetryService.init(serviceLocator: serviceLocator) + self.telemetryService = telemetryService } public func workspaceDidOpen(_: WorkspaceInfo) {} public func workspaceDidClose(_: WorkspaceInfo) {} - public func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) { + public func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) async { guard isLanguageServerInUse else { return } // check if file size is larger than 15MB, if so, return immediately if let attrs = try? FileManager.default @@ -48,14 +51,19 @@ public final class GitHubCopilotExtension: BuiltinExtension { fileSize > 15 * 1024 * 1024 { return } - Task { - do { - let content = try String(contentsOf: documentURL, encoding: .utf8) - guard let service = await serviceLocator.getService(from: workspace) else { return } - try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) - } catch { - Logger.gitHubCopilot.error(error.localizedDescription) - } + let content: String + do { + content = try String(contentsOf: documentURL, encoding: .utf8) + } catch { + Logger.extension.info("Failed to read \(documentURL.lastPathComponent): \(error)") + return + } + + do { + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) + } catch { + Logger.gitHubCopilot.info(error.localizedDescription) } } @@ -66,7 +74,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { guard let service = await serviceLocator.getService(from: workspace) else { return } try await service.notifySaveTextDocument(fileURL: documentURL) } catch { - Logger.gitHubCopilot.error(error.localizedDescription) + Logger.gitHubCopilot.info(error.localizedDescription) } } } @@ -78,7 +86,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { guard let service = await serviceLocator.getService(from: workspace) else { return } try await service.notifyCloseTextDocument(fileURL: documentURL) } catch { - Logger.gitHubCopilot.error(error.localizedDescription) + Logger.gitHubCopilot.info(error.localizedDescription) } } } @@ -86,8 +94,9 @@ public final class GitHubCopilotExtension: BuiltinExtension { public func workspace( _ workspace: WorkspaceInfo, didUpdateDocumentAt documentURL: URL, - content: String? - ) { + content: String?, + contentChanges: [TextDocumentContentChangeEvent]? = nil + ) async { guard isLanguageServerInUse else { return } // check if file size is larger than 15MB, if so, return immediately if let attrs = try? FileManager.default @@ -96,27 +105,26 @@ public final class GitHubCopilotExtension: BuiltinExtension { fileSize > 15 * 1024 * 1024 { return } - Task { - guard let content else { return } - guard let service = await serviceLocator.getService(from: workspace) else { return } - do { - try await service.notifyChangeTextDocument( - fileURL: documentURL, - content: content, - version: 0 - ) - } catch let error as ServerError { - switch error { - case .serverError(-32602, _, _): // parameter incorrect - Logger.gitHubCopilot.error(error.localizedDescription) - // Reopen document if it's not found in the language server - self.workspace(workspace, didOpenDocumentAt: documentURL) - default: - Logger.gitHubCopilot.error(error.localizedDescription) - } - } catch { + guard let content else { return } + guard let service = await serviceLocator.getService(from: workspace) else { return } + do { + try await service.notifyChangeTextDocument( + fileURL: documentURL, + content: content, + version: 0, + contentChanges: contentChanges + ) + } catch let error as ServerError { + switch error { + case .serverError(-32602, _, _): // parameter incorrect Logger.gitHubCopilot.error(error.localizedDescription) + // Reopen document if it's not found in the language server + await self.workspace(workspace, didOpenDocumentAt: documentURL) + default: + Logger.gitHubCopilot.info(error.localizedDescription) } + } catch { + Logger.gitHubCopilot.info(error.localizedDescription) } } diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift index 1b6edf95..cf73d46d 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift @@ -21,7 +21,7 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { } func createGitHubCopilotService() throws -> GitHubCopilotService { - let newService = try GitHubCopilotService(projectRootURL: projectRootURL) + let newService = try GitHubCopilotService(projectRootURL: projectRootURL, workspaceURL: workspaceURL) Task { try await Task.sleep(nanoseconds: 1_000_000_000) finishLaunchingService() diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift new file mode 100644 index 00000000..1168d954 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift @@ -0,0 +1,44 @@ +import Foundation + +public class BYOKModelManager { + private static var availableApiKeys: [BYOKApiKeyInfo] = [] + private static var availableBYOKModels: [BYOKModelInfo] = [] + + public static func updateBYOKModels(BYOKModels: [BYOKModelInfo]) { + let sortedModels = BYOKModels.sorted() + guard sortedModels != availableBYOKModels else { return } + availableBYOKModels = sortedModels + NotificationCenter.default.post(name: .gitHubCopilotModelsDidChange, object: nil) + } + + public static func hasBYOKModels(providerName: BYOKProviderName? = nil) -> Bool { + if let providerName = providerName { + return availableBYOKModels.contains { $0.providerName == providerName } + } + return !availableBYOKModels.isEmpty + } + + public static func getRegisteredBYOKModels() -> [BYOKModelInfo] { + let fullRegisteredBYOKModels = availableBYOKModels.filter({ $0.isRegistered }) + return fullRegisteredBYOKModels + } + + public static func clearBYOKModels() { + availableBYOKModels = [] + } + + public static func updateApiKeys(apiKeys: [BYOKApiKeyInfo]) { + availableApiKeys = apiKeys + } + + public static func hasApiKey(providerName: BYOKProviderName? = nil) -> Bool { + if let providerName = providerName { + return availableApiKeys.contains { $0.providerName == providerName } + } + return !availableApiKeys.isEmpty + } + + public static func clearApiKeys() { + availableApiKeys = [] + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift new file mode 100644 index 00000000..3d7d5cfd --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift @@ -0,0 +1,130 @@ + +import ConversationServiceProvider + +func registerClientTools(server: GitHubCopilotConversationServiceType) async -> [LanguageModelTool] { + var tools: [LanguageModelToolInformation] = [] + let runInTerminalTool = LanguageModelToolInformation( + name: ToolName.runInTerminal.rawValue, + description: "Run a shell command in a terminal. State is persistent across tool calls.\n- Use this tool instead of printing a shell codeblock and asking the user to run it.\n- If the command is a long-running background process, you MUST pass isBackground=true. Background terminals will return a terminal ID which you can use to check the output of a background process with get_terminal_output.\n- If a command may use a pager, you must something to disable it. For example, you can use `git --no-pager`. Otherwise you should add something like ` | cat`. Examples: git, less, man, etc.", + inputSchema: LanguageModelToolSchema( + type: "object", + properties: [ + "command": ToolInputPropertySchema( + type: "string", + description: "The command to run in the terminal."), + "explanation": ToolInputPropertySchema( + type: "string", + description: "A one-sentence description of what the command does. This will be shown to the user before the command is run."), + "isBackground": ToolInputPropertySchema( + type: "boolean", + description: "Whether the command starts a background process. If true, the command will run in the background and you will not see the output. If false, the tool call will block on the command finishing, and then you will get the output. Examples of background processes: building in watch mode, starting a server. You can check the output of a background process later on by using get_terminal_output.") + ], + required: [ + "command", + "explanation", + "isBackground" + ]), + confirmationMessages: LanguageModelToolConfirmationMessages( + title: "Run command In Terminal", + message: "Run command In Terminal" + ) + ) + let getErrorsTool: LanguageModelToolInformation = .init( + name: ToolName.getErrors.rawValue, + description: "Get any compile or lint errors in a code file. If the user mentions errors or problems in a file, they may be referring to these. Use the tool to see the same errors that the user is seeing. Also use this tool after editing a file to validate the change.", + inputSchema: .init( + type: "object", + properties: [ + "filePaths": .init( + type: "array", + description: "The absolute paths to the files to check for errors.", + items: .init(type: "string") + ) + ], + required: ["filePaths"] + ) + ) + + let getTerminalOutputTool = LanguageModelToolInformation( + name: ToolName.getTerminalOutput.rawValue, + description: "Get the output of a terminal command previously started using run_in_terminal", + inputSchema: LanguageModelToolSchema( + type: "object", + properties: [ + "id": ToolInputPropertySchema( + type: "string", + description: "The ID of the terminal command output to check." + ) + ], + required: [ + "id" + ]) + ) + + let createFileTool: LanguageModelToolInformation = .init( + name: ToolName.createFile.rawValue, + description: "This is a tool for creating a new file in the workspace. The file will be created with the specified content.", + inputSchema: .init( + type: "object", + properties: [ + "filePath": .init( + type: "string", + description: "The absolute path to the file to create." + ), + "content": .init( + type: "string", + description: "The content to write to the file." + ) + ], + required: ["filePath", "content"] + ) + ) + + let insertEditIntoFileTool: LanguageModelToolInformation = .init( + name: ToolName.insertEditIntoFile.rawValue, + description: "Insert new code into an existing file in the workspace. Use this tool once per file that needs to be modified, even if there are multiple changes for a file. Generate the \"explanation\" property first.\nThe system is very smart and can understand how to apply your edits to the files, you just need to provide minimal hints.\nAvoid repeating existing code, instead use comments to represent regions of unchanged code. Be as concise as possible. For example:\n// ...existing code...\n{ changed code }\n// ...existing code...\n{ changed code }\n// ...existing code...\n\nHere is an example of how you should use format an edit to an existing Person class:\nclass Person {\n\t// ...existing code...\n\tage: number;\n\t// ...existing code...\n\tgetAge() {\n\treturn this.age;\n\t}\n}", + inputSchema: .init( + type: "object", + properties: [ + "filePath": .init(type: "string", description: "An absolute path to the file to edit."), + "code": .init(type: "string", description: "The code change to apply to the file.\nThe system is very smart and can understand how to apply your edits to the files, you just need to provide minimal hints.\nAvoid repeating existing code, instead use comments to represent regions of unchanged code. Be as concise as possible. For example:\n// ...existing code...\n{ changed code }\n// ...existing code...\n{ changed code }\n// ...existing code...\n\nHere is an example of how you should use format an edit to an existing Person class:\nclass Person {\n\t// ...existing code...\n\tage: number;\n\t// ...existing code...\n\tgetAge() {\n\t\treturn this.age;\n\t}\n}"), + "explanation": .init(type: "string", description: "A short explanation of the edit being made.") + ], + 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 { + let response = try? await server.registerTools(tools: tools) + return response ?? [] + } + + return [] +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift new file mode 100644 index 00000000..2c530455 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift @@ -0,0 +1,108 @@ +import ConversationServiceProvider +import Foundation +import Logger + +public extension Notification.Name { + static let gitHubCopilotToolsDidChange = Notification + .Name("com.github.CopilotForXcode.CopilotToolsDidChange") + static let gitHubCopilotCustomAgentToolsDidChange = Notification + .Name("com.github.CopilotForXcode.CustomAgentToolsDidChange") +} + +public class CopilotLanguageModelToolManager { + private static var availableLanguageModelTools: [LanguageModelTool]? + + public static func updateToolsStatus(_ tools: [LanguageModelTool]) { + // If we have no previous snapshot, just adopt what we received. + guard let previous = availableLanguageModelTools, !previous.isEmpty else { + let sorted = sortTools(tools) + guard sorted != availableLanguageModelTools else { return } + availableLanguageModelTools = sorted + DispatchQueue.main.async { + Logger.client.info("Notify about language model tools change: \(getLanguageModelToolsSummary())") + DistributedNotificationCenter.default().post(name: .gitHubCopilotToolsDidChange, object: nil) + } + return + } + + // Map previous and new by name for merging. + let previousByName = Dictionary(previous.map { ($0.name, $0) }) { first, _ in first } + let incomingByName = Dictionary(tools.map { ($0.name, $0) }) { first, _ in first } + + var merged: [LanguageModelTool] = [] + + for (name, oldTool) in previousByName { + if let updated = incomingByName[name] { + merged.append(updated) + } else { + if oldTool.status == .disabled { + merged.append(oldTool) // already disabled, keep as-is + } else { + // Synthesize a disabled copy (all fields same except status). + let disabledCopy = LanguageModelTool( + id: oldTool.id, + type: oldTool.type, + toolProvider: oldTool.toolProvider, + nameForModel: oldTool.nameForModel, + name: oldTool.name, + displayName: oldTool.displayName, + description: oldTool.description, + displayDescription: oldTool.displayDescription, + inputSchema: oldTool.inputSchema, + annotations: oldTool.annotations, + status: .disabled + ) + merged.append(disabledCopy) + } + } + } + + for (name, newTool) in incomingByName { + if previousByName[name] == nil { + merged.append(newTool) + } + } + + let sorted = sortTools(merged) + guard sorted != availableLanguageModelTools else { return } + availableLanguageModelTools = sorted + + DispatchQueue.main.async { + Logger.client.info("Notify about language model tools change (merged): \(getLanguageModelToolsSummary())") + DistributedNotificationCenter.default().post(name: .gitHubCopilotToolsDidChange, object: nil) + } + } + + // Extracted sorting logic to keep behavior identical. + private static func sortTools(_ tools: [LanguageModelTool]) -> [LanguageModelTool] { + tools.sorted { lhs, rhs in + let lKey = lhs.displayName ?? lhs.name + let rKey = rhs.displayName ?? rhs.name + let primary = lKey.localizedCaseInsensitiveCompare(rKey) + if primary == .orderedSame { + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return primary == .orderedAscending + } + } + + private static func getLanguageModelToolsSummary() -> String { + guard let tools = availableLanguageModelTools else { return "" } + return "\(tools.filter { $0.status == .enabled }.count) enabled, \(tools.filter { $0.status == .disabled }.count) disabled." + } + + public static func getAvailableLanguageModelTools() -> [LanguageModelTool]? { + return availableLanguageModelTools + } + + public static func hasLanguageModelTools() -> Bool { + return availableLanguageModelTools != nil && !availableLanguageModelTools!.isEmpty + } + + public static func clearLanguageModelTools() { + availableLanguageModelTools = [] + DispatchQueue.main.async { + DistributedNotificationCenter.default().post(name: .gitHubCopilotToolsDidChange, object: nil) + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index f97aec1f..7b380443 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -1,4 +1,5 @@ import Combine +import ConversationServiceProvider import Foundation import JSONRPC import LanguageClient @@ -7,17 +8,76 @@ 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 decodingError(_ error: DecodingError) -> ServerError { + let message: String + + switch error { + case .typeMismatch(let type, let context): + message = "Type mismatch: Expected \(type). \(context.debugDescription)" + + case .valueNotFound(let type, let context): + message = "Value not found: Expected \(type). \(context.debugDescription)" + + case .keyNotFound(let key, let context): + message = "Key '\(key.stringValue)' not found. \(context.debugDescription)" + + case .dataCorrupted(let context): + message = "Data corrupted: \(context.debugDescription)" + + @unknown default: + message = error.localizedDescription + } + + return ServerError.serverError(code: -32700, message: message, data: nil) + } + + static func convertToServerError(error: any Error) -> ServerError { + if let serverError = error as? ServerError { + return serverError + } else if let jsonRPCError = error as? AnyJSONRPCResponseError { + return responseError(jsonRPCError) + } else if let decodeError = error as? DecodingError { + return decodingError(decodeError) + } + + return .unknownError(error) + } +} + +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() - 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]() @@ -36,111 +96,188 @@ class CopilotLocalProcessServer { } init(executionParameters parameters: Process.ExecutionParameters) { - transport = StdioDataTransport() - let framing = SeperatedHTTPHeaderMessageFraming() - let messageTransport = MessageTransport( - dataTransport: transport, - messageProtocol: framing + 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)") + } + } + + deinit { + self.process?.terminate() + } + + 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) + } + } + + let wrappedChannel = DataChannel( + writeHandler: handler, + dataSequence: channel.dataSequence ) - customTransport = CustomDataTransport(nextTransport: messageTransport) - wrappedServer = CustomJSONRPCLanguageServer(dataTransport: customTransport) - process = Process() + return wrappedChannel + } + + @Sendable + private func onWriteRequest(data: Data) { + guard let request = try? JSONDecoder().decode(JSONRPCRequest.self, from: data) else { + return + } - // 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)") - } + if request.method == "getCompletionsCycling" || request.method == "textDocument/copilotInlineEdit" { + 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)") } } - } 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)") - } + } + } 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)") } } } } - - wrappedServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in - self?.notificationPublisher.send(notification) - }).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() - + @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 - terminationHandler?() } - var logMessages: Bool { - get { return wrappedServer?.logMessages ?? false } - set { wrappedServer?.logMessages = newValue } + private func handleNotification( + _ anyNotification: AnyJSONRPCNotification, + data: Data + ) -> Bool { + let methodName = anyNotification.method + let debugDescription = encodeJSONParams(params: anyNotification.params) + if let method = ServerNotification.Method(rawValue: methodName) { + switch method { + case .windowLogMessage: + Logger.gitHubCopilot.info("\(anyNotification.method): \(debugDescription)") + return true + case .protocolProgress: + notificationPublisher.send(anyNotification) + return true + default: + return false + } + } else { + switch methodName { + case "LogMessage": + Logger.gitHubCopilot.info("\(anyNotification.method): \(debugDescription)") + return true + case "didChangeStatus": + Logger.gitHubCopilot.info("\(anyNotification.method): \(debugDescription)") + if let payload = GitHubCopilotNotification.StatusNotification.decode(fromParams: anyNotification.params) { + Task { + await Status.shared + .updateCLSStatus( + payload.kind.clsStatus, + busy: payload.busy, + message: payload.message ?? "" + ) + } + } + return true + case "copilot/didChangeFeatureFlags": + notificationPublisher.send(anyNotification) + return true + case "copilot/mcpTools": + notificationPublisher.send(anyNotification) + return true + case "copilot/mcpRuntimeLogs": + notificationPublisher.send(anyNotification) + return true + case "policy/didChange": + notificationPublisher.send(anyNotification) + return true + case "conversation/preconditionsNotification", "statusNotification": + // Ignore + return true + default: + return false + } + } } } -extension CopilotLocalProcessServer: LanguageServerProtocol.Server { - public var requestHandler: RequestHandler? { - get { return wrappedServer?.requestHandler } - set { wrappedServer?.requestHandler = newValue } +extension CopilotLocalProcessServer: ServerConnection { + var eventSequence: EventSequence { + guard let server = wrappedServer else { + let result = EventSequence.makeStream() + result.continuation.finish() + return result.stream + } + + return server.eventSequence } - public var notificationHandler: NotificationHandler? { - get { wrappedServer?.notificationHandler } - set { wrappedServer?.notificationHandler = newValue } + 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) + } } - - public func sendNotification( - _ notif: ClientNotification, - completionHandler: @escaping (ServerError?) -> Void - ) { - guard let server = wrappedServer, process.isRunning else { - completionHandler(.serverUnavailable) - return + + /// 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 + + do { + switch notif { + case .copilotDidChangeWatchedFiles(let params): + try await server.sendNotification(params, method: method) + case .clientProtocolProgress(let params): + try await server.sendNotification(params, method: method) + case .textDocumentDidShowInlineEdit(let params): + try await server.sendNotification(params, method: method) + } + } catch { + throw ServerError.unableToSendNotification(error) } - - server.sendNotification(notif, completionHandler: completionHandler) } /// Cancel ongoing completion requests. @@ -163,7 +300,7 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server { } public func cancelTask(_ id: JSONId) async { - guard let server = wrappedServer, process.isRunning else { + guard let server = wrappedServer, let process = process, process.isRunning else { return } @@ -174,155 +311,65 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server { 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)) - return + + 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) } - - server.sendRequest(request, completionHandler: completionHandler) } } -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() - - 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) - } - } - protocolTransport - .notificationHandler = { [weak self] in - guard let self else { return } - if !self.handleNotification($0, data: $1, block: $2) { - previouseNotificationHandler?($0, $1, $2) - } - } - } - - 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 } +func encodeJSONParams(params: JSONValue?) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + if let jsonData = try? encoder.encode(params), + let text = String(data: jsonData, encoding: .utf8) + { + return text } + return "N/A" } -extension CustomJSONRPCLanguageServer { - private func handleNotification( - _ anyNotification: AnyJSONRPCNotification, - data: Data, - block: @escaping (Error?) -> Void - ) -> Bool { - let methodName = anyNotification.method - let debugDescription = { - if let params = anyNotification.params { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - if let jsonData = try? encoder.encode(params), - let text = String(data: jsonData, encoding: .utf8) - { - return text - } - } - return "N/A" - }() - - if let method = ServerNotification.Method(rawValue: methodName) { - 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 - } - } else { - switch methodName { - case "LogMessage": - Logger.gitHubCopilot.info("\(anyNotification.method): \(debugDescription)") - block(nil) - return true - case "statusNotification": - Logger.gitHubCopilot.info("\(anyNotification.method): \(debugDescription)") - if let payload = GitHubCopilotNotification.StatusNotification.decode(fromParams: anyNotification.params) { - Task { await Status.shared.updateCLSStatus(payload.status.clsStatus, message: payload.message) } - } - block(nil) - return true - case "featureFlagsNotification": - notificationPublisher.send(anyNotification) - block(nil) - return true - case "conversation/preconditionsNotification": - // Ignore - block(nil) - return true - default: - return false - } - } - } +// MARK: - Copilot custom notification - public func sendNotification( - _ notif: ClientNotification, - completionHandler: @escaping (ServerError?) -> Void - ) { - internalServer.sendNotification(notif, completionHandler: completionHandler) - } -} +public struct CopilotDidChangeWatchedFilesParams: Codable, Hashable { + /// The CLS need an additional parameter `workspaceUri` for "workspace/didChangeWatchedFiles" event + public var workspaceUri: String + public var changes: [FileEvent] -extension CustomJSONRPCLanguageServer { - private func handleRequest( - _ request: AnyJSONRPCRequest, - data: Data, - callback: @escaping (AnyJSONRPCResponse) -> Void - ) -> Bool { - return false + public init(workspaceUri: String, changes: [FileEvent]) { + self.workspaceUri = workspaceUri + self.changes = changes } } -extension CustomJSONRPCLanguageServer { - public func sendRequest( - _ request: ClientRequest, - completionHandler: @escaping (ServerResult) -> Void - ) { - internalServer.sendRequest(request, completionHandler: completionHandler) +public enum CopilotClientNotification { + public enum Method: String { + case workspaceDidChangeWatchedFiles = "workspace/didChangeWatchedFiles" + case protocolProgress = "$/progress" + case textDocumentDidShowInlineEdit = "textDocument/didShowInlineEdit" + } + + case copilotDidChangeWatchedFiles(CopilotDidChangeWatchedFilesParams) + case clientProtocolProgress(ProgressParams) + case textDocumentDidShowInlineEdit(TextDocumentDidShowInlineEditParams) + + public var method: Method { + switch self { + case .copilotDidChangeWatchedFiles: + return .workspaceDidChangeWatchedFiles + case .clientProtocolProgress: + return .protocolProgress + case .textDocumentDidShowInlineEdit: + return .textDocumentDidShowInlineEdit + } } } - diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift new file mode 100644 index 00000000..9533e367 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift @@ -0,0 +1,61 @@ +import Foundation +import Logger + +public extension Notification.Name { + static let gitHubCopilotMCPToolsDidChange = Notification + .Name("com.github.CopilotForXcode.CopilotMCPToolsDidChange") +} + +public class CopilotMCPToolManager { + 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 { + Logger.client.info("Notify about MCP tools change: \(getToolsSummary())") + DistributedNotificationCenter.default().postNotificationName( + .gitHubCopilotMCPToolsDidChange, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } + } + + private static func getToolsSummary() -> String { + var summary = "" + guard let tools = availableMCPServerTools else { return summary } + for server in tools { + summary += "Server: \(server.name) \(server.status), 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 } + } + + public static func getAvailableMCPServerToolsCollections() -> [MCPServerToolsCollection]? { + return availableMCPServerTools + } + + public static func hasMCPTools() -> Bool { + return availableMCPServerTools != nil && !availableMCPServerTools!.isEmpty + } + + public static func clearMCPTools() { + availableMCPServerTools = [] + DispatchQueue.main.async { + DistributedNotificationCenter.default().postNotificationName( + .gitHubCopilotMCPToolsDidChange, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotModelManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotModelManager.swift new file mode 100644 index 00000000..898dd5b0 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotModelManager.swift @@ -0,0 +1,43 @@ +import ConversationServiceProvider +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) + } + + public static func getAvailableLLMs() -> [CopilotModel] { + return availableLLMs + } + + 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 = [] + NotificationCenter.default.post(name: .gitHubCopilotModelsDidChange, object: nil) + } +} 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+Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift deleted file mode 100644 index e918ac6c..00000000 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift +++ /dev/null @@ -1,98 +0,0 @@ -import CopilotForXcodeKit -import Foundation -import LanguageServerProtocol -import SuggestionBasic -import ConversationServiceProvider - -enum ConversationSource: String, Codable { - case panel, inline -} - -public struct Doc: Codable { - var position: Position? - var uri: String -} - -struct Reference: Codable { - let uri: String - let position: Position? - let visibleRange: SuggestionBasic.CursorRange? - let selection: SuggestionBasic.CursorRange? - let openedAt: String? - let activeAt: String? -} - -struct ConversationCreateParams: Codable { - var workDoneToken: String - var turns: [ConversationTurn] - var capabilities: Capabilities - var doc: Doc? - var references: [Reference]? - var computeSuggestions: Bool? - var source: ConversationSource? - var workspaceFolder: String? - - struct Capabilities: Codable { - var skills: [String] - var allSkills: Bool? - } -} - -public struct ConversationProgress: Codable { - public struct FollowUp: Codable { - public var message: String - public var id: String - public var type: String - } - - public let kind: String - public let conversationId: String - public let turnId: String - public let reply: String? - public let suggestedTitle: String? - - init(kind: String, conversationId: String, turnId: String, reply: String = "", suggestedTitle: String? = nil) { - self.kind = kind - self.conversationId = conversationId - self.turnId = turnId - self.reply = reply - self.suggestedTitle = suggestedTitle - } -} - -// MARK: Conversation rating - -struct ConversationRatingParams: Codable { - var turnId: String - var rating: ConversationRating - var doc: Doc? - var source: ConversationSource? -} - -// MARK: Conversation turn - -struct ConversationTurn: Codable { - var request: String - var response: String? - var turnId: String? -} - -struct TurnCreateParams: Codable { - var workDoneToken: String - var conversationId: String - var message: String - var doc: Doc? -} - -// MARK: Copy - -struct CopyCodeParams: Codable { - var turnId: String - var codeBlockIndex: Int - var copyType: CopyKind - var copiedCharacters: Int - var totalCharacters: Int - var copiedText: String - var doc: Doc? - var source: ConversationSource? -} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 9b5bd1f3..0ada31e5 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -1,6 +1,8 @@ +import ConversationServiceProvider import Foundation import JSONRPC import LanguageServerProtocol +import Preferences import Status import SuggestionBasic @@ -50,7 +52,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 } @@ -79,15 +81,111 @@ public func editorConfiguration() -> JSONValue { var authProvider: JSONValue? { let enterpriseURI = UserDefaults.shared.value(for: \.gitHubCopilotEnterpriseURI) - return .hash([ "uri": .string(enterpriseURI) ]) + return .hash(["uri": .string(enterpriseURI)]) + } + + var mcp: 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 agent: JSONValue? { + var d: [String: JSONValue] = [:] + + let agentMaxToolCallingLoop = Double(UserDefaults.shared.value(for: \.agentMaxToolCallingLoop)) + d["maxToolCallingLoop"] = .number(agentMaxToolCallingLoop) + + // Auto Approval Settings + // Disable auto approval (yolo mode) + let enableAutoApproval = false + d["toolConfirmAutoApprove"] = .bool(enableAutoApproval) + + let trustToolAnnotations = UserDefaults.shared.value(for: \.trustToolAnnotations) + d["trustToolAnnotations"] = .bool(trustToolAnnotations) + + let state = UserDefaults.autoApproval.value(for: \.sensitiveFilesGlobalApprovals) + var autoApproveList: [JSONValue] = [] + for (key, rule) in state.rules { + let item: [String: JSONValue] = [ + "pattern": .string(key), + "autoApprove": .bool(rule.autoApprove), + "description": .string(rule.description) + ] + autoApproveList.append(.hash(item)) + } + + var tools: [String: JSONValue] = [:] + + if !autoApproveList.isEmpty { + tools["edit"] = .hash([ + "autoApprove": .array(autoApproveList) + ]) + } + + let mcpGlobalApprovals = UserDefaults.autoApproval.value(for: \.mcpServersGlobalApprovals) + var mcpAutoApproveList: [JSONValue] = [] + + for (serverName, state) in mcpGlobalApprovals.servers { + let item: [String: JSONValue] = [ + "serverName": .string(serverName), + "isServerAllowed": .bool(state.isServerAllowed), + "allowedTools": .array(state.allowedTools.map { .string($0) }) + ] + mcpAutoApproveList.append(.hash(item)) + } + + if !mcpAutoApproveList.isEmpty { + tools["mcp"] = .hash([ + "autoApprove": .array(mcpAutoApproveList) + ]) + } + + let terminalState = UserDefaults.autoApproval.value(for: \.terminalCommandsGlobalApprovals) + var terminalAutoApprove: [String: JSONValue] = [:] + for (command, approved) in terminalState.commands { + terminalAutoApprove[command] = .bool(approved) + } + + if !terminalAutoApprove.isEmpty { + tools["terminal"] = .hash([ + "autoApprove": .hash(terminalAutoApprove) + ]) + } + + if !tools.isEmpty { + d["tools"] = .hash(tools) + } + + return .hash(d) } var d: [String: JSONValue] = [:] if let http { d["http"] = http } if let authProvider { d["github-enterprise"] = authProvider } + if (includeMCP && mcp != nil) || customInstructions != nil { + var github: [String: JSONValue] = [:] + var copilot: [String: JSONValue] = [:] + if includeMCP { + copilot["mcp"] = mcp + } + copilot["globalCopilotInstructions"] = customInstructions + copilot["agent"] = agent + github["copilot"] = .hash(copilot) + d["github"] = .hash(github) + } return .hash(d) } +public enum SignInInitiateStatus: String, Codable { + case promptUserDeviceFlow = "PromptUserDeviceFlow" + case alreadySignedIn = "AlreadySignedIn" +} + enum GitHubCopilotRequest { struct GetVersion: GitHubCopilotRequestType { struct Response: Codable { @@ -95,7 +193,7 @@ enum GitHubCopilotRequest { } var request: ClientRequest { - .custom("getVersion", .hash([:])) + .custom("getVersion", .hash([:]), ClientRequest.NullHandler) } } @@ -106,21 +204,30 @@ 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) } } struct SignInInitiate: GitHubCopilotRequestType { struct Response: Codable { - var verificationUri: String - var status: String - var userCode: String - var expiresIn: Int - var interval: Int + var status: SignInInitiateStatus + var userCode: String? + var verificationUri: String? + var expiresIn: Int? + var interval: Int? + var user: String? } var request: ClientRequest { - .custom("signInInitiate", .hash([:])) + .custom("signInInitiate", .hash([:]), ClientRequest.NullHandler) } } @@ -135,7 +242,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { .custom("signInConfirm", .hash([ "userCode": .string(userCode), - ])) + ]), ClientRequest.NullHandler) } } @@ -145,7 +252,7 @@ enum GitHubCopilotRequest { } var request: ClientRequest { - .custom("signOut", .hash([:])) + .custom("signOut", .hash([:]), ClientRequest.NullHandler) } } @@ -161,7 +268,7 @@ enum GitHubCopilotRequest { let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) return .custom("getCompletions", .hash([ "doc": dict, - ])) + ]), ClientRequest.NullHandler) } } @@ -177,7 +284,7 @@ enum GitHubCopilotRequest { let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) return .custom("getCompletionsCycling", .hash([ "doc": dict, - ])) + ]), ClientRequest.NullHandler) } } @@ -227,7 +334,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) } } @@ -243,7 +350,21 @@ enum GitHubCopilotRequest { let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) return .custom("getPanelCompletions", .hash([ "doc": dict, - ])) + ]), ClientRequest.NullHandler) + } + } + + // MARK: - NES + + struct CopilotInlineEdit: GitHubCopilotRequestType { + typealias Response = CopilotInlineEditsResponse + + var params: CopilotInlineEditsParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("textDocument/copilotInlineEdit", dict, ClientRequest.NullHandler) } } @@ -255,7 +376,7 @@ enum GitHubCopilotRequest { var request: ClientRequest { .custom("notifyShown", .hash([ "uuid": .string(completionUUID), - ])) + ]), ClientRequest.NullHandler) } } @@ -274,7 +395,22 @@ enum GitHubCopilotRequest { dict["acceptedLength"] = .number(Double(acceptedLength)) } - return .custom("notifyAccepted", .hash(dict)) + return .custom("notifyAccepted", .hash(dict), ClientRequest.NullHandler) + } + } + + struct NotifyCopilotInlineEditAccepted: GitHubCopilotRequestType { + typealias Response = Bool + + // NES suggestion ID + var params: [String] + + var request: ClientRequest { + let args: [JSONValue] = params.map { JSONValue.string($0) } + return .workspaceExecuteCommand( + .init(command: "github.copilot.didAcceptNextEditSuggestionItem", arguments: args), + ClientRequest.NullHandler + ) } } @@ -286,35 +422,47 @@ enum GitHubCopilotRequest { var request: ClientRequest { .custom("notifyRejected", .hash([ "uuids": .array(completionUUIDs.map(JSONValue.string)), - ])) + ]), ClientRequest.NullHandler) } } // MARK: Conversation struct CreateConversation: GitHubCopilotRequestType { - struct Response: Codable {} + typealias Response = ConversationCreateResponse var params: ConversationCreateParams 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) } } // MARK: Conversation turn struct CreateTurn: GitHubCopilotRequestType { - struct Response: Codable {} + typealias Response = ConversationCreateResponse var params: TurnCreateParams 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) + } + } + + struct DeleteTurn: GitHubCopilotRequestType { + struct Response: Codable {} + + var params: TurnDeleteParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/turnDelete", dict, ClientRequest.NullHandler) } } @@ -328,7 +476,141 @@ 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) + } + } + + // MARK: Conversation templates + + struct GetTemplates: GitHubCopilotRequestType { + typealias Response = Array + + var params: ConversationTemplatesParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/templates", dict, ClientRequest.NullHandler) + } + } + + // MARK: Conversation Modes + + struct GetModes: GitHubCopilotRequestType { + typealias Response = Array + + var params: ConversationModesParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/modes", dict, ClientRequest.NullHandler) + } + } + + // MARK: Copilot Models + + struct CopilotModels: GitHubCopilotRequestType { + typealias Response = Array + + var request: ClientRequest { + .custom("copilot/models", .hash([:]), ClientRequest.NullHandler) + } + } + + // MARK: MCP Tools + + struct UpdatedMCPToolsStatus: GitHubCopilotRequestType { + typealias Response = Array + + var params: UpdateMCPToolsStatusParams + + 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, ClientRequest.NullHandler) + } + } + + // MARK: MCP Registry + + struct MCPRegistryListServers: GitHubCopilotRequestType { + typealias Response = MCPRegistryServerList + + var params: MCPRegistryListServersParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("mcp/registry/listServers", dict, ClientRequest.NullHandler) + } + } + + struct MCPRegistryGetServer: GitHubCopilotRequestType { + typealias Response = MCPRegistryServerDetail + + var params: MCPRegistryGetServerParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("mcp/registry/getServer", dict, ClientRequest.NullHandler) + } + } + + struct MCPRegistryGetAllowlist: GitHubCopilotRequestType { + typealias Response = GetMCPRegistryAllowlistResult + + var request: ClientRequest { + .custom("mcp/registry/getAllowlist", .hash([:]), ClientRequest.NullHandler) + } + } + + // MARK: - Conversation Agents + + struct GetAgents: GitHubCopilotRequestType { + typealias Response = Array + + var request: ClientRequest { + .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 { + 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, 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) } } @@ -342,7 +624,95 @@ 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) + } + } + + // MARK: Telemetry + + struct TelemetryException: GitHubCopilotRequestType { + struct Response: Codable {} + + var params: TelemetryExceptionParams + + 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, 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) } } } @@ -350,11 +720,9 @@ enum GitHubCopilotRequest { // MARK: Notifications public enum GitHubCopilotNotification { - public struct StatusNotification: Codable { - public enum StatusKind : String, Codable { + public enum StatusKind: String, Codable { case normal = "Normal" - case inProgress = "InProgress" case error = "Error" case warning = "Warning" case inactive = "Inactive" @@ -362,25 +730,41 @@ public enum GitHubCopilotNotification { public var clsStatus: CLSStatus.Status { switch self { case .normal: - .normal - case .inProgress: - .inProgress + .normal case .error: - .error + .error case .warning: - .warning + .warning case .inactive: - .inactive + .inactive } } } - public var status: StatusKind - public var message: String + public var kind: StatusKind + public var busy: Bool + public var message: String? public static func decode(fromParams params: JSONValue?) -> StatusNotification? { try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data()) } } + 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/GitHubCopilotRequestTypes/Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift new file mode 100644 index 00000000..95bff025 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift @@ -0,0 +1,154 @@ +import CopilotForXcodeKit +import Foundation +import LanguageServerProtocol +import SuggestionBasic +import ConversationServiceProvider +import JSONRPC +import Logger + +// MARK: Conversation Progress + +public enum ConversationProgressKind: String, Codable { + case begin, report, end +} + +protocol BaseConversationProgress: Codable { + var kind: ConversationProgressKind { get } + var conversationId: String { get } + var turnId: String { get } +} + +public struct ConversationProgressBegin: BaseConversationProgress { + public let kind: ConversationProgressKind + public let conversationId: String + public let turnId: String + public let parentTurnId: String? +} + +public struct ConversationProgressReport: BaseConversationProgress { + + public let kind: ConversationProgressKind + public let conversationId: String + public let turnId: String + public let reply: String? + public let references: [FileReference]? + public let steps: [ConversationProgressStep]? + public let editAgentRounds: [AgentRound]? + public let parentTurnId: String? +} + +public struct ConversationProgressEnd: BaseConversationProgress { + public let kind: ConversationProgressKind + public let conversationId: String + public let turnId: String + public let error: CopilotLanguageServerError? + public let followUp: ConversationFollowUp? + public let suggestedTitle: String? +} + +enum ConversationProgressContainer: Decodable { + case begin(ConversationProgressBegin) + case report(ConversationProgressReport) + case end(end: ConversationProgressEnd) + + enum CodingKeys: String, CodingKey { + case kind + } + + init(from decoder: Decoder) throws { + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(ConversationProgressKind.self, forKey: .kind) + + switch kind { + case .begin: + let begin = try ConversationProgressBegin(from: decoder) + self = .begin(begin) + case .report: + let report = try ConversationProgressReport(from: decoder) + self = .report(report) + case .end: + let end = try ConversationProgressEnd(from: decoder) + self = .end(end: end) + } + } catch { + Logger.gitHubCopilot.error("Error decoding ConversationProgressContainer: \(error)") + throw error + } + } + } + +// MARK: Conversation rating + +struct ConversationRatingParams: Codable { + var turnId: String + var rating: ConversationRating + var doc: Doc? + var source: ConversationSource? +} + +// MARK: Conversation templates +struct ConversationTemplatesParams: Codable { + var workspaceFolders: [WorkspaceFolder]? +} + +typealias ConversationModesParams = ConversationTemplatesParams + +// MARK: Conversation turn +struct TurnCreateParams: Codable { + var workDoneToken: String + var conversationId: 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? + var customChatModeId: String? + var needToolCallConfirmation: Bool? +} + +struct TurnDeleteParams: Codable { + var conversationId: String + var turnId: String + var source: ConversationSource? +} + +// MARK: Copy + +struct CopyCodeParams: Codable { + var turnId: String + var codeBlockIndex: Int + var copyType: CopyKind + var copiedCharacters: Int + var totalCharacters: Int + var copiedText: String + var doc: Doc? + var source: ConversationSource? +} + +// MARK: Conversation context + +public struct ConversationContextParams: Codable { + public var conversationId: String + public var turnId: String + public var skillId: String +} + +public typealias ConversationContextRequest = JSONRPCRequest + + +// MARK: Watched Files + +public struct WatchedFilesParams: Codable { + public var workspaceFolder: WorkspaceFolder + public var excludeGitignoredFiles: Bool + public var excludeIDEIgnoredFiles: Bool + public var partialResultToken: ProgressToken? +} + +public typealias WatchedFilesRequest = JSONRPCRequest diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift new file mode 100644 index 00000000..17792a88 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift @@ -0,0 +1,233 @@ +import Foundation +import JSONRPC +import LanguageServerProtocol +import ConversationServiceProvider + +public enum MCPServerStatus: String, Codable, Equatable, Hashable { + case running = "running" + case stopped = "stopped" + case error = "error" + case blocked = "blocked" +} + +public struct InputSchema: Codable, Equatable, Hashable { + public var type: String = "object" + public var properties: [String: JSONValue]? + + public init(properties: [String: JSONValue]? = nil) { + self.properties = properties + } + + // Custom coding for handling `properties` as Any + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decode(String.self, forKey: .type) + + if let propertiesData = try? container.decode(Data.self, forKey: .properties), + let props = try? JSONSerialization.jsonObject(with: propertiesData) as? [String: JSONValue] { + properties = props + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + + if let props = properties, + let propertiesData = try? JSONSerialization.data(withJSONObject: props) { + try container.encode(propertiesData, forKey: .properties) + } + } + + enum CodingKeys: String, CodingKey { + case type + case properties + } +} + +public struct ToolAnnotations: Codable, Equatable, Hashable { + public var title: String? + public var readOnlyHint: Bool? + public var destructiveHint: Bool? + public var idempotentHint: Bool? + public var openWorldHint: Bool? + + public init( + title: String? = nil, + readOnlyHint: Bool? = nil, + destructiveHint: Bool? = nil, + idempotentHint: Bool? = nil, + openWorldHint: Bool? = nil + ) { + self.title = title + self.readOnlyHint = readOnlyHint + self.destructiveHint = destructiveHint + self.idempotentHint = idempotentHint + self.openWorldHint = openWorldHint + } + + enum CodingKeys: String, CodingKey { + case title + case readOnlyHint + case destructiveHint + case idempotentHint + case openWorldHint + } +} + +public struct MCPTool: Codable, Equatable, Hashable { + public let name: String + public let description: String? + public let _status: ToolStatus + public let inputSchema: InputSchema + public var annotations: ToolAnnotations? + + public init( + name: String, + description: String? = nil, + _status: ToolStatus, + inputSchema: InputSchema, + annotations: ToolAnnotations? = nil + ) { + self.name = name + self.description = description + self._status = _status + self.inputSchema = inputSchema + self.annotations = annotations + } + + enum CodingKeys: String, CodingKey { + case name + case description + case _status + case inputSchema + case annotations + } +} + +public struct MCPServerToolsCollection: Codable, Equatable, Hashable { + public let name: String + public let status: MCPServerStatus + public let tools: [MCPTool] + public let error: String? + public let registryInfo: String? + + public init( + name: String, + status: MCPServerStatus, + tools: [MCPTool], + error: String? = nil, + registryInfo: String? = nil + ) { + self.name = name + self.status = status + self.tools = tools + self.error = error + self.registryInfo = registryInfo + } +} + +public struct GetAllToolsParams: Codable, Hashable { + public var servers: [MCPServerToolsCollection] + + public static func decode(fromParams params: JSONValue?) -> GetAllToolsParams? { + try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data()) + } +} + +public struct UpdatedMCPToolsStatus: Codable, Hashable { + public var name: String + public var status: ToolStatus + + public init(name: String, status: ToolStatus) { + self.name = name + self.status = status + } +} + +public struct UpdateMCPToolsStatusServerCollection: Codable, Hashable { + public var name: String + public var tools: [UpdatedMCPToolsStatus] + + public init(name: String, tools: [UpdatedMCPToolsStatus]) { + self.name = name + self.tools = tools + } +} + +public struct UpdateMCPToolsStatusParams: Codable, Hashable { + public var chatModeKind: ChatMode? + public var customChatModeId: String? + public var workspaceFolders: [WorkspaceFolder]? + public var servers: [UpdateMCPToolsStatusServerCollection] + + public init( + chatModeKind: ChatMode? = nil, + customChatModeId: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil, + servers: [UpdateMCPToolsStatusServerCollection] + ) { + self.chatModeKind = chatModeKind + self.customChatModeId = customChatModeId + self.workspaceFolders = workspaceFolders + self.servers = servers + } +} + +public typealias CopilotMCPToolsRequest = JSONRPCRequest + +public struct DynamicOAuthParams: Codable, Hashable { + public let title: String + public let header: String? + public let detail: String + public let inputs: [DynamicOAuthInput] + + public init( + title: String, + header: String?, + detail: String, + inputs: [DynamicOAuthInput] + ) { + self.title = title + self.header = header + self.detail = detail + self.inputs = inputs + } +} + +public struct DynamicOAuthInput: Codable, Hashable { + public let title: String + public let value: String + public let description: String + public let placeholder: String + public let required: Bool + + public init( + title: String, + value: String, + description: String, + placeholder: String, + required: Bool + ) { + self.title = title + self.value = value + self.description = description + self.placeholder = placeholder + self.required = required + } +} + +public typealias DynamicOAuthRequest = JSONRPCRequest + +public struct DynamicOAuthResponse: Codable, Hashable { + public let clientId: String + public let clientSecret: String + + public init( + clientId: String, + clientSecret: String + ) { + self.clientId = clientId + self.clientSecret = clientSecret + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift new file mode 100644 index 00000000..fd1c8bf6 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift @@ -0,0 +1,768 @@ +import Foundation +import JSONRPC +import ConversationServiceProvider + +/// Schema definitions for MCP Registry API based on the OpenAPI spec: +/// https://github.com/modelcontextprotocol/registry/blob/v1.3.3/docs/reference/api/openapi.yaml + +// MARK: - Inputs + +public enum ArgumentFormat: String, Codable { + case string + case number + case boolean + case filepath +} + +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 placeholder: String? { get } + var choices: [String]? { get } +} + +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 placeholder: String? + public let choices: [String]? + + enum CodingKeys: String, CodingKey { + case description, isRequired, format, value, isSecret, placeholder, choices + case defaultValue = "default" + } +} + +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 placeholder: String? + public let choices: [String]? + public let variables: [String: Input]? + + enum CodingKeys: String, CodingKey { + case description, isRequired, format, value, isSecret, placeholder, choices, variables + case defaultValue = "default" + } +} + +public struct KeyValueInput: InputProtocol, Hashable { + public let name: String + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let placeholder: String? + public let choices: [String]? + public let variables: [String: Input]? + + public init( + name: String, + description: String?, + isRequired: Bool?, + format: ArgumentFormat?, + value: String?, + isSecret: Bool?, + defaultValue: String?, + placeholder: String?, + choices: [String]?, + variables: [String : Input]? + ) { + self.name = name + self.description = description + self.isRequired = isRequired + self.format = format + self.value = value + self.isSecret = isSecret + self.defaultValue = defaultValue + self.placeholder = placeholder + self.choices = choices + self.variables = variables + } + + enum CodingKeys: String, CodingKey { + case name, description, isRequired, format, value, isSecret, placeholder, choices, variables + case defaultValue = "default" + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(description) + hasher.combine(isRequired) + hasher.combine(format) + hasher.combine(value) + hasher.combine(isSecret) + hasher.combine(defaultValue) + hasher.combine(placeholder) + hasher.combine(choices) + } + + public static func == (lhs: KeyValueInput, rhs: KeyValueInput) -> Bool { + lhs.name == rhs.name && + lhs.description == rhs.description && + lhs.isRequired == rhs.isRequired && + lhs.format == rhs.format && + lhs.value == rhs.value && + lhs.isSecret == rhs.isSecret && + lhs.defaultValue == rhs.defaultValue && + lhs.placeholder == rhs.placeholder && + lhs.choices == rhs.choices + } +} + +// MARK: - Arguments + +public enum ArgumentType: String, Codable { + case positional + case named +} + +public protocol ArgumentProtocol: InputProtocol { + var type: ArgumentType { get } + var variables: [String: Input]? { get } +} + +public struct PositionalArgument: ArgumentProtocol, Hashable { + public let type: ArgumentType = .positional + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let placeholder: 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, isRequired, format, value, isSecret, placeholder, choices, variables, valueHint, isRepeated + case defaultValue = "default" + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(description) + hasher.combine(isRequired) + hasher.combine(format) + hasher.combine(value) + hasher.combine(isSecret) + hasher.combine(defaultValue) + hasher.combine(placeholder) + hasher.combine(choices) + hasher.combine(valueHint) + hasher.combine(isRepeated) + } + + public static func == (lhs: PositionalArgument, rhs: PositionalArgument) -> Bool { + lhs.type == rhs.type && + lhs.description == rhs.description && + lhs.isRequired == rhs.isRequired && + lhs.format == rhs.format && + lhs.value == rhs.value && + lhs.isSecret == rhs.isSecret && + lhs.defaultValue == rhs.defaultValue && + lhs.placeholder == rhs.placeholder && + lhs.choices == rhs.choices && + lhs.valueHint == rhs.valueHint && + lhs.isRepeated == rhs.isRepeated + } +} + +public struct NamedArgument: ArgumentProtocol, Hashable { + public let type: ArgumentType = .named + public let name: String + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let placeholder: String? + public let choices: [String]? + public let variables: [String: Input]? + public let isRepeated: Bool? + + enum CodingKeys: String, CodingKey { + case type, name, description, isRequired, format, value, isSecret, placeholder, choices, variables, isRepeated + case defaultValue = "default" + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(name) + hasher.combine(description) + hasher.combine(isRequired) + hasher.combine(format) + hasher.combine(value) + hasher.combine(isSecret) + hasher.combine(defaultValue) + hasher.combine(placeholder) + hasher.combine(choices) + hasher.combine(isRepeated) + } + + public static func == (lhs: NamedArgument, rhs: NamedArgument) -> Bool { + lhs.type == rhs.type && + lhs.name == rhs.name && + lhs.description == rhs.description && + lhs.isRequired == rhs.isRequired && + lhs.format == rhs.format && + lhs.value == rhs.value && + lhs.isSecret == rhs.isSecret && + lhs.defaultValue == rhs.defaultValue && + lhs.placeholder == rhs.placeholder && + lhs.choices == rhs.choices && + lhs.isRepeated == rhs.isRepeated + } +} + +public enum Argument: Codable, Hashable { + case positional(PositionalArgument) + case named(NamedArgument) + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Discriminator.self) + let type = try container.decode(ArgumentType.self, forKey: .type) + switch type { + case .positional: + self = .positional(try PositionalArgument(from: decoder)) + case .named: + self = .named(try NamedArgument(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .positional(let arg): + try arg.encode(to: encoder) + case .named(let arg): + try arg.encode(to: encoder) + } + } + + private enum Discriminator: String, CodingKey { + case type + } +} + +// MARK: - Transport + +public enum TransportType: String, Codable { + case streamableHttp = "streamable-http" + case stdio = "stdio" + case sse = "sse" + + public var displayText: String { + switch self { + case .streamableHttp: + return "Streamable HTTP" + case .stdio: + return "Stdio" + case .sse: + return "SSE" + } + } +} + +public protocol TransportProtocol: Codable { + var type: TransportType { get } + var variables: [String: Input]? { get } +} + +public struct StdioTransport: TransportProtocol, Hashable { + public let type: TransportType = .stdio + public let variables: [String : Input]? + + enum CodingKeys: String, CodingKey { + case type, variables + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + } + + public static func == (lhs: StdioTransport, rhs: StdioTransport) -> Bool { + lhs.type == rhs.type + } +} + +public struct StreamableHttpTransport: TransportProtocol, Hashable { + public let type: TransportType = .streamableHttp + public let url: String + public let headers: [KeyValueInput]? + public let variables: [String : Input]? + + enum CodingKeys: String, CodingKey { + case type, url, headers, variables + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(url) + hasher.combine(headers) + } + + public static func == (lhs: StreamableHttpTransport, rhs: StreamableHttpTransport) -> Bool { + lhs.type == rhs.type && + lhs.url == rhs.url && + lhs.headers == rhs.headers + } +} + +public struct SseTransport: TransportProtocol, Hashable { + public let type: TransportType = .sse + public let url: String + public let headers: [KeyValueInput]? + public let variables: [String : Input]? + + enum CodingKeys: String, CodingKey { + case type, url, headers, variables + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(url) + hasher.combine(headers) + } + + public static func == (lhs: SseTransport, rhs: SseTransport) -> Bool { + lhs.type == rhs.type && + lhs.url == rhs.url && + lhs.headers == rhs.headers + } +} + +public enum Transport: Codable, Hashable { + case stdio(StdioTransport) + case streamableHTTP(StreamableHttpTransport) + case sse(SseTransport) + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Discriminator.self) + let type = try container.decode(TransportType.self, forKey: .type) + switch type { + case .stdio: + self = .stdio(try StdioTransport(from: decoder)) + case .streamableHttp: + self = .streamableHTTP(try StreamableHttpTransport(from: decoder)) + case .sse: + self = .sse(try SseTransport(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .stdio(let arg): + try arg.encode(to: encoder) + case .streamableHTTP(let arg): + try arg.encode(to: encoder) + case .sse(let arg): + try arg.encode(to: encoder) + } + } + + private enum Discriminator: String, CodingKey { + case type + } +} + +public enum Remote: Codable, Hashable { + case streamableHTTP(StreamableHttpTransport) + case sse(SseTransport) + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Discriminator.self) + let type = try container.decode(TransportType.self, forKey: .type) + switch type { + case .stdio: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unexpected type: stdio for Remote" + ) + case .streamableHttp: + self = .streamableHTTP(try StreamableHttpTransport(from: decoder)) + case .sse: + self = .sse(try SseTransport(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .streamableHTTP(let arg): + try arg.encode(to: encoder) + case .sse(let arg): + try arg.encode(to: encoder) + } + } + + private enum Discriminator: String, CodingKey { + case type + } +} + +// MARK: - Package + +public struct Package: Codable, Hashable { + public let registryType: String + public let registryBaseUrl: String? + public let identifier: String + public let version: String? + public let fileSha256: String? + public let runtimeHint: String? + public let transport: Transport + public let runtimeArguments: [Argument]? + public let packageArguments: [Argument]? + public let environmentVariables: [KeyValueInput]? + + public init( + registryType: String, + registryBaseUrl: String?, + identifier: String, + version: String?, + fileSha256: String?, + runtimeHint: String?, + transport: Transport, + runtimeArguments: [Argument]?, + packageArguments: [Argument]?, + environmentVariables: [KeyValueInput]? + ) { + self.registryType = registryType + self.registryBaseUrl = registryBaseUrl + self.identifier = identifier + self.version = version + self.fileSha256 = fileSha256 + self.runtimeHint = runtimeHint + self.transport = transport + self.runtimeArguments = runtimeArguments + self.packageArguments = packageArguments + self.environmentVariables = environmentVariables + } +} + +// MARK: - Icons + +public enum IconMimeType: String, Codable { + case png = "image/png" + case jpeg = "image/jpeg" + case jpg = "image/jpg" + case svg = "image/svg+xml" + case webp = "image/webp" +} + +public enum IconTheme: String, Codable { + case light, dark +} + +public struct Icon: Codable, Hashable { + public let src: String + public let mimeType: IconMimeType? + public let sizes: [String]? + public let theme: IconTheme? +} + +// MARK: - Repository + +public struct Repository: Codable { + public let url: String + public let source: String + public let id: String? + public let subfolder: String? + + public init(url: String, source: String, id: String?, subfolder: String?) { + self.url = url + self.source = source + self.id = id + self.subfolder = subfolder + } + + enum CodingKeys: String, CodingKey { + case url, source, id, subfolder + } +} + +// MARK: - Meta + +public enum ServerStatus: String, Codable { + case active + case deprecated + case deleted +} + +public struct OfficialMeta: Codable { + public let status: ServerStatus? + public let publishedAt: String? + public let updatedAt: String? + public let isLatest: Bool? + + public init( + status: ServerStatus? = nil, + publishedAt: String? = nil, + updatedAt: String? = nil, + isLatest: Bool? = nil + ) { + self.status = status + self.publishedAt = publishedAt + self.updatedAt = updatedAt + self.isLatest = isLatest + } +} + +public struct PublisherProvidedMeta: Codable { + private let additionalProperties: [String: AnyCodable]? + + public init( + additionalProperties: [String: AnyCodable]? = nil + ) { + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let allKeys = try decoder.container(keyedBy: AnyCodingKey.self) + var extras: [String: AnyCodable] = [:] + + for key in allKeys.allKeys { + extras[key.stringValue] = try allKeys.decode(AnyCodable.self, forKey: key) + } + additionalProperties = extras.isEmpty ? nil : extras + } + + public func encode(to encoder: Encoder) throws { + if let additionalProperties = additionalProperties { + var dynamicContainer = encoder.container(keyedBy: AnyCodingKey.self) + for (key, value) in additionalProperties { + try dynamicContainer.encode(value, forKey: AnyCodingKey(stringValue: key)!) + } + } + } +} + +public struct MCPRegistryExtensionMeta: Codable { + public let publisherProvided: PublisherProvidedMeta? + + enum CodingKeys: String, CodingKey { + case publisherProvided = "io.modelcontextprotocol.registry/publisher-provided" + } + + public init(publisherProvided: PublisherProvidedMeta?) { + self.publisherProvided = publisherProvided + } +} + +public struct ServerMeta: Codable { + public let official: OfficialMeta? + private let additionalProperties: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case official = "io.modelcontextprotocol.registry/official" + } + + public init( + official: OfficialMeta? = nil, + additionalProperties: [String: AnyCodable]? = nil + ) { + self.official = official + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + official = try container.decode(OfficialMeta.self, forKey: .official) + + let allKeys = try decoder.container(keyedBy: AnyCodingKey.self) + var extras: [String: AnyCodable] = [:] + + for key in allKeys.allKeys { + if key.stringValue != "io.modelcontextprotocol.registry/official" { + 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(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: - Servers + +public struct MCPRegistryServerDetail: Codable { + public let name: String + public let description: String + public let title: String? + public let repository: Repository? + public let version: String + public let websiteUrl: String? + public let icons: [Icon]? + public let schemaURL: String? + public let packages: [Package]? + public let remotes: [Remote]? + public let meta: MCPRegistryExtensionMeta? + + enum CodingKeys: String, CodingKey { + case name, description, title, repository, version, packages, remotes, websiteUrl, icons + case schemaURL = "$schema" + case meta = "_meta" + } + + public init( + name: String, + description: String, + title: String?, + repository: Repository?, + version: String, + websiteUrl: String?, + icons: [Icon]?, + schemaURL: String?, + packages: [Package]?, + remotes: [Remote]?, + meta: MCPRegistryExtensionMeta? + ) { + self.name = name + self.description = description + self.title = title + self.repository = repository + self.version = version + self.websiteUrl = websiteUrl + self.icons = icons + self.schemaURL = schemaURL + self.packages = packages + self.remotes = remotes + self.meta = meta + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + name = try container.decode(String.self, forKey: .name) + description = try container.decode(String.self, forKey: .description) + title = try container.decodeIfPresent(String.self, forKey: .title) + version = try container.decode(String.self, forKey: .version) + websiteUrl = try container.decodeIfPresent(String.self, forKey: .websiteUrl) + icons = try container.decodeIfPresent([Icon].self, forKey: .icons) + schemaURL = try container.decodeIfPresent(String.self, forKey: .schemaURL) + packages = try container.decodeIfPresent([Package].self, forKey: .packages) + remotes = try container.decodeIfPresent([Remote].self, forKey: .remotes) + meta = try container.decodeIfPresent(MCPRegistryExtensionMeta.self, forKey: .meta) + + // Custom handling for repository: {} → nil + if container.contains(.repository) { + // Decode raw dictionary to see if it is empty + let repoDict = try container.decode([String: AnyCodable].self, forKey: .repository) + if repoDict.isEmpty { + repository = nil + } else { + // Re-decode as Repository from the same key + repository = try container.decode(Repository.self, forKey: .repository) + } + } else { + repository = nil + } + } +} + +public struct MCPRegistryServerResponse : Codable { + public let server: MCPRegistryServerDetail + public let meta: ServerMeta? + + public init(server: MCPRegistryServerDetail, meta: ServerMeta? = nil) { + self.server = server + self.meta = meta + } + + enum CodingKeys: String, CodingKey { + case server + case meta = "_meta" + } +} + +public struct MCPRegistryServerListMetadata: Codable { + public let nextCursor: String? + public let count: Int? +} + +public struct MCPRegistryServerList: Codable { + public let servers: [MCPRegistryServerResponse] + public let metadata: MCPRegistryServerListMetadata? +} + +// MARK: - Requests + +public struct MCPRegistryListServersParams: Codable { + public let baseUrl: String + public let cursor: String? + public let limit: Int? + public let search: String? + public let updatedSince: String? + public let version: String? + + public init( + baseUrl: String, + cursor: String? = nil, + limit: Int?, + search: String? = nil, + updatedSince: String? = nil, + version: String? = nil + ) { + self.baseUrl = baseUrl + self.cursor = cursor + self.limit = limit + self.search = search + self.updatedSince = updatedSince + self.version = version + } +} + +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 + } +} + +// MARK: - Internal Helpers + +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 + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistryAllowlist.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistryAllowlist.swift new file mode 100644 index 00000000..39cf074f --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistryAllowlist.swift @@ -0,0 +1,83 @@ +import Foundation + +// MARK: - MCPRegistryOwner + +public struct MCPRegistryOwner: Codable, Hashable { + public let login: String + public let id: Int + public let type: String // "Business" (Enterprise) or "Organization" + public let parentLogin: String? + public let parentId: Int? + + enum CodingKeys: String, CodingKey { + case login + case id + case type + case parentLogin = "parent_login" + case parentId = "parent_id" + } + + public init(login: String, id: Int, type: String, parentLogin: String? = nil, parentId: Int? = nil) { + self.login = login + self.id = id + self.type = type + self.parentLogin = parentLogin + self.parentId = parentId + } +} + +// MARK: - RegistryAccess + +public enum RegistryAccess: String, Codable, Hashable { + case registryOnly = "registry_only" + case allowAll = "allow_all" +} + +// MARK: - McpRegistryEntry + +public struct MCPRegistryEntry: Codable, Hashable { + public let url: String + public let registryAccess: RegistryAccess + public let owner: MCPRegistryOwner + + enum CodingKeys: String, CodingKey { + case url + case registryAccess = "registry_access" + case owner + } + + public init(url: String, registryAccess: RegistryAccess, owner: MCPRegistryOwner) { + self.url = url + self.registryAccess = registryAccess + self.owner = owner + } +} + +// MARK: - GetMCPRegistryAllowlistResult + +/// Result schema for getMCPRegistryAllowlist method +public struct GetMCPRegistryAllowlistResult: Codable, Hashable { + public let mcpRegistries: [MCPRegistryEntry] + + enum CodingKeys: String, CodingKey { + case mcpRegistries = "mcp_registries" + } +} + +public struct MCPRegistryErrorData: Codable { + public let errorType: String + public let status: Int? + public let shouldRetry: Bool? + + enum CodingKeys: String, CodingKey { + case errorType + case status + case shouldRetry + } + + public init(errorType: String, status: Int? = nil, shouldRetry: Bool? = nil) { + self.errorType = errorType + self.status = status + self.shouldRetry = shouldRetry + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/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/GitHubCopilotRequestTypes/NES.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/NES.swift new file mode 100644 index 00000000..9d87086e --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/NES.swift @@ -0,0 +1,59 @@ +import SuggestionBasic +import LanguageServerProtocol + + +public struct CopilotInlineEditsParams: Codable { + public let textDocument: VersionedTextDocumentIdentifier + public let position: CursorPosition +} + +public struct CopilotInlineEdit: Codable { + public struct Command: Codable { + public let title: String + public let command: String + public let arguments: [String] + } + /** + * The new text for this edit. + */ + public let text: String + /** + * The text document this edit applies to including the version + * Uses the same schema as for completions: src + * + * "textDocument": { + * "uri": "file:///path/to/file", + * "version": 0 + * }, + * + */ + public let textDocument: VersionedTextDocumentIdentifier + public let range: CursorRange + /** + * Called by the client with workspace/executeCommand after accepting the next edit suggestion. + */ + public let command: Command? +} + +public struct CopilotInlineEditsResponse: Codable { + public let edits: [CopilotInlineEdit] +} + +// MARK: - Notification + +public struct TextDocumentDidShowInlineEditParams: Codable, Hashable { + public struct Command: Codable, Hashable { + public var arguments: [String] + } + + public struct NotificationCommandSchema: Codable, Hashable { + public var command: Command + } + + public var item: NotificationCommandSchema + + public static func from(id: String) -> Self { + .init(item: .init(command: .init(arguments: [id]))) + } +} + diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Telemetry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Telemetry.swift new file mode 100644 index 00000000..8d1b580e --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Telemetry.swift @@ -0,0 +1,32 @@ +import Foundation +import TelemetryServiceProvider + +struct TelemetryExceptionParams: Codable { + public let transaction: String? + public let stacktrace: String? + public let properties: [String: String]? + public let platform: String? + public let exceptionDetail: [ExceptionDetail]? + + public init( + transaction: String? = nil, + stacktrace: String? = nil, + properties: [String: String]? = nil, + platform: String? = nil, + exceptionDetail: [ExceptionDetail]? = nil + ) { + self.transaction = transaction + self.stacktrace = stacktrace + self.properties = properties + self.platform = platform + self.exceptionDetail = exceptionDetail + } + + enum CodingKeys: String, CodingKey { + case transaction + case stacktrace + case properties + case platform + case exceptionDetail = "exception_detail" + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index e6d64c1f..978c6e92 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -1,4 +1,5 @@ import AppKit +import TelemetryServiceProvider import Combine import ConversationServiceProvider import Foundation @@ -9,10 +10,13 @@ import Logger import Preferences import Status import SuggestionBasic +import SystemUtils +import Persist public protocol GitHubCopilotAuthServiceType { func checkStatus() async throws -> GitHubCopilotAccountStatus - func signInInitiate() async throws -> (verificationUri: String, userCode: String) + 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) func signOut() async throws -> GitHubCopilotAccountStatus @@ -29,37 +33,86 @@ public protocol GitHubCopilotSuggestionServiceType { indentSize: Int, usesTabsForIndentation: Bool ) async throws -> [CodeSuggestion] + func getCopilotInlineEdit( + fileURL: URL, + content: String, + cursorPosition: CursorPosition + ) async throws -> [CodeSuggestion] func notifyShown(_ completion: CodeSuggestion) async + func notifyCopilotInlineEditShown(_ completion: CodeSuggestion) async func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int?) async + func notifyCopilotInlineEditAccepted(_ completion: CodeSuggestion) async func notifyRejected(_ completions: [CodeSuggestion]) async func notifyOpenTextDocument(fileURL: URL, content: String) async throws - func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws + func notifyChangeTextDocument( + fileURL: URL, + content: String, + version: Int, + contentChanges: [TextDocumentContentChangeEvent]? + ) async throws func notifyCloseTextDocument(fileURL: URL) async throws func notifySaveTextDocument(fileURL: URL) async throws func cancelRequest() async func terminate() async } +public protocol GitHubCopilotTelemetryServiceType { + func sendError(transaction: String?, + stacktrace: String?, + properties: [String: String]?, + platform: String?, + exceptionDetail: [ExceptionDetail]?) async throws +} + public protocol GitHubCopilotConversationServiceType { - func createConversation(_ message: String, + func createConversation(_ message: MessageContent, workDoneToken: String, workspaceFolder: String, - doc: Doc?, - skills: [String]) async throws - func createTurn(_ message: String, + workspaceFolders: [WorkspaceFolder]?, + activeDoc: Doc?, + skills: [String], + ignoredSkills: [String]?, + references: [ConversationAttachedReference], + model: String?, + modelProviderName: String?, + turns: [TurnSchema], + agentMode: Bool, + customChatModeId: String?, + userLanguage: String?) async throws -> ConversationCreateResponse + func createTurn(_ message: MessageContent, workDoneToken: String, conversationId: String, - doc: Doc?) async throws + turnId: String?, + activeDoc: Doc?, + ignoredSkills: [String]?, + references: [ConversationAttachedReference], + model: String?, + modelProviderName: String?, + workspaceFolder: String, + workspaceFolders: [WorkspaceFolder]?, + agentMode: Bool, + customChatModeId: String?) async throws -> ConversationCreateResponse + func deleteTurn(conversationId: String, turnId: String) async throws func rateConversation(turnId: String, rating: ConversationRating) async throws func copyCode(turnId: String, codeBlockIndex: Int, copyType: CopyKind, copiedCharacters: Int, totalCharacters: Int, copiedText: String) async throws func cancelProgress(token: String) async + func templates(workspaceFolders: [WorkspaceFolder]?) async throws -> [ChatTemplate] + func modes(workspaceFolders: [WorkspaceFolder]?) async throws -> [ConversationMode] + func models() async throws -> [CopilotModel] + 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 sendNotification(_ notif: ClientNotification) async throws } +protocol GitHubCopilotLSPNotification { + func sendCopilotNotification(_ notif: CopilotClientNotification) async throws +} + public enum GitHubCopilotError: Error, LocalizedError { case languageServerNotInstalled case languageServerError(ServerError) @@ -101,6 +154,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)" } } } @@ -109,27 +164,58 @@ public enum GitHubCopilotError: Error, LocalizedError { public extension Notification.Name { static let gitHubCopilotShouldRefreshEditorInformation = Notification .Name("com.github.CopilotForXcode.GitHubCopilotShouldRefreshEditorInformation") + static let githubCopilotAgentMaxToolCallingLoopDidChange = Notification + .Name("com.github.CopilotForXcode.GithubCopilotAgentMaxToolCallingLoopDidChange") + static let githubCopilotAgentAutoApprovalDidChange = Notification + .Name("com.github.CopilotForXcode.GithubCopilotAgentAutoApprovalDidChange") + static let githubCopilotAgentTrustToolAnnotationsDidChange = Notification + .Name("com.github.CopilotForXcode.GithubCopilotAgentTrustToolAnnotationsDidChange") } public class GitHubCopilotBaseService { let projectRootURL: URL var server: GitHubCopilotLSP var localProcessServer: CopilotLocalProcessServer? + let sessionId: String init(designatedServer: GitHubCopilotLSP) { projectRootURL = URL(fileURLWithPath: "/") server = designatedServer + sessionId = UUID().uuidString } - init(projectRootURL: URL) throws { + init(projectRootURL: URL, workspaceURL: URL = URL(fileURLWithPath: "/")) throws { self.projectRootURL = projectRootURL + self.sessionId = UUID().uuidString let (server, localServer) = try { let urls = try GitHubCopilotBaseService.createFoldersIfNeeded() - var path = SystemInfo().binaryPath() + var path = SystemUtils.shared.getXcodeBinaryPath() var args = ["--stdio"] let home = ProcessInfo.processInfo.homePath - let versionNumber = JSONValue(stringLiteral: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "") - let xcodeVersion = JSONValue(stringLiteral: SystemInfo().xcodeVersion() ?? "") + + var environment: [String: String] = ["HOME": home] + let envVarNamesToFetch = ["PATH", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED"] + let terminalEnvVars = getTerminalEnvironmentVariables(envVarNamesToFetch) + + for varName in envVarNamesToFetch { + if let value = terminalEnvVars[varName] ?? ProcessInfo.processInfo.environment[varName] { + environment[varName] = value + Logger.gitHubCopilot.info("Setting env \(varName): \(value)") + } + } + + environment["PATH"] = SystemUtils.shared.appendCommonBinPaths(path: environment["PATH"] ?? "") + + let versionNumber = JSONValue( + stringLiteral: SystemUtils.editorPluginVersion ?? "" + ) + let xcodeVersion = JSONValue( + stringLiteral: SystemUtils.xcodeVersion ?? "" + ) + let watchedFiles = JSONValue( + booleanLiteral: projectRootURL.path == "/" ? false : true + ) + let enableSubagent = UserDefaults.shared.value(for: \.enableSubagent) #if DEBUG // Use local language server if set and available @@ -140,17 +226,21 @@ public class GitHubCopilotBaseService { let nodePath = Bundle.main.infoDictionary?["NODE_PATH"] as? String ?? "node" if FileManager.default.fileExists(atPath: jsPath.path) { path = "/usr/bin/env" - args = [nodePath, jsPath.path, "--stdio"] + if projectRootURL.path == "/" { + args = [nodePath, jsPath.path, "--stdio"] + } else { + args = [nodePath, "--inspect", jsPath.path, "--stdio"] + } Logger.debug.info("Using local language server \(path) \(args)") } } - // Set debug port and verbose when running in debug - let environment: [String: String] = ["HOME": home, "GH_COPILOT_DEBUG_UI_PORT": "8080", "GH_COPILOT_VERBOSE": "true"] + // Add debug-specific environment variables + environment["GH_COPILOT_DEBUG_UI_PORT"] = "8180" + environment["GH_COPILOT_VERBOSE"] = "true" #else - let environment: [String: String] = if UserDefaults.shared.value(for: \.verboseLoggingEnabled) { - ["HOME": home, "GH_COPILOT_VERBOSE": "true"] - } else { - ["HOME": home] + // Add release-specific environment variables + if UserDefaults.shared.value(for: \.verboseLoggingEnabled) { + environment["GH_COPILOT_VERBOSE"] = "true" } #endif @@ -165,19 +255,28 @@ 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) - server.initializeParamsProvider = { + + let initializeParamsProvider = { @Sendable () -> InitializeParams in let capabilities = ClientCapabilities( - workspace: nil, + workspace: .init( + applyEdit: false, + workspaceEdit: nil, + didChangeConfiguration: nil, + didChangeWatchedFiles: nil, + symbol: nil, + executeCommand: nil, + /// enable for "watchedFiles capability", set others to default value + workspaceFolders: true, + configuration: nil, + semanticTokens: nil + ), textDocument: nil, window: nil, general: nil, experimental: nil ) + let authAppId = Bundle.main.infoDictionary?["GITHUB_APP_ID"] as? String return InitializeParams( processId: Int(ProcessInfo.processInfo.processIdentifier), locale: nil, @@ -191,43 +290,36 @@ public class GitHubCopilotBaseService { "editorPluginInfo": [ "name": "copilot-xcode", "version": versionNumber, - ] + ], + "copilotCapabilities": [ + /// The editor has support for watching files over LSP + "watchedFiles": watchedFiles, + "didChangeFeatureFlags": true, + "stateDatabase": true, + "subAgent": JSONValue(booleanLiteral: enableSubagent), + "mcpAllowlist": true, + ], + "githubAppId": authAppId.map(JSONValue.string) ?? .null, ], capabilities: capabilities, trace: .off, workspaceFolders: [WorkspaceFolder( - uri: projectRootURL.path, + uri: projectRootURL.absoluteString, name: projectRootURL.lastPathComponent )] ) } + + 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 - // 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, @@ -268,6 +360,49 @@ public class GitHubCopilotBaseService { return (supportURL, gitHubCopilotFolderURL, executableFolderURL, supportFolderURL) } + + public func getSessionId() -> String { + return sessionId + } +} + +func getTerminalEnvironmentVariables(_ variableNames: [String]) -> [String: String] { + var results = [String: String]() + guard !variableNames.isEmpty else { return results } + + let userShell: String? = { + if let shell = ProcessInfo.processInfo.environment["SHELL"] { + return shell + } + + // Check for zsh executable + if FileManager.default.fileExists(atPath: "/bin/zsh") { + Logger.gitHubCopilot.info("SHELL not found, falling back to /bin/zsh") + return "/bin/zsh" + } + // Check for bash executable + if FileManager.default.fileExists(atPath: "/bin/bash") { + Logger.gitHubCopilot.info("SHELL not found, falling back to /bin/bash") + return "/bin/bash" + } + + Logger.gitHubCopilot.info("Cannot determine user's shell, returning empty environment") + return nil // No shell found + }() + + guard let shell = userShell else { + return results + } + + if let env = SystemUtils.shared.getLoginShellEnvironment(shellPath: shell) { + variableNames.forEach { varName in + if let value = env[varName] { + results[varName] = value + } + } + } + + return results } @globalActor public enum GitHubCopilotSuggestionActor { @@ -275,25 +410,118 @@ public class GitHubCopilotBaseService { public static let shared = TheActor() } -public final class GitHubCopilotService: GitHubCopilotBaseService, - GitHubCopilotSuggestionServiceType, GitHubCopilotConversationServiceType, GitHubCopilotAuthServiceType -{ +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, + GitHubCopilotConversationServiceType, + GitHubCopilotAuthServiceType, + GitHubCopilotTelemetryServiceType +{ private var ongoingTasks = Set>() private var serverNotificationHandler: ServerNotificationHandler = ServerNotificationHandlerImpl.shared + private var serverRequestHandler: ServerRequestHandler = ServerRequestHandlerImpl.shared private var cancellables = Set() private var statusWatcher: CopilotAuthStatusWatcher? + private static var services: [GitHubCopilotService] = [] // cache all alive copilot service instances + private var mcpRuntimeLogFileName: String = "" + private static let toolInitializationActor = ToolInitializationActor() + private var lastSentConfiguration: JSONValue? + private var mcpToolsContinuation: AsyncStream.Continuation? override init(designatedServer: any GitHubCopilotLSP) { super.init(designatedServer: designatedServer) } - override public init(projectRootURL: URL = URL(fileURLWithPath: "/")) throws { - try super.init(projectRootURL: projectRootURL) - localProcessServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in - self?.serverNotificationHandler.handleNotification(notification) - }).store(in: &cancellables) - updateStatusInBackground() + override public init(projectRootURL: URL = URL(fileURLWithPath: "/"), workspaceURL: URL = URL(fileURLWithPath: "/")) throws { + do { + try super.init(projectRootURL: projectRootURL, workspaceURL: workspaceURL) + + self.handleSendWorkspaceDidChangeNotifications() + + let (stream, continuation) = AsyncStream.makeStream(of: AnyJSONRPCNotification.self) + self.mcpToolsContinuation = continuation + + Task { [weak self] in + for await notification in stream { + await self?.handleMCPToolsNotification(notification) + } + } + + localProcessServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in + if notification.method == "copilot/mcpTools" && projectRootURL.path != "/" { + self?.mcpToolsContinuation?.yield(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) + + Task { + for await event in server.eventSequence { + switch event { + case let .request(id, request): + self.serverRequestHandler.handleRequest( + id: id, + request, + workspaceURL: workspaceURL, + service: self + ) + default: + break + } + } + } + + updateStatusInBackground() + + GitHubCopilotService.services.append(self) + + Task { + let tools = await registerClientTools(server: self) + CopilotLanguageModelToolManager.updateToolsStatus(tools) + await restoreRegisteredToolsStatus() + } + } catch { + Logger.gitHubCopilot.error(error) + throw error + } + + } + + deinit { + GitHubCopilotService.services.removeAll { $0 === self } } @GitHubCopilotSuggestionActor @@ -314,7 +542,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, do { let completions = try await self .sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init( - textDocument: .init(uri: fileURL.path, version: 1), + textDocument: .init(uri: fileURL.absoluteString, version: 0), position: cursorPosition, formattingOptions: .init( tabSize: tabSize, @@ -341,8 +569,13 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, // sometimes the content inside language server is not new enough, which can // lead to an version mismatch error. We can try a few times until the content // is up to date. - if maxTry <= 0 { break } - Logger.gitHubCopilot.error( + if maxTry <= 0 { + Logger.gitHubCopilot.error( + "Max retry for getting suggestions reached: \(GitHubCopilotError.languageServerError(error).localizedDescription)" + ) + break + } + Logger.gitHubCopilot.info( "Try getting suggestions again: \(GitHubCopilotError.languageServerError(error).localizedDescription)" ) try await Task.sleep(nanoseconds: 200_000_000) @@ -356,36 +589,12 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, } } - func recoverContent() async { - try? await notifyChangeTextDocument( - fileURL: fileURL, - content: originalContent, - version: 0 - ) - } - - // since when the language server is no longer using the passed in content to generate - // suggestions, we will need to update the content to the file before we do any request. - // - // And sometimes the language server's content was not up to date and may generate - // weird result when the cursor position exceeds the line. let task = Task { @GitHubCopilotSuggestionActor in - try? await notifyChangeTextDocument( - fileURL: fileURL, - content: content, - version: 1 - ) - do { + let maxTry: Int = 5 try Task.checkCancellation() - return try await sendRequest() - } catch let error as CancellationError { - if ongoingTasks.isEmpty { - await recoverContent() - } - throw error + return try await sendRequest(maxTry: maxTry) } catch { - await recoverContent() throw error } } @@ -394,25 +603,94 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, return try await task.value } + + // MARK: - NES + @GitHubCopilotSuggestionActor + public func getCopilotInlineEdit( + fileURL: URL, + content: String, + cursorPosition: CursorPosition + ) async throws -> [CodeSuggestion] { + ongoingTasks.forEach { $0.cancel() } + ongoingTasks.removeAll() + await localProcessServer?.cancelOngoingTasks() + + do { + let completions = try await sendRequest( + GitHubCopilotRequest.CopilotInlineEdit( + params: CopilotInlineEditsParams( + textDocument: .init(uri: fileURL.absoluteString, version: 0), + position: cursorPosition + ) + )) + .edits + .compactMap { edit in + CodeSuggestion.init( + id: edit.command?.arguments.first ?? UUID().uuidString, + text: edit.text, + position: cursorPosition, + range: edit.range + ) + } + return completions + } catch { + Logger.gitHubCopilot.error("Failed to get copilot inline edit: \(error.localizedDescription)") + throw error + } + } @GitHubCopilotSuggestionActor - public func createConversation(_ message: String, - workDoneToken: String, - workspaceFolder: String, - doc: Doc?, - skills: [String]) async throws { + public func createConversation( + _ message: MessageContent, + workDoneToken: String, + workspaceFolder: String, + workspaceFolders: [WorkspaceFolder]? = nil, + activeDoc: Doc?, + skills: [String], + ignoredSkills: [String]?, + references: [ConversationAttachedReference], + model: String?, + modelProviderName: String?, + turns: [TurnSchema], + agentMode: Bool, + customChatModeId: String?, + userLanguage: String? + ) async throws -> ConversationCreateResponse { + var conversationCreateTurns: [TurnSchema] = [] + // invoke conversation history + if turns.count > 0 { + conversationCreateTurns.append( + contentsOf: turns.map { + TurnSchema( + request: $0.request, + response: $0.response, + agentSlug: $0.agentSlug, + turnId: $0.turnId + ) + } + ) + } + conversationCreateTurns.append(TurnSchema(request: message)) let params = ConversationCreateParams(workDoneToken: workDoneToken, - turns: [ConversationTurn(request: message)], + turns: conversationCreateTurns, capabilities: ConversationCreateParams.Capabilities( skills: skills, allSkills: false), - doc: doc, + textDocument: activeDoc, + references: references.map { Reference.from($0) }, source: .panel, - workspaceFolder: workspaceFolder) + workspaceFolder: workspaceFolder, + workspaceFolders: workspaceFolders, + ignoredSkills: ignoredSkills, + model: model, + modelProviderName: modelProviderName, + chatMode: agentMode ? "Agent" : nil, + customChatModeId: customChatModeId, + needToolCallConfirmation: true, + userLanguage: userLanguage) do { - let _ = try await sendRequest( - GitHubCopilotRequest.CreateConversation(params: params) - ) + return try await sendRequest( + GitHubCopilotRequest.CreateConversation(params: params)) } catch { print("Failed to create conversation. Error: \(error)") throw error @@ -420,17 +698,187 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, } @GitHubCopilotSuggestionActor - public func createTurn(_ message: String, workDoneToken: String, conversationId: String, doc: Doc?) async throws { + public func createTurn( + _ message: MessageContent, + workDoneToken: String, + conversationId: String, + turnId: String?, + activeDoc: Doc?, + ignoredSkills: [String]?, + references: [ConversationAttachedReference], + model: String?, + modelProviderName: String?, + workspaceFolder: String, + workspaceFolders: [WorkspaceFolder]? = nil, + agentMode: Bool, + customChatModeId: String? + ) async throws -> ConversationCreateResponse { do { - let params = TurnCreateParams(workDoneToken: workDoneToken, conversationId: conversationId, message: message, doc: doc) - let _ = try await sendRequest( - GitHubCopilotRequest.CreateTurn(params: params) - ) + let params = TurnCreateParams(workDoneToken: workDoneToken, + conversationId: conversationId, + turnId: turnId, + message: message, + textDocument: activeDoc, + ignoredSkills: ignoredSkills, + references: references.map { Reference.from($0) }, + model: model, + modelProviderName: modelProviderName, + workspaceFolder: workspaceFolder, + workspaceFolders: workspaceFolders, + chatMode: agentMode ? "Agent" : nil, + customChatModeId: customChatModeId, + needToolCallConfirmation: true) + return try await sendRequest( + GitHubCopilotRequest.CreateTurn(params: params)) } catch { print("Failed to create turn. Error: \(error)") throw error } } + + @GitHubCopilotSuggestionActor + public func deleteTurn(conversationId: String, turnId: String) async throws { + do { + let params = TurnDeleteParams(conversationId: conversationId, turnId: turnId, source: .panel) + _ = try await sendRequest(GitHubCopilotRequest.DeleteTurn(params: params)) + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func templates(workspaceFolders: [WorkspaceFolder]? = nil) async throws -> [ChatTemplate] { + do { + let params = ConversationTemplatesParams(workspaceFolders: workspaceFolders) + let response = try await sendRequest( + GitHubCopilotRequest.GetTemplates(params: params) + ) + return response + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func modes(workspaceFolders: [WorkspaceFolder]? = nil) async throws -> [ConversationMode] { + do { + let params = ConversationModesParams(workspaceFolders: workspaceFolders) + let response = try await sendRequest( + GitHubCopilotRequest.GetModes(params: params) + ) + return response + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func models() async throws -> [CopilotModel] { + do { + let response = try await sendRequest( + GitHubCopilotRequest.CopilotModels() + ) + return response + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func agents() async throws -> [ChatAgent] { + do { + let response = try await sendRequest( + GitHubCopilotRequest.GetAgents() + ) + return response + } catch { + 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 -> [LanguageModelTool] { + do { + 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 + } + } + + @GitHubCopilotSuggestionActor + public func updateMCPToolsStatus(params: UpdateMCPToolsStatusParams) async throws -> [MCPServerToolsCollection] { + do { + let response = try await sendRequest( + GitHubCopilotRequest.UpdatedMCPToolsStatus(params: params) + ) + return response + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func listMCPRegistryServers(_ params: MCPRegistryListServersParams) async throws -> MCPRegistryServerList { + do { + let response = try await sendRequest( + GitHubCopilotRequest.MCPRegistryListServers(params: params) + ) + return response + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func getMCPRegistryServer(_ params: MCPRegistryGetServerParams) async throws -> MCPRegistryServerDetail { + do { + let response = try await sendRequest( + GitHubCopilotRequest.MCPRegistryGetServer(params: params) + ) + return response + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func getMCPRegistryAllowlist() async throws -> GetMCPRegistryAllowlistResult { + do { + let response = try await sendRequest( + GitHubCopilotRequest.MCPRegistryGetAllowlist() + ) + return response + } catch { + throw error + } + } @GitHubCopilotSuggestionActor public func rateConversation(turnId: String, rating: ConversationRating) async throws { @@ -475,6 +923,11 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, GitHubCopilotRequest.NotifyShown(completionUUID: completion.id) ) } + + @GitHubCopilotSuggestionActor + public func notifyCopilotInlineEditShown(_ completion: CodeSuggestion) async { + try? await sendCopilotNotification(.textDocumentDidShowInlineEdit(.from(id: completion.id))) + } @GitHubCopilotSuggestionActor public func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int? = nil) async { @@ -482,6 +935,13 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, GitHubCopilotRequest.NotifyAccepted(completionUUID: completion.id, acceptedLength: acceptedLength) ) } + + @GitHubCopilotSuggestionActor + public func notifyCopilotInlineEditAccepted(_ completion: CodeSuggestion) async { + _ = try? await sendRequest( + GitHubCopilotRequest.NotifyCopilotInlineEditAccepted(params: [completion.id]) + ) + } @GitHubCopilotSuggestionActor public func notifyRejected(_ completions: [CodeSuggestion]) async { @@ -499,7 +959,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, let uri = "file://\(fileURL.path)" // Logger.service.debug("Open \(uri), \(content.count)") try await server.sendNotification( - .didOpenTextDocument( + .textDocumentDidOpen( DidOpenTextDocumentParams( textDocument: .init( uri: uri, @@ -516,20 +976,18 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, public func notifyChangeTextDocument( fileURL: URL, content: String, - version: Int + version: Int, + contentChanges: [TextDocumentContentChangeEvent]? = nil ) async throws { - let uri = "file://\(fileURL.path)" + let uri = fileURL.absoluteString + let changes: [TextDocumentContentChangeEvent] = contentChanges ?? [.init(range: nil, rangeLength: nil, text: content)] // Logger.service.debug("Change \(uri), \(content.count)") try await server.sendNotification( - .didChangeTextDocument( + .textDocumentDidChange( DidChangeTextDocumentParams( uri: uri, version: version, - contentChange: .init( - range: nil, - rangeLength: nil, - text: content - ) + contentChanges: changes ) ) ) @@ -539,14 +997,20 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, 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 + public func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent) async throws { +// Logger.service.debug("notifyDidChangeWatchedFiles \(event)") + try await sendCopilotNotification(.copilotDidChangeWatchedFiles(.init(workspaceUri: event.workspaceUri, changes: event.changes))) } @GitHubCopilotSuggestionActor @@ -566,6 +1030,19 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, 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 @@ -577,7 +1054,44 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, Logger.gitHubCopilot.info("check status response: \(status)") if status.status == .ok || status.status == .maybeOk { await Status.shared.updateAuthStatus(.loggedIn, username: status.user) + if !CopilotModelManager.hasLLMs() { + Logger.gitHubCopilot.info("No models found, fetching models...") + let models = try? await models() + if let models = models, !models.isEmpty { + 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 + .updateAuthStatus( + .notAuthorized, + username: status.user, + message: status.status.description + ) + await watchAuthStatus() } else { await Status.shared.updateAuthStatus(.notLoggedIn, message: status.status.description) await watchAuthStatus() @@ -596,10 +1110,27 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, } @GitHubCopilotSuggestionActor - public func signInInitiate() async throws -> (verificationUri: String, userCode: String) { + public func signInInitiate() async throws -> ( + status: SignInInitiateStatus, + verificationUri: String?, + userCode: String?, + user: String? + ) { do { let result = try await sendRequest(GitHubCopilotRequest.SignInInitiate()) - return (result.verificationUri, result.userCode) + switch result.status { + case .promptUserDeviceFlow: + guard let verificationUri = result.verificationUri, + let userCode = result.userCode else { + throw GitHubCopilotError.languageServerError(.missingExpectedResult) + } + return (status: .promptUserDeviceFlow, verificationUri: verificationUri, userCode: userCode, user: nil) + case .alreadySignedIn: + guard let user = result.user else { + throw GitHubCopilotError.languageServerError(.missingExpectedResult) + } + return (status: .alreadySignedIn, verificationUri: nil, userCode: nil, user: user) + } } catch let error as ServerError { throw GitHubCopilotError.languageServerError(error) } catch { @@ -645,54 +1176,417 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, @GitHubCopilotSuggestionActor public func shutdown() async throws { - 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 + GitHubCopilotService.services.removeAll { $0 === self } + if let localProcessServer { + try await localProcessServer.shutdown() + } else { + throw GitHubCopilotError.languageServerError(ServerError.serverUnavailable) } } @GitHubCopilotSuggestionActor public func exit() async throws { - let stream = AsyncThrowingStream { continuation in - if let localProcessServer { - localProcessServer.exit() { err in - continuation.finish(throwing: err) - } - } else { - continuation.finish(throwing: GitHubCopilotError.languageServerError(ServerError.serverUnavailable)) - } + GitHubCopilotService.services.removeAll { $0 === self } + if let localProcessServer { + try await localProcessServer.exit() + } else { + throw GitHubCopilotError.languageServerError(ServerError.serverUnavailable) } - for try await _ in stream { - return + } + + @GitHubCopilotSuggestionActor + public func sendError( + transaction: String?, + stacktrace: String?, + properties: [String: String]?, + platform: String?, + exceptionDetail: [ExceptionDetail]? + ) async throws { + let params = TelemetryExceptionParams( + transaction: transaction, + stacktrace: stacktrace, + properties: properties, + platform: platform ?? "macOS", + exceptionDetail: exceptionDetail + ) + do { + let _ = try await sendRequest( + GitHubCopilotRequest.TelemetryException(params: params) + ) + } catch { + print("Failed to send telemetry exception. Error: \(error)") + throw error } } - private func sendRequest(_ endpoint: E) async throws -> E.Response { + private func sendRequest(_ endpoint: E, timeout: TimeInterval? = nil) async throws -> E.Response { do { return try await server.sendRequest(endpoint) - } catch let error as ServerError { + } 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) { updateStatusInBackground() } } + let methodName: String + switch endpoint.request { + case .custom(let method, _, _): + methodName = method + default: + methodName = endpoint.request.method.rawValue + } + if methodName != "telemetry/exception" { // ignore telemetry request + Logger.gitHubCopilot.error( + "Failed to send request \(methodName). Error: \(GitHubCopilotError.languageServerError(error).localizedDescription)" + ) + } + throw error + } + } + + public static func signOutAll() async throws { + var signoutError: Error? = nil + for service in services { + do { + let _ = try await service.signOut() + } catch let error as ServerError { + signoutError = GitHubCopilotError.languageServerError(error) + } catch { + signoutError = error + } + } + + if let signoutError { + throw signoutError + } else { + CopilotModelManager.clearLLMs() + } + } + + public static func updateAllClsMCP(collections: [UpdateMCPToolsStatusServerCollection]) async { + var updateError: Error? = nil + var servers: [MCPServerToolsCollection] = [] + + 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] = [] + + for service in services { + if service.projectRootURL.path == "/" { + continue // Skip services with root project URL + } + + do { + updatedTools = try await service.updateToolsStatus( + params: .init(tools: tools) + ) + } catch let error as ServerError { + updateError = GitHubCopilotError.languageServerError(error) + } catch { + updateError = error + } + } + + CopilotLanguageModelToolManager.updateToolsStatus(updatedTools) + Logger.gitHubCopilot.info("Updated All Built-In Tools: \(tools.count) tools") + + if let updateError { + Logger.gitHubCopilot.error("Failed to update Built-In Tools status: \(updateError)") + } + + return updatedTools + } + + /// Refresh client tools by registering an empty list to get the latest tools from the server. + /// This is a workaround for the issue where server-side tools may not be ready when client tools are initially registered. + public static func refreshClientTools() async { + // Use the first available service since CopilotLanguageModelToolManager is shared + guard let service = services.first(where: { $0.projectRootURL.path != "/" }) else { + Logger.gitHubCopilot.error("No available service to refresh client tools") + return + } + + do { + // Capture previous snapshot to detect newly added tools only + let previousNames = Set((CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? []).map { $0.name }) + + // Register empty list to get the complete updated tool list from server + let refreshedTools = try await service.registerTools(tools: []) + CopilotLanguageModelToolManager.updateToolsStatus(refreshedTools) + Logger.gitHubCopilot.info("Refreshed client tools: \(refreshedTools.count) tools available (previous: \(previousNames.count))") + + // Restore status ONLY for newly added tools whose saved status differs. + if let savedJSON = AppState.shared.get(key: "languageModelToolsStatus"), + let data = try? JSONEncoder().encode(savedJSON), + let savedStatusList = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data), + !savedStatusList.isEmpty { + let refreshedByName = Dictionary(uniqueKeysWithValues: (CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? []).map { ($0.name, $0) }) + let newlyAddedNames = refreshedTools.map { $0.name }.filter { !previousNames.contains($0) } + if !newlyAddedNames.isEmpty { + let neededUpdates: [ToolStatusUpdate] = newlyAddedNames.compactMap { newName in + guard let saved = savedStatusList.first(where: { $0.name == newName }), + let current = refreshedByName[newName], current.status != saved.status else { return nil } + return saved + } + if !neededUpdates.isEmpty { + do { + let finalTools = try await service.updateToolsStatus(params: .init(tools: neededUpdates)) + CopilotLanguageModelToolManager.updateToolsStatus(finalTools) + Logger.gitHubCopilot.info("Restored statuses for newly added tools: \(neededUpdates.map{ $0.name }.joined(separator: ", "))") + } catch { + Logger.gitHubCopilot.error("Failed to restore newly added tool statuses: \(error)") + } + } + } + } + } catch { + Logger.gitHubCopilot.error("Failed to refresh client tools: \(error)") + } + } + + private func loadUnrestoredLanguageModelTools() -> [ToolStatusUpdate] { + if let savedJSON = AppState.shared.get(key: "languageModelToolsStatus"), + let data = try? JSONEncoder().encode(savedJSON), + let savedTools = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data) { + return savedTools + } + return [] + } + + private func restoreRegisteredToolsStatus() async { + // Get unrestored tools from the shared coordinator + let toolsToRestore = await GitHubCopilotService.toolInitializationActor.loadUnrestoredToolsIfNeeded() + + guard !toolsToRestore.isEmpty else { + Logger.gitHubCopilot.info("No previously disabled tools need to be restored") + return + } + + do { + let updatedTools = try await updateToolsStatus(params: .init(tools: toolsToRestore)) + CopilotLanguageModelToolManager.updateToolsStatus(updatedTools) + Logger.gitHubCopilot.info("Restored \(toolsToRestore.count) disabled tools for service at \(projectRootURL.path)") + } catch { + Logger.gitHubCopilot.error("Failed to restore tools for service at \(projectRootURL.path): \(error)") + } + } + + public func handleMCPToolsNotification(_ notification: AnyJSONRPCNotification) async { + if let payload = GetAllToolsParams.decode(fromParams: notification.params) { + 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 static func getProjectGithubCopilotService(for projectRootURL: URL) -> GitHubCopilotService? { + if let existingService = services.first(where: { $0.projectRootURL == projectRootURL }) { + return existingService + } else { + return nil + } + } + + public func handleSendWorkspaceDidChangeNotifications() { + Task { + if projectRootURL.path != "/" { + try? await self.server.sendNotification( + .workspaceDidChangeWorkspaceFolders( + .init(event: .init(added: [.init(uri: projectRootURL.absoluteString, name: projectRootURL.lastPathComponent)], removed: [])) + ) + ) + } + + // Send initial configuration after initialize + await sendConfigurationUpdate() + + // Combine both notification streams + let combinedNotifications = Publishers.MergeMany( + NotificationCenter.default + .publisher(for: .gitHubCopilotShouldRefreshEditorInformation) + .map { _ in "editorInfo" } + .eraseToAnyPublisher(), + FeatureFlagNotifierImpl.shared.featureFlagsDidChange + .map { _ in "featureFlags" } + .eraseToAnyPublisher(), + DistributedNotificationCenter.default() + .publisher(for: .githubCopilotAgentMaxToolCallingLoopDidChange) + .map { _ in "agentMaxToolCallingLoop" } + .eraseToAnyPublisher(), + DistributedNotificationCenter.default() + .publisher(for: .githubCopilotAgentAutoApprovalDidChange) + .map { _ in "agentAutoApproval" } + .eraseToAnyPublisher(), + NotificationCenter.default + .publisher(for: .githubCopilotAgentAutoApprovalDidChange) + .map { _ in "agentAutoApproval" } + .eraseToAnyPublisher(), + DistributedNotificationCenter.default() + .publisher(for: .githubCopilotAgentTrustToolAnnotationsDidChange) + .map { _ in "agentTrustToolAnnotations" } + .eraseToAnyPublisher() + ) + + 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 InitializingServer: GitHubCopilotLSP { +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/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..e7f9eba9 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift @@ -13,6 +13,7 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { var protocolProgressSubject: PassthroughSubject var conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared var featureFlagNotifier: FeatureFlagNotifier = FeatureFlagNotifierImpl.shared + var copilotPolicyNotifier: CopilotPolicyNotifier = CopilotPolicyNotifierImpl.shared init() { self.protocolProgressSubject = PassthroughSubject() @@ -35,10 +36,22 @@ 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 + case "policy/didChange": + if let data = try? JSONEncoder().encode(notification.params), + let policy = try? JSONDecoder().decode( + CopilotPolicy.self, + from: data + ) { + copilotPolicyNotifier.handleCopilotPolicyNotification(policy) } break default: diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift new file mode 100644 index 00000000..eb61fa50 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift @@ -0,0 +1,133 @@ +import Combine +import ConversationServiceProvider +import Foundation +import JSONRPC +import LanguageClient +import LanguageServerProtocol +import Logger + +public typealias ResponseHandler = ServerRequest.Handler +public typealias LegacyResponseHandler = (AnyJSONRPCResponse) -> Void + +protocol ServerRequestHandler { + func handleRequest(id: JSONId, _ request: ServerRequest, workspaceURL: URL, service: GitHubCopilotService?) +} + +class ServerRequestHandlerImpl: ServerRequestHandler { + public static let shared = ServerRequestHandlerImpl() + private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared + private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared + private let showMessageRequestHandler: ShowMessageRequestHandler = ShowMessageRequestHandlerImpl.shared + private let dynamicOAuthRequestHandler: DynamicOAuthRequestHandler = DynamicOAuthRequestHandlerImpl.shared + + func handleRequest(id: JSONId, _ request: ServerRequest, workspaceURL: URL, service: GitHubCopilotService?) { + switch request { + case let .windowShowMessageRequest(params, callback): + if workspaceURL.path != "/" { + do { + let paramsData = try JSONEncoder().encode(params) + let showMessageRequestParams = try JSONDecoder().decode(ShowMessageRequestParams.self, from: paramsData) + + showMessageRequestHandler.handleShowMessageRequest( + ShowMessageRequest( + id: id, + method: "window/showMessageRequest", + params: showMessageRequestParams + ), + callback: callback + ) + } catch { + Task { + await callback(.success(nil)) + } + } + } + + case let .custom(method, params, callback): + let legacyResponseHandler = toLegacyResponseHandler(callback) + do { + switch method { + case "conversation/context": + let paramsData = try JSONEncoder().encode(params) + let contextParams = try JSONDecoder().decode(ConversationContextParams.self, from: paramsData) + conversationContextHandler.handleConversationContext( + ConversationContextRequest(id: id, method: method, params: contextParams), + completion: legacyResponseHandler + ) + + case "copilot/watchedFiles": + let paramsData = try JSONEncoder().encode(params) + let watchedFilesParams = try JSONDecoder().decode(WatchedFilesParams.self, from: paramsData) + watchedFilesHandler.handleWatchedFiles( + WatchedFilesRequest(id: id, method: method, params: watchedFilesParams), + workspaceURL: workspaceURL, + completion: legacyResponseHandler, + service: service + ) + + case "conversation/invokeClientTool": + let paramsData = try JSONEncoder().encode(params) + let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: paramsData) + ClientToolHandlerImpl.shared.invokeClientTool( + InvokeClientToolRequest(id: id, method: method, params: invokeParams), + completion: legacyResponseHandler + ) + + case "conversation/invokeClientToolConfirmation": + let paramsData = try JSONEncoder().encode(params) + let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: paramsData) + ClientToolHandlerImpl.shared.invokeClientToolConfirmation( + InvokeClientToolConfirmationRequest(id: id, method: method, params: invokeParams), + completion: legacyResponseHandler + ) + + case "copilot/dynamicOAuth": + let paramsData = try JSONEncoder().encode(params) + let dynamicOAuthParams = try JSONDecoder().decode(DynamicOAuthParams.self, from: paramsData) + DynamicOAuthRequestHandlerImpl.shared.handleDynamicOAuthRequest( + DynamicOAuthRequest(id: id, method: method, params: dynamicOAuthParams), + completion: legacyResponseHandler + ) + + default: + break + } + } catch { + handleError(id: id, method: method, error: error, callback: legacyResponseHandler) + } + + default: + break + } + } + + private func handleError(id: JSONId, method: String, error: Error, callback: @escaping (AnyJSONRPCResponse) -> Void) { + callback( + AnyJSONRPCResponse( + id: id, + result: JSONValue.array([ + JSONValue.null, + JSONValue.hash([ + "code": .number(-32602 /* Invalid params */ ), + "message": .string("Error handling \(method): \(error.localizedDescription)")]), + ]) + ) + ) + Logger.gitHubCopilot.error(error) + } + + /// Converts a new Handler to work with old code that expects LegacyResponseHandler + private func toLegacyResponseHandler( + _ newHandler: @escaping ResponseHandler + ) -> 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/LanguageServer/SystemInfo.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/SystemInfo.swift deleted file mode 100644 index abf949f0..00000000 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/SystemInfo.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Darwin -import Foundation - -final class SystemInfo { - func binaryPath() -> String { - var systemInfo = utsname() - uname(&systemInfo) - - let machineMirror = Mirror(reflecting: systemInfo.machine) - let identifier = machineMirror.children.reduce("") { identifier, element in - guard let value = element.value as? Int8, value != 0 else { return identifier } - return identifier + String(UnicodeScalar(UInt8(value))) - } - - let path: String - if identifier == "x86_64" { - path = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/copilot-language-server").path - } else if identifier == "arm64" { - path = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/copilot-language-server-arm64").path - } else { - fatalError("Unsupported architecture") - } - - return path - } - - func xcodeVersion() -> String? { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") - process.arguments = ["xcodebuild", "-version"] - - let pipe = Pipe() - process.standardOutput = pipe - - do { - try process.run() - } catch { - print("Error running xcrun xcodebuild: \(error)") - return nil - } - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let output = String(data: data, encoding: .utf8) else { - return nil - } - - let lines = output.split(separator: "\n") - return lines.first?.split(separator: " ").last.map(String.init) - } -} diff --git a/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift new file mode 100644 index 00000000..5072ae12 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift @@ -0,0 +1,53 @@ +import Combine +import SwiftUI +import JSONRPC + +public extension Notification.Name { + static let gitHubCopilotPolicyDidChange = Notification + .Name("com.github.CopilotForXcode.CopilotPolicyDidChange") +} + +public struct CopilotPolicy: Hashable, Codable { + public var mcpContributionPointEnabled: Bool = true + public var customAgentEnabled: Bool = true + public var subagentEnabled: Bool = true + public var cveRemediatorAgentEnabled: Bool = true + public var agentModeAutoApprovalEnabled: Bool = false + + enum CodingKeys: String, CodingKey { + case mcpContributionPointEnabled = "mcp.contributionPoint.enabled" + case customAgentEnabled = "customAgent.enabled" + case subagentEnabled = "subagent.enabled" + case cveRemediatorAgentEnabled = "cveRemediatorAgent.enabled" + case agentModeAutoApprovalEnabled = "agentMode.autoApproval.enabled" + } +} + +public protocol CopilotPolicyNotifier { + var copilotPolicy: CopilotPolicy { get } + var policyDidChange: PassthroughSubject { get } + func handleCopilotPolicyNotification(_ policy: CopilotPolicy) +} + +public class CopilotPolicyNotifierImpl: CopilotPolicyNotifier { + public private(set) var copilotPolicy: CopilotPolicy + public static let shared = CopilotPolicyNotifierImpl() + public var policyDidChange: PassthroughSubject + + init( + copilotPolicy: CopilotPolicy = CopilotPolicy(), + policyDidChange: PassthroughSubject = PassthroughSubject() + ) { + self.copilotPolicy = copilotPolicy + self.policyDidChange = policyDidChange + } + + public func handleCopilotPolicyNotification(_ policy: CopilotPolicy) { + self.copilotPolicy = policy + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.policyDidChange.send(self.copilotPolicy) + DistributedNotificationCenter.default().post(name: .gitHubCopilotPolicyDidChange, object: nil) + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift index f14572ac..fe08a348 100644 --- a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift +++ b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift @@ -1,36 +1,121 @@ 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 var agentModeAutoApproval: Bool + + 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 = [:], + agentModeAutoApproval: Bool = true + ) { + 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 + self.agentModeAutoApproval = agentModeAutoApproval + } } 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: false), - 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 + self.featureFlags.agentModeAutoApproval = self.didChangeFeatureFlagsParams.token["agent_mode_auto_approval"] != "0" + } - 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 ae43a454..b99c854f 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -2,29 +2,93 @@ import CopilotForXcodeKit import Foundation import ConversationServiceProvider import BuiltinExtension +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 init(serviceLocator: ServiceLocator) { self.serviceLocator = serviceLocator } + + private func getWorkspaceFolders(workspace: WorkspaceInfo) -> [WorkspaceFolder] { + let projects = WorkspaceFile.getProjects(workspace: workspace) + return projects.map { project in + WorkspaceFolder(uri: project.uri, name: project.name) + } + } - public func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws { - guard let service = await serviceLocator.getService(from: workspace) else { return } + 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 try await service.createConversation(request.content, + return message + } + + public func createConversation( + _ request: ConversationRequest, workspace: WorkspaceInfo + ) async throws -> ConversationCreateResponse? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } + + let message = getMessageContent(request) + + return try await service.createConversation(message, workDoneToken: request.workDoneToken, - workspaceFolder: request.workspaceFolder, - doc: nil, - skills: request.skills) + workspaceFolder: workspace.projectURL.absoluteString, + workspaceFolders: getWorkspaceFolders(workspace: workspace), + activeDoc: request.activeDoc, + skills: request.skills, + ignoredSkills: request.ignoredSkills, + references: request.references ?? [], + model: request.model, + modelProviderName: request.modelProviderName, + turns: request.turns, + agentMode: request.agentMode, + customChatModeId: request.customChatModeId, + userLanguage: request.userLanguage) + } + + public func createTurn( + with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo + ) async throws -> ConversationCreateResponse? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } + + 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, + customChatModeId: request.customChatModeId) } - public func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws { + public func deleteTurn(with conversationId: String, turnId: String, workspace: WorkspaceInfo) async throws { guard let service = await serviceLocator.getService(from: workspace) else { return } - return try await service.createTurn(request.content, workDoneToken: request.workDoneToken, conversationId: conversationId, doc: nil) + return try await service.deleteTurn(conversationId: conversationId, turnId: turnId) } public func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws { @@ -42,5 +106,53 @@ public final class GitHubCopilotConversationService: ConversationServiceType { guard let service = await serviceLocator.getService(from: workspace) else { return } try await service.copyCode(turnId: request.turnId, codeBlockIndex: request.codeBlockIndex, copyType: request.copyType, copiedCharacters: request.copiedCharacters, totalCharacters: request.totalCharacters, copiedText: request.copiedText) } + + public func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } + let isPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures + let workspaceFolders = isPreviewEnabled ? getWorkspaceFolders(workspace: workspace) : nil + return try await service.templates(workspaceFolders: workspaceFolders) + } + + public func modes(workspace: WorkspaceInfo) async throws -> [ConversationMode]? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } + let isPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures + let isCustomAgentEnabled = CopilotPolicyNotifierImpl.shared.copilotPolicy.customAgentEnabled + let workspaceFolders = isPreviewEnabled && isCustomAgentEnabled ? getWorkspaceFolders( + workspace: workspace + ) : nil + return try await service.modes(workspaceFolders: workspaceFolders) + } + + public func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } + return try await service.models() + } + + public func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws { + guard let service = await serviceLocator.getService(from: workspace) else { + return + } + + return try await service.notifyDidChangeWatchedFiles(.init(workspaceUri: event.workspaceUri, changes: event.changes)) + } + + public func agents(workspace: WorkspaceInfo) async throws -> [ChatAgent]? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } + return try await service.agents() + } + + public func reviewChanges( + workspace: WorkspaceInfo, + changes: [ReviewChangesParams.Change] + ) async throws -> CodeReviewResult? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } + + return try await service + .reviewChanges(params: .init( + changes: changes, + workspaceFolders: getWorkspaceFolders(workspace: workspace)) + ) + } } diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift index f9f8a9b5..b135fb65 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift @@ -2,8 +2,9 @@ import CopilotForXcodeKit import Foundation import SuggestionBasic import Workspace +import SuggestionProvider -public final class GitHubCopilotSuggestionService: SuggestionServiceType { +public final class GitHubCopilotSuggestionService: SuggestionServiceType, NESSuggestionServiceType { public var configuration: SuggestionServiceConfiguration { .init( acceptsRelevantCodeSnippets: true, @@ -19,7 +20,7 @@ public final class GitHubCopilotSuggestionService: SuggestionServiceType { } public func getSuggestions( - _ request: SuggestionRequest, + _ request: CopilotForXcodeKit.SuggestionRequest, workspace: WorkspaceInfo ) async throws -> [CopilotForXcodeKit.CodeSuggestion] { guard let service = await serviceLocator.getService(from: workspace) else { return [] } @@ -36,6 +37,21 @@ public final class GitHubCopilotSuggestionService: SuggestionServiceType { usesTabsForIndentation: request.usesTabsForIndentation ).map(Self.convert) } + + public func getNESSuggestions( + _ request: CopilotForXcodeKit.SuggestionRequest, + workspace: WorkspaceInfo + ) async throws -> [CopilotForXcodeKit.CodeSuggestion] { + guard let service = await serviceLocator.getService(from: workspace) else { return [] } + + return try await service + .getCopilotInlineEdit( + fileURL: request.fileURL, + content: request.content, + cursorPosition: .init(line: request.cursorPosition.line, character: request.cursorPosition.character) + ) + .map(Self.convert) + } public func notifyAccepted( _ suggestion: CopilotForXcodeKit.CodeSuggestion, diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotTelemetryService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotTelemetryService.swift new file mode 100644 index 00000000..a38cbf82 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotTelemetryService.swift @@ -0,0 +1,30 @@ +import CopilotForXcodeKit +import Foundation +import TelemetryServiceProvider +import BuiltinExtension + +public final class GitHubCopilotTelemetryService: TelemetryServiceType { + + private let serviceLocator: ServiceLocator + + init(serviceLocator: ServiceLocator) { + self.serviceLocator = serviceLocator + } + + public func sendError(_ request: TelemetryExceptionRequest, + workspace: WorkspaceInfo) async throws { + guard let service = await serviceLocator.getService(from: workspace) else { return } + let sessionId = service.getSessionId() + var properties = request.properties ?? [:] + properties.updateValue(sessionId, forKey: "common_vscodesessionid") + properties.updateValue(sessionId, forKey: "client_sessionid") + + try await service.sendError( + transaction: request.transaction, + stacktrace: request.stacktrace, + properties: properties, + platform: request.platform, + exceptionDetail: request.exceptionDetail + ) + } +} diff --git a/Tool/Sources/HostAppActivator/HostAppActivator.swift b/Tool/Sources/HostAppActivator/HostAppActivator.swift new file mode 100644 index 00000000..6e52319b --- /dev/null +++ b/Tool/Sources/HostAppActivator/HostAppActivator.swift @@ -0,0 +1,223 @@ +import Foundation +import AppKit +import Logger + +public let HostAppURL = locateHostBundleURL(url: Bundle.main.bundleURL) + +public extension Notification.Name { + static let openSettingsWindowRequest = Notification + .Name("com.github.CopilotForXcode.OpenSettingsWindowRequest") + static let openToolsSettingsWindowRequest = Notification + .Name("com.github.CopilotForXcode.OpenToolsSettingsWindowRequest") + static let openToolsSettingsAutoApproveWindowRequest = Notification + .Name("com.github.CopilotForXcode.OpenToolsSettingsAutoApproveWindowRequest") + static let openBYOKSettingsWindowRequest = Notification + .Name("com.github.CopilotForXcode.OpenBYOKSettingsWindowRequest") + static let openAdvancedSettingsWindowRequest = Notification + .Name("com.github.CopilotForXcode.OpenAdvancedSettingsWindowRequest") + static let selectedAgentSubModeDidChange = Notification + .Name("com.github.CopilotForXcode.SelectedAgentSubModeDidChange") +} + +public enum GitHubCopilotForXcodeSettingsLaunchError: Error, LocalizedError { + case appNotFound + case openFailed(errorDescription: String) + + public var errorDescription: String? { + switch self { + case .appNotFound: + return "\(hostAppName()) settings application not found" + case let .openFailed(errorDescription): + return "Failed to launch \(hostAppName()) settings (\(errorDescription))" + } + } +} + +public func getRunningHostApp() -> NSRunningApplication? { + return NSWorkspace.shared.runningApplications.first(where: { + $0.bundleIdentifier == (Bundle.main.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String) + }) +} + +public func launchHostAppSettings() 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)") + + let scriptSuccess = tryLaunchWithAppleScript() + + // If AppleScript fails, fall back to notification center + if !scriptSuccess { + DistributedNotificationCenter.default().postNotificationName( + .openSettingsWindowRequest, + object: nil + ) + Logger.ui.info("\(hostAppName()) settings notification sent after activation") + return + } + } else { + // If app is not running, launch it with the settings flag + try launchHostAppWithArgs(args: ["--settings"]) + } +} + +public func launchHostAppToolsSettings(currentAgentSubMode: String) throws { + // Try the AppleScript approach first, but only if app is already running + if let hostApp = getRunningHostApp() { + let activated = hostApp.activate(options: [.activateIgnoringOtherApps]) + Logger.ui.info("\(hostAppName()) activated: \(activated)") + + _ = tryLaunchWithAppleScript() + + DistributedNotificationCenter.default().postNotificationName( + .openToolsSettingsWindowRequest, + object: nil + ) + + // Notify settings app of current agent submode + DistributedNotificationCenter.default().postNotificationName( + .selectedAgentSubModeDidChange, + object: nil, + userInfo: ["agentSubMode": currentAgentSubMode], + options: .deliverImmediately + ) + + Logger.ui.info("\(hostAppName()) MCP settings notification sent after activation") + return + } else { + // If app is not running, launch it with the settings flag + try launchHostAppWithArgs(args: ["--tools"]) + } +} + +public func launchHostAppToolsSettingsAutoApprove() 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( + .openToolsSettingsAutoApproveWindowRequest, + object: nil + ) + + Logger.ui.info("\(hostAppName()) MCP settings (Auto-Approve) notification sent after activation") + return + } else { + // If app is not running, launch it with the settings flag + try launchHostAppWithArgs(args: ["--tools-auto-approve"]) + } +} + +public func launchHostAppBYOKSettings() throws { + // Try the AppleScript approach first, but only if app is already running + if let hostApp = getRunningHostApp() { + let activated = hostApp.activate(options: [.activateIgnoringOtherApps]) + Logger.ui.info("\(hostAppName()) activated: \(activated)") + + _ = tryLaunchWithAppleScript() + + DistributedNotificationCenter.default().postNotificationName( + .openBYOKSettingsWindowRequest, + object: nil + ) + Logger.ui.info("\(hostAppName()) BYOK settings notification sent after activation") + return + } else { + // If app is not running, launch it with the settings flag + try launchHostAppWithArgs(args: ["--byok"]) + } +} + +public func launchHostAppAdvancedSettings() throws { + // Try the AppleScript approach first, but only if app is already running + if let hostApp = getRunningHostApp() { + let activated = hostApp.activate(options: [.activateIgnoringOtherApps]) + Logger.ui.info("\(hostAppName()) activated: \(activated)") + + _ = tryLaunchWithAppleScript() + + DistributedNotificationCenter.default().postNotificationName( + .openAdvancedSettingsWindowRequest, + object: nil + ) + Logger.ui.info("\(hostAppName()) Advanced settings notification sent after activation") + return + } else { + // If app is not running, launch it with the settings flag + try launchHostAppWithArgs(args: ["--advanced"]) + } +} + +private func tryLaunchWithAppleScript() -> Bool { + // Try to launch settings using AppleScript + let script = """ + tell application "\(hostAppName())" + activate + tell application "System Events" + keystroke "," using command down + end tell + end tell + """ + + var error: NSDictionary? + if let scriptObject = NSAppleScript(source: script) { + scriptObject.executeAndReturnError(&error) + + // Log the result + if let error = error { + Logger.ui.info("\(hostAppName()) settings script error: \(error)") + return false + } + + Logger.ui.info("\(hostAppName()) settings opened successfully via AppleScript") + return true + } + + return false +} + +public func launchHostAppDefault() throws { + try launchHostAppWithArgs(args: nil) +} + +func launchHostAppWithArgs(args: [String]?) throws { + guard let appURL = HostAppURL else { + throw GitHubCopilotForXcodeSettingsLaunchError.appNotFound + } + + Task { + let configuration = NSWorkspace.OpenConfiguration() + if let args { + configuration.arguments = args + } + configuration.activates = true + + try await NSWorkspace.shared + .openApplication(at: appURL, configuration: configuration) + } +} + +func locateHostBundleURL(url: URL) -> URL? { + var nextURL = url + while nextURL.path != "/" { + nextURL = nextURL.deletingLastPathComponent() + if nextURL.lastPathComponent.hasSuffix(".app") { + return nextURL + } + } + let devAppURL = url + .deletingLastPathComponent() + .appendingPathComponent("GitHub Copilot for Xcode Dev.app") + return devAppURL +} + +func hostAppName() -> String { + return Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String + ?? "GitHub Copilot for Xcode" +} + +public let SELECTED_AGENT_SUBMODE_KEY = "selectedAgentSubMode" diff --git a/Tool/Sources/Logger/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 518fec15..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,7 +25,9 @@ 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 /// Use a temp logger to log something temporary. I won't be available in release builds. public static let temp = Logger(category: "Temp") @@ -39,9 +42,11 @@ public final class Logger { func log( level: LogLevel, message: String, + error: Error? = nil, file: StaticString = #file, line: UInt = #line, - function: StaticString = #function + function: StaticString = #function, + callStackSymbols: [String] = [] ) { let osLogType: OSLogType switch level { @@ -54,7 +59,31 @@ 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 { + Logger.telemetryLogger?.sendError( + error: error, + category: category, + file: file, + line: line, + function: function, + callStackSymbols: callStackSymbols + ) + } else { + Logger.telemetryLogger?.sendError( + message: message, + category: category, + file: file, + line: line, + function: function, + callStackSymbols: callStackSymbols + ) + } + } } public func debug( @@ -84,26 +113,56 @@ public final class Logger { _ message: String, file: StaticString = #file, line: UInt = #line, - function: StaticString = #function + function: StaticString = #function, + callStackSymbols: [String] = [] ) { - log(level: .error, message: message, file: file, line: line, function: function) + log( + level: .error, + message: message, + file: file, + line: line, + function: function, + callStackSymbols: callStackSymbols + ) } public func error( _ error: Error, file: StaticString = #file, line: UInt = #line, - function: StaticString = #function + function: StaticString = #function, + callStackSymbols: [String] = Thread.callStackSymbols ) { log( level: .error, message: error.localizedDescription, + error: error, file: file, line: line, - function: function + function: function, + callStackSymbols: callStackSymbols ) } + 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..d633b440 --- /dev/null +++ b/Tool/Sources/Logger/MCPRuntimeLogger.swift @@ -0,0 +1,61 @@ +import Foundation +import System + +public final class MCPRuntimeFileLogger { + private lazy var dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private let implementation = MCPRuntimeFileLoggerImplementation() + + /// Converts a timestamp in milliseconds since the Unix epoch to a formatted date string. + private func timestamp(timeStamp: Double) -> String { + let date = Date(timeIntervalSince1970: timeStamp/1000) + return dateFormatter.string(from: date) + } + + public func log( + logFileName: String, + level: String, + message: String, + server: String, + tool: String? = nil, + time: Double + ) { + guard time.isFinite, time >= 0 else { + return + } + + let toolSuffix = tool.map { "-\($0)" } ?? "" + let timestampStr = timestamp(timeStamp: time) + let log = "[\(timestampStr)] [\(level)] [\(server)\(toolSuffix)] \(message)\(message.hasSuffix("\n") ? "" : "\n")" + + Task { [implementation] in + await 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/Logger/TelemetryLoggerProvider.swift b/Tool/Sources/Logger/TelemetryLoggerProvider.swift new file mode 100644 index 00000000..db580619 --- /dev/null +++ b/Tool/Sources/Logger/TelemetryLoggerProvider.swift @@ -0,0 +1,18 @@ +public protocol TelemetryLoggerProvider { + func sendError( + message: String, + category: String, + file: StaticString, + line: UInt, + function: StaticString, + callStackSymbols: [String] + ) + func sendError( + error: Error, + category: String, + file: StaticString, + line: UInt, + function: StaticString, + callStackSymbols: [String] + ) +} diff --git a/Tool/Sources/Persist/AppState.swift b/Tool/Sources/Persist/AppState.swift new file mode 100644 index 00000000..e9e8424a --- /dev/null +++ b/Tool/Sources/Persist/AppState.swift @@ -0,0 +1,121 @@ +import CryptoKit +import Foundation +import JSONRPC +import Logger +import Status + +public extension JSONValue { + subscript(key: String) -> JSONValue? { + if case .hash(let dict) = self { + return dict[key] + } + return nil + } + + var stringValue: String? { + if case .string(let value) = self { + return value + } + 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 { + let data = try JSONEncoder().encode(object) + let jsonValue = try JSONDecoder().decode(JSONValue.self, from: data) + return jsonValue + } catch { + Logger.client.info("Error converting to JSONValue: \(error)") + return nil + } + } +} + +public class AppState { + public static let shared = AppState() + + private var cache: [String: [String: JSONValue]] = [:] + private let cacheFileName = "appstate.json" + private let queue = DispatchQueue(label: "com.github.AppStateCacheQueue") + private var loadStatus: [String: Bool] = [:] + + private init() { + cache[""] = [:] // initialize a default cache if no user exists + initCacheForUserIfNeeded() + } + + func toHash(contents: String, _ length: Int = 16) -> String { + let data = Data(contents.utf8) + let hashData = SHA256.hash(data: data) + let hashValue = hashData.compactMap { String(format: "%02x", $0 ) }.joined() + let index = hashValue.index(hashValue.startIndex, offsetBy: length) + return String(hashValue[..(key: String, value: T) { + queue.async { + let userName = UserDefaults.shared.value(for: \.currentUserName) + self.initCacheForUserIfNeeded(userName) + self.cache[userName]![key] = JSONValue.convertToJSONValue(value) + self.saveCacheForUser(userName) + } + } + + public func get(key: String) -> JSONValue? { + return queue.sync { + let userName = UserDefaults.shared.value(for: \.currentUserName) + initCacheForUserIfNeeded(userName) + return (self.cache[userName] ?? [:])[key] + } + } + + private func configFilePath(userName: String) -> URL { + return ConfigPathUtils.configFilePath(userName: userName, fileName: cacheFileName) + } + + private func saveCacheForUser(_ userName: String? = nil) { + 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] ?? [:]) + try data.write(to: cacheFilePath) + } catch { + Logger.client.info("Failed to save AppState cache: \(error)") + } + } + } + + private func initCacheForUserIfNeeded(_ userName: String? = nil) { + 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) + guard FileManager.default.fileExists(atPath: cacheFilePath.path) else { + return + } + + do { + let data = try Data(contentsOf: cacheFilePath) + self.cache[user] = try JSONDecoder().decode([String: JSONValue].self, from: data) + } catch { + Logger.client.info("Failed to load AppState cache: \(error)") + } + } + } +} diff --git a/Tool/Sources/Persist/ConfigPathUtils.swift b/Tool/Sources/Persist/ConfigPathUtils.swift new file mode 100644 index 00000000..603581ba --- /dev/null +++ b/Tool/Sources/Persist/ConfigPathUtils.swift @@ -0,0 +1,87 @@ +import Foundation +import CryptoKit +import Logger + +let BaseAppDirectory = "github-copilot/xcode" + +/// String extension for hashing functionality +extension String { + /// Generates a SHA256 hash of the string + /// - Parameter length: The length of the hash to return, defaults to 16 characters + /// - Returns: The hashed string + func hashed(_ length: Int = 16) -> String { + let data = Data(self.utf8) + let hashData = SHA256.hash(data: data) + let hashValue = hashData.compactMap { String(format: "%02x", $0 ) }.joined() + let index = hashValue.index(hashValue.startIndex, offsetBy: length) + return String(hashValue[.. URL { + if let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], + xdgConfigHome.hasPrefix("/") { + return URL(fileURLWithPath: xdgConfigHome) + } + return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config") + } + + /// Generates a config file path for a specific user. + /// - Parameters: + /// - userName: The user name to generate a path for + /// - appDirectory: The application directory name, defaults to "github-copilot/xcode" + /// - fileName: The file name to append to the path + /// - Returns: The complete URL for the config file + static func configFilePath( + userName: String, + baseDirectory: String = BaseAppDirectory, + subDirectory: String? = nil, + fileName: String + ) -> URL { + var baseURL: URL = getXdgConfigHome() + .appendingPathComponent(baseDirectory) + .appendingPathComponent(toHash(contents: userName)) + + if let subDirectory = subDirectory { + baseURL = baseURL.appendingPathComponent(subDirectory) + } + + ensureDirectoryExists(at: baseURL) + return baseURL.appendingPathComponent(fileName) + } + + /// Ensures a directory exists at the specified URL, creating it if necessary. + /// - Parameter url: The directory URL + private static func ensureDirectoryExists(at url: URL) { + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: url.path) { + do { + try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + } 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)") + } + } + } + } + + /// Generates a hash from a string using SHA256. + /// - Parameters: + /// - contents: The string to hash + /// - length: The length of the hash to return, defaults to 16 characters + /// - Returns: The hashed string + static func toHash(contents: String, _ length: Int = 16) -> String { + let data = Data(contents.utf8) + let hashData = SHA256.hash(data: data) + let hashValue = hashData.compactMap { String(format: "%02x", $0 ) }.joined() + let index = hashValue.index(hashValue.startIndex, offsetBy: length) + return String(hashValue[.. [TurnItem] + func fetchConversationItems(_ type: ConversationFetchType) throws -> [ConversationItem] + func operate(_ request: OperationRequest) throws +} + +public final class ConversationStorage: ConversationStorageProtocol { + static let BusyTimeout: Double = 5 // error after 5 seconds + private var path: String + private var db: Connection? + + let conversationTable = ConversationTable() + let turnTable = TurnTable() + + public init(_ path: String) throws { + guard !path.isEmpty else { throw DatabaseError.invalidPath(path) } + self.path = path + + do { + let db = try Connection(path) + db.busyTimeout = ConversationStorage.BusyTimeout + self.db = db + } catch { + throw DatabaseError.connectionFailed(error.localizedDescription) + } + } + + deinit { db = nil } + + private func withDB(_ operation: (Connection) throws -> T) throws -> T { + guard let db = self.db else { + throw DatabaseError.connectionLost + } + return try operation(db) + } + + private func withDBTransaction(_ operation: (Connection) throws -> Void) throws { + guard let db = self.db else { + throw DatabaseError.connectionLost + } + try db.transaction { + try operation(db) + } + } + + public func createTableIfNeeded() throws { + try withDB { db in + try db.execute(""" + BEGIN TRANSACTION; + CREATE TABLE IF NOT EXISTS Conversation ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT, + isSelected INTEGER NOT NULL, + CLSConversationID TEXT, + data BLOB NOT NULL, + createdAt REAL DEFAULT (strftime('%s','now')), + updatedAt REAL DEFAULT (strftime('%s','now')) + ); + CREATE TABLE IF NOT EXISTS Turn ( + rowID INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + conversationID TEXT NOT NULL, + CLSTurnID TEXT, + role TEXT NOT NULL, + data BLOB NOT NULL, + createdAt REAL DEFAULT (strftime('%s','now')), + updatedAt REAL DEFAULT (strftime('%s','now')), + UNIQUE (conversationID, id) + ); + COMMIT TRANSACTION; + """) + } + } + + public func operate(_ request: OperationRequest) throws { + guard request.operations.count > 0 else { return } + + try withDBTransaction { db in + + let now = Date().timeIntervalSince1970 + + for operation in request.operations { + switch operation { + case .upsertConversation(let conversationItems): + for conversationItems in conversationItems { + try db.run( + conversationTable.table.upsert( + conversationTable.column.id <- conversationItems.id, + conversationTable.column.title <- conversationItems.title, + conversationTable.column.isSelected <- conversationItems.isSelected, + conversationTable.column.CLSConversationID <- conversationItems.CLSConversationID ?? "", + conversationTable.column.data <- conversationItems.data.toBlob(), + conversationTable.column.createdAt <- conversationItems.createdAt.timeIntervalSince1970, + conversationTable.column.updatedAt <- conversationItems.updatedAt.timeIntervalSince1970, + onConflictOf: conversationTable.column.id + ) + ) + } + case .upsertTurn(let turnItems): + for turnItem in turnItems { + try db.run( + turnTable.table.upsert( + turnTable.column.conversationID <- turnItem.conversationID, + turnTable.column.id <- turnItem.id, + turnTable.column.CLSTurnID <- turnItem.CLSTurnID ?? "", + turnTable.column.role <- turnItem.role, + turnTable.column.data <- turnItem.data.toBlob(), + turnTable.column.createdAt <- turnItem.createdAt.timeIntervalSince1970, + turnTable.column.updatedAt <- turnItem.updatedAt.timeIntervalSince1970, + onConflictOf: SQLite.Expression(literal: "\"conversationID\", \"id\"") + ) + ) + } + case .delete(let deleteItems): + for deleteItem in deleteItems { + switch deleteItem { + case let .conversation(id): + try db.run(conversationTable.table.filter(conversationTable.column.id == id).delete()) + case .turn(let id): + try db.run(turnTable.table.filter(conversationTable.column.id == id).delete()) + case .turnByConversationID(let conversationID): + try db.run(turnTable.table.filter(turnTable.column.conversationID == conversationID).delete()) + } + } + } + } + } + } + + public func fetchTurnItems(for conversationID: String) throws -> [TurnItem] { + var items: [TurnItem] = [] + + try withDB { db in + let table = turnTable.table + let column = turnTable.column + + var query = table + .filter(column.conversationID == conversationID) + .order(column.rowID.asc) + let rowIterator = try db.prepareRowIterator(query) + items = try rowIterator.map { row in + TurnItem( + id: row[column.id], + conversationID: row[column.conversationID], + CLSTurnID: row[column.CLSTurnID], + role: row[column.role], + data: row[column.data].toString(), + createdAt: row[column.createdAt].toDate(), + updatedAt: row[column.updatedAt].toDate() + ) + } + } + + return items + } + + public func fetchConversationItems(_ type: ConversationFetchType) throws -> [ConversationItem] { + var items: [ConversationItem] = [] + + try withDB { db in + let table = conversationTable.table + let column = conversationTable.column + var query = table + + switch type { + case .all: + 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) + items = try rowIterator.map { row in + ConversationItem( + id: row[column.id], + title: row[column.title], + isSelected: row[column.isSelected], + CLSConversationID: row[column.CLSConversationID], + data: row[column.data].toString(), + createdAt: row[column.createdAt].toDate(), + updatedAt: row[column.updatedAt].toDate() + ) + } + } + + 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 + } +} + + +extension String { + func toBlob() -> Blob { + let data = self.data(using: .utf8) ?? Data() // TODO: handle exception + return Blob(bytes: [UInt8](data)) + } +} + +extension Blob { + func toString() -> String { + return String(data: Data(bytes), encoding: .utf8) ?? "" + } +} + +extension Double { + func toDate() -> Date { + return Date(timeIntervalSince1970: self) + } +} diff --git a/Tool/Sources/Persist/Storage/ConversationStorage/Model.swift b/Tool/Sources/Persist/Storage/ConversationStorage/Model.swift new file mode 100644 index 00000000..6193f4d5 --- /dev/null +++ b/Tool/Sources/Persist/Storage/ConversationStorage/Model.swift @@ -0,0 +1,73 @@ +import Foundation + +public struct TurnItem: Codable, Equatable { + public let id: String + public let conversationID: String + public let CLSTurnID: String? + public let role: String + public let data: String + public let createdAt: Date + public let updatedAt: Date + + public init(id: String, conversationID: String, CLSTurnID: String?, role: String, data: String, createdAt: Date, updatedAt: Date) { + self.id = id + self.conversationID = conversationID + self.CLSTurnID = CLSTurnID + self.role = role + self.data = data + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public struct ConversationItem: Codable, Equatable { + public let id: String + public let title: String? + public let isSelected: Bool + public let CLSConversationID: String? + public let data: String + public let createdAt: Date + public let updatedAt: Date + + public init(id: String, title: String?, isSelected: Bool, CLSConversationID: String?, data: String, createdAt: Date, updatedAt: Date) { + self.id = id + self.title = title + self.isSelected = isSelected + self.CLSConversationID = CLSConversationID + self.data = data + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +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) + case turnByConversationID(conversationID: String) +} + +public enum OperationType { + case upsertTurn([TurnItem]) + case upsertConversation([ConversationItem]) + case delete([DeleteType]) +} + +public struct OperationRequest { + + var operations: [OperationType] + + public init(_ operations: [OperationType]) { + self.operations = operations + } +} + +public enum ConversationFetchType { + case all, selected, latest, id(String) +} diff --git a/Tool/Sources/Persist/Storage/ConversationStorage/Table.swift b/Tool/Sources/Persist/Storage/ConversationStorage/Table.swift new file mode 100644 index 00000000..c6932c83 --- /dev/null +++ b/Tool/Sources/Persist/Storage/ConversationStorage/Table.swift @@ -0,0 +1,40 @@ +import SQLite + +struct ConversationTable { + let table = Table("Conversation") + + // Column + struct Column { + let id = SQLite.Expression("id") + let title = SQLite.Expression("title") + // 0 -> false, 1 -> true + let isSelected = SQLite.Expression("isSelected") + let CLSConversationID = SQLite.Expression("CLSConversationID") + // for extensibility purpose + let data = SQLite.Expression("data") + let createdAt = SQLite.Expression("createdAt") + let updatedAt = SQLite.Expression("updatedAt") + } + + let column = Column() +} + +struct TurnTable { + let table = Table("Turn") + + // Column + struct Column { + // an auto-incremental id genrated by SQLite + let rowID = SQLite.Expression("rowID") + let id = SQLite.Expression("id") + let conversationID = SQLite.Expression("conversationID") + let CLSTurnID = SQLite.Expression("CLSTurnID") + let role = SQLite.Expression("role") + // for extensibility purpose + let data = SQLite.Expression("data") + let createdAt = SQLite.Expression("createdAt") + let updatedAt = SQLite.Expression("updatedAt") + } + + let column = Column() +} diff --git a/Tool/Sources/Persist/Storage/ConversationStorageService.swift b/Tool/Sources/Persist/Storage/ConversationStorageService.swift new file mode 100644 index 00000000..113eafa2 --- /dev/null +++ b/Tool/Sources/Persist/Storage/ConversationStorageService.swift @@ -0,0 +1,142 @@ +import Foundation +import CryptoKit +import Logger + +extension String { + + func appendingPathComponents(_ components: String...) -> String { + var url = URL(fileURLWithPath: self) + components.forEach { component in + url = url.appendingPathComponent(component) + } + + return url.path + } +} + +protocol ConversationStorageServiceProtocol { + func fetchConversationItems(_ type: ConversationFetchType, metadata: StorageMetadata) -> [ConversationItem] + func fetchTurnItems(for conversationID: String, metadata: StorageMetadata) -> [TurnItem] + + func operate(_ request: OperationRequest, metadata: StorageMetadata) + + func terminate() +} + +public struct StorageMetadata: Hashable { + public var workspacePath: String + public var username: String + + public init(workspacePath: String, username: String) { + self.workspacePath = workspacePath + self.username = username + } +} + +public final class ConversationStorageService: ConversationStorageServiceProtocol { + private var conversationStoragePool: [StorageMetadata: ConversationStorage] = [:] + public static let shared = ConversationStorageService() + private init() { } + + // The storage path would be xdgConfigHome/usernameHash/conversations/workspacePathHash.db + private func getPersistenceFile(_ metadata: StorageMetadata) -> String { + let fileName = "\(ConfigPathUtils.toHash(contents: metadata.workspacePath)).db" + let persistenceFileURL = ConfigPathUtils.configFilePath( + userName: metadata.username, + subDirectory: "conversations", + fileName: fileName + ) + + return persistenceFileURL.path + } + + private func getConversationStorage(_ metadata: StorageMetadata) throws -> ConversationStorage { + if let existConversationStorage = conversationStoragePool[metadata] { + return existConversationStorage + } + + let persistenceFile = getPersistenceFile(metadata) + + let conversationStorage = try ConversationStorage(persistenceFile) + try conversationStorage.createTableIfNeeded() + conversationStoragePool[metadata] = conversationStorage + return conversationStorage + } + + private func ensurePathExists(_ path: String) -> Bool { + + do { + let fileManager = FileManager.default + let pathURL = URL(fileURLWithPath: path) + if !fileManager.fileExists(atPath: path) { + try fileManager.createDirectory(at: pathURL, withIntermediateDirectories: true) + } + } catch { + Logger.client.error("Failed to create persistence path: \(error)") + return false + } + + return true + } + + private func withStorage(_ metadata: StorageMetadata, operation: (ConversationStorage) throws -> T) throws -> T { + let storage = try getConversationStorage(metadata) + return try operation(storage) + } + + public func fetchConversationItems(_ type: ConversationFetchType, metadata: StorageMetadata) -> [ConversationItem] { + var items: [ConversationItem] = [] + do { + try withStorage(metadata) { conversationStorage in + items = try conversationStorage.fetchConversationItems(type) + } + } catch { + Logger.client.error("Failed to fetch conversation items: \(error)") + } + + 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] = [] + + do { + try withStorage(metadata) { conversationStorage in + items = try conversationStorage.fetchTurnItems(for: conversationID) + } + } catch { + Logger.client.error("Failed to fetch turn items: \(error)") + } + + return items + } + + public func operate(_ request: OperationRequest, metadata: StorageMetadata) { + do { + try withStorage(metadata) { conversationStorage in + try conversationStorage.operate(request) + } + + } catch { + Logger.client.error("Failed to operate database request: \(error)") + } + } + + public func terminate() { + conversationStoragePool = [:] + } +} diff --git a/Tool/Sources/Persist/Storage/Storage.swift b/Tool/Sources/Persist/Storage/Storage.swift new file mode 100644 index 00000000..b0770b20 --- /dev/null +++ b/Tool/Sources/Persist/Storage/Storage.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum DatabaseError: Error { + case connectionFailed(String) + case invalidPath(String) + case connectionLost +} diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 3e4a4c1a..50ead5c8 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 @@ -161,6 +166,10 @@ public extension UserDefaultPreferenceKeys { var realtimeSuggestionToggle: PreferenceKey { .init(defaultValue: true, key: "RealtimeSuggestionToggle") } + + var realtimeNESToggle: PreferenceKey { + .init(defaultValue: true, key: "RealtimeNESToggle") + } var suggestionDisplayCompactMode: PreferenceKey { .init(defaultValue: true, key: "SuggestionDisplayCompactMode") @@ -229,14 +238,22 @@ public extension UserDefaultPreferenceKeys { var isSuggestionTypeInTheMiddleEnabled: PreferenceKey { .init(defaultValue: true, key: "IsSuggestionTypeInTheMiddleEnabled") } + + var clsWarningDismissedUntilRelaunch: PreferenceKey { + .init(defaultValue: false, key: "CLSWarningDismissedUntilRelaunch") + } } // MARK: - Chat public extension UserDefaultPreferenceKeys { + + var fontScale: PreferenceKey { + .init(defaultValue: 1.0, key: "FontScale") + } var chatFontSize: PreferenceKey { - .init(defaultValue: 12, key: "ChatFontSize") + .init(defaultValue: 13, key: "ChatFontSize") } var chatCodeFontSize: PreferenceKey { @@ -287,6 +304,38 @@ public extension UserDefaultPreferenceKeys { var keepFloatOnTopIfChatPanelAndXcodeOverlaps: PreferenceKey { .init(defaultValue: true, key: "KeepFloatOnTopIfChatPanelAndXcodeOverlaps") } + + var enableCurrentEditorContext: PreferenceKey { + .init(defaultValue: true, key: "EnableCurrentEditorContext") + } + + var chatResponseLocale: PreferenceKey { + .init(defaultValue: "en", key: "ChatResponseLocale") + } + + var agentMaxToolCallingLoop: PreferenceKey { + .init(defaultValue: 25, key: "AgentMaxToolCallingLoop") + } + + var globalCopilotInstructions: PreferenceKey { + .init(defaultValue: "", key: "GlobalCopilotInstructions") + } + + var autoAttachChatToXcode: PreferenceKey { + .init(defaultValue: true, key: "AutoAttachChatToXcode") + } + + var enableFixError: PreferenceKey { + .init(defaultValue: true, key: "EnableFixError") + } + + var suppressRestoreCheckpointConfirmation: PreferenceKey { + .init(defaultValue: false, key: "SuppressRestoreCheckpointConfirmation") + } + + var enableSubagent: PreferenceKey { + .init(defaultValue: true, key: "EnableSubagent") + } } // MARK: - Theme @@ -546,6 +595,14 @@ public extension UserDefaultPreferenceKeys { var gitHubCopilotProxyPassword: PreferenceKey { .init(defaultValue: "", key: "GitHubCopilotProxyPassword") } + + var gitHubCopilotMCPConfig: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotMCPConfig") + } + + var gitHubCopilotMCPUpdatedStatus: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotMCPUpdatedStatus") + } var gitHubCopilotEnterpriseURI: PreferenceKey { .init(defaultValue: "", key: "GitHubCopilotEnterpriseURI") @@ -554,4 +611,40 @@ public extension UserDefaultPreferenceKeys { var verboseLoggingEnabled: PreferenceKey { .init(defaultValue: false, key: "VerboseLoggingEnabled") } + + var currentUserName: PreferenceKey { + .init(defaultValue: "", key: "CurrentUserName") + } + + var mcpRegistryBaseURL: PreferenceKey { + .init(defaultValue: "https://api.mcp.github.com", key: "MCPRegistryBaseURL") + } + + var mcpRegistryBaseURLHistory: PreferenceKey<[String]> { + .init(defaultValue: [], key: "MCPRegistryBaseURLHistory") + } +} + +// MARK: - Auto Approval +public extension UserDefaultPreferenceKeys { + + var enableAutoApproval: PreferenceKey { + .init(defaultValue: false, key: "EnableAutoApproval") + } + + var trustToolAnnotations: PreferenceKey { + .init(defaultValue: false, key: "TrustToolAnnotations") + } + + var sensitiveFilesGlobalApprovals: PreferenceKey { + .init(defaultValue: SensitiveFilesRules(), key: "AutoApproval_SensitiveFiles_GlobalApprovals") + } + + var mcpServersGlobalApprovals: PreferenceKey { + .init(defaultValue: AutoApprovedMCPServers(), key: "AutoApproval_MCP_GlobalApprovals") + } + + var terminalCommandsGlobalApprovals: PreferenceKey { + .init(defaultValue: TerminalCommandsRules(), key: "AutoApproval_Terminal_GlobalApprovals") + } } diff --git a/Tool/Sources/Preferences/Types/AutoApprovedMCPServers.swift b/Tool/Sources/Preferences/Types/AutoApprovedMCPServers.swift new file mode 100644 index 00000000..902f4f02 --- /dev/null +++ b/Tool/Sources/Preferences/Types/AutoApprovedMCPServers.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct MCPServerApprovalState: Codable, Equatable { + public var isServerAllowed: Bool + public var allowedTools: Set + + public init(isServerAllowed: Bool = false, allowedTools: Set = []) { + self.isServerAllowed = isServerAllowed + self.allowedTools = allowedTools + } +} + +public struct AutoApprovedMCPServers: Codable, Equatable, RawRepresentable { + public var servers: [String: MCPServerApprovalState] + + public init(servers: [String: MCPServerApprovalState] = [:]) { + self.servers = servers + } + + public init?(rawValue: [String: Any]) { + let serversDict = rawValue["servers"] as? [String: Any] ?? [:] + var parsedServers: [String: MCPServerApprovalState] = [:] + + for (serverName, value) in serversDict { + if let dict = value as? [String: Any] { + let isServerAllowed = dict["isServerAllowed"] as? Bool ?? false + let allowedToolsArray = dict["allowedTools"] as? [String] ?? [] + parsedServers[serverName] = MCPServerApprovalState( + isServerAllowed: isServerAllowed, + allowedTools: Set(allowedToolsArray) + ) + } + } + self.servers = parsedServers + } + + public var rawValue: [String: Any] { + var serversDict: [String: Any] = [:] + for (serverName, state) in servers { + serversDict[serverName] = [ + "isServerAllowed": state.isServerAllowed, + "allowedTools": Array(state.allowedTools) + ] + } + return ["servers": serversDict] + } +} diff --git a/Tool/Sources/Preferences/Types/SensitiveFilesRules.swift b/Tool/Sources/Preferences/Types/SensitiveFilesRules.swift new file mode 100644 index 00000000..e05f4b44 --- /dev/null +++ b/Tool/Sources/Preferences/Types/SensitiveFilesRules.swift @@ -0,0 +1,43 @@ +import Foundation + +public struct SensitiveFileRule: Codable, Equatable { + public var description: String + public var autoApprove: Bool + + public init(description: String, autoApprove: Bool) { + self.description = description + self.autoApprove = autoApprove + } +} + +public struct SensitiveFilesRules: Codable, Equatable, RawRepresentable { + public var rules: [String: SensitiveFileRule] + + public init(rules: [String: SensitiveFileRule] = [:]) { + self.rules = rules + } + + public init?(rawValue: [String: Any]) { + let rulesDict = rawValue["rules"] as? [String: Any] ?? [:] + var parsedRules: [String: SensitiveFileRule] = [:] + for (key, value) in rulesDict { + if let dict = value as? [String: Any] { + let description = dict["description"] as? String ?? "" + let autoApprove = dict["autoApprove"] as? Bool ?? false + parsedRules[key] = SensitiveFileRule(description: description, autoApprove: autoApprove) + } + } + self.rules = parsedRules + } + + public var rawValue: [String: Any] { + var rulesDict: [String: Any] = [:] + for (pattern, rule) in rules { + rulesDict[pattern] = [ + "description": rule.description, + "autoApprove": rule.autoApprove + ] + } + return ["rules": rulesDict] + } +} diff --git a/Tool/Sources/Preferences/Types/TerminalCommandsRules.swift b/Tool/Sources/Preferences/Types/TerminalCommandsRules.swift new file mode 100644 index 00000000..52dcbbfb --- /dev/null +++ b/Tool/Sources/Preferences/Types/TerminalCommandsRules.swift @@ -0,0 +1,24 @@ +import Foundation + +public struct TerminalCommandsRules: Codable, Equatable, RawRepresentable { + public var commands: [String: Bool] + + public init(commands: [String: Bool] = [:]) { + self.commands = commands + } + + public init?(rawValue: [String: Any]) { + let rulesDict = rawValue["commands"] as? [String: Any] ?? [:] + var parsedRules: [String: Bool] = [:] + for (key, value) in rulesDict { + if let autoApprove = value as? Bool { + parsedRules[key] = autoApprove + } + } + self.commands = parsedRules + } + + public var rawValue: [String: Any] { + return ["commands": commands] + } +} diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift index 6971134f..9055c3c3 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -10,11 +10,23 @@ public protocol UserDefaultsType { public extension UserDefaults { static var shared = UserDefaults(suiteName: userDefaultSuiteName)! + /// Workspace-level auto-approval storage. + /// + /// Backed by the `group..autoApproval.prefs` suite so it persists + /// across app restarts and is isolated from general app preferences. + static var autoApproval = UserDefaults(suiteName: autoApprovalUserDefaultSuiteName)! + static func setupDefaultSettings() { shared.setupDefaultValue(for: \.quitXPCServiceOnXcodeAndAppQuit) shared.setupDefaultValue(for: \.realtimeSuggestionToggle) + shared.setupDefaultValue(for: \.realtimeNESToggle) shared.setupDefaultValue(for: \.realtimeSuggestionDebounce) shared.setupDefaultValue(for: \.suggestionPresentationMode) + shared.setupDefaultValue(for: \.autoAttachChatToXcode) + shared.setupDefaultValue(for: \.enableFixError) + shared.setupDefaultValue(for: \.enableSubagent) + shared.setupDefaultValue(for: \.enableAutoApproval) + shared.setupDefaultValue(for: \.trustToolAnnotations) shared.setupDefaultValue(for: \.widgetColorScheme) shared.setupDefaultValue(for: \.customCommands) shared.setupDefaultValue( @@ -60,6 +72,10 @@ public extension UserDefaults { weight: .regular ))) ) + shared.setupDefaultValue( + for: \.fontScale, + defaultValue: shared.value(for: \.fontScale) + ) } } @@ -73,8 +89,10 @@ extension Bool: UserDefaultsStorable {} extension String: UserDefaultsStorable {} extension Data: UserDefaultsStorable {} extension URL: UserDefaultsStorable {} +extension Dictionary: UserDefaultsStorable {} + -extension Array: RawRepresentable where Element: Codable { +extension Array: @retroactive RawRepresentable where Element: Codable { public init?(rawValue: String) { guard let data = rawValue.data(using: .utf8), let result = try? JSONDecoder().decode([Element].self, from: data) @@ -303,3 +321,35 @@ public extension UserDefaultsType { } } +public extension UserDefaultsType { + // MARK: Dictionary Raw Representable + + func value( + for keyPath: KeyPath + ) -> K.Value where K.Value: RawRepresentable, K.Value.RawValue == [String: Any] { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? [String: Any] else { + return key.defaultValue + } + return K.Value(rawValue: rawValue) ?? key.defaultValue + } + + func set( + _ value: K.Value, + for keyPath: KeyPath + ) where K.Value: RawRepresentable, K.Value.RawValue == [String: Any] { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + set(value.rawValue, forKey: key.key) + } + + func setupDefaultValue( + for keyPath: KeyPath, + defaultValue: K.Value? = nil + ) where K.Value: RawRepresentable, K.Value.RawValue == [String: Any] { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + if value(forKey: key.key) == nil { + set(defaultValue?.rawValue ?? key.defaultValue.rawValue, forKey: key.key) + } + } +} + diff --git a/Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift b/Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift new file mode 100644 index 00000000..5e06037b --- /dev/null +++ b/Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// A small adaptive help link button that uses the native `HelpLink` on macOS 14+ +/// and falls back to a styled question-mark button on earlier versions. +public struct AdaptiveHelpLink: View { + let action: () -> Void + var controlSize: ControlSize = .small + + public init(controlSize: ControlSize = .small, action: @escaping () -> Void) { + self.controlSize = controlSize + self.action = action + } + + public var body: some View { + Group { + if #available(macOS 14.0, *) { + HelpLink(action: action) + } else { + Button(action: action) { + Image(systemName: "questionmark") + } + .clipShape(Circle()) + .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0) + .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5) + } + } + .controlSize(controlSize) + } +} diff --git a/Tool/Sources/SharedUIComponents/Base/Colors.swift b/Tool/Sources/SharedUIComponents/Base/Colors.swift new file mode 100644 index 00000000..9ab89738 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/Base/Colors.swift @@ -0,0 +1,45 @@ +import SwiftUI + +public extension Color { + static var hoverColor: Color { .gray.opacity(0.1) } + + static var chatWindowBackgroundColor: Color { Color("ChatWindowBackgroundColor") } + + static var successLightGreen: Color { Color("LightGreen") } + + static var agentToolStatusDividerColor: Color { Color("AgentToolStatusDividerColor") } + + static var agentToolStatusOutlineColor: Color { Color("AgentToolStatusOutlineColor") } +} + +public var QuinarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .quinarySystemFill) + } else { + return Color("QuinarySystemFillColor") + } +} + +public var QuaternarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .quaternarySystemFill) + } else { + return Color("QuaternarySystemFillColor") + } +} + +public var TertiarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .tertiarySystemFill) + } else { + return Color("TertiarySystemFillColor") + } +} + +public var SecondarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .secondarySystemFill) + } else { + return Color("SecondarySystemFillColor") + } +} diff --git a/Tool/Sources/SharedUIComponents/Base/FileIcon.swift b/Tool/Sources/SharedUIComponents/Base/FileIcon.swift new file mode 100644 index 00000000..62a647d1 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/Base/FileIcon.swift @@ -0,0 +1,119 @@ +import Foundation +import SwiftUI + +@ViewBuilder +public func drawFileIcon(_ file: URL?) -> some View { + let fileExtension = file?.pathExtension.lowercased() ?? "" + + switch fileExtension { + case "swift": + if let nsImage = NSImage(named: "SwiftIcon") { + Image(nsImage: nsImage) + .resizable() + } else { + Image(systemName: "doc.text") + .resizable() + } + case "md": + Text("M↓") + .scaledFont(size: 10, weight: .bold, design: .monospaced) + .foregroundColor(.indigo) + case "plist": + Image(systemName: "table") + .resizable() + case "xcconfig": + Image(systemName: "gearshape.2") + .resizable() + case "html": + Image(systemName: "chevron.left.slash.chevron.right") + .resizable() + .foregroundColor(.blue) + case "entitlements": + Image(systemName: "checkmark.seal.text.page") + .resizable() + .foregroundColor(.yellow) + case "sh": + Image(systemName: "terminal") + .resizable() + case "txt": + Image(systemName: "doc.plaintext") + .resizable() + case "c", "m", "mm": + Text("C") + .scaledFont(size: 12, weight: .bold, design: .monospaced) + .foregroundColor(.blue) + case "cpp": + Text("C++") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.blue) + case "h": + Text("h") + .scaledFont(size: 12, weight: .bold, design: .monospaced) + .foregroundColor(.blue) + case "xml": + Text("XML") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.orange) + case "yml", "yaml": + Text("YML") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.pink) + case "json": + Text("{}") + .scaledFont(size: 10, weight: .bold, design: .monospaced) + .foregroundColor(.red) + case "ts": + Text("TS") + .scaledFont(size: 10, weight: .bold, design: .monospaced) + .foregroundColor(.blue) + case "tsx": + Text("TSX") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.blue) + case "js": + Text("JS") + .scaledFont(size: 10, weight: .bold, design: .monospaced) + .foregroundColor(.yellow) + case "jsx": + Text("JSX") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.yellow) + case "css": + Text("CSS") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.purple) + case "py": + Text("PY") + .scaledFont(size: 10, weight: .bold, design: .monospaced) + .foregroundColor(.indigo) + case "xctestplan": + ZStack { + Text("P") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.blue) + RoundedRectangle(cornerRadius: 1.5) + .stroke(Color.blue, lineWidth: 1.5) + .scaledFrame(width: 10, height: 10) + .rotationEffect(.degrees(45)) + } + default: + Image(systemName: "doc.text") + .resizable() + } +} + +@ViewBuilder +public func drawFileIcon(_ file: URL?, isDirectory: Bool = false) -> some View { + if isDirectory { + if file?.lastPathComponent == "xcassets" { + Image(systemName: "photo.on.rectangle.angled") + .resizable() + .foregroundColor(.blue) + } else { + Image(systemName: "folder") + .resizable() + } + } else { + drawFileIcon(file) + } +} diff --git a/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift new file mode 100644 index 00000000..313346ad --- /dev/null +++ b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift @@ -0,0 +1,40 @@ +import SwiftUI + +// This is a custom button style that changes its background color when hovered +public struct HoverButtonStyle: ButtonStyle { + @State private var isHovered: Bool + private var padding: CGFloat + private var hoverColor: Color + private var backgroundColor: Color + private var cornerRadius: CGFloat + + public init( + isHovered: Bool = false, + padding: CGFloat = 4, + hoverColor: Color = .hoverColor, + backgroundColor: Color = .clear, + cornerRadius: CGFloat = 4 + ) { + self.isHovered = isHovered + self.padding = padding + self.hoverColor = hoverColor + self.backgroundColor = backgroundColor + self.cornerRadius = cornerRadius + } + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaledPadding(padding) + .background( + configuration.isPressed + ? Color.gray.opacity(0.2) + : isHovered + ? hoverColor + : backgroundColor + ) + .cornerRadius(cornerRadius) + .onHover { hover in + isHovered = hover + } + } +} diff --git a/Tool/Sources/SharedUIComponents/Base/HoverScrollView.swift b/Tool/Sources/SharedUIComponents/Base/HoverScrollView.swift new file mode 100644 index 00000000..ec9ec307 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/Base/HoverScrollView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +public struct HoverScrollView: View { + let content: Content + @State private var isHovered = false + + public init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + public var body: some View { + ScrollView(showsIndicators: isHovered) { + content + } + .onHover { hovering in + isHovered = hovering + } + } +} diff --git a/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift b/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift new file mode 100644 index 00000000..54edfe08 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift @@ -0,0 +1,118 @@ +import SwiftUI +import AppKit + +public struct CollapsibleSearchField: View { + @Binding public var searchText: String + @Binding public var isExpanded: Bool + public let placeholderString: String + + public init( + searchText: Binding, + isExpanded: Binding, + placeholderString: String = "Search..." + ) { + self._searchText = searchText + self._isExpanded = isExpanded + self.placeholderString = placeholderString + } + + public var body: some View { + Group { + if isExpanded { + SearchFieldRepresentable( + searchText: $searchText, + isExpanded: $isExpanded, + placeholderString: placeholderString + ) + .frame(width: 200, height: 24) + .transition(.opacity) + } else { + Button(action: { + isExpanded = true + }) { + Image(systemName: "magnifyingglass") + .font(.system(size: 13)) + } + .buttonStyle(.plain) + .frame(height: 24) + .transition(.opacity) + } + } + } +} + +private struct SearchFieldRepresentable: NSViewRepresentable { + @Binding var searchText: String + @Binding var isExpanded: Bool + let placeholderString: String + + func makeNSView(context: Context) -> NSSearchField { + let searchField = NSSearchField() + searchField.placeholderString = placeholderString + searchField.delegate = context.coordinator + searchField.target = context.coordinator + searchField.action = #selector(Coordinator.searchFieldDidChange(_:)) + + // Make the magnifying glass clickable to collapse + if let cell = searchField.cell as? NSSearchFieldCell { + cell.searchButtonCell?.target = context.coordinator + cell.searchButtonCell?.action = #selector(Coordinator.magnifyingGlassClicked(_:)) + } + + return searchField + } + + func updateNSView(_ nsView: NSSearchField, context: Context) { + if nsView.stringValue != searchText { + nsView.stringValue = searchText + } + + context.coordinator.isExpanded = $isExpanded + + // Auto-focus when expanded, only if not already first responder + if isExpanded && nsView.window?.firstResponder != nsView.currentEditor() { + DispatchQueue.main.async { + nsView.window?.makeFirstResponder(nsView) + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(searchText: $searchText, isExpanded: $isExpanded) + } + + class Coordinator: NSObject, NSSearchFieldDelegate, NSTextFieldDelegate { + @Binding var searchText: String + var isExpanded: Binding + + init(searchText: Binding, isExpanded: Binding) { + _searchText = searchText + self.isExpanded = isExpanded + } + + @objc func searchFieldDidChange(_ sender: NSSearchField) { + searchText = sender.stringValue + } + + @objc func magnifyingGlassClicked(_ sender: Any) { + // Collapse when magnifying glass is clicked + DispatchQueue.main.async { [weak self] in + withAnimation(.easeInOut(duration: 0.2)) { + self?.isExpanded.wrappedValue = false + } + } + } + + func controlTextDidEndEditing(_ obj: Notification) { + // Collapse search field when it loses focus and text is empty + if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + DispatchQueue.main.async { [weak self] in + withAnimation(.easeInOut(duration: 0.2)) { + self?.isExpanded.wrappedValue = false + self?.searchText = "" + } + } + } + } + } +} diff --git a/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift b/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift new file mode 100644 index 00000000..55cc15c7 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift @@ -0,0 +1,23 @@ +import SwiftUI + +public struct ConditionalFontWeight: ViewModifier { + let weight: Font.Weight? + + public init(weight: Font.Weight?) { + self.weight = weight + } + + public func body(content: Content) -> some View { + if #available(macOS 13.0, *), weight != nil { + content.fontWeight(weight) + } else { + content + } + } +} + +public extension View { + func conditionalFontWeight(_ weight: Font.Weight?) -> some View { + self.modifier(ConditionalFontWeight(weight: weight)) + } +} diff --git a/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift b/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift index 9192e83b..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)) @@ -59,23 +59,29 @@ struct CopilotIntroContent: View { .font(.title.bold()) .padding(.bottom, 38) - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 20) { CopilotIntroItem( imageName: "CopilotLogo", heading: "In-line Code Suggestions", - text: "Copilot's code suggestions and text completion now available in Xcode. Press Tab ⇥ to accept a suggestion." + text: "Receive context-aware code suggestions and text completion in your Xcode editor. Just press Tab ⇥ to accept a suggestion." ) CopilotIntroItem( systemImage: "option", - heading: "Full Suggestion", - text: "Press Option ⌥ key to display the full suggestion. Only the first line of suggestions are shown inline." + heading: "Full Suggestions", + text: "Press Option ⌥ for full multi-line suggestions. Only the first line is shown inline. Use Copilot Chat to refine, explain, or improve them." + ) + + CopilotIntroItem( + imageName: "ChatIcon", + heading: "Chat", + text: "Get real-time coding assistance, debug issues, and generate code snippets directly within Xcode." ) CopilotIntroItem( imageName: "GitHubMark", heading: "GitHub Context", - text: "Copilot utilizes project context to deliver smarter code suggestions relevant to your unique codebase." + text: "Copilot gives smarter code suggestions using your GitHub and project context. Use chat to discuss your code, debug issues, or get explanations." ) } .padding(.bottom, 64) diff --git a/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift b/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift new file mode 100644 index 00000000..34494137 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift @@ -0,0 +1,31 @@ +import SwiftUI + +public struct CopilotMessageHeader: View { + let spacing: CGFloat + + public init(spacing: CGFloat = 4) { + self.spacing = spacing + } + + public var body: some View { + 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") + .scaledFont(size: 13, weight: .semibold) + .padding(.leading, 4) + + Spacer() + } + } +} diff --git a/Tool/Sources/SharedUIComponents/CopyButton.swift b/Tool/Sources/SharedUIComponents/CopyButton.swift index 022e84df..e705d183 100644 --- a/Tool/Sources/SharedUIComponents/CopyButton.swift +++ b/Tool/Sources/SharedUIComponents/CopyButton.swift @@ -4,9 +4,13 @@ import SwiftUI public struct CopyButton: View { public var copy: () -> Void @State var isCopied = false + private var foregroundColor: Color? + private var fontWeight: Font.Weight? - public init(copy: @escaping () -> Void) { + public init(copy: @escaping () -> Void, foregroundColor: Color? = nil, fontWeight: Font.Weight? = nil) { self.copy = copy + self.foregroundColor = foregroundColor + self.fontWeight = fontWeight } public var body: some View { @@ -24,16 +28,13 @@ public struct CopyButton: View { }) { Image(systemName: isCopied ? "checkmark.circle" : "doc.on.doc") .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - .frame(width: 20, height: 20, alignment: .center) - .foregroundColor(.secondary) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: 4, style: .circular) - ) - .padding(4) + .scaledToFit() + .scaledPadding(2) + .scaledFrame(width: 16, height: 16) + .foregroundColor(foregroundColor ?? .secondary) + .conditionalFontWeight(fontWeight) } - .buttonStyle(.borderless) + .buttonStyle(HoverButtonStyle(padding: 0)) + .help("Copy") } } diff --git a/Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift b/Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift new file mode 100644 index 00000000..2758a5cf --- /dev/null +++ b/Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift @@ -0,0 +1,195 @@ +import SwiftUI +import ConversationServiceProvider +import AppKitExtension + +public struct CreateCustomCopilotFileView: View { + public let promptType: PromptType + public let editorPluginVersion: String + public let getCurrentProjectURL: () async -> URL? + public let onSuccess: (String) -> Void + public let onError: (String) -> Void + + @State private var fileName = "" + @State private var projectURL: URL? + @State private var fileAlreadyExists = false + + @Environment(\.dismiss) private var dismiss + + public init( + promptType: PromptType, + editorPluginVersion: String, + getCurrentProjectURL: @escaping () async -> URL?, + onSuccess: @escaping (String) -> Void, + onError: @escaping (String) -> Void + ) { + self.promptType = promptType + self.editorPluginVersion = editorPluginVersion + self.getCurrentProjectURL = getCurrentProjectURL + self.onSuccess = onSuccess + self.onError = onError + } + + public var body: some View { + Form { + VStack(alignment: .center, spacing: 20) { + HStack(alignment: .center) { + Spacer() + Text("Create \(promptType.displayName)").font(.headline) + Spacer() + AdaptiveHelpLink(action: openHelpLink) + } + + // Content + VStack(alignment: .leading, spacing: 4) { + TextFieldsContainer { + TextField("File name", text: Binding( + get: { fileName }, + set: { newValue in + fileName = newValue + updateFileExistence() + } + )) + .disableAutocorrection(true) + .textContentType(.none) + .onSubmit { + Task { await createPromptFile() } + } + } + + validationMessageView + } + + HStack(spacing: 8) { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button("Create") { Task { await createPromptFile() } } + .buttonStyle(.borderedProminent) + .disabled(disableCreateButton) + .keyboardShortcut(.defaultAction) + } + } + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + .padding(20) + } + .frame(width: 350, height: 190) + .onAppear { + fileName = "" + Task { await resolveProjectURL() } + } + } + + // MARK: - Derived values + + private var trimmedFileName: String { + fileName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var disableCreateButton: Bool { + trimmedFileName.isEmpty || fileAlreadyExists + } + + @ViewBuilder + private var validationMessageView: some View { + HStack(alignment: .center, spacing: 6) { + if fileAlreadyExists && !trimmedFileName.isEmpty { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + Text("'.github/\(promptType.directoryName)/\(trimmedFileName)\(promptType.fileExtension)' already exists") + .font(.caption) + .foregroundColor(.red) + .lineLimit(2) + .multilineTextAlignment(.leading) + .truncationMode(.middle) + .fixedSize(horizontal: false, vertical: true) + } else if trimmedFileName.isEmpty { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + Text("Enter the name of \(promptType.rawValue) file") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("Location:") + .foregroundColor(.primary) + .padding(.leading, 10) + .layoutPriority(1) + Text(".github/\(promptType.directoryName)/\(trimmedFileName)\(promptType.fileExtension)") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .truncationMode(.middle) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 2) + } + + // MARK: - Actions / Helpers + + private func openHelpLink() { + if let url = URL(string: promptType.helpLink(editorPluginVersion: editorPluginVersion)) { + NSWorkspace.shared.open(url) + } + } + + /// Resolves the active project URL (if any) and updates state. + private func resolveProjectURL() async { + let projectURL = await getCurrentProjectURL() + await MainActor.run { + self.projectURL = projectURL + updateFileExistence() + } + } + + private func updateFileExistence() { + let name = trimmedFileName + guard !name.isEmpty, let projectURL else { + fileAlreadyExists = false + return + } + let filePath = promptType.getFilePath(fileName: name, projectURL: projectURL) + fileAlreadyExists = FileManager.default.fileExists(atPath: filePath.path) + } + + /// Creates the prompt file if it doesn't already exist. + private func createPromptFile() async { + guard let projectURL else { + await MainActor.run { + onError("No active workspace found") + } + return + } + + let directoryPath = promptType.getDirectoryPath(projectURL: projectURL) + let filePath = promptType.getFilePath(fileName: trimmedFileName, projectURL: projectURL) + + // Re-check existence to avoid race with external creation. + if FileManager.default.fileExists(atPath: filePath.path) { + await MainActor.run { + self.fileAlreadyExists = true + onError("\(promptType.displayName) '\(trimmedFileName)\(promptType.fileExtension)' already exists") + } + return + } + + do { + try FileManager.default.createDirectory( + at: directoryPath, + withIntermediateDirectories: true + ) + + try promptType.defaultTemplate.write(to: filePath, atomically: true, encoding: .utf8) + + await MainActor.run { + onSuccess("Created \(promptType.rawValue) file '\(trimmedFileName)\(promptType.fileExtension)'") + NSWorkspace.openFileInXcode(fileURL: filePath) + dismiss() + } + } catch { + await MainActor.run { + onError("Failed to create \(promptType.rawValue) file: \(error)") + } + } + } +} diff --git a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift index d001da8e..3110838f 100644 --- a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift +++ b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift @@ -1,49 +1,60 @@ 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, - 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) } } @@ -54,27 +65,34 @@ 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 @@ -84,17 +102,43 @@ public struct CustomTextEditor: NSViewRepresentable { textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false + textView.setAccessibilityLabel("Chat Input, Ask Copilot. Type to ask questions or type / for topics, press enter to send out the request. Use the Chat Accessibility Help command for more information.") + + // 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 - return context.coordinator.theTextView + // Configure scroll view + let scrollView = context.coordinator.theTextView + scrollView.hasHorizontalScroller = false + 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) + } + } } @@ -103,21 +147,108 @@ public extension CustomTextEditor { var view: CustomTextEditor var theTextView = NSTextView.scrollableTextView() var affectedCharRange: NSRange? - var completions: (String, [String], _ range: NSRange) -> [String] = { _, _, _ in [] } 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 @@ -142,16 +273,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) - } } } - diff --git a/Tool/Sources/SharedUIComponents/DestructiveButtonStyle.swift b/Tool/Sources/SharedUIComponents/DestructiveButtonStyle.swift new file mode 100644 index 00000000..3896b01f --- /dev/null +++ b/Tool/Sources/SharedUIComponents/DestructiveButtonStyle.swift @@ -0,0 +1,20 @@ +import SwiftUI + +public struct DestructiveButtonStyle: ButtonStyle { + public init() {} + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.red) + .padding(.horizontal, 13) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.red.opacity(0.25)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .fill(Color.black.opacity(configuration.isPressed ? 0.15 : 0)) + ) + ) + } +} diff --git a/Tool/Sources/SharedUIComponents/DownvoteButton.swift b/Tool/Sources/SharedUIComponents/DownvoteButton.swift index 33d6ec97..b61e423c 100644 --- a/Tool/Sources/SharedUIComponents/DownvoteButton.swift +++ b/Tool/Sources/SharedUIComponents/DownvoteButton.swift @@ -17,16 +17,12 @@ public struct DownvoteButton: View { }) { Image(systemName: isSelected ? "hand.thumbsdown.fill" : "hand.thumbsdown") .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - .frame(width: 20, height: 20, alignment: .center) + .scaledToFit() + .scaledPadding(2) + .scaledFrame(width: 16, height: 16) .foregroundColor(.secondary) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: 4, style: .circular) - ) - .padding(4) + .help("Unhelpful") } - .buttonStyle(.borderless) + .buttonStyle(HoverButtonStyle(padding: 0)) } } diff --git a/Tool/Sources/SharedUIComponents/InsertButton.swift b/Tool/Sources/SharedUIComponents/InsertButton.swift new file mode 100644 index 00000000..a6aca8c5 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/InsertButton.swift @@ -0,0 +1,30 @@ +import SwiftUI + +public struct InsertButton: View { + public var insert: () -> Void + + @Environment(\.colorScheme) var colorScheme + + private var icon: Image { + return Image("CodeBlockInsertIcon") + } + + public init(insert: @escaping () -> Void) { + self.insert = insert + } + + public var body: some View { + Button(action: { + insert() + }) { + self.icon + .resizable() + .scaledToFit() + .scaledPadding(2) + .scaledFrame(width: 16, height: 16) + .foregroundColor(.secondary) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .help("Insert at Cursor") + } +} diff --git a/Tool/Sources/SharedUIComponents/InstructionView.swift b/Tool/Sources/SharedUIComponents/InstructionView.swift new file mode 100644 index 00000000..8a17b57b --- /dev/null +++ b/Tool/Sources/SharedUIComponents/InstructionView.swift @@ -0,0 +1,64 @@ +import ComposableArchitecture +import SwiftUI + +public struct Instruction: View { + @Binding var isAgentMode: Bool + + public init(isAgentMode: Binding) { + self._isAgentMode = isAgentMode + } + + public var body: some View { + WithPerceptionTracking { + VStack { + VStack(spacing: 24) { + + VStack(spacing: 16) { + Image("CopilotLogo") + .resizable() + .renderingMode(.template) + .scaledToFill() + .scaledFrame(width: 60.0, height: 60.0) + .foregroundColor(.secondary) + + if isAgentMode { + Text("Copilot Agent Mode") + .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.") + .scaledFont(size: 14, weight: .light) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + + Text("Copilot is powered by AI, so mistakes are possible. Review output carefully before use.") + .scaledFont(size: 14, weight: .light) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + + VStack(alignment: .leading, spacing: 8) { + if isAgentMode { + Label("to configure MCP server", systemImage: "wrench.and.screwdriver") + .foregroundColor(Color("DescriptionForegroundColor")) + .scaledFont(.system(size: 14)) + } + Label("to reference context", systemImage: "paperclip") + .foregroundColor(Color("DescriptionForegroundColor")) + .scaledFont(.system(size: 14)) + if !isAgentMode { + Text("@ to chat with extensions") + .foregroundColor(Color("DescriptionForegroundColor")) + .scaledFont(.system(size: 14)) + Text("Type / to use commands") + .foregroundColor(Color("DescriptionForegroundColor")) + .scaledFont(.system(size: 14)) + } + } + } + }.scaledFrame(maxWidth: 350) + } + } +} + diff --git a/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift b/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift new file mode 100644 index 00000000..bee224dd --- /dev/null +++ b/Tool/Sources/SharedUIComponents/MixedStateCheckbox.swift @@ -0,0 +1,78 @@ +import SwiftUI +import AppKit + +public enum CheckboxMixedState { + case off, mixed, on +} + +public struct MixedStateCheckbox: View { + let title: String + let font: NSFont + let action: () -> Void + + @Binding var state: CheckboxMixedState + + public init(title: String, font: NSFont, state: Binding, action: @escaping () -> Void) { + self.title = title + self.font = font + self.action = action + self._state = state + } + + public var body: some View { + MixedStateCheckboxView(title: title, font: font, state: state, action: action) + } +} + +private struct MixedStateCheckboxView: NSViewRepresentable { + let title: String + let font: NSFont + let state: CheckboxMixedState + let action: () -> Void + + func makeNSView(context: Context) -> NSButton { + let button = NSButton() + button.setButtonType(.switch) + button.allowsMixedState = true + button.title = title + button.font = font + button.target = context.coordinator + button.action = #selector(Coordinator.onButtonClicked) + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + return button + } + + func makeCoordinator() -> Coordinator { + Coordinator(action: action) + } + + class Coordinator: NSObject { + let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + @objc func onButtonClicked() { + action() + } + } + + func updateNSView(_ nsView: NSButton, context: Context) { + if nsView.font != font { + nsView.font = font + } + + nsView.title = title + + switch state { + case .off: + nsView.state = .off + case .mixed: + nsView.state = .mixed + case .on: + nsView.state = .on + } + } +} diff --git a/Tool/Sources/SharedUIComponents/OverlayScrollView.swift b/Tool/Sources/SharedUIComponents/OverlayScrollView.swift new file mode 100644 index 00000000..751d3d4f --- /dev/null +++ b/Tool/Sources/SharedUIComponents/OverlayScrollView.swift @@ -0,0 +1,45 @@ +import SwiftUI +import AppKit + +public struct OverlayScrollView: NSViewRepresentable { + let showsVerticalScroller: Bool + let showsHorizontalScroller: Bool + let content: Content + + public init(showsVerticalScroller: Bool = true, + showsHorizontalScroller: Bool = false, + @ViewBuilder content: () -> Content) { + self.showsVerticalScroller = showsVerticalScroller + self.showsHorizontalScroller = showsHorizontalScroller + self.content = content() + } + + public func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = showsVerticalScroller + scrollView.hasHorizontalScroller = showsHorizontalScroller + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.verticalScrollElasticity = .automatic + scrollView.horizontalScrollElasticity = .automatic + + let hosting = NSHostingView(rootView: content) + hosting.translatesAutoresizingMaskIntoConstraints = false + + scrollView.documentView = hosting + + if let docView = scrollView.contentView.documentView { + docView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor).isActive = true + docView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor).isActive = true + docView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor).isActive = true + } + return scrollView + } + + public func updateNSView(_ nsView: NSScrollView, context: Context) { + if let hosting = nsView.documentView as? NSHostingView { + hosting.rootView = content + } + } +} diff --git a/Tool/Sources/SharedUIComponents/ScaledComponent/FontScaleManager.swift b/Tool/Sources/SharedUIComponents/ScaledComponent/FontScaleManager.swift new file mode 100644 index 00000000..f7b25d55 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/FontScaleManager.swift @@ -0,0 +1,104 @@ +import SwiftUI +import Combine + +extension Notification.Name { + static let fontScaleDidChange = Notification + .Name("com.github.CopilotForXcode.FontScaleDidChange") +} + +@MainActor +public class FontScaleManager: ObservableObject { + @AppStorage(\.fontScale) private var fontScale { + didSet { + // Only post notification if this change originated locally + postNotificationIfNeeded() + } + } + + public static let shared: FontScaleManager = .init() + + public static let maxScale: Double = 2.0 + public static let minScale: Double = 0.8 + public static let scaleStep: Double = 0.1 + public static let defaultScale: Double = 1.0 + + private let processIdentifier = UUID().uuidString + private var lastReceivedNotificationId: String? + + private init() { + // Listen for font scale changes from other processes + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleFontScaleChanged(_:)), + name: .fontScaleDidChange, + object: nil + ) + } + + deinit { + DistributedNotificationCenter.default().removeObserver(self) + } + + private func postNotificationIfNeeded() { + // Don't post notification if we're processing an external notification + guard lastReceivedNotificationId == nil else { return } + + let notificationId = UUID().uuidString + DistributedNotificationCenter.default().postNotificationName( + .fontScaleDidChange, + object: nil, + userInfo: [ + "fontScale": fontScale, + "sourceProcess": processIdentifier, + "notificationId": notificationId + ], + deliverImmediately: true + ) + } + + @objc private func handleFontScaleChanged(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let scale = userInfo["fontScale"] as? Double, + let sourceProcess = userInfo["sourceProcess"] as? String, + let notificationId = userInfo["notificationId"] as? String else { + return + } + + // Ignore notifications from this process + guard sourceProcess != processIdentifier else { return } + + // Ignore duplicate notifications + guard notificationId != lastReceivedNotificationId else { return } + + // Only update if the value actually changed (with epsilon for floating-point) + guard abs(fontScale - scale) > 0.001 else { return } + + lastReceivedNotificationId = notificationId + fontScale = scale + lastReceivedNotificationId = nil + } + + public func increaseFontScale() { + fontScale = min(fontScale + Self.scaleStep, Self.maxScale) + } + + public func decreaseFontScale() { + fontScale = max(fontScale - Self.scaleStep, Self.minScale) + } + + public func setFontScale(_ scale: Double) { + guard scale <= Self.maxScale && scale >= Self.minScale else { + return + } + + fontScale = scale + } + + public func resetFontScale() { + fontScale = Self.defaultScale + } + + public var currentScale: Double { + fontScale + } +} diff --git a/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFont.swift b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFont.swift new file mode 100644 index 00000000..2d01ebcc --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFont.swift @@ -0,0 +1,82 @@ +import SwiftUI +import AppKit + +// MARK: built-in fonts +// Refer to https://developer.apple.com/design/human-interface-guidelines/typography#macOS-built-in-text-styles +extension Font { + + public var builtinSize: CGFloat { + let textStyle = nsTextStyle ?? .body + + return NSFont.preferredFont(forTextStyle: textStyle).pointSize + } + + // Map SwiftUI Font to NSFont.TextStyle + private var nsTextStyle: NSFont.TextStyle? { + switch self { + case .largeTitle: .largeTitle + case .title: .title1 + case .title2: .title2 + case .title3: .title3 + case .headline: .headline + case .subheadline: .subheadline + case .body: .body + case .callout: .callout + case .footnote: .footnote + case .caption: .caption1 + case .caption2: .caption2 + default: nil + } + } + + var builtinWeight: Font.Weight { + switch self { + case .headline: .bold + case .caption2: .medium + default: .regular + } + } +} + +public extension View { + func scaledFont(_ font: Font) -> some View { + ScaledFontView(self, font: font) + } + + func scaledFont(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> some View { + ScaledFontView(self, size: size, weight: weight, design: design) + } +} + + +public struct ScaledFontView: View { + let fontSize: CGFloat + let fontWeight: Font.Weight + var fontDesign: Font.Design + let content: Content + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + init(_ content: Content, font: Font) { + self.fontSize = font.builtinSize + self.fontWeight = font.builtinWeight + self.fontDesign = .default + self.content = content + } + + public init(_ content: Content, size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) { + self.fontSize = size + self.fontWeight = weight + self.fontDesign = design + self.content = content + } + + public var body: some View { + content + .font(.system(size: fontSize * fontScale, weight: fontWeight, design: fontDesign)) + } +} diff --git a/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift new file mode 100644 index 00000000..08c33882 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift @@ -0,0 +1,127 @@ +import SwiftUI + +extension View { + public func scaledFrame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View { + ScaledFrameView(self, width: width, height: height, alignment: alignment) + } + + /// Applies a scaled frame to the target view based on the current font scaling factor. + /// Use this function only when the target view requires dynamic scaling to adapt to font size changes. + public func scaledFrame( + minWidth: CGFloat? = nil, + idealWidth: CGFloat? = nil, + maxWidth: CGFloat? = nil, + minHeight: CGFloat? = nil, + idealHeight: CGFloat? = nil, + maxHeight: CGFloat? = nil, + alignment: Alignment = .center + ) -> some View { + ScaledConstraintFrameView( + self, + minWidth: minWidth, + idealWidth: idealWidth, + maxWidth: maxWidth, + minHeight: minHeight, + idealHeight: idealHeight, + maxHeight: maxHeight, + alignment: alignment + ) + } +} + +struct ScaledFrameView: View { + let content: Content + let width: CGFloat? + let height: CGFloat? + let alignment: Alignment + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + var scaledWidth: CGFloat? { + guard let width else { + return nil + } + return width * fontScale + } + + var scaledHeight: CGFloat? { + guard let height else { + return nil + } + return height * fontScale + } + + init(_ content: Content, width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) { + self.content = content + self.width = width + self.height = height + self.alignment = alignment + } + + var body: some View { + content + .frame(width: scaledWidth, height: scaledHeight, alignment: alignment) + } +} + +struct ScaledConstraintFrameView: View { + let content: Content + let minWidth: CGFloat? + let idealWidth: CGFloat? + let maxWidth: CGFloat? + let minHeight: CGFloat? + let idealHeight: CGFloat? + let maxHeight: CGFloat? + let alignment: Alignment + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + private func getScaledValue(_ v: CGFloat?) -> CGFloat? { + guard let v = v else { + return nil + } + + return v * fontScale + } + + init( + _ content: Content, + minWidth: CGFloat? = nil, + idealWidth: CGFloat? = nil, + maxWidth: CGFloat? = nil, + minHeight: CGFloat? = nil, + idealHeight: CGFloat? = nil, + maxHeight: CGFloat? = nil, + alignment: Alignment = .center + ) { + self.content = content + self.minWidth = minWidth + self.idealWidth = idealWidth + self.maxWidth = maxWidth + self.minHeight = minHeight + self.idealHeight = idealHeight + self.maxHeight = maxHeight + self.alignment = alignment + } + + var body: some View { + content + .frame( + minWidth: getScaledValue(minWidth), + idealWidth: getScaledValue(idealWidth), + maxWidth: getScaledValue(maxWidth), + minHeight: getScaledValue(minHeight), + idealHeight: getScaledValue(idealHeight), + maxHeight: getScaledValue(maxHeight), + alignment: alignment + ) + } +} diff --git a/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledModifier.swift b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledModifier.swift new file mode 100644 index 00000000..a5f51378 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledModifier.swift @@ -0,0 +1,66 @@ +import SwiftUI + +// MARK: - padding +public extension View { + func scaledPadding(_ length: CGFloat?) -> some View { + scaledPadding(.all, length) + } + + func scaledPadding(_ edges: Edge.Set = .all, _ length: CGFloat? = nil) -> some View { + ScaledPaddingView(self, edges: edges, length: length) + } +} + +struct ScaledPaddingView: View { + let content: Content + let edges: Edge.Set + let length: CGFloat? + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + init(_ content: Content, edges: Edge.Set, length: CGFloat? = nil) { + self.content = content + self.edges = edges + self.length = length + } + + var body: some View { + content + .padding(edges, length.map { $0 * fontScale }) + } +} + + +// MARK: - scaleEffect +public extension View { + func scaledScaleEffect(_ s: CGFloat, anchor: UnitPoint = .center) -> some View { + ScaledScaleEffectView(self, s, anchor: anchor) + } +} + +struct ScaledScaleEffectView: View { + let content: Content + let s: CGFloat + let anchor: UnitPoint + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + init(_ content: Content, _ s: CGFloat, anchor: UnitPoint = .center) { + self.content = content + self.s = s + self.anchor = anchor + } + + var body: some View { + content + .scaleEffect(s * fontScale, anchor: anchor) + } +} diff --git a/Tool/Sources/SharedUIComponents/SplitButton.swift b/Tool/Sources/SharedUIComponents/SplitButton.swift new file mode 100644 index 00000000..8ddead7b --- /dev/null +++ b/Tool/Sources/SharedUIComponents/SplitButton.swift @@ -0,0 +1,292 @@ +import SwiftUI +import AppKit + +// MARK: - SplitButton Menu Item + +public struct SplitButtonMenuItem: Identifiable { + public enum Kind { + case action(() -> Void) + case divider + case header + } + + public let id: UUID + public let title: String + public let kind: Kind + + public init(title: String, action: @escaping () -> Void) { + self.id = UUID() + self.title = title + self.kind = .action(action) + } + + private init(id: UUID = UUID(), title: String, kind: Kind) { + self.id = id + self.title = title + self.kind = kind + } + + public static func divider(id: UUID = UUID()) -> SplitButtonMenuItem { + .init(id: id, title: "", kind: .divider) + } + + public static func header(_ title: String, id: UUID = UUID()) -> SplitButtonMenuItem { + .init(id: id, title: title, kind: .header) + } +} + +@available(macOS 13.0, *) +private enum SplitButtonMenuBuilder { + static func buildMenu( + items: [SplitButtonMenuItem], + pullsDownCoverItem: Bool, + target: NSObject, + action: Selector, + menuItemActions: inout [UUID: () -> Void] + ) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + menuItemActions.removeAll() + + if pullsDownCoverItem { + // First item is the "cover" item for pullsDown + menu.addItem(NSMenuItem(title: "", action: nil, keyEquivalent: "")) + } + + for item in items { + switch item.kind { + case .divider: + menu.addItem(.separator()) + + case .header: + if #available(macOS 14.0, *) { + menu.addItem(NSMenuItem.sectionHeader(title: item.title)) + } else { + let headerItem = NSMenuItem() + headerItem.title = item.title + headerItem.isEnabled = false + menu.addItem(headerItem) + } + + case .action(let handler): + let menuItem = NSMenuItem( + title: item.title, + action: action, + keyEquivalent: "" + ) + menuItem.target = target + menuItem.representedObject = item.id + menuItemActions[item.id] = handler + menu.addItem(menuItem) + } + } + + return menu + } +} + +// MARK: - SplitButton using NSComboButton + +@available(macOS 13.0, *) +public struct SplitButton: View { + let title: String + let primaryAction: () -> Void + let isDisabled: Bool + let menuItems: [SplitButtonMenuItem] + var style: SplitButtonStyle + + @AppStorage(\.fontScale) private var fontScale + + public enum SplitButtonStyle { + case standard + case prominent + } + + public init( + title: String, + isDisabled: Bool = false, + primaryAction: @escaping () -> Void, + menuItems: [SplitButtonMenuItem] = [], + style: SplitButtonStyle = .standard + ) { + self.title = title + self.isDisabled = isDisabled + self.primaryAction = primaryAction + self.menuItems = menuItems + self.style = style + } + + public var body: some View { + switch style { + case .standard: + SplitButtonRepresentable( + title: title, + isDisabled: isDisabled, + primaryAction: primaryAction, + menuItems: menuItems + ) + case .prominent: + HStack(spacing: 0) { + Button(action: primaryAction) { + Text(title) + .scaledFont(.body) + .padding(.horizontal, 6) + .padding(.vertical, 4) + } + .buttonStyle(.borderless) + + Rectangle() + .fill(Color.white.opacity(0.2)) + .frame(width: fontScale) + .padding(.vertical, 4) + + ProminentMenuButton( + menuItems: menuItems, + isDisabled: isDisabled + ) + .frame(width: 16) + } + .background(Color.accentColor) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .disabled(isDisabled) + .opacity(isDisabled ? 0.5 : 1) + } + } +} + +@available(macOS 13.0, *) +private struct ProminentMenuButton: NSViewRepresentable { + let menuItems: [SplitButtonMenuItem] + let isDisabled: Bool + + func makeNSView(context: Context) -> NSPopUpButton { + let button = NSPopUpButton(frame: .zero, pullsDown: true) + button.bezelStyle = .smallSquare + button.isBordered = false + button.imagePosition = .imageOnly + + updateImage(for: button) + + button.contentTintColor = .white + + return button + } + + func updateNSView(_ nsView: NSPopUpButton, context: Context) { + nsView.isEnabled = !isDisabled + nsView.contentTintColor = isDisabled ? NSColor.white.withAlphaComponent(0.5) : .white + + updateImage(for: nsView) + + context.coordinator.updateMenu(for: nsView, with: menuItems) + } + + private func updateImage(for button: NSPopUpButton) { + let config = NSImage.SymbolConfiguration(textStyle: .body) + let image = NSImage(systemSymbolName: "chevron.down", accessibilityDescription: "More options")? + .withSymbolConfiguration(config) + button.image = image + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: NSObject { + private var menuItemActions: [UUID: () -> Void] = [:] + + func updateMenu(for button: NSPopUpButton, with items: [SplitButtonMenuItem]) { + button.menu = SplitButtonMenuBuilder.buildMenu( + items: items, + pullsDownCoverItem: true, + target: self, + action: #selector(handleMenuItemAction(_:)), + menuItemActions: &menuItemActions + ) + } + + @objc func handleMenuItemAction(_ sender: NSMenuItem) { + if let itemId = sender.representedObject as? UUID, + let action = menuItemActions[itemId] { + action() + } + } + } +} + +@available(macOS 13.0, *) +struct SplitButtonRepresentable: NSViewRepresentable { + let title: String + let primaryAction: () -> Void + let isDisabled: Bool + let menuItems: [SplitButtonMenuItem] + + init( + title: String, + isDisabled: Bool = false, + primaryAction: @escaping () -> Void, + menuItems: [SplitButtonMenuItem] = [] + ) { + self.title = title + self.isDisabled = isDisabled + self.primaryAction = primaryAction + self.menuItems = menuItems + } + + func makeNSView(context: Context) -> NSComboButton { + let button = NSComboButton() + + button.title = title + button.target = context.coordinator + button.action = #selector(Coordinator.handlePrimaryAction) + button.isEnabled = !isDisabled + + + context.coordinator.button = button + context.coordinator.updateMenu(with: menuItems) + + return button + } + + func updateNSView(_ nsView: NSComboButton, context: Context) { + nsView.title = title + nsView.isEnabled = !isDisabled + context.coordinator.updateMenu(with: menuItems) + } + + func makeCoordinator() -> Coordinator { + Coordinator(primaryAction: primaryAction) + } + + class Coordinator: NSObject { + let primaryAction: () -> Void + weak var button: NSComboButton? + private var menuItemActions: [UUID: () -> Void] = [:] + + init(primaryAction: @escaping () -> Void) { + self.primaryAction = primaryAction + } + + @objc func handlePrimaryAction() { + primaryAction() + } + + @objc func handleMenuItemAction(_ sender: NSMenuItem) { + if let itemId = sender.representedObject as? UUID, + let action = menuItemActions[itemId] { + action() + } + } + + func updateMenu(with items: [SplitButtonMenuItem]) { + button?.menu = SplitButtonMenuBuilder.buildMenu( + items: items, + pullsDownCoverItem: false, + target: self, + action: #selector(handleMenuItemAction(_:)), + menuItemActions: &menuItemActions + ) + } + } +} diff --git a/Tool/Sources/SharedUIComponents/TextFieldsContainer.swift b/Tool/Sources/SharedUIComponents/TextFieldsContainer.swift new file mode 100644 index 00000000..b4c9bcc9 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/TextFieldsContainer.swift @@ -0,0 +1,25 @@ +import SwiftUI + +public struct TextFieldsContainer: View { + let content: Content + + public init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + public var body: some View { + VStack(spacing: 8) { + content + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(QuaternarySystemFillColor.opacity(0.75)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .inset(by: 0.5) + .stroke(SecondarySystemFillColor, lineWidth: 1) + ) + } +} diff --git a/Tool/Sources/SharedUIComponents/UpvoteButton.swift b/Tool/Sources/SharedUIComponents/UpvoteButton.swift index 40c985c1..1af8ebf7 100644 --- a/Tool/Sources/SharedUIComponents/UpvoteButton.swift +++ b/Tool/Sources/SharedUIComponents/UpvoteButton.swift @@ -17,16 +17,12 @@ public struct UpvoteButton: View { }) { Image(systemName: isSelected ? "hand.thumbsup.fill" : "hand.thumbsup") .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - .frame(width: 20, height: 20, alignment: .center) + .scaledToFit() + .scaledPadding(2) + .scaledFrame(width: 16, height: 16) .foregroundColor(.secondary) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: 4, style: .circular) - ) - .padding(4) + .help("Helpful") } - .buttonStyle(.borderless) + .buttonStyle(HoverButtonStyle(padding: 0)) } } diff --git a/Tool/Sources/Status/Status.swift b/Tool/Sources/Status/Status.swift index 5cc7e9a0..8a5f2ff4 100644 --- a/Tool/Sources/Status/Status.swift +++ b/Tool/Sources/Status/Status.swift @@ -1,50 +1,30 @@ import AppKit +import Preferences import Foundation -public enum ExtensionPermissionStatus { - case unknown - case succeeded - case failed +@objc public enum ExtensionPermissionStatus: Int { + case unknown = -1, notGranted = 0, disabled = 1, granted = 2 } @objc public enum ObservedAXStatus: Int { - case unknown = -1 - case granted = 1 - case notGranted = 0 + case unknown = -1, granted = 1, notGranted = 0 } -public struct CLSStatus: Equatable { - public enum Status { - case unknown - case normal - case inProgress - case error - case warning - case inactive - } - - public let status: Status - public let message: String - - public var isInactiveStatus: Bool { - status == .inactive && !message.isEmpty - } - - public var isErrorStatus: Bool { - (status == .warning || status == .error) && !message.isEmpty - } +private struct AuthStatusInfo { + let authIcon: StatusResponse.Icon? + let authStatus: AuthStatus.Status + let userName: String? } -public struct AuthStatus: Equatable { - public enum Status { - case unknown - case loggedIn - case notLoggedIn - } +private struct CLSStatusInfo { + let icon: StatusResponse.Icon? + let message: String +} - public let status: Status - public let username: String? - public let message: String? +private struct AccessibilityStatusInfo { + let icon: StatusResponse.Icon? + let message: String? + let url: String? } public extension Notification.Name { @@ -52,40 +32,41 @@ public extension Notification.Name { static let serviceStatusDidChange = Notification.Name("com.github.CopilotForXcode.serviceStatusDidChange") } -public struct StatusResponse { - public struct Icon { - public let name: String - - public init(name: String) { - self.name = name - } - - public var nsImage: NSImage? { - NSImage(named: name) - } - } - - public let icon: Icon - public let inProgress: Bool - public let message: String? - public let url: String? - public let authMessage: String -} +private var currentUserName: String? = nil +private var currentUserCopilotPlan: String? = nil public final actor Status { public static let shared = Status() private var extensionStatus: ExtensionPermissionStatus = .unknown private var axStatus: ObservedAXStatus = .unknown - private var clsStatus = CLSStatus(status: .unknown, message: "") + 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() {} + 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 } extensionStatus = status @@ -98,116 +79,136 @@ public final actor Status { broadcast() } - public func updateCLSStatus(_ status: CLSStatus.Status, message: String) { - let newStatus = CLSStatus(status: status, message: message) + public func updateCLSStatus(_ status: CLSStatus.Status, busy: Bool, message: String) { + let newStatus = CLSStatus(status: status, busy: busy, message: message) guard newStatus != clsStatus else { return } clsStatus = newStatus broadcast() } 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 broadcast() } + + public func getExtensionStatus() -> ExtensionPermissionStatus { + extensionStatus + } public func getAXStatus() -> ObservedAXStatus { - // if Xcode is running, return the observed status if isXcodeRunning() { return axStatus } else if AXIsProcessTrusted() { - // if Xcode is not running but AXIsProcessTrusted() is true, return granted return .granted } else { - // otherwise, return the last observed status, which may be unknown return axStatus } } private func isXcodeRunning() -> Bool { - let xcode = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dt.Xcode") - return !xcode.isEmpty + !NSRunningApplication.runningApplications( + withBundleIdentifier: "com.apple.dt.Xcode" + ).isEmpty } - public func getAuthStatus() -> AuthStatus.Status { - return authStatus.status + public func getAuthStatus() -> AuthStatus { + authStatus + } + + public func getCLSStatus() -> CLSStatus { + clsStatus + } + + public func getQuotaInfo() -> GitHubCopilotQuotaInfo? { + currentUserQuotaInfo } public func getStatus() -> StatusResponse { - let (authIcon, authMessage) = getAuthStatusInfo() - let (icon, message, url) = getExtensionStatusInfo() + let authStatusInfo: AuthStatusInfo = getAuthStatusInfo() + let clsStatusInfo: CLSStatusInfo = getCLSStatusInfo() + let extensionStatusIcon = ( + extensionStatus == ExtensionPermissionStatus.disabled || extensionStatus == ExtensionPermissionStatus.notGranted + ) ? errorIcon : nil + let accessibilityStatusInfo: AccessibilityStatusInfo = getAccessibilityStatusInfo() return .init( - icon: authIcon ?? icon ?? okIcon, - inProgress: clsStatus.status == .inProgress, - message: message, - url: url, - authMessage: authMessage + icon: authStatusInfo.authIcon ?? clsStatusInfo.icon ?? extensionStatusIcon ?? accessibilityStatusInfo.icon ?? okIcon, + inProgress: clsStatus.busy, + clsMessage: clsStatus.message, + message: accessibilityStatusInfo.message, + extensionStatus: extensionStatus, + url: accessibilityStatusInfo.url, + authStatus: authStatusInfo.authStatus, + userName: authStatusInfo.userName, + quotaInfo: currentUserQuotaInfo ) } - private func getAuthStatusInfo() -> (authIcon: StatusResponse.Icon?, authMessage: String) { + private func getAuthStatusInfo() -> AuthStatusInfo { switch authStatus.status { - case .unknown, - .loggedIn: - (authIcon: nil, authMessage: "Logged in as \(authStatus.username ?? "")") + case .unknown, .loggedIn: + return AuthStatusInfo( + authIcon: nil, + authStatus: authStatus.status, + userName: authStatus.username + ) case .notLoggedIn: - (authIcon: errorIcon, authMessage: authStatus.message ?? "Not logged in") + return AuthStatusInfo( + authIcon: errorIcon, + authStatus: authStatus.status, + userName: nil + ) + case .notAuthorized: + return AuthStatusInfo( + authIcon: inactiveIcon, + authStatus: authStatus.status, + userName: authStatus.username + ) } } - - private func getExtensionStatusInfo() -> (icon: StatusResponse.Icon?, message: String?, url: String?) { + + private func getCLSStatusInfo() -> CLSStatusInfo { if clsStatus.isInactiveStatus { - return (icon: inactiveIcon, message: clsStatus.message, url: nil) - } else if clsStatus.isErrorStatus { - return (icon: errorIcon, message: clsStatus.message, url: nil) + return CLSStatusInfo(icon: inactiveIcon, message: clsStatus.message) } - - if extensionStatus == .failed { - // TODO differentiate between the permission not being granted and the - // extension just getting disabled by Xcode. - return ( - icon: errorIcon, - message: """ - Extension is not enabled. Enable GitHub Copilot under Xcode - and then restart Xcode. - """, - url: "x-apple.systempreferences:com.apple.ExtensionsPreferences" - ) + if clsStatus.isWarningStatus { + return CLSStatusInfo(icon: warningIcon, message: clsStatus.message) } + if clsStatus.isErrorStatus { + return CLSStatusInfo(icon: errorIcon, message: clsStatus.message) + } + return CLSStatusInfo(icon: nil, message: "") + } + private func getAccessibilityStatusInfo() -> AccessibilityStatusInfo { switch getAXStatus() { case .granted: - return (icon: nil, message: nil, url: nil) + return AccessibilityStatusInfo(icon: nil, message: nil, url: nil) case .notGranted: - return ( + return AccessibilityStatusInfo( icon: errorIcon, message: """ - Accessibility permission not granted. \ - Click to open System Preferences. - """, + Enable accessibility in system preferences + """, url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" ) case .unknown: - return ( + return AccessibilityStatusInfo( icon: errorIcon, message: """ - Accessibility permission not granted or Copilot restart needed. - """, + Enable accessibility or restart Copilot + """, url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" ) } } private func broadcast() { - NotificationCenter.default.post( - name: .serviceStatusDidChange, - object: nil - ) + NotificationCenter.default.post(name: .serviceStatusDidChange, object: nil) // Can remove DistributedNotificationCenter if the settings UI moves in-process - DistributedNotificationCenter.default().post( - name: .serviceStatusDidChange, - object: nil - ) + DistributedNotificationCenter.default().post(name: .serviceStatusDidChange, object: nil) } } diff --git a/Tool/Sources/Status/StatusObserver.swift b/Tool/Sources/Status/StatusObserver.swift new file mode 100644 index 00000000..e19e3f70 --- /dev/null +++ b/Tool/Sources/Status/StatusObserver.swift @@ -0,0 +1,134 @@ +import SwiftUI +import Cache + +@MainActor +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() + + private init() { + Task { @MainActor in + await observeAuthStatus() + await observeCLSStatus() + await observeAXStatus() + await observeQuotaInfo() + } + } + + private func observeAuthStatus() async { + await updateAuthStatus() + setupAuthStatusNotificationObserver() + } + + private func observeCLSStatus() async { + await updateCLSStatus() + setupCLSStatusNotificationObserver() + } + + private func observeAXStatus() async { + await updateAXStatus() + setupAXStatusNotificationObserver() + } + + private func observeQuotaInfo() async { + await updateQuotaInfo() + setupQuotaInfoNotificationObserver() + } + + private func updateAuthStatus() async { + let authStatus = await Status.shared.getAuthStatus() + let statusInfo = await Status.shared.getStatus() + + if authStatus.status == .notLoggedIn { + await Status.shared.updateQuotaInfo(nil) + } + + self.authStatus = AuthStatus( + status: authStatus.status, + username: statusInfo.userName, + message: nil + ) + + // load avatar when auth status changed + AvatarViewModel.shared.loadAvatar(forUser: self.authStatus.username) + } + + private func updateCLSStatus() async { + self.clsStatus = await Status.shared.getCLSStatus() + } + + private func updateAXStatus() async { + self.observedAXStatus = await Status.shared.getAXStatus() + } + + private func updateQuotaInfo() async { + self.quotaInfo = await Status.shared.getQuotaInfo() + } + + private func setupAuthStatusNotificationObserver() { + NotificationCenter.default.addObserver( + forName: .serviceStatusDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + Task { @MainActor [self] in + await self.updateAuthStatus() + } + } + + DistributedNotificationCenter.default().addObserver( + forName: .authStatusDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + Task { @MainActor [self] in + await self.updateAuthStatus() + } + } + } + + private func setupCLSStatusNotificationObserver() { + NotificationCenter.default.addObserver( + forName: .serviceStatusDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + Task { @MainActor [self] in + await self.updateCLSStatus() + } + } + } + + private func setupAXStatusNotificationObserver() { + NotificationCenter.default.addObserver( + forName: .serviceStatusDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + Task { @MainActor [self] in + await self.updateAXStatus() + } + } + } + + 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 new file mode 100644 index 00000000..3eff1406 --- /dev/null +++ b/Tool/Sources/StatusBarItemView/AccountItemView.swift @@ -0,0 +1,204 @@ +import SwiftUI +import Cache + +public class AccountItemView: NSView { + private var target: AnyObject? + private var action: Selector? + private var isHovered = false + private var visualEffect: NSVisualEffectView + private let menuItemPadding: CGFloat = 6 + private let topInset: CGFloat = 4 // Customize this value + private let bottomInset: CGFloat = 0 + + private var userName: String + private var nameLabel: NSTextField! + let avatarSize = 28.0 + let horizontalPadding = 14.0 + let verticalPadding = 8.0 + + public override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + updateVisualEffectFrame() + } + + public init( + target: AnyObject? = nil, + action: Selector? = nil, + userName: String = "" + ) { + self.target = target + self.action = action + self.userName = userName + + // Initialize visualEffect with zero frame - it will be updated in layout + self.visualEffect = NSVisualEffectView(frame: .zero) + self.visualEffect.material = .selection + self.visualEffect.state = .active + self.visualEffect.blendingMode = .withinWindow + self.visualEffect.isHidden = true + self.visualEffect.wantsLayer = true + self.visualEffect.layer?.cornerRadius = 4 + self.visualEffect.layer?.backgroundColor = NSColor.controlAccentColor.cgColor + self.visualEffect.isEmphasized = true + + // Initialize with a reasonable starting size + super.init( + frame: NSRect( + x: 0, + y: 0, + width: 240, + height: avatarSize+verticalPadding+topInset + ) + ) + + // Set up autoresizing mask to allow the view to resize with its superview + self.autoresizingMask = [.width] + self.visualEffect.autoresizingMask = [.width, .height] + + wantsLayer = true + addSubview(visualEffect) + + // Create and configure subviews + setupSubviews() + } + + private func setupSubviews() { + // Create avatar view with hover state + let avatarView = NSHostingView(rootView: AvatarView(userName: userName, isHovered: isHovered)) + avatarView.frame = NSRect( + x: horizontalPadding, + y: 4, + width: avatarSize, + height: avatarSize + ) + addSubview(avatarView) + + // Store nameLabel as property and configure it + nameLabel = NSTextField( + labelWithString: userName.isEmpty ? "Sign In to GitHub Account" : userName + ) + nameLabel.font = + .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) + nameLabel.frame = NSRect( + x: horizontalPadding*1.5 + avatarSize, + y: 0, + width: 180, + height: avatarSize + ) + nameLabel.cell?.truncatesLastVisibleLine = true + nameLabel.cell?.lineBreakMode = .byTruncatingTail + nameLabel.textColor = .labelColor + addSubview(nameLabel) + + // Make sure nameLabel resizes with the view + nameLabel.autoresizingMask = [.width] + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func mouseUp(with event: NSEvent) { + if let target = target, let action = action { + NSApp.sendAction(action, to: target, from: self) + } + } + + public override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach { removeTrackingArea($0) } + let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways] + let trackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) + addTrackingArea(trackingArea) + } + + public override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + isHovered = true + visualEffect.isHidden = false + nameLabel.textColor = .white + if let avatarView = subviews.first(where: { $0 is NSHostingView }) as? NSHostingView { + avatarView.rootView = AvatarView(userName: userName, isHovered: true) + } + } + + public override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + isHovered = false + visualEffect.isHidden = true + nameLabel.textColor = .labelColor + if let avatarView = subviews.first(where: { $0 is NSHostingView }) as? NSHostingView { + avatarView.rootView = AvatarView(userName: userName, isHovered: false) + } + } + + public override func resetCursorRects() { + addCursorRect(bounds, cursor: .pointingHand) + } + + public override func layout() { + super.layout() + updateVisualEffectFrame() + } + + private func updateVisualEffectFrame() { + var paddedFrame = bounds + paddedFrame.origin.x += menuItemPadding + paddedFrame.origin.y += bottomInset + paddedFrame.size.width -= menuItemPadding*2 + paddedFrame.size.height -= (topInset + bottomInset) + visualEffect.frame = paddedFrame + } +} + +struct AvatarView: View { + let userName: String + let isHovered: Bool + @ObservedObject private var viewModel = AvatarViewModel.shared + + init(userName: String, isHovered: Bool = false) { + self.userName = userName + self.isHovered = isHovered + } + + var body: some View { + Group { + if let avatarImage = viewModel.avatarImage { + avatarImage + .resizable() + .scaledToFit() + .clipShape(Circle()) + } else if userName.isEmpty { + Image(systemName: "person.crop.circle") + .resizable() + .scaledToFit() + .foregroundStyle(isHovered ? .white : .primary) + } else { + ProgressView() + .clipShape(Circle()) + } + } + } +} + +struct NSViewPreview: NSViewRepresentable { + var userName: String = "" + + func makeNSView(context: Context) -> NSView { + let NSView = AccountItemView( + userName: userName + ) + return NSView + } + + func updateNSView(_ nsView: NSView, context: Context) { + // Update as needed... + } +} + +#Preview("Not Signed In") { + NSViewPreview().frame(width: 245, height: 52) +} +#Preview("Signed In, Active") { + NSViewPreview(userName: "xcode-test").frame(width: 245, height: 52) +} diff --git a/Tool/Sources/StatusBarItemView/ErrorMessageView.swift b/Tool/Sources/StatusBarItemView/ErrorMessageView.swift new file mode 100644 index 00000000..8229c841 --- /dev/null +++ b/Tool/Sources/StatusBarItemView/ErrorMessageView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +public class ErrorMessageView: NSView { + public init(errorMessage: String) { + // Create a custom view for the menu item + let maxWidth: CGFloat = 240 + let padding = NSEdgeInsets(top: 8, left: 12, bottom: 8, right: 12) + + // Initialize with temporary frame, will be adjusted + super.init(frame: NSRect(x: 0, y: 0, width: maxWidth, height: 0)) + + let textField = NSTextField(frame: .zero) + textField.stringValue = errorMessage + textField.isEditable = false + textField.isBordered = false + textField.drawsBackground = false + textField.lineBreakMode = .byWordWrapping + textField.usesSingleLineMode = false + textField.cell?.wraps = true + textField.cell?.isScrollable = false + textField.textColor = .secondaryLabelColor + + // Calculate the required height + let fittingSize = textField.sizeThatFits( + NSSize(width: maxWidth - padding.left - padding.right, + height: CGFloat.greatestFiniteMagnitude) + ) + + // Set the final frames + self.frame = NSRect( + x: 0, y: 0, + width: maxWidth, + height: fittingSize.height + padding.top + padding.bottom + ) + + textField.frame = NSRect( + x: padding.left, + y: padding.bottom, + width: maxWidth - padding.left - padding.right, + height: fittingSize.height + ) + + addSubview(textField) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} 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..4f073716 --- /dev/null +++ b/Tool/Sources/StatusBarItemView/QuotaView.swift @@ -0,0 +1,624 @@ +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() + + 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 { + if #available(macOS 26.0, *) { + button.attributedTitle = NSAttributedString( + string: upgradeTitle, + attributes: [.foregroundColor: NSColor.controlTextColor] + ) + button.bezelColor = .controlBackgroundColor + } else { + button.attributedTitle = NSAttributedString( + string: upgradeTitle, + attributes: [.foregroundColor: NSColor.white] + ) + button.bezelColor = .controlAccentColor + } + } else { + button.title = upgradeTitle + } + 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/CodeSuggestion.swift b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift index bd124fc1..e910af17 100644 --- a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift +++ b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift @@ -1,6 +1,10 @@ import Foundation import CodableWrappers +public enum CodeSuggestionType: String { + case codeCompletion, nes +} + public struct CodeSuggestion: Codable, Equatable { public init( id: String, diff --git a/Tool/Sources/SuggestionBasic/EditorInformation.swift b/Tool/Sources/SuggestionBasic/EditorInformation.swift index 8518b8b0..5c717f5a 100644 --- a/Tool/Sources/SuggestionBasic/EditorInformation.swift +++ b/Tool/Sources/SuggestionBasic/EditorInformation.swift @@ -1,14 +1,20 @@ import Foundation import Parsing +import AppKit +import AXExtension public struct EditorInformation { - public struct LineAnnotation { + public struct LineAnnotation: Equatable, Hashable { public var type: String - public var line: Int + public var line: Int // 1-Based public var message: String + public var originalAnnotation: String + public var rect: CGRect? = nil + + public var isError: Bool { type == "Error" } } - public struct SourceEditorContent { + public struct SourceEditorContent: Equatable { /// The content of the source editor. public var content: String /// The content of the source editor in lines. Every line should ends with `\n`. @@ -44,14 +50,18 @@ public struct EditorInformation { selections: [CursorRange], cursorPosition: CursorPosition, cursorOffset: Int, - lineAnnotations: [String] + lineAnnotationElements: [AXUIElement] ) { self.content = content self.lines = lines self.selections = selections self.cursorPosition = cursorPosition self.cursorOffset = cursorOffset - self.lineAnnotations = lineAnnotations.map(EditorInformation.parseLineAnnotation) + self.lineAnnotations = lineAnnotationElements.map { + var parsedLineAnnotation = EditorInformation.parseLineAnnotation($0.description) + parsedLineAnnotation.rect = $0.rect + return parsedLineAnnotation + } } } @@ -153,14 +163,15 @@ public struct EditorInformation { return LineAnnotation( type: type.trimmingCharacters(in: .whitespacesAndNewlines), line: line, - message: message.trimmingCharacters(in: .whitespacesAndNewlines) + message: message.trimmingCharacters(in: .whitespacesAndNewlines), + originalAnnotation: annotation ) } do { return try lineAnnotationParser.parse(annotation[...]) } catch { - return .init(type: "", line: 0, message: annotation) + return .init(type: "", line: 0, message: annotation, originalAnnotation: annotation) } } } diff --git a/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift b/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift index f4345fd0..0a008da7 100644 --- a/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift +++ b/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift @@ -64,6 +64,19 @@ public struct CursorRange: Codable, Hashable, Sendable, Equatable, CustomStringC public var description: String { return "\(start.readableText) - \(end.readableText)" } + + public var isValid: Bool { + let startLine = start.line + let startCharacter = start.character + let endLine = end.line + let endCharacter = end.character + + guard startLine >= 0 && startCharacter >= 0 && endLine >= 0 && endCharacter >= 0 else {return false} + + guard startLine < endLine || (startLine == endLine && startCharacter <= endCharacter) else {return false} + + return true + } } public extension CursorRange { diff --git a/Tool/Sources/SuggestionBasic/Modification.swift b/Tool/Sources/SuggestionBasic/Modification.swift index c4547e85..5e35c96e 100644 --- a/Tool/Sources/SuggestionBasic/Modification.swift +++ b/Tool/Sources/SuggestionBasic/Modification.swift @@ -3,6 +3,7 @@ import Foundation public enum Modification: Codable, Equatable { case deleted(ClosedRange) case inserted(Int, [String]) + case deletedSelection(CursorRange) } public extension [String] { @@ -15,6 +16,36 @@ public extension [String] { removeSubrange(removingRange.clamped(to: 0.. [CopilotForXcodeKit.CodeSuggestion] +} + diff --git a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift index e69e29d2..3a60489a 100644 --- a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift @@ -19,6 +19,23 @@ public struct PostProcessingSuggestionServiceMiddleware: SuggestionServiceMiddle return suggestion } } + + public func getNESSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] { + let suggestions = try await next(request) + + return suggestions.compactMap { + var suggestion = $0 + if suggestion.text.allSatisfy({ $0.isWhitespace || $0.isNewline }) { return nil } + Self.removeTrailingWhitespacesAndNewlines(&suggestion) + // TODO: If need to check? + // if !Self.checkIfSuggestionHasNoEffect(suggestion, request: request) { return nil } + return suggestion + } + } static func removeTrailingWhitespacesAndNewlines(_ suggestion: inout CodeSuggestion) { var text = suggestion.text[...] diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift index dcbfba5e..1bec7d30 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift @@ -10,6 +10,12 @@ public protocol SuggestionServiceMiddleware { configuration: SuggestionServiceConfiguration, next: Next ) async throws -> [CodeSuggestion] + + func getNESSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] } public enum SuggestionServiceMiddlewareContainer { @@ -49,6 +55,24 @@ public struct DisabledLanguageSuggestionServiceMiddleware: SuggestionServiceMidd return try await next(request) } + + public func getNESSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] { + let language = languageIdentifierFromFileURL(request.fileURL) + if UserDefaults.shared.value(for: \.suggestionFeatureDisabledLanguageList) + .contains(where: { $0 == language.rawValue }) + { + #if DEBUG + Logger.service.info("Suggestion service is disabled for \(language).") + #endif + return [] + } + + return try await next(request) + } } public struct DebugSuggestionServiceMiddleware: SuggestionServiceMiddleware { @@ -76,5 +100,28 @@ public struct DebugSuggestionServiceMiddleware: SuggestionServiceMiddleware { throw error } } + + public func getNESSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] { + Logger.service.info(""" + Get suggestion for \(request.fileURL) at \(request.cursorPosition) + """) + do { + let suggestions = try await next(request) + Logger.service.info(""" + Receive \(suggestions.count) suggestions for \(request.fileURL) \ + at \(request.cursorPosition) + """) + return suggestions + } catch { + Logger.service.info(""" + Error: \(error.localizedDescription) + """) + throw error + } + } } diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift index 24265613..bec85e8f 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift @@ -63,6 +63,10 @@ public protocol SuggestionServiceProvider { _ request: SuggestionRequest, workspaceInfo: CopilotForXcodeKit.WorkspaceInfo ) async throws -> [CodeSuggestion] + func getNESSuggestions( + _ request: SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo, + ) async throws -> [CodeSuggestion] func notifyAccepted( _ suggestion: CodeSuggestion, workspaceInfo: CopilotForXcodeKit.WorkspaceInfo diff --git a/Tool/Sources/SystemUtils/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 new file mode 100644 index 00000000..fb785f45 --- /dev/null +++ b/Tool/Sources/SystemUtils/SystemUtils.swift @@ -0,0 +1,264 @@ +import Foundation +import Logger +import IOKit +import CryptoKit + +public class SystemUtils { + public static let shared = SystemUtils() + + // Static properties for constant values + public static let machineId: String = { + return shared.computeMachineId() + }() + + public static let osVersion: String = { + return "\(ProcessInfo.processInfo.operatingSystemVersion.majorVersion).\(ProcessInfo.processInfo.operatingSystemVersion.minorVersion).\(ProcessInfo.processInfo.operatingSystemVersion.patchVersion)" + }() + + public static let xcodeVersion: String? = { + return shared.computeXcodeVersion() + }() + + public static let editorVersionString: String = { + return "Xcode/\(xcodeVersion ?? "0.0.0")" + }() + + public static let editorPluginVersion: String? = { + return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + }() + + public static let editorPluginVersionString: String = { + return "\(editorPluginVersion ?? "0.0.0")" + }() + + public static let build: String = { + return shared.isDeveloperMode() ? "dev" : "" + }() + + public static let buildType: String = { + return shared.isDeveloperMode() ? "true" : "false" + }() + + public static let isDeveloperMode: Bool = { + return shared.isDeveloperMode() + }() + + public static let isPrereleaseBuild: Bool = { + let components = editorPluginVersionString.split(separator: ".") + if components.count >= 3 { + let patchComponent = String(components[2]) + // If patch version is not "0" + return patchComponent != "0" + } + + return false + }() + + private init() {} + + // Renamed to computeMachineId since it's now an internal implementation detail + private func computeMachineId() -> String { + // Original getMachineId implementation + let matchingDict = IOServiceMatching("IOEthernetInterface") as NSMutableDictionary + var iterator: io_iterator_t = 0 + let result = IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) + + if result != KERN_SUCCESS { + return UUID().uuidString + } + + var macAddress: String = "" + var service = IOIteratorNext(iterator) + + while service != 0 { + var parentService: io_object_t = 0 + let kernResult = IORegistryEntryGetParentEntry(service, "IOService", &parentService) + + if kernResult == KERN_SUCCESS { + let propertyPtr = UnsafeMutablePointer?>.allocate(capacity: 1) + _ = IORegistryEntryCreateCFProperties( + parentService, + propertyPtr, + kCFAllocatorDefault, + 0 + ) + + if let properties = propertyPtr.pointee?.takeUnretainedValue() as? [String: Any], + let data = properties["IOMACAddress"] as? Data { + macAddress = data.map { String(format: "%02x", $0) }.joined() + IOObjectRelease(parentService) + break + } + + IOObjectRelease(parentService) + } + + IOObjectRelease(service) + service = IOIteratorNext(iterator) + } + + IOObjectRelease(iterator) + + // Hash the MAC address using SHA256 + if !macAddress.isEmpty, let macData = macAddress.data(using: .utf8) { + let hashedData = SHA256.hash(data: macData) + return hashedData.compactMap { String(format: "%02x", $0) }.joined() + } + + return "unknown" + } + + public func getXcodeBinaryPath() -> String { + var systemInfo = utsname() + uname(&systemInfo) + + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + + let path: String + if identifier == "x86_64" { + path = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/copilot-language-server").path + } else if identifier == "arm64" { + path = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/copilot-language-server-arm64").path + } else { + fatalError("Unsupported architecture") + } + + return path + } + + private func computeXcodeVersion() -> String? { + let process = Process() + let pipe = Pipe() + + defer { + pipe.fileHandleForReading.closeFile() + if process.isRunning { + process.terminate() + } + } + + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["xcodebuild", "-version"] + process.standardOutput = pipe + + do { + try process.run() + } catch { + print("Error running xcrun xcodebuild: \(error)") + return nil + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { + return nil + } + + let lines = output.split(separator: "\n") + return lines.first?.split(separator: " ").last.map(String.init) + } + + public func getEditorVersionString() -> String { + return "Xcode/\(computeXcodeVersion() ?? "0.0.0")" + } + + public func getEditorPluginVersion() -> String? { + return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + } + + public func getEditorPluginVersionString() -> String { + return "copilot-xcode/\(getEditorPluginVersion() ?? "0.0.0")" + } + + public func getBuild() -> String { + return isDeveloperMode() ? "dev" : "" + } + + public func getBuildType() -> String { + return isDeveloperMode() ? "true" : "false" + } + + func isDeveloperMode() -> Bool { + #if DEBUG + return true + #else + 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/TelemetryService/GithubPanicErrorReporter.swift b/Tool/Sources/TelemetryService/GithubPanicErrorReporter.swift new file mode 100644 index 00000000..a7bb0763 --- /dev/null +++ b/Tool/Sources/TelemetryService/GithubPanicErrorReporter.swift @@ -0,0 +1,201 @@ +import Foundation +import TelemetryServiceProvider +import UserDefaultsObserver +import Preferences + +public class GitHubPanicErrorReporter { + private static let panicEndpoint = URL(string: "https://copilot-telemetry.githubusercontent.com/telemetry")! + private static let sessionId = UUID().uuidString + private static let standardChannelKey = Bundle.main + .object(forInfoDictionaryKey: "STANDARD_TELEMETRY_CHANNEL_KEY") as! String + + private static let userDefaultsObserver = UserDefaultsObserver( + object: UserDefaults.shared, + forKeyPaths: [ + UserDefaultPreferenceKeys().gitHubCopilotProxyUrl.key, + UserDefaultPreferenceKeys().gitHubCopilotProxyUsername.key, + UserDefaultPreferenceKeys().gitHubCopilotProxyPassword.key, + UserDefaultPreferenceKeys().gitHubCopilotUseStrictSSL.key, + ], + context: nil + ) + + // Use static initializer to set up the observer + private static let _initializer: Void = { + userDefaultsObserver.onChange = { + urlSession = configuredURLSession() + } + }() + + private static var urlSession: URLSession = { + // Initialize urlSession after observer setup + _ = _initializer + return configuredURLSession() + }() + + // Helper: Format current time in ISO8601 style + private static func currentTime() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSX" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter.string(from: Date()) + } + + // Helper: Create failbot payload JSON string and update properties + private static func createFailbotPayload( + for request: TelemetryExceptionRequest, + properties: inout [String: Any] + ) -> String? { + let payload: [String: Any] = [ + "context": [:], + "app": "copilot-xcode", + "catalog_service": "CopilotXcode", + "release": "copilot-xcode@\(properties["common_extversion"] ?? "0.0.0")", + "rollup_id": "auto", + "platform": "macOS", + "exception_detail": request.exceptionDetail?.toDictionary() ?? [] + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload, options: []) else { + return nil + } + return String(data: data, encoding: .utf8) + } + + // Helper: Create payload with a channel input, but always using standard telemetry key. + private static func createPayload( + for request: TelemetryExceptionRequest, + properties: inout [String: Any] + ) -> [String: Any] { + // Build and add failbot payload to properties + if let payloadString = createFailbotPayload(for: request, properties: &properties) { + properties["failbot_payload"] = payloadString + } + properties["common_vscodesessionid"] = sessionId + properties["client_sessionid"] = sessionId + + let baseData: [String: Any] = [ + "ver": 2, + "severityLevel": "Error", + "name": "agent/error.exception", + "properties": properties, + "exceptions": [], + "measurements": [:] + ] + + return [ + "ver": 1, + "time": currentTime(), + "severityLevel": "Error", + "name": "Microsoft.ApplicationInsights.standard.Event", + "iKey": standardChannelKey, + "data": [ + "baseData": baseData, + "baseType": "ExceptionData" + ] + ] + } + + private static func configuredURLSession() -> URLSession { + let proxyURL = UserDefaults.shared.value(for: \.gitHubCopilotProxyUrl) + let strictSSL = UserDefaults.shared.value(for: \.gitHubCopilotUseStrictSSL) + + // If no proxy, use shared session + if proxyURL.isEmpty { + return .shared + } + + let configuration = URLSessionConfiguration.default + + if let url = URL(string: proxyURL) { + var proxyConfig: [String: Any] = [:] + let scheme = url.scheme?.lowercased() + + // Set proxy type based on URL scheme + switch scheme { + case "https": + proxyConfig[kCFProxyTypeKey as String] = kCFProxyTypeHTTPS + proxyConfig[kCFNetworkProxiesHTTPSEnable as String] = true + proxyConfig[kCFNetworkProxiesHTTPSProxy as String] = url.host + proxyConfig[kCFNetworkProxiesHTTPSPort as String] = url.port + case "socks", "socks5": + proxyConfig[kCFProxyTypeKey as String] = kCFProxyTypeSOCKS + proxyConfig[kCFNetworkProxiesSOCKSEnable as String] = true + proxyConfig[kCFNetworkProxiesSOCKSProxy as String] = url.host + proxyConfig[kCFNetworkProxiesSOCKSPort as String] = url.port + default: + proxyConfig[kCFProxyTypeKey as String] = kCFProxyTypeHTTP + proxyConfig[kCFProxyHostNameKey as String] = url.host + proxyConfig[kCFProxyPortNumberKey as String] = url.port + } + + // Add proxy authentication if configured + let username = UserDefaults.shared.value(for: \.gitHubCopilotProxyUsername) + let password = UserDefaults.shared.value(for: \.gitHubCopilotProxyPassword) + if !username.isEmpty { + proxyConfig[kCFProxyUsernameKey as String] = username + proxyConfig[kCFProxyPasswordKey as String] = password + } + + configuration.connectionProxyDictionary = proxyConfig + } + + // Configure SSL verification + if strictSSL { + return URLSession(configuration: configuration) + } + + let sessionDelegate = CustomURLSessionDelegate() + + return URLSession( + configuration: configuration, + delegate: sessionDelegate, + delegateQueue: nil + ) + } + + private class CustomURLSessionDelegate: NSObject, URLSessionDelegate { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + // Accept all certificates when strict SSL is disabled + guard let serverTrust = challenge.protectionSpace.serverTrust else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + let credential = URLCredential(trust: serverTrust) + completionHandler(.useCredential, credential) + } + } + + public static func report(_ request: TelemetryExceptionRequest) async { + do { + var properties: [String : Any] = request.properties ?? [:] + let payload = createPayload( + for: request, + properties: &properties + ) + + let jsonData = try JSONSerialization.data(withJSONObject: [payload], options: []) + var httpRequest = URLRequest(url: panicEndpoint) + httpRequest.httpMethod = "POST" + httpRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + httpRequest.httpBody = jsonData + + // Use the cached URLSession instead of creating a new one + let (_, response) = try await urlSession.data(for: httpRequest) + #if DEBUG + guard let httpResp = response as? HTTPURLResponse, httpResp.statusCode == 200 else { + throw URLError(.badServerResponse) + } + #endif + } catch { + #if DEBUG + print("Fails to send to Panic Endpoint: \(error)") + #endif + } + } +} diff --git a/Tool/Sources/TelemetryService/TelemetryCleaner.swift b/Tool/Sources/TelemetryService/TelemetryCleaner.swift new file mode 100644 index 00000000..069ad843 --- /dev/null +++ b/Tool/Sources/TelemetryService/TelemetryCleaner.swift @@ -0,0 +1,86 @@ +import Foundation + +// reference the redact algorithm from https://github.com/microsoft/vscode/blame/main/src/vs/platform/telemetry/common/telemetryUtils.ts +public struct TelemetryCleaner { + private let cleanupPatterns: [NSRegularExpression] + + public init(cleanupPatterns: [NSRegularExpression]) { + self.cleanupPatterns = cleanupPatterns + } + + public func redactMap(_ data: [String: Any]?) -> [String: Any]? { + guard let data = data else { + return nil + } + return data.mapValues { value in + if let stringValue = value as? String { + return redact(stringValue) ?? "" + } + + return value + } + } + + public func redact(_ value: String?) -> String? { + guard let value = value else { + return nil + } + var cleanedValue = value.replacingOccurrences(of: "%20", with: " ") + cleanedValue = anonymizeFilePaths(cleanedValue) + cleanedValue = removeUserInfo(cleanedValue) + return cleanedValue + } + + private func anonymizeFilePaths(_ stack: String) -> String { + guard stack.contains("/") || stack.contains("\\") else { + return stack + } + + var updatedStack = stack + for pattern in cleanupPatterns { + updatedStack = pattern.stringByReplacingMatches( + in: updatedStack, + range: NSRange(updatedStack.startIndex..., in: updatedStack), + withTemplate: "" + ) + } + + // Replace file paths with redacted marker + let filePattern = try! NSRegularExpression( + pattern: "(file:\\/\\/)?([a-zA-Z]:(\\\\|\\/)|(\\\\\\\\/|\\\\|\\/))?([\\w-\\._]+(\\\\|\\/))+" + ) + updatedStack = filePattern.stringByReplacingMatches( + in: updatedStack, + range: NSRange(updatedStack.startIndex..., in: updatedStack), + withTemplate: "" + ) + + return updatedStack + } + + private func removeUserInfo(_ value: String) -> String { + let patterns: [(label: String, pattern: String)] = [ + ("Google API Key", "AIza[A-Za-z0-9_\\\\\\-]{35}"), + ("Slack Token", "xox[pbar]\\-[A-Za-z0-9]"), + ("GitHub Token", "(gh[psuro]_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})"), + ("Generic Secret", "(key|token|sig|secret|signature|password|passwd|pwd|android:value)[^a-zA-Z0-9]"), + ("CLI Credentials", "((login|psexec|(certutil|psexec)\\.exe).{1,50}(\\s-u(ser(name)?)?\\s+.{3,100})?\\s-(admin|user|vm|root)?p(ass(word)?)?\\s+[\"']?[^$\\-\\/\\s]|(^|[\\s\\r\\n\\])net(\\.exe)?.{1,5}(user\\s+|share\\s+\\/user:| user -? secrets ? set) \\s + [^ $\\s \\/])"), + ("Microsoft Entra ID", "eyJ(?:0eXAiOiJKV1Qi|hbGci|[a-zA-Z0-9\\-_]+\\.[a-zA-Z0-9\\-_]+\\.)"), + ("Email", "@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-]+") + ] + + var cleanedValue = value + for (label, pattern) in patterns { + if let regex = try? NSRegularExpression(pattern: pattern) { + if regex.firstMatch( + in: cleanedValue, + range: NSRange(cleanedValue.startIndex..., in: cleanedValue) + ) != nil { + return "" + } + } + } + + return cleanedValue + } +} diff --git a/Tool/Sources/TelemetryService/TelemetryService.swift b/Tool/Sources/TelemetryService/TelemetryService.swift new file mode 100644 index 00000000..79cda709 --- /dev/null +++ b/Tool/Sources/TelemetryService/TelemetryService.swift @@ -0,0 +1,337 @@ +import Foundation +import SystemUtils +import TelemetryServiceProvider +import BuiltinExtension +import GitHubCopilotService + +public protocol WrappedTelemetryServiceType { + func sendError( + _ error: Error?, + transaction: String?, + additionalProperties: [String: String]?, + category: String, + file: StaticString, + line: UInt, + function: StaticString, + from symbols: [String] + ) + + func sendError( + _ message: String, + transaction: String?, + additionalProperties: [String: String]?, + category: String, + file: StaticString, + line: UInt, + function: StaticString, + from symbols: [String] + ) +} + +public actor TelemetryService: WrappedTelemetryServiceType { + private let telemetryProvider: TelemetryServiceProvider? + private var commonProperties: [String: String] = [:] + private let telemetryCleaner: TelemetryCleaner = TelemetryCleaner(cleanupPatterns: []) + + public static var shared: TelemetryService = TelemetryService.service() + + init( + provider: any TelemetryServiceProvider + ) { + telemetryProvider = provider + self.commonProperties = [ + "common_extname": "copilot-xcode", + "common_extversion": SystemUtils.editorPluginVersionString, + "common_os": "darwin", + "common_platformversion": SystemUtils.osVersion, + "common_uikind": "desktop", + "common_vscodemachineid": SystemUtils.machineId, + "client_machineid": SystemUtils.machineId, + "editor_version": SystemUtils.editorVersionString, + "editor_plugin_version": "copilot-xcode/\(SystemUtils.editorPluginVersionString)", + "copilot_build": SystemUtils.build, + "copilot_buildType": SystemUtils.buildType + ] + } + + public static func service() -> TelemetryService { + let provider = BuiltinExtensionTelemetryServiceProvider( + extension: GitHubCopilotExtension.self + ) + return TelemetryService(provider: provider) + } + + enum TelemetryServiceError: Error { + case providerNotFound + } + + private enum ErrorSource { + case message(String) + case error(Error?) + } + + /// Sends an error with the given parameters + public nonisolated func sendError( + _ error: Error?, + transaction: String? = nil, + additionalProperties: [String: String]? = nil, + category: String = "", + file: StaticString, + line: UInt, + function: StaticString, + from symbols: [String] + ) { + Task.detached(priority: .background) { + await self.sendErrorInternal( + .error(error), + transaction: transaction, + additionalProperties: additionalProperties, + category: category, + file: file, + line: line, + function: function, + from: symbols + ) + } + } + + /// Sends an error message with the given parameters + public nonisolated func sendError( + _ message: String, + transaction: String? = nil, + additionalProperties: [String: String]? = nil, + category: String = "", + file: StaticString, + line: UInt, + function: StaticString, + from symbols: [String] + ) { + Task.detached(priority: .background) { + await self.sendErrorInternal( + .message(message), + transaction: transaction, + additionalProperties: additionalProperties, + category: category, + file: file, + line: line, + function: function, + from: symbols + ) + } + } + + /// Internal implementation for sending errors + private func sendErrorInternal( + _ source: ErrorSource, + transaction: String? = nil, + additionalProperties: [String: String]? = nil, + category: String = "", + file: StaticString, + line: UInt, + function: StaticString, + from symbols: [String] + ) async { + var props = commonProperties + additionalProperties?.forEach { props[$0.key] = $0.value } + let fileName: String = telemetryCleaner.redact(String(describing: file)) ?? "" + let request = createTelemetryExceptionRequest( + errorSource: source, + transaction: transaction, + additionalProperties: props, + category: category, + file: fileName, + line: line, + function: function, + symbols: symbols + ) + + do { + if let provider = telemetryProvider { + try await provider.sendError(request) + } else { + throw TelemetryServiceError.providerNotFound + } + } catch { + await GitHubPanicErrorReporter.report(request) + } + } + + /// Creates a telemetry exception request from the given parameters + private func createTelemetryExceptionRequest( + errorSource: ErrorSource, + transaction: String?, + additionalProperties: [String: String], + category: String, + file: String, + line: UInt, + function: StaticString, + symbols: [String] + ) -> TelemetryExceptionRequest { + let stacktrace: String? = switch errorSource { + case .message(let message): + message + case .error(let error): + error?.localizedDescription + } + + let exceptionDetails = convertErrorToExceptionDetails( + errorSource, + category: category, + file: file, + line: line, + function: function, + from: symbols + ) + + return TelemetryExceptionRequest( + transaction: transaction, + stacktrace: telemetryCleaner.redact(stacktrace), + properties: additionalProperties, + platform: "macOS", + exceptionDetail: exceptionDetails + ) + } + + /// Converts error source to exception details array + private func convertErrorToExceptionDetails( + _ errorSource: ErrorSource, + category: String, + file: String, + line: UInt, + function: StaticString, + from symbols: [String] + ) -> [ExceptionDetail] { + let (errorType, errorValue) = extractErrorInfo(from: errorSource, category: category) + let stackFrames = createStackFrames( + errorSource: errorSource, + file: file, + line: line, + function: function, + symbols: symbols + ) + + return [ + ExceptionDetail( + type: errorType, + value: telemetryCleaner.redact(errorValue), + stacktrace: stackFrames + ) + ] + } + + /// Extracts error type and value from error source + private func extractErrorInfo(from errorSource: ErrorSource, category: String) -> (type: String, value: String) { + switch errorSource { + case .message(let message): + let type = "ErrorMessage \(category)" + return (type, message) + + case .error(let error): + guard let error = error else { + let type = "UnknownError \(category)" + return (type, "Unknown error occurred") + } + + var typePrefix = String(describing: type(of: error)) + if typePrefix == "NSError" { + let nsError = error as NSError + typePrefix += ":\(nsError.domain):\(nsError.code)" + } + + let type = typePrefix + " \(category)" + return (type, error.localizedDescription) + } + } + + /// Creates stack trace frames from error information + private func createStackFrames( + errorSource: ErrorSource, + file: String, + line: UInt, + function: StaticString, + symbols: [String] + ) -> [StackTraceFrame] { + let callSiteFrame = StackTraceFrame( + filename: file, + lineno: .integer(Int(line)), + colno: nil, + function: String(describing: function), + inApp: true + ) + + switch errorSource { + case .message: + return [callSiteFrame] + + case .error: + var frames = parseStackFrames(from: symbols) + frames.insert(callSiteFrame, at: 0) + return frames + } + } + + /// Parses call stack symbols into stack trace frames + private func parseStackFrames(from symbols: [String]) -> [StackTraceFrame] { + symbols.map { symbol -> StackTraceFrame? in + let pattern = #"^(\d+)\s+(.+?)\s+(0x[0-9a-fA-F]+)\s+(.+?)\s+\+\s+(\d+)$"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + guard let match = regex.firstMatch(in: symbol, range: NSRange(symbol.startIndex..., in: symbol)) else { return nil } + + let components = (1.. String in + if let range = Range(match.range(at: i), in: symbol) { + return String(symbol[range]) + } + return "" + } + + guard components.count == 5, + let offset = Int(components[4]) else { return nil } + + let module = components[1] + let parsedSymbol = parseDemangledSymbol(swift_demangle(components[3])) + + return StackTraceFrame( + filename: parsedSymbol?.module ?? module, + lineno: .integer(offset), + colno: nil, + function: parsedSymbol?.function ?? components[3], + inApp: module.contains("GitHub Copilot for Xcode Extension") + ) + }.compactMap { $0 } + } + + /// Demangles Swift symbol names using the Swift runtime + typealias Swift_Demangle = @convention(c) (_ mangledName: UnsafePointer?, + _ mangledNameLength: Int, + _ outputBuffer: UnsafeMutablePointer?, + _ outputBufferSize: UnsafeMutablePointer?, + _ flags: UInt32) -> UnsafeMutablePointer? + + func swift_demangle(_ mangled: String) -> String { + let RTLD_DEFAULT = dlopen(nil, RTLD_NOW) + if let sym = dlsym(RTLD_DEFAULT, "swift_demangle") { + let f = unsafeBitCast(sym, to: Swift_Demangle.self) + if let cString = f(mangled, mangled.count, nil, nil, 0) { + defer { cString.deallocate() } + return String(cString: cString) + } + } + return "" + } + + /// Parses demangled symbol into module and function components + func parseDemangledSymbol(_ demangled: String) -> (module: String, function: String)? { + let regex = try! NSRegularExpression( + pattern: #"^\((\d+)\)\s*(.*?)\s*for\s*([^\s]+(?: [^\s]+)*?)\s*((?:async)?)\s*((?:throws)?)\s*(?:->\s*(.*))?$"#, + options: [.anchorsMatchLines] + ) + guard let match = regex.firstMatch( + in: demangled, options: [], + range: NSRange(location: 0, length: demangled.utf16.count) + ) else { + return nil + } + let functionName = (demangled as NSString).substring(with: match.range(at: 3)) + return (module: functionName, function: demangled) + } +} diff --git a/Tool/Sources/TelemetryServiceProvider/TelemetryServiceProvider.swift b/Tool/Sources/TelemetryServiceProvider/TelemetryServiceProvider.swift new file mode 100644 index 00000000..b82df33a --- /dev/null +++ b/Tool/Sources/TelemetryServiceProvider/TelemetryServiceProvider.swift @@ -0,0 +1,167 @@ +import CopilotForXcodeKit +import Foundation +import CodableWrappers + +public protocol TelemetryServiceType { + func sendError( + _ request: TelemetryExceptionRequest, + workspace: WorkspaceInfo + ) async throws +} + +public protocol TelemetryServiceProvider { + func sendError(_ request: TelemetryExceptionRequest) async throws +} + +/// Represents a telemetry exception request, containing error details and additional properties. +public struct TelemetryExceptionRequest { + /// An identifier to group or track the transaction. + public let transaction: String? + /// The error stacktrace as a string. + public let stacktrace: String? + /// Additional telemetry properties as key-value pairs. + public let properties: [String: String]? + /// The target platform information (default to macOS). + public let platform: String? + /// A list of detailed exceptions, each with its own context. + public let exceptionDetail: [ExceptionDetail]? + + public init( + transaction: String? = nil, + stacktrace: String? = nil, + properties: [String: String]? = nil, + platform: String? = nil, + exceptionDetail: [ExceptionDetail]? = nil + ) { + self.transaction = transaction + self.stacktrace = stacktrace + self.properties = properties + self.platform = platform + self.exceptionDetail = exceptionDetail + } +} + +public struct ExceptionDetail: Codable { + public let type: String? + public let value: String? + public let stacktrace: [StackTraceFrame]? + + public init(type: String? = nil, value: String? = nil, stacktrace: [StackTraceFrame]? = nil) { + self.type = type + self.value = value + self.stacktrace = stacktrace + } + + func toDictionary() -> [String: Any] { + var dict: [String: Any] = [:] + if let type = type { + dict["type"] = type + } + if let value = value { + dict["value"] = value + } + if let stacktrace = stacktrace { + dict["stacktrace"] = stacktrace.map { $0.toDictionary() } + } + return dict + } +} + +public struct StackTraceFrame: Codable { + public let filename: String? + public let lineno: PositionNumberType? + public let colno: PositionNumberType? + public let function: String? + public let inApp: Bool? + + public init( + filename: String? = nil, + lineno: PositionNumberType? = nil, + colno: PositionNumberType? = nil, + function: String? = nil, + inApp: Bool? = nil + ) { + self.filename = filename + self.lineno = lineno + self.colno = colno + self.function = function + self.inApp = inApp + } + + enum CodingKeys: String, CodingKey { + case filename + case lineno + case colno + case function + case inApp = "in_app" + } + + func toDictionary() -> [String: Any] { + var dict: [String: Any] = [:] + if let filename = filename { + dict["filename"] = filename + } + if let lineno = lineno { + dict["lineno"] = lineno.toAny() + } + if let colno = colno { + dict["colno"] = colno.toAny() + } + if let function = function { + dict["function"] = function + } + if let inApp = inApp { + dict["in_app"] = inApp + } + return dict + } +} + +public enum PositionNumberType: Codable { + case string(String) + case integer(Int) + + 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 intValue = try? container.decode(Int.self) { + self = .integer(intValue) + } else { + self = .string("") + } + } + + public init(fromInt intValue: Int) { + self = .integer(intValue) + } + + public init(fromString stringValue: String) { + self = .string(stringValue) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .integer(let value): + try container.encode(value) + } + } + + func toAny() -> Any { + switch self { + case .string(let value): + return value + case .integer(let value): + return value + } + } +} + +extension Array where Element == ExceptionDetail { + public func toDictionary() -> [[String: Any]] { + return self.map { $0.toDictionary() } + } +} diff --git a/Tool/Sources/Terminal/TerminalSession.swift b/Tool/Sources/Terminal/TerminalSession.swift new file mode 100644 index 00000000..6db53ef6 --- /dev/null +++ b/Tool/Sources/Terminal/TerminalSession.swift @@ -0,0 +1,237 @@ +import Foundation +import SystemUtils +import Logger +import Combine + +/** + * Manages shell processes for terminal emulation + */ +class ShellProcessManager { + private var process: Process? + private var outputPipe: Pipe? + private var inputPipe: Pipe? + private var isRunning = false + var onOutputReceived: ((String) -> Void)? + + private let shellIntegrationScript = """ + # Shell integration for tracking command execution and exit codes + __terminal_command_start() { + printf "\\033]133;C\\007" # Command started + } + + __terminal_command_finished() { + local EXIT="$?" + printf "\\033]133;D;%d\\007" "$EXIT" # Command finished with exit code + return $EXIT + } + + # Set up precmd and preexec hooks + autoload -Uz add-zsh-hook + add-zsh-hook precmd __terminal_command_finished + add-zsh-hook preexec __terminal_command_start + + # print the initial prompt to output + echo -n + """ + + /** + * Starts a shell process + */ + func startShell(inDirectory directory: String = NSHomeDirectory()) { + guard !isRunning else { return } + + process = Process() + outputPipe = Pipe() + inputPipe = Pipe() + + // Configure the process + process?.executableURL = URL(fileURLWithPath: "/bin/zsh") + process?.arguments = ["-i", "-l"] + + // Create temporary file for shell integration + let tempDir = FileManager.default.temporaryDirectory + let copilotZshPath = tempDir.appendingPathComponent("xcode-copilot-zsh") + + var zshdir = tempDir + if !FileManager.default.fileExists(atPath: copilotZshPath.path) { + do { + try FileManager.default.createDirectory(at: copilotZshPath, withIntermediateDirectories: true, attributes: nil) + zshdir = copilotZshPath + } catch { + Logger.client.info("Error creating zsh directory: \(error.localizedDescription)") + } + } else { + zshdir = copilotZshPath + } + + let integrationFile = zshdir.appendingPathComponent("shell_integration.zsh") + try? shellIntegrationScript.write(to: integrationFile, atomically: true, encoding: .utf8) + + var environment = ProcessInfo.processInfo.environment + // Fetch login shell environment to get correct PATH + 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 + environment["USER_ZDOTDIR"] = userZdotdir + environment["SHELL_INTEGRATION"] = integrationFile.path + process?.environment = environment + + // Source shell integration in zsh startup + let zshrcContent = "source \"$SHELL_INTEGRATION\"\n" + try? zshrcContent.write(to: zshdir.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) + + process?.standardOutput = outputPipe + process?.standardError = outputPipe + process?.standardInput = inputPipe + process?.currentDirectoryURL = URL(fileURLWithPath: directory) + + // Handle output from the process + outputPipe?.fileHandleForReading.readabilityHandler = { [weak self] fileHandle in + let data = fileHandle.availableData + if !data.isEmpty, let output = String(data: data, encoding: .utf8) { + DispatchQueue.main.async { + self?.onOutputReceived?(output) + } + } + } + + do { + try process?.run() + isRunning = true + } catch { + onOutputReceived?("Failed to start shell: \(error.localizedDescription)\r\n") + Logger.client.error("Failed to start shell: \(error.localizedDescription)") + } + } + + /** + * Sends a command to the shell process + * @param command The command to send + */ + func sendCommand(_ command: String) { + guard isRunning, let inputPipe = inputPipe else { return } + + if let data = (command).data(using: .utf8) { + try? inputPipe.fileHandleForWriting.write(contentsOf: data) + } + } + + func stopCommand() { + // Send SIGINT (Ctrl+C) to the running process + guard let process = process else { return } + process.interrupt() // Sends SIGINT to the process + } + + /** + * Terminates the shell process + */ + func terminateShell() { + guard isRunning else { return } + + outputPipe?.fileHandleForReading.readabilityHandler = nil + process?.terminate() + isRunning = false + } + + deinit { + terminateShell() + } +} + +public struct CommandExecutionResult { + public let success: Bool + public let output: String +} + +public class TerminalSession: ObservableObject { + @Published public var terminalOutput = "" + + private var shellManager = ShellProcessManager() + private var hasPendingCommand = false + private var pendingCommandResult = "" + // Add command completion handler + private var onCommandCompleted: ((CommandExecutionResult) -> Void)? + + init() { + // Set up the shell process manager to handle shell output + shellManager.onOutputReceived = { [weak self] output in + self?.handleShellOutput(output) + } + } + + public func executeCommand(currentDirectory: String, command: String, completion: @escaping (CommandExecutionResult) -> Void) { + onCommandCompleted = completion + pendingCommandResult = "" + + // Start shell in the requested directory + self.shellManager.startShell(inDirectory: currentDirectory.isEmpty ? NSHomeDirectory() : currentDirectory) + + // Wait for shell prompt to appear before sending command + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.terminalOutput += "\(command)\n" + self?.shellManager.sendCommand(command + "\n") + self?.hasPendingCommand = true + } + } + + /** + * Handles input from the terminal view + * @param input Input received from terminal + */ + public func handleTerminalInput(_ input: String) { + DispatchQueue.main.async { [weak self] in + if input.contains("\u{03}") { // CTRL+C + let newInput = input.replacingOccurrences(of: "\u{03}", with: "\n") + self?.terminalOutput += newInput + self?.shellManager.stopCommand() + self?.shellManager.sendCommand("\n") + return + } + + // Echo the input to the terminal + self?.terminalOutput += input + self?.shellManager.sendCommand(input) + } + } + + public func getCommandOutput() -> String { + return self.pendingCommandResult + } + + /** + * Handles output from the shell process + * @param output Output from shell process + */ + private func handleShellOutput(_ output: String) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + self.terminalOutput += output + // Look for shell integration escape sequences + if output.contains("\u{1B}]133;D;0\u{07}") && self.hasPendingCommand { + // Command succeeded + self.onCommandCompleted?(CommandExecutionResult(success: true, output: self.pendingCommandResult)) + self.hasPendingCommand = false + } else if output.contains("\u{1B}]133;D;") && self.hasPendingCommand { + // Command failed + self.onCommandCompleted?(CommandExecutionResult(success: false, output: self.pendingCommandResult)) + self.hasPendingCommand = false + } else if output.contains("\u{1B}]133;C\u{07}") { + // Command start + } else if self.hasPendingCommand { + self.pendingCommandResult += output + } + } + } + + public func cleanup() { + shellManager.terminateShell() + } +} diff --git a/Tool/Sources/Terminal/TerminalSessionManager.swift b/Tool/Sources/Terminal/TerminalSessionManager.swift new file mode 100644 index 00000000..19fb9e6f --- /dev/null +++ b/Tool/Sources/Terminal/TerminalSessionManager.swift @@ -0,0 +1,26 @@ +import Foundation +import Combine + +public class TerminalSessionManager { + public static let shared = TerminalSessionManager() + private var sessions: [String: TerminalSession] = [:] + + public func createSession(for terminalId: String) -> TerminalSession { + if let existingSession = sessions[terminalId] { + return existingSession + } else { + let newSession = TerminalSession() + sessions[terminalId] = newSession + return newSession + } + } + + public func getSession(for terminalId: String) -> TerminalSession? { + return sessions[terminalId] + } + + public func clearSession(for terminalId: String) { + sessions[terminalId]?.cleanup() + sessions.removeValue(forKey: terminalId) + } +} diff --git a/Tool/Sources/Toast/NotificationView.swift b/Tool/Sources/Toast/NotificationView.swift new file mode 100644 index 00000000..f29c9a8e --- /dev/null +++ b/Tool/Sources/Toast/NotificationView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +struct AutoDismissMessage: View { + let message: ToastController.Message + + init(message: ToastController.Message) { + self.message = message + } + + var body: some View { + message.content + .foregroundColor(.white) + .padding(8) + .background( + message.level.color as Color, + in: RoundedRectangle(cornerRadius: 8) + ) + .frame(minWidth: 300) + } +} + +public struct NotificationView: View { + let message: ToastController.Message + let onDismiss: () -> Void + + public init( + message: ToastController.Message, + onDismiss: @escaping () -> Void = {} + ) { + self.message = message + self.onDismiss = onDismiss + } + + public var body: some View { + if let notificationTitle = message.title { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 4) { + Image(systemName: message.level.icon) + .foregroundColor(message.level.color) + Text(notificationTitle) + + Spacer() + + Button(action: onDismiss) { + Image(systemName: "xmark") + .foregroundColor(Color("ToastDismissButtonColor")) + } + .buttonStyle(.plain) + } + + HStack(alignment: .bottom, spacing: 1) { + message.content + + Spacer() + + if let button = message.button { + Button(action: { + button.action() + onDismiss() + }) { + Text(button.title) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color("ToastActionButtonColor")) + .cornerRadius(5) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 16) + .frame(width: 450, alignment: .topLeading) + .background(Color("ToastBackgroundColor")) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color("ToastStrokeColor"), lineWidth: 1) + ) + } else { + AutoDismissMessage(message: message) + .frame(maxWidth: .infinity) + } + } +} diff --git a/Tool/Sources/Toast/Toast.swift b/Tool/Sources/Toast/Toast.swift index 2abcca9a..704af7df 100644 --- a/Tool/Sources/Toast/Toast.swift +++ b/Tool/Sources/Toast/Toast.swift @@ -2,19 +2,38 @@ import ComposableArchitecture import Dependencies import Foundation import SwiftUI +import AppKitExtension -public enum ToastType { +public enum ToastLevel { case info case warning + case danger case error + + var icon: String { + switch self { + case .warning: return "exclamationmark.circle.fill" + case .danger: return "exclamationmark.circle.fill" + case .error: return "xmark.circle.fill" + case .info: return "exclamationmark.triangle.fill" + } + } + + var color: Color { + switch self { + case .warning: return Color(nsColor: .systemOrange) + case .danger, .error: return Color(nsColor: .systemRed) + case .info: return Color.accentColor + } + } } public struct ToastKey: EnvironmentKey { - public static var defaultValue: (String, ToastType) -> Void = { _, _ in } + public static var defaultValue: (String, ToastLevel) -> Void = { _, _ in } } public extension EnvironmentValues { - var toast: (String, ToastType) -> Void { + var toast: (String, ToastLevel) -> Void { get { self[ToastKey.self] } set { self[ToastKey.self] = newValue } } @@ -30,31 +49,79 @@ public extension DependencyValues { set { self[ToastControllerDependencyKey.self] = newValue } } - var toast: (String, ToastType) -> Void { - return { content, type in - toastController.toast(content: content, type: type, namespace: nil) + var toast: (String, ToastLevel) -> Void { + return { content, level in + toastController.toast(content: content, level: level, namespace: nil) } } - var namespacedToast: (String, ToastType, String) -> Void { + var namespacedToast: (String, ToastLevel, String) -> Void { return { - content, type, namespace in - toastController.toast(content: content, type: type, namespace: namespace) + content, level, namespace in + toastController.toast(content: content, level: level, namespace: namespace) + } + } + + var persistentToast: (String, String, ToastLevel) -> Void { + return { title, content, level in + toastController.toast(title: title, content: content, level: level, namespace: nil) } } } +public struct ToastButton: Equatable { + public let title: String + public let action: () -> Void + + public init(title: String, action: @escaping () -> Void) { + self.title = title + self.action = action + } + + public static func ==(lhs: ToastButton, rhs: ToastButton) -> Bool { + lhs.title == rhs.title + } +} + public class ToastController: ObservableObject { public struct Message: Identifiable, Equatable { public var namespace: String? + public var title: String? public var id: UUID - public var type: ToastType + public var level: ToastLevel public var content: Text - public init(id: UUID, type: ToastType, namespace: String? = nil, content: Text) { + public var button: ToastButton? + + // Convenience initializer for auto-dismissing messages (no title, no button) + public init( + id: UUID = UUID(), + level: ToastLevel, + namespace: String? = nil, + content: Text + ) { + self.id = id + self.level = level self.namespace = namespace + self.title = nil + self.content = content + self.button = nil + } + + // Convenience initializer for persistent messages (title is required) + public init( + id: UUID = UUID(), + level: ToastLevel, + namespace: String? = nil, + title: String, + content: Text, + button: ToastButton? = nil + ) { self.id = id - self.type = type + self.level = level + self.namespace = namespace + self.title = title self.content = content + self.button = button } } @@ -64,21 +131,66 @@ public class ToastController: ObservableObject { self.messages = messages } - public func toast(content: String, type: ToastType, namespace: String? = nil) { - let id = UUID() - let message = Message(id: id, type: type, namespace: namespace, content: Text(content)) + @MainActor + private func removeMessageWithAnimation(withId id: UUID) { + withAnimation(.easeInOut(duration: 0.2)) { + messages.removeAll { $0.id == id } + } + } + private func showMessage(_ message: Message, autoDismissDelay: UInt64?) { Task { @MainActor in withAnimation(.easeInOut(duration: 0.2)) { messages.append(message) messages = messages.suffix(3) } - try await Task.sleep(nanoseconds: 4_000_000_000) - withAnimation(.easeInOut(duration: 0.2)) { - messages.removeAll { $0.id == id } + if let autoDismissDelay = autoDismissDelay { + try await Task.sleep(nanoseconds: autoDismissDelay) + removeMessageWithAnimation(withId: message.id) } } } + + // Auto-dismissing toast (title and button are not allowed) + public func toast( + content: String, + level: ToastLevel, + namespace: String? = nil + ) { + let message = Message(level: level, namespace: namespace, content: Text(content)) + showMessage(message, autoDismissDelay: 4_000_000_000) + } + + // Persistent toast (title is required, button is optional) + public func toast( + title: String, + content: String, + level: ToastLevel, + namespace: String? = nil, + button: ToastButton? = nil + ) { + // Support markdown in persistent toasts + let contentText: Text + if let attributedString = try? AttributedString(markdown: content) { + contentText = Text(attributedString) + } else { + contentText = Text(content) + } + let message = Message( + level: level, + namespace: namespace, + title: title, + content: contentText, + button: button + ) + showMessage(message, autoDismissDelay: nil) + } + + public func dismissMessage(withId id: UUID) { + Task { @MainActor in + removeMessageWithAnimation(withId: id) + } + } } @Reducer @@ -98,7 +210,8 @@ public struct Toast { public enum Action: Equatable { case start case updateMessages([Message]) - case toast(String, ToastType, String?) + case toast(String, ToastLevel, String?) + case toastPersistent(String, String, ToastLevel, String?, ToastButton?) } @Dependency(\.toastController) var toastController @@ -130,11 +243,71 @@ public struct Toast { case let .updateMessages(messages): state.messages = messages return .none - case let .toast(content, type, namespace): - toastController.toast(content: content, type: type, namespace: namespace) + case let .toast(content, level, namespace): + toastController.toast(content: content, level: level, namespace: namespace) + return .none + case let .toastPersistent(title, content, level, namespace, button): + toastController + .toast( + title: title, + content: content, + level: level, + namespace: namespace, + button: button + ) return .none } } } } +public extension NSWorkspace { + /// Opens the System Preferences/Settings app at the Extensions pane + /// - Parameter extensionPointIdentifier: Optional identifier for specific extension type + static func openExtensionsPreferences(extensionPointIdentifier: String? = nil) { + if #available(macOS 13.0, *) { + var urlString = "x-apple.systempreferences:com.apple.ExtensionsPreferences" + if let extensionPointIdentifier = extensionPointIdentifier { + urlString += "?extensionPointIdentifier=\(extensionPointIdentifier)" + } + NSWorkspace.shared.open(URL(string: urlString)!) + } else { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = [ + "-b", + "com.apple.systempreferences", + "/System/Library/PreferencePanes/Extensions.prefPane" + ] + + do { + try process.run() + } catch { + // Handle error silently + return + } + } + } + + /// Opens the Xcode Extensions preferences directly + static func openXcodeExtensionsPreferences() { + openExtensionsPreferences(extensionPointIdentifier: "com.apple.dt.Xcode.extension.source-editor") + } + + static func restartXcode() { + // Find current Xcode path before quitting + // Restart if we found a valid path + if let xcodeURL = getXcodeBundleURL() { + // Quit Xcode + let script = NSAppleScript(source: "tell application \"Xcode\" to quit") + script?.executeAndReturnError(nil) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + NSWorkspace.shared.openApplication( + at: xcodeURL, + configuration: NSWorkspace.OpenConfiguration() + ) + } + } + } +} diff --git a/Tool/Sources/WebContentExtractor/HTMLToMarkdownConverter.swift b/Tool/Sources/WebContentExtractor/HTMLToMarkdownConverter.swift new file mode 100644 index 00000000..56236a0d --- /dev/null +++ b/Tool/Sources/WebContentExtractor/HTMLToMarkdownConverter.swift @@ -0,0 +1,217 @@ +import SwiftSoup +import WebKit + +class HTMLToMarkdownConverter { + + // MARK: - Configuration + private struct Config { + static let unwantedSelectors = "script, style, nav, header, footer, aside, noscript, iframe, .navigation, .sidebar, .ad, .advertisement, .cookie-banner, .popup, .social, .share, .social-share, .related, .comments, .menu, .breadcrumb" + static let mainContentSelectors = [ + "main", + "article", + "div.content", + "div#content", + "div.post-content", + "div.article-body", + "div.main-content", + "section.content", + ".content", + ".main", + ".main-content", + ".article", + ".article-content", + ".post-content", + "#content", + "#main", + ".container .row .col", + "[role='main']" + ] + } + + // MARK: - Main Conversion Method + func convertToMarkdown(from html: String) throws -> String { + let doc = try SwiftSoup.parse(html) + let rawMarkdown = try extractCleanContent(from: doc) + return cleanupExcessiveNewlines(rawMarkdown) + } + + // MARK: - Content Extraction + private func extractCleanContent(from doc: Document) throws -> String { + try removeUnwantedElements(from: doc) + + // Try to find main content areas + for selector in Config.mainContentSelectors { + if let mainElement = try findMainContent(in: doc, using: selector) { + return try convertElementToMarkdown(mainElement) + } + } + + // Fallback: clean body content + return try fallbackContentExtraction(from: doc) + } + + private func removeUnwantedElements(from doc: Document) throws { + try doc.select(Config.unwantedSelectors).remove() + } + + private func findMainContent(in doc: Document, using selector: String) throws -> Element? { + let elements = try doc.select(selector) + guard let mainElement = elements.first() else { return nil } + + // Clean nested unwanted elements + try mainElement.select("nav, aside, .related, .comments, .social-share, .advertisement").remove() + return mainElement + } + + private func fallbackContentExtraction(from doc: Document) throws -> String { + guard let body = doc.body() else { return "" } + try body.select(Config.unwantedSelectors).remove() + return try convertElementToMarkdown(body) + } + + // MARK: - Cleanup Method + private func cleanupExcessiveNewlines(_ markdown: String) -> String { + // Replace 3+ consecutive newlines with just 2 newlines + let cleaned = markdown.replacingOccurrences( + of: #"\n{3,}"#, + with: "\n\n", + options: .regularExpression + ) + return cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Element Processing + private func convertElementToMarkdown(_ element: Element) throws -> String { + let markdown = try convertElement(element) + return markdown + } + + func convertElement(_ element: Element) throws -> String { + var result = "" + + for node in element.getChildNodes() { + if let textNode = node as? TextNode { + result += textNode.text() + } else if let childElement = node as? Element { + result += try convertSpecificElement(childElement) + } + } + + return result + } + + private func convertSpecificElement(_ element: Element) throws -> String { + let tagName = element.tagName().lowercased() + let text = try element.text() + + switch tagName { + case "h1": + return "\n# \(text)\n" + case "h2": + return "\n## \(text)\n" + case "h3": + return "\n### \(text)\n" + case "h4": + return "\n#### \(text)\n" + case "h5": + return "\n##### \(text)\n" + case "h6": + return "\n###### \(text)\n" + case "p": + return "\n\(try convertElement(element))\n" + case "br": + return "\n" + case "strong", "b": + return "**\(text)**" + case "em", "i": + return "*\(text)*" + case "code": + return "`\(text)`" + case "pre": + return "\n```\n\(text)\n```\n" + case "a": + let href = try element.attr("href") + let title = try element.attr("title") + if href.isEmpty { + return text + } + + // Skip non-http/https/file schemes + if let url = URL(string: href), + let scheme = url.scheme?.lowercased(), + !["http", "https", "file"].contains(scheme) { + return text + } + + let titlePart = title.isEmpty ? "" : " \"\(title.replacingOccurrences(of: "\"", with: "\\\""))\"" + return "[\(text)](\(href)\(titlePart))" + case "img": + let src = try element.attr("src") + let alt = try element.attr("alt") + let title = try element.attr("title") + + var finalSrc = src + // Remove data URIs + if src.hasPrefix("data:") { + finalSrc = src.components(separatedBy: ",").first ?? "" + "..." + } + + let titlePart = title.isEmpty ? "" : " \"\(title.replacingOccurrences(of: "\"", with: "\\\""))\"" + return "![\(alt)](\(finalSrc)\(titlePart))" + case "ul": + return try convertList(element, ordered: false) + case "ol": + return try convertList(element, ordered: true) + case "li": + return try convertElement(element) + case "table": + return try convertTable(element) + case "blockquote": + let content = try convertElement(element) + return content.components(separatedBy: .newlines) + .map { "> \($0)" } + .joined(separator: "\n") + default: + return try convertElement(element) + } + } + + private func convertList(_ element: Element, ordered: Bool) throws -> String { + var result = "\n" + let items = try element.select("li") + + for (index, item) in items.enumerated() { + let content = try convertElement(item).trimmingCharacters(in: .whitespacesAndNewlines) + if ordered { + result += "\(index + 1). \(content)\n" + } else { + result += "- \(content)\n" + } + } + + return result + } + + private func convertTable(_ element: Element) throws -> String { + var result = "\n" + let rows = try element.select("tr") + + guard !rows.isEmpty() else { return "" } + + var isFirstRow = true + for row in rows { + let cells = try row.select("td, th") + let cellContents = try cells.map { try $0.text() } + + result += "| " + cellContents.joined(separator: " | ") + " |\n" + + if isFirstRow { + let separator = Array(repeating: "---", count: cellContents.count).joined(separator: " | ") + result += "| \(separator) |\n" + isFirstRow = false + } + } + + return result + } +} diff --git a/Tool/Sources/WebContentExtractor/WebContentExtractor.swift b/Tool/Sources/WebContentExtractor/WebContentExtractor.swift new file mode 100644 index 00000000..aee0d889 --- /dev/null +++ b/Tool/Sources/WebContentExtractor/WebContentExtractor.swift @@ -0,0 +1,227 @@ +import WebKit +import Logger +import Preferences + +public class WebContentFetcher: NSObject, WKNavigationDelegate { + private var webView: WKWebView? + private var loadingTimer: Timer? + private static let converter = HTMLToMarkdownConverter() + private var completion: ((Result) -> Void)? + + private struct Config { + static let timeout: TimeInterval = 30.0 + static let contentLoadDelay: TimeInterval = 2.0 + } + + public enum WebContentError: Error, LocalizedError { + case invalidURL(String) + case timeout + case noContent + case navigationFailed(Error) + case javascriptError(Error) + + public var errorDescription: String? { + switch self { + case .invalidURL(let url): "Invalid URL: \(url)" + case .timeout: "Request timed out" + case .noContent: "No content found" + case .navigationFailed(let error): "Navigation failed: \(error.localizedDescription)" + case .javascriptError(let error): "JavaScript execution error: \(error.localizedDescription)" + } + } + } + + // MARK: - Initialization + public override init() { + super.init() + setupWebView() + } + + deinit { + cleanup() + } + + // MARK: - Public Methods + public func fetchContent(from urlString: String, completion: @escaping (Result) -> Void) { + guard let url = URL(string: urlString) else { + completion(.failure(WebContentError.invalidURL(urlString))) + return + } + + DispatchQueue.main.async { [weak self] in + self?.completion = completion + self?.setupTimeout() + self?.loadContent(from: url) + } + } + + public static func fetchContentAsync(from urlString: String) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let fetcher = WebContentFetcher() + fetcher.fetchContent(from: urlString) { result in + withExtendedLifetime(fetcher) { + continuation.resume(with: result) + } + } + } + } + + public static func fetchMultipleContentAsync(from urls: [String]) async -> [String] { + var results: [String] = [] + + for url in urls { + do { + let content = try await fetchContentAsync(from: url) + results.append("Successfully fetched content from \(url): \(content)") + } catch { + Logger.client.error("Failed to fetch content from \(url): \(error.localizedDescription)") + results.append("Failed to fetch content from \(url) with error: \(error.localizedDescription)") + } + } + + return results + } + + // MARK: - Private Methods + private func setupWebView() { + let configuration = WKWebViewConfiguration() + let dataSource = WKWebsiteDataStore.nonPersistent() + + if #available(macOS 14.0, *) { + configureProxy(for: dataSource) + } + + configuration.websiteDataStore = dataSource + webView = WKWebView(frame: .zero, configuration: configuration) + webView?.navigationDelegate = self + } + + @available(macOS 14.0, *) + private func configureProxy(for dataSource: WKWebsiteDataStore) { + let proxyURL = UserDefaults.shared.value(for: \.gitHubCopilotProxyUrl) + guard let url = URL(string: proxyURL), + let host = url.host, + let port = url.port, + let proxyPort = NWEndpoint.Port(port.description) else { return } + + let tlsOptions = NWProtocolTLS.Options() + let useStrictSSL = UserDefaults.shared.value(for: \.gitHubCopilotUseStrictSSL) + + if !useStrictSSL { + let secOptions = tlsOptions.securityProtocolOptions + sec_protocol_options_set_verify_block(secOptions, { _, _, completion in + completion(true) + }, .main) + } + + let httpProxy = ProxyConfiguration( + httpCONNECTProxy: NWEndpoint.hostPort( + host: NWEndpoint.Host(host), + port: proxyPort + ), + tlsOptions: tlsOptions + ) + + httpProxy.applyCredential( + username: UserDefaults.shared.value(for: \.gitHubCopilotProxyUsername), + password: UserDefaults.shared.value(for: \.gitHubCopilotProxyPassword) + ) + + dataSource.proxyConfigurations = [httpProxy] + } + + private func cleanup() { + loadingTimer?.invalidate() + loadingTimer = nil + webView?.navigationDelegate = nil + webView?.stopLoading() + webView = nil + } + + private func setupTimeout() { + loadingTimer?.invalidate() + loadingTimer = Timer.scheduledTimer(withTimeInterval: Config.timeout, repeats: false) { [weak self] _ in + DispatchQueue.main.async { + Logger.client.error("Request timed out") + self?.completeWithError(WebContentError.timeout) + } + } + } + + private func loadContent(from url: URL) { + if webView == nil { + setupWebView() + } + + guard let webView = webView else { + completeWithError(WebContentError.navigationFailed(NSError(domain: "WebView creation failed", code: -1))) + return + } + + let request = URLRequest( + url: url, + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, + timeoutInterval: Config.timeout + ) + webView.load(request) + } + + private func processHTML(_ html: String) { + do { + let cleanedText = try Self.converter.convertToMarkdown(from: html) + completeWithSuccess(cleanedText) + } catch { + Logger.client.error("SwiftSoup parsing error: \(error.localizedDescription)") + completeWithError(error) + } + } + + private func completeWithSuccess(_ content: String) { + completion?(.success(content)) + completion = nil + } + + private func completeWithError(_ error: Error) { + completion?(.failure(error)) + completion = nil + } + + // MARK: - WKNavigationDelegate + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + loadingTimer?.invalidate() + + DispatchQueue.main.asyncAfter(deadline: .now() + Config.contentLoadDelay) { + webView.evaluateJavaScript("document.body.innerHTML") { [weak self] result, error in + DispatchQueue.main.async { + if let error = error { + Logger.client.error("JavaScript execution error: \(error.localizedDescription)") + self?.completeWithError(WebContentError.javascriptError(error)) + return + } + + if let html = result as? String, !html.isEmpty { + self?.processHTML(html) + } else { + self?.completeWithError(WebContentError.noContent) + } + } + } + } + } + + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + handleNavigationFailure(error) + } + + public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + handleNavigationFailure(error) + } + + private func handleNavigationFailure(_ error: Error) { + loadingTimer?.invalidate() + DispatchQueue.main.async { + Logger.client.error("Navigation failed: \(error.localizedDescription)") + self.completeWithError(WebContentError.navigationFailed(error)) + } + } +} diff --git a/Tool/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 new file mode 100644 index 00000000..3a15c016 --- /dev/null +++ b/Tool/Sources/Workspace/FileChangeWatcher/FSEventProvider.swift @@ -0,0 +1,59 @@ +import Foundation + +public protocol FSEventProvider { + func createEventStream( + paths: CFArray, + latency: CFTimeInterval, + flags: UInt32, + callback: @escaping FSEventStreamCallback, + context: UnsafeMutablePointer + ) -> FSEventStreamRef? + + func startStream(_ stream: FSEventStreamRef) + func stopStream(_ stream: FSEventStreamRef) + func invalidateStream(_ stream: FSEventStreamRef) + func releaseStream(_ stream: FSEventStreamRef) + func setDispatchQueue(_ stream: FSEventStreamRef, queue: DispatchQueue) +} + +class FileChangeWatcherFSEventProvider: FSEventProvider { + init() {} + + func createEventStream( + paths: CFArray, + latency: CFTimeInterval, + flags: UInt32, + callback: @escaping FSEventStreamCallback, + context: UnsafeMutablePointer + ) -> FSEventStreamRef? { + return FSEventStreamCreate( + kCFAllocatorDefault, + callback, + context, + paths, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + latency, + flags + ) + } + + func startStream(_ stream: FSEventStreamRef) { + FSEventStreamStart(stream) + } + + func stopStream(_ stream: FSEventStreamRef) { + FSEventStreamStop(stream) + } + + func invalidateStream(_ stream: FSEventStreamRef) { + FSEventStreamInvalidate(stream) + } + + func releaseStream(_ stream: FSEventStreamRef) { + FSEventStreamRelease(stream) + } + + func setDispatchQueue(_ stream: FSEventStreamRef, queue: DispatchQueue) { + FSEventStreamSetDispatchQueue(stream, queue) + } +} 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 new file mode 100644 index 00000000..151effdb --- /dev/null +++ b/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift @@ -0,0 +1,38 @@ +import ConversationServiceProvider +import CopilotForXcodeKit +import Foundation + +public protocol WorkspaceFileProvider { + 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 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) -> [ConversationFileReference] { + return WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: workspaceURL, workspaceRootURL: workspaceRootURL) + } + + public func isXCProject(_ url: URL) -> Bool { + return WorkspaceFile.isXCProject(url) + } + + 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/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift index 2b31c4ab..da77d574 100644 --- a/Tool/Sources/Workspace/Filespace.swift +++ b/Tool/Sources/Workspace/Filespace.swift @@ -27,6 +27,7 @@ public final class FilespacePropertyValues { } public struct FilespaceCodeMetadata: Equatable { + /// Stands for `Uniform Type Identifier` public var uti: String? public var tabSize: Int? public var indentSize: Int? @@ -66,6 +67,7 @@ public final class Filespace { // MARK: Metadata public let fileURL: URL + public private(set) var fileContent: String? = nil public private(set) lazy var language: CodeLanguage = languageIdentifierFromFileURL(fileURL) public var codeMetadata: FilespaceCodeMetadata = .init() public var isTextReadable: Bool { @@ -76,21 +78,39 @@ public final class Filespace { public private(set) var suggestionIndex: Int = 0 public internal(set) var suggestions: [CodeSuggestion] = [] { - didSet { refreshUpdateTime() } + didSet{ refreshUpdateTime() } + } + // Use Array for potential extensibility + public internal(set) var nesSuggestions: [CodeSuggestion] = [] { + didSet { refreshNESUpdateTime() } } public var presentingSuggestion: CodeSuggestion? { guard suggestions.endIndex > suggestionIndex, suggestionIndex >= 0 else { return nil } return suggestions[suggestionIndex] } + + public var presentingNESSuggestion: CodeSuggestion? { + // Currently, only one nes suggestion will exist there + return nesSuggestions.first + } + + public private(set) var errorMessage: String = "" { + didSet { refreshUpdateTime() } + } // MARK: Life Cycle public var isExpired: Bool { Environment.now().timeIntervalSince(lastUpdateTime) > 60 * 3 } + + public var isNESExpired: Bool { + Environment.now().timeIntervalSince(lastNESUpdateTime) > 60 * 3 + } public private(set) var lastUpdateTime: Date = Environment.now() + public private(set) var lastNESUpdateTime: Date = Environment.now() private var additionalProperties = FilespacePropertyValues() let fileSaveWatcher: FileSaveWatcher let onClose: (URL) -> Void @@ -106,15 +126,19 @@ public final class Filespace { init( fileURL: URL, + content: String, onSave: @escaping (Filespace) -> Void, onClose: @escaping (URL) -> Void ) { self.fileURL = fileURL + self.fileContent = content self.onClose = onClose fileSaveWatcher = .init(fileURL: fileURL) fileSaveWatcher.changeHandler = { [weak self] in guard let self else { return } + // TODO: should distinguish code completion and NES? onSave(self) + self.fileContent = try? String(contentsOf: self.fileURL) } } @@ -131,6 +155,11 @@ public final class Filespace { suggestions = [] suggestionIndex = 0 } + + @WorkspaceActor + public func resetNESSuggestion() { + nesSuggestions = [] + } @WorkspaceActor public func updateSuggestionsWithSameSelection(_ suggestions: [CodeSuggestion]) { @@ -141,11 +170,25 @@ public final class Filespace { public func refreshUpdateTime() { lastUpdateTime = Environment.now() } + + public func refreshNESUpdateTime() { + lastNESUpdateTime = Date.now + } @WorkspaceActor public func setSuggestions(_ suggestions: [CodeSuggestion]) { self.suggestions = suggestions suggestionIndex = 0 + if !self.suggestions.isEmpty { + self.resetNESSuggestion() + } + } + + @WorkspaceActor + public func setNESSuggestions(_ nesSuggestions: [CodeSuggestion]) { + // Only when there is no code completion suggestion, NES suggestion can be set + guard self.suggestions.isEmpty else { return } + self.nesSuggestions = nesSuggestions } @WorkspaceActor @@ -168,5 +211,33 @@ public final class Filespace { public func bumpVersion() { version += 1 } + + @WorkspaceActor + public func setError(_ message: String) { + errorMessage = message + } + + @WorkspaceActor + public func dismissError() { + errorMessage = "" + } + + @WorkspaceActor + public func updateCodeMetadata( + uti: String, + tabSize: Int, + indentSize: Int, + usesTabsForIndentation: Bool + ) { + self.codeMetadata.uti = uti + self.codeMetadata.tabSize = tabSize + self.codeMetadata.indentSize = indentSize + self.codeMetadata.usesTabsForIndentation = usesTabsForIndentation + } + + @WorkspaceActor + public func setFileContent(_ content: String) { + fileContent = content + } } diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index 117f47d7..a179bc83 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -2,6 +2,9 @@ import Foundation import Preferences import UserDefaultsObserver import XcodeInspector +import Logger +import UniformTypeIdentifiers +import LanguageServerProtocol enum Environment { static var now = { Date() } @@ -41,22 +44,28 @@ open class WorkspacePlugin { self.workspace = workspace } - open func didOpenFilespace(_: Filespace) {} + open func didOpenFilespace(_: Filespace) async {} open func didSaveFilespace(_: Filespace) {} - open func didUpdateFilespace(_: Filespace, content: String) {} + open func didUpdateFilespace(_: Filespace, content: String, contentChanges: [TextDocumentContentChangeEvent]?) async {} open func didCloseFilespace(_: URL) {} } @dynamicMemberLookup public final class Workspace { - public struct UnsupportedFileError: Error, LocalizedError { - public var extensionName: String + public enum WorkspaceFileError: LocalizedError { + case unsupportedFile(extensionName: String) + case fileNotFound(fileURL: URL) + case invalidFileFormat(fileURL: URL) + public var errorDescription: String? { - "File type \(extensionName) unsupported." - } - - public init(extensionName: String) { - self.extensionName = extensionName + switch self { + case .unsupportedFile(let extensionName): + return "File type \(extensionName) unsupported." + case .fileNotFound(let fileURL): + return "File \(fileURL) not found." + case .invalidFileFormat(let fileURL): + return "The file \(fileURL.lastPathComponent) couldn't be opened because it isn't in the correct format." + } } } @@ -106,7 +115,13 @@ public final class Workspace { let openedFiles = openedFileRecoverableStorage.openedFiles Task { @WorkspaceActor in for fileURL in openedFiles { - _ = createFilespaceIfNeeded(fileURL: fileURL) + do { + _ = try await createFilespaceIfNeeded(fileURL: fileURL) + } catch _ as WorkspaceFileError { + openedFileRecoverableStorage.closeFile(fileURL: fileURL) + } catch { + Logger.workspacePool.error(error) + } } } } @@ -116,10 +131,31 @@ public final class Workspace { } @WorkspaceActor - public func createFilespaceIfNeeded(fileURL: URL) -> Filespace { + public func createFilespaceIfNeeded(fileURL: URL) async throws -> Filespace { + let extensionName = fileURL.pathExtension + + if ["xcworkspace", "xcodeproj"].contains( + extensionName + ) || FileManager.default + .fileIsDirectory(atPath: fileURL.path) { + throw WorkspaceFileError.unsupportedFile(extensionName: extensionName) + } + + guard FileManager.default.fileExists(atPath: fileURL.path) else { + throw WorkspaceFileError.fileNotFound(fileURL: fileURL) + } + + if let contentType = try fileURL.resourceValues(forKeys: [.contentTypeKey]).contentType, + !contentType.conforms(to: UTType.data) { + throw WorkspaceFileError.invalidFileFormat(fileURL: fileURL) + } + + let content = try String(contentsOf: fileURL) + let existedFilespace = filespaces[fileURL] let filespace = existedFilespace ?? .init( fileURL: fileURL, + content: content, onSave: { [weak self] filespace in guard let self else { return } self.didSaveFilespace(filespace) @@ -133,7 +169,7 @@ public final class Workspace { filespaces[fileURL] = filespace } if existedFilespace == nil { - didOpenFilespace(filespace) + await didOpenFilespace(filespace) } else { filespace.refreshUpdateTime() } @@ -146,22 +182,38 @@ public final class Workspace { } @WorkspaceActor - public func didUpdateFilespace(fileURL: URL, content: String) { + public func didUpdateFilespace(fileURL: URL, content: String) async { refreshUpdateTime() guard let filespace = filespaces[fileURL] else { return } filespace.bumpVersion() filespace.refreshUpdateTime() + + let oldContent = filespace.fileContent + + // Calculate incremental changes if NES is enabled and we have old content + let changes: [TextDocumentContentChangeEvent]? = { + guard let oldContent = oldContent else { return nil } + return calculateIncrementalChanges(oldContent: oldContent, newContent: content) + }() + for plugin in plugins.values { - plugin.didUpdateFilespace(filespace, content: content) + if let changes, let oldContent { + await plugin.didUpdateFilespace(filespace, content: oldContent, contentChanges: changes) + } else { + // fallback to full content sync + await plugin.didUpdateFilespace(filespace, content: content, contentChanges: nil) + } } + + filespace.setFileContent(content) } @WorkspaceActor - func didOpenFilespace(_ filespace: Filespace) { + public func didOpenFilespace(_ filespace: Filespace) async { refreshUpdateTime() openedFileRecoverableStorage.openFile(fileURL: filespace.fileURL) for plugin in plugins.values { - plugin.didOpenFilespace(filespace) + await plugin.didOpenFilespace(filespace) } } @@ -182,3 +234,138 @@ public final class Workspace { } } +extension Workspace { + static let maxCalculationLength = 200_000 + + /// Calculates incremental changes between two document states. + /// Each change is computed on the state resulting from the previous change, + /// as required by the LSP specification. + /// + /// This implementation finds the common prefix and suffix, then creates + /// a single change event for the differing middle section. This ensures + /// correctness while being efficient for typical editing scenarios. + /// + /// - Parameters: + /// - oldContent: The original document content + /// - newContent: The new document content + /// - Returns: Array of TextDocumentContentChangeEvent in order + func calculateIncrementalChanges( + oldContent: String, + newContent: String + ) -> [TextDocumentContentChangeEvent]? { + // Handle identical content + if oldContent == newContent { + return nil + } + + // Handle empty old content (new file) + if oldContent.isEmpty { + let endPosition = calculateEndPosition(content: oldContent) + return [TextDocumentContentChangeEvent( + range: LSPRange( + start: Position(line: 0, character: 0), + end: Position(line: 0, character: 0) + ), + rangeLength: 0, + text: newContent + )] + } + + // Handle empty new content (cleared file) + if newContent.isEmpty { + let endPosition = calculateEndPosition(content: oldContent) + return [TextDocumentContentChangeEvent( + range: LSPRange( + start: Position(line: 0, character: 0), + end: endPosition + ), + rangeLength: oldContent.utf16.count, + text: "" + )] + } + + // Find common prefix + let oldUTF16 = Array(oldContent.utf16) + let newUTF16 = Array(newContent.utf16) + guard oldUTF16.count <= Self.maxCalculationLength, + newUTF16.count <= Self.maxCalculationLength else { + // Fallback to full replacement for very large contents + return nil + } + + var prefixLength = 0 + let minLength = min(oldUTF16.count, newUTF16.count) + while prefixLength < minLength && oldUTF16[prefixLength] == newUTF16[prefixLength] { + prefixLength += 1 + } + + // Find common suffix (after prefix) + var suffixLength = 0 + while suffixLength < minLength - prefixLength && + oldUTF16[oldUTF16.count - 1 - suffixLength] == newUTF16[newUTF16.count - 1 - suffixLength] { + suffixLength += 1 + } + + // Calculate positions + let startPosition = utf16OffsetToPosition( + content: oldContent, + offset: prefixLength + ) + + let endOffset = oldUTF16.count - suffixLength + let endPosition = utf16OffsetToPosition( + content: oldContent, + offset: endOffset + ) + + // Extract replacement text from new content + let newStartOffset = prefixLength + let newEndOffset = newUTF16.count - suffixLength + + let replacementText: String + if newStartOffset <= newEndOffset { + let startIndex = newContent.utf16.index(newContent.utf16.startIndex, offsetBy: newStartOffset) + let endIndex = newContent.utf16.index(newContent.utf16.startIndex, offsetBy: newEndOffset) + replacementText = String(newContent[startIndex.. Position { + var line = 0 + var character = 0 + + let utf16View = content.utf16 + let safeOffset = min(offset, utf16View.count) + let endIndex = utf16View.index(utf16View.startIndex, offsetBy: safeOffset) + + for char in utf16View[.. Position { + return utf16OffsetToPosition(content: content, offset: content.utf16.count) + } +} diff --git a/Tool/Sources/Workspace/WorkspaceDependency.swift b/Tool/Sources/Workspace/WorkspaceDependency.swift new file mode 100644 index 00000000..25ad22fb --- /dev/null +++ b/Tool/Sources/Workspace/WorkspaceDependency.swift @@ -0,0 +1,20 @@ +import Dependencies +import Foundation + +public final class WorkspaceInvoker { + // Manually trigger the update of the filespace + public var invokeFilespaceUpdate: (URL, String) async -> Void = { _, _ in } + + public init() {} +} + +struct WorkspaceInvokerKey: DependencyKey { + static let liveValue = WorkspaceInvoker() +} + +public extension DependencyValues { + var workspaceInvoker: WorkspaceInvoker { + get { self[WorkspaceInvokerKey.self] } + set { self[WorkspaceInvokerKey.self] = newValue } + } +} 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 new file mode 100644 index 00000000..11c68ce2 --- /dev/null +++ b/Tool/Sources/Workspace/WorkspaceFile.swift @@ -0,0 +1,298 @@ +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] = [ + ".git", + ".svn", + ".hg", + "CVS", + ".DS_Store", + "Thumbs.db", + "node_modules", + "bower_components", + "Preview Content", + ".swiftpm" +] + +public struct ProjectInfo { + public let uri: String + 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) + } + + static func isXCProject(_ url: URL) -> Bool { + 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] { + do { + let xml = try XMLDocument(data: data) + let workspaceBaseURL = workspaceURL.deletingLastPathComponent() + // Process all FileRefs and Groups recursively + return processWorkspaceNodes(xml.rootElement()?.children ?? [], baseURL: workspaceBaseURL) + } catch { + Logger.client.error("Failed to parse workspace file: \(error)") + } + + return [] + } + + /// Recursively processes all nodes in a workspace file, collecting project URLs + private static func processWorkspaceNodes(_ nodes: [XMLNode], baseURL: URL, currentGroupPath: String = "") -> [URL] { + var results: [URL] = [] + + for node in nodes { + guard let element = node as? XMLElement else { continue } + + let location = element.attribute(forName: "location")?.stringValue ?? "" + if element.name == "FileRef" { + if let url = resolveProjectLocation(location: location, baseURL: baseURL, groupPath: currentGroupPath), + !results.contains(url) { + results.append(url) + } + } else if element.name == "Group" { + var groupPath = currentGroupPath + if !location.isEmpty, let path = extractPathFromLocation(location) { + groupPath = (groupPath as NSString).appendingPathComponent(path) + } + + // Process all children of this group, passing the updated group path + let childResults = processWorkspaceNodes(element.children ?? [], baseURL: baseURL, currentGroupPath: groupPath) + + for url in childResults { + if !results.contains(url) { + results.append(url) + } + } + } + } + + return results + } + + /// Extracts path component from a location string + private static func extractPathFromLocation(_ location: String) -> String? { + for prefix in ["group:", "container:", "self:"] { + if location.starts(with: prefix) { + return location.replacingOccurrences(of: prefix, with: "") + } + } + return nil + } + + static func resolveProjectLocation(location: String, baseURL: URL, groupPath: String = "") -> URL? { + var path = "" + + // Extract the path from the location string + if let extractedPath = extractPathFromLocation(location) { + path = extractedPath + } else { + // Unknown location format + return nil + } + + var url: URL = groupPath.isEmpty ? baseURL : baseURL.appendingPathComponent(groupPath) + url = path.isEmpty ? url : url.appendingPathComponent(path) + url = url.standardized // normalize “..” or “.” in the path + if isXCProject(url) { // return the containing directory of the .xcodeproj file + url.deleteLastPathComponent() + } + + return url + } + + static func matchesPatterns(_ url: URL, patterns: [String]) -> Bool { + let fileName = url.lastPathComponent + for pattern in patterns { + if fnmatch(pattern, fileName, 0) == 0 { + return true + } + } + 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) { + subprojects = getSubprojectURLs(in: workspace.workspaceURL).map( { projectURL in + ProjectInfo(uri: projectURL.absoluteString, name: getDisplayNameOfXcodeWorkspace(url: projectURL)) + }) + } else { + subprojects.append(ProjectInfo(uri: workspace.projectURL.absoluteString, name: getDisplayNameOfXcodeWorkspace(url: workspace.projectURL))) + } + return subprojects + } + + public static func getDisplayNameOfXcodeWorkspace(url: URL) -> String { + var name = url.lastPathComponent + let suffixes = [".xcworkspace", ".xcodeproj", ".playground"] + for suffix in suffixes { + if name.hasSuffix(suffix) { + name = String(name.dropLast(suffix.count)) + break + } + } + 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 + ) -> [ConversationFileReference] { + var files: [ConversationFileReference] = [] + do { + let fileManager = FileManager.default + var subprojects: [URL] = [] + if isXCWorkspace(workspaceURL) { + subprojects = getSubprojectURLs(in: workspaceURL) + } else { + subprojects.append(workspaceRootURL) + } + for subproject in subprojects { + guard FileManager.default.fileExists(atPath: subproject.path) else { + continue + } + + let enumerator = fileManager.enumerator( + at: subproject, + includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) + + while let fileURL = enumerator?.nextObject() as? URL { + // Skip items matching the specified pattern + if shouldSkipURL(fileURL) { + enumerator?.skipDescendants() + continue + } + + guard try isValidFile(fileURL, shouldExcludeFile: shouldExcludeFile) else { continue } + + let relativePath = fileURL.path.replacingOccurrences(of: workspaceRootURL.path, with: "") + let fileName = fileURL.lastPathComponent + + let file = ConversationFileReference(url: fileURL, relativePath: relativePath, fileName: fileName) + files.append(file) + } + } + } catch { + Logger.client.error("Failed to get files in workspace: \(error)") + } + + return files + } + + /* + used for `project-context` skill. Get filed for watching for syncing to CLS + */ + public static func getWatchedFiles( + workspaceURL: URL, + projectURL: URL, + excludeGitIgnoredFiles: Bool, + excludeIDEIgnoredFiles: Bool + ) -> [ConversationFileReference] { + // Directly return for invalid workspace + guard workspaceURL.path != "/" else { return [] } + + // TODO: implement + let shouldExcludeFile: ((URL) -> Bool)? = nil + + let files = getFilesInActiveWorkspace( + workspaceURL: workspaceURL, + workspaceRootURL: projectURL, + shouldExcludeFile: shouldExcludeFile + ) + + 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/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift index 96627858..44468e07 100644 --- a/Tool/Sources/Workspace/WorkspacePool.swift +++ b/Tool/Sources/Workspace/WorkspacePool.swift @@ -67,7 +67,24 @@ public class WorkspacePool { if filespaces.count == 1 { return filespaces.first } Logger.workspacePool.info("Multiple workspaces found with file: \(fileURL)") // If multiple workspaces are found, return the first with a suggestion - return filespaces.first { $0.presentingSuggestion != nil } + return filespaces.first { $0.presentingSuggestion != nil } ?? filespaces.first { $0.presentingNESSuggestion != nil } + } + + public func fetchWorkspaceAndFilespace(fileURL: URL) -> (Workspace, Filespace)? { + var workspace: Workspace? + var filespace: Filespace? + + for wp in workspaces.values { + if let fp = wp.filespaces[fileURL] { + if fp.presentingSuggestion != nil || fp.presentingNESSuggestion != nil { + return (wp, fp) + } + workspace = wp + filespace = fp + } + } + + return workspace.flatMap { ws in filespace.map { fs in (ws, fs) } } } @WorkspaceActor @@ -93,13 +110,13 @@ public class WorkspacePool { if let currentWorkspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL { if let existed = workspaces[currentWorkspaceURL] { // Reuse the existed workspace. - let filespace = existed.createFilespaceIfNeeded(fileURL: fileURL) + let filespace = try await existed.createFilespaceIfNeeded(fileURL: fileURL) return (existed, filespace) } let new = createNewWorkspace(workspaceURL: currentWorkspaceURL) workspaces[currentWorkspaceURL] = new - let filespace = new.createFilespaceIfNeeded(fileURL: fileURL) + let filespace = try await new.createFilespaceIfNeeded(fileURL: fileURL) return (new, filespace) } @@ -133,7 +150,7 @@ public class WorkspacePool { return createNewWorkspace(workspaceURL: workspaceURL) }() - let filespace = workspace.createFilespaceIfNeeded(fileURL: fileURL) + let filespace = try await workspace.createFilespaceIfNeeded(fileURL: fileURL) workspaces[workspaceURL] = workspace workspace.refreshUpdateTime() return (workspace, filespace) diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift index 47e1d9dc..03656855 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift @@ -4,6 +4,7 @@ import Workspace import XPCShared public struct FilespaceSuggestionSnapshot: Equatable { + public let lines: [String] public let linesHash: Int public let prefixLinesHash: Int public let suffixLinesHash: Int @@ -15,6 +16,7 @@ public struct FilespaceSuggestionSnapshot: Equatable { return max(min(index, lines.endIndex), lines.startIndex) } + self.lines = lines self.linesHash = lines.hashValue self.cursorPosition = cursorPosition self.prefixLinesHash = lines[0.. FilespaceSuggestionSnapshot { .init(lines: [], cursorPosition: .outOfScope) } } +public struct FilespaceNESSuggestionSnapshotKey: FilespacePropertyKey { + public static func createDefaultValue() + -> FilespaceSuggestionSnapshot { .init(lines: [], cursorPosition: .outOfScope) } +} + public extension FilespacePropertyValues { @WorkspaceActor var suggestionSourceSnapshot: FilespaceSuggestionSnapshot { get { self[FilespaceSuggestionSnapshotKey.self] } set { self[FilespaceSuggestionSnapshotKey.self] = newValue } } + + @WorkspaceActor + var nesSuggestionSourceSnapshot: FilespaceSuggestionSnapshot { + get { self[FilespaceNESSuggestionSnapshotKey.self] } + set { self[FilespaceNESSuggestionSnapshotKey.self] = newValue } + } } public extension Filespace { @@ -53,6 +66,13 @@ public extension Filespace { self.suggestionSourceSnapshot = FilespaceSuggestionSnapshotKey.createDefaultValue() // swiftformat:enable all } + + @WorkspaceActor + func resetNESSnapshot() { + // swiftformat:disable redundantSelf + self.nesSuggestionSourceSnapshot = FilespaceNESSuggestionSnapshotKey.createDefaultValue() + // swiftformat:enable all + } /// Validate the suggestion is still valid. /// - Parameters: @@ -125,6 +145,26 @@ public extension Filespace { resetSnapshot() return false } - + + /// Validate the nes suggestion is still valid. + /// - Parameters: + /// - lines: lines of the file + /// - cursorPosition: cursor position + /// - Returns: `true` if the nes suggestion is still valid + @WorkspaceActor + func validateNESSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { + guard let presentingNESSuggestion else { return false } + + let updatedSnapshot = FilespaceSuggestionSnapshot(lines: lines, cursorPosition: cursorPosition) + + // document state is unchanged + if updatedSnapshot == self.nesSuggestionSourceSnapshot { + return true + } + + resetNESSuggestion() + resetNESSnapshot() + return false + } } diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift index e1173ad5..d59859bb 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift @@ -3,6 +3,7 @@ import GitHubCopilotService import SuggestionBasic import SuggestionProvider import Workspace +import Status import XPCShared public extension Workspace { @@ -31,6 +32,12 @@ public extension Workspace { "Suggestion feature is disabled for this project." } } + + struct EditorCursorOutOfScopeError: Error, LocalizedError { + public var errorDescription: String? { + "Cursor position is out of scope." + } + } } public extension Workspace { @@ -39,45 +46,86 @@ public extension Workspace { func generateSuggestions( forFileAt fileURL: URL, editor: EditorContent + ) async throws -> [CodeSuggestion] { + refreshUpdateTime() + + guard editor.cursorPosition != .outOfScope else { + throw EditorCursorOutOfScopeError() + } + + let filespace = try await createFilespaceIfNeeded(fileURL: fileURL) + + if !editor.uti.isEmpty { + filespace.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) + } + + filespace.codeMetadata.guessLineEnding(from: editor.lines.first) + + let snapshot = FilespaceSuggestionSnapshot(content: editor) + filespace.suggestionSourceSnapshot = snapshot + + guard let suggestionService else { throw SuggestionFeatureDisabledError() } + let content = editor.lines.joined(separator: "") + let completions = try await suggestionService.getSuggestions( + .from(fileURL: fileURL, content: content, editor: editor, projectRootURL: projectRootURL), + workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) + ) + + let clsStatus = await Status.shared.getCLSStatus() + if clsStatus.isErrorStatus && clsStatus.message.contains("Completions limit reached") { + filespace.setError(clsStatus.message) + } else { + filespace.setError("") + filespace.setSuggestions(completions) + } + + return completions +} + + @WorkspaceActor + @discardableResult + func generateNESSuggestions( + forFileAt fileURL: URL, + editor: EditorContent ) async throws -> [CodeSuggestion] { refreshUpdateTime() + + guard editor.cursorPosition != .outOfScope else { + throw EditorCursorOutOfScopeError() + } - let filespace = createFilespaceIfNeeded(fileURL: fileURL) + let filespace = try await createFilespaceIfNeeded(fileURL: fileURL) if !editor.uti.isEmpty { - filespace.codeMetadata.uti = editor.uti - filespace.codeMetadata.tabSize = editor.tabSize - filespace.codeMetadata.indentSize = editor.indentSize - filespace.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + filespace.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) } filespace.codeMetadata.guessLineEnding(from: editor.lines.first) let snapshot = FilespaceSuggestionSnapshot(content: editor) - filespace.suggestionSourceSnapshot = snapshot + filespace.nesSuggestionSourceSnapshot = snapshot guard let suggestionService else { throw SuggestionFeatureDisabledError() } let content = editor.lines.joined(separator: "") - let completions = try await suggestionService.getSuggestions( - .init( - fileURL: fileURL, - relativePath: fileURL.path.replacingOccurrences(of: projectRootURL.path, with: ""), - content: content, - originalContent: content, - lines: editor.lines, - cursorPosition: editor.cursorPosition, - cursorOffset: editor.cursorOffset, - tabSize: editor.tabSize, - indentSize: editor.indentSize, - usesTabsForIndentation: editor.usesTabsForIndentation, - relevantCodeSnippets: [] - ), + let completions = try await suggestionService.getNESSuggestions( + .from(fileURL: fileURL, content: content, editor: editor, projectRootURL: projectRootURL), workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) ) - - filespace.setSuggestions(completions) - + + // TODO: How to get the `limit reached` error? Same as Code Completion? + filespace.setNESSuggestions(completions) + return completions } @@ -107,16 +155,27 @@ public extension Workspace { } } } + + @WorkspaceActor + func notifyNESSuggestionShown(forFileAt fileURL: URL) { + if let suggestion = filespaces[fileURL]?.presentingNESSuggestion { + Task { + await gitHubCopilotService?.notifyCopilotInlineEditShown(suggestion) + } + } + } @WorkspaceActor func rejectSuggestion(forFileAt fileURL: URL, editor: EditorContent?) { refreshUpdateTime() if let editor, !editor.uti.isEmpty { - filespaces[fileURL]?.codeMetadata.uti = editor.uti - filespaces[fileURL]?.codeMetadata.tabSize = editor.tabSize - filespaces[fileURL]?.codeMetadata.indentSize = editor.indentSize - filespaces[fileURL]?.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + filespaces[fileURL]?.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) } Task { @@ -130,6 +189,31 @@ public extension Workspace { } filespaces[fileURL]?.reset() } + + @WorkspaceActor + func rejectNESSuggestion(forFileAt fileURL: URL, editor: EditorContent?) { + refreshUpdateTime() + + if let editor, !editor.uti.isEmpty { + filespaces[fileURL]?.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) + } + + Task { + await suggestionService?.notifyRejected( + filespaces[fileURL]?.nesSuggestions ?? [], + workspaceInfo: .init( + workspaceURL: workspaceURL, + projectURL: projectRootURL + ) + ) + } + filespaces[fileURL]?.resetNESSuggestion() + } @WorkspaceActor func acceptSuggestion(forFileAt fileURL: URL, editor: EditorContent?, suggestionLineLimit: Int? = nil) -> CodeSuggestion? { @@ -141,10 +225,12 @@ public extension Workspace { else { return nil } if let editor, !editor.uti.isEmpty { - filespaces[fileURL]?.codeMetadata.uti = editor.uti - filespaces[fileURL]?.codeMetadata.tabSize = editor.tabSize - filespaces[fileURL]?.codeMetadata.indentSize = editor.indentSize - filespaces[fileURL]?.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + filespaces[fileURL]?.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) } var allSuggestions = filespace.suggestions @@ -167,5 +253,57 @@ public extension Workspace { return suggestion } + + @WorkspaceActor + func acceptNESSuggestion(forFileAt fileURL: URL, editor: EditorContent?, suggestionLineLimit: Int? = nil) -> CodeSuggestion? { + refreshUpdateTime() + guard let filespace = filespaces[fileURL], + let suggestion = filespace.presentingNESSuggestion + else { return nil } + + if let editor, !editor.uti.isEmpty { + filespaces[fileURL]?.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) + } + + Task { + await gitHubCopilotService?.notifyCopilotInlineEditAccepted(suggestion) + } + + filespace.resetNESSuggestion() + filespace.resetNESSnapshot() + + return suggestion + } + + @WorkspaceActor + func getNESSuggestion(forFileAt fileURL: URL) -> CodeSuggestion? { + guard let filespace = filespaces[fileURL], + let suggestion = filespace.presentingNESSuggestion + else { return nil } + + return suggestion + } } +extension SuggestionRequest { + static func from(fileURL: URL, content: String, editor: EditorContent, projectRootURL: URL) -> Self { + return .init( + fileURL: fileURL, + relativePath: fileURL.path.replacingOccurrences(of: projectRootURL.path, with: ""), + content: content, + originalContent: content, + lines: editor.lines, + cursorPosition: editor.cursorPosition, + cursorOffset: editor.cursorOffset, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation, + relevantCodeSnippets: [] + ) + } +} diff --git a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift index 610b6c53..4b7d09cb 100644 --- a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift +++ b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift @@ -1,5 +1,6 @@ import Foundation import Logger +import AppKit public enum XPCCommunicationBridgeError: Swift.Error, LocalizedError { case failedToCreateXPCConnection @@ -79,3 +80,18 @@ extension XPCCommunicationBridge { } } +@available(macOS 13.0, *) +public func showBackgroundPermissionAlert() { + let alert = NSAlert() + alert.messageText = "Background Permission Required" + alert.informativeText = "GitHub Copilot for Xcode needs permission to run in the background. Without this permission, features won't work correctly." + alert.alertStyle = .warning + + alert.addButton(withTitle: "Open Settings") + alert.addButton(withTitle: "Later") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.LoginItems-Settings.extension")!) + } +} diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index 3541ab6a..4e4d59f5 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -1,6 +1,9 @@ import Foundation +import GitHubCopilotService +import ConversationServiceProvider import Logger import Status +import LanguageServerProtocol public enum XPCExtensionServiceError: Swift.Error, LocalizedError { case failedToGetServiceEndpoint @@ -17,6 +20,15 @@ public enum XPCExtensionServiceError: Swift.Error, LocalizedError { return "Connection to extension service error: \(error.localizedDescription)" } } + + public var underlyingError: Error? { + switch self { + case let .xpcServiceError(error): + return error + default: + return nil + } + } } public class XPCExtensionService { @@ -48,6 +60,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 { @@ -57,6 +78,15 @@ public class XPCExtensionService { } } } + + public func getXPCServiceExtensionPermission() async throws -> ExtensionPermissionStatus { + try await withXPCServiceConnected { + service, continuation in + service.getXPCServiceExtensionPermission { isGranted in + continuation.resume(isGranted) + } + } + } public func getSuggestedCode(editorContent: EditorContent) async throws -> UpdatedContent? { try await suggestionRequest( @@ -89,6 +119,13 @@ public class XPCExtensionService { { $0.getSuggestionAcceptedCode } ) } + + public func getNESSuggestionAcceptedCode(editorContent: EditorContent) async throws -> UpdatedContent? { + try await suggestionRequest( + editorContent, + { $0.getNESSuggestionAcceptedCode } + ) + } public func getSuggestionRejectedCode(editorContent: EditorContent) async throws -> UpdatedContent? @@ -98,6 +135,15 @@ public class XPCExtensionService { { $0.getSuggestionRejectedCode } ) } + + public func getNESSuggestionRejectedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + editorContent, + { $0.getNESSuggestionRejectedCode } + ) + } public func getRealtimeSuggestedCode(editorContent: EditorContent) async throws -> UpdatedContent? @@ -129,6 +175,19 @@ public class XPCExtensionService { } } as Void } + + public func toggleRealtimeNES() async throws { + try await withXPCServiceConnected { + service, continuation in + service.toggleRealtimeNES { error in + if let error { + continuation.reject(error) + return + } + continuation.resume(()) + } + } as Void + } public func prefetchRealtimeSuggestions(editorContent: EditorContent) async { guard let data = try? JSONEncoder().encode(editorContent) else { return } @@ -139,11 +198,17 @@ public class XPCExtensionService { } } - public func openChat(editorContent: EditorContent) async throws -> UpdatedContent? { - try await suggestionRequest( - editorContent, - { $0.openChat } - ) + public func openChat() async throws { + try await withXPCServiceConnected { + service, continuation in + service.openChat { error in + if let error { + continuation.reject(error) + return + } + continuation.resume(()) + } + } as Void } public func promptToCode(editorContent: EditorContent) async throws -> UpdatedContent? { @@ -163,7 +228,6 @@ public class XPCExtensionService { ) } - public func quitService() async throws { try await withXPCServiceConnectedWithoutLaunching { service, continuation in @@ -308,5 +372,517 @@ 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], + chatAgentMode: ChatMode? = nil, + customChatModeId: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil + ) async throws { + return try await withXPCServiceConnected { + service, continuation in + do { + let data = try JSONEncoder().encode(update) + let foldersData = workspaceFolders.flatMap { try? JSONEncoder().encode($0) } + let modeData = chatAgentMode.flatMap { try? JSONEncoder().encode($0) } + let modeIdData = customChatModeId.flatMap { try? JSONEncoder().encode($0) } + service.updateMCPServerToolsStatus( + tools: data, + chatAgentMode: modeData, + customChatModeId: modeIdData, + workspaceFolders: foldersData + ) + continuation.resume(()) + } catch { + continuation.reject(error) + } + } + } + + // MARK: MCP Registry + + @XPCServiceActor + public func listMCPRegistryServers(_ params: MCPRegistryListServersParams) async throws -> MCPRegistryServerList? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.listMCPRegistryServers(params) { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(MCPRegistryServerList.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func getMCPRegistryServer(_ params: MCPRegistryGetServerParams) async throws -> MCPRegistryServerDetail? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.getMCPRegistryServer(params) { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(MCPRegistryServerDetail.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func getMCPRegistryAllowlist() async throws -> GetMCPRegistryAllowlistResult? { + return try await withXPCServiceConnected { + service, continuation in + service.getMCPRegistryAllowlist { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(GetMCPRegistryAllowlistResult.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } + } + + @XPCServiceActor + public func getAvailableLanguageModelTools() async throws -> [LanguageModelTool]? { + return try await withXPCServiceConnected { + service, continuation in + service.getAvailableLanguageModelTools { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let tools = try JSONDecoder().decode([LanguageModelTool].self, from: data) + continuation.resume(tools) + } catch { + continuation.reject(error) + } + } + } + } + + @XPCServiceActor + public func refreshClientTools() async throws -> [LanguageModelTool]? { + return try await withXPCServiceConnected { + service, continuation in + service.refreshClientTools { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let tools = try JSONDecoder().decode([LanguageModelTool].self, from: data) + continuation.resume(tools) + } catch { + continuation.reject(error) + } + } + } + } + + @XPCServiceActor + public func updateToolsStatus( + _ update: [ToolStatusUpdate], + chatAgentMode: ChatMode? = nil, + customChatModeId: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil + ) async throws -> [LanguageModelTool]? { + return try await withXPCServiceConnected { + service, continuation in + do { + let data = try JSONEncoder().encode(update) + let foldersData = workspaceFolders.flatMap { try? JSONEncoder().encode($0) } + let modeData = chatAgentMode.flatMap { try? JSONEncoder().encode($0) } + let modeIdData = customChatModeId.flatMap { try? JSONEncoder().encode($0) } + service.updateToolsStatus( + tools: data, + chatAgentMode: modeData, + customChatModeId: modeIdData, + workspaceFolders: foldersData + ) { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode([LanguageModelTool].self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func getCopilotFeatureFlags() async throws -> FeatureFlags? { + return try await withXPCServiceConnected { + 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 getCopilotPolicy() async throws -> CopilotPolicy? { + return try await withXPCServiceConnected { + service, continuation in + service.getCopilotPolicy { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let copilotPolicy = try JSONDecoder().decode(CopilotPolicy.self, from: data) + continuation.resume(copilotPolicy) + } catch { + continuation.reject(error) + } + } + } + } + + @XPCServiceActor + public func getModes(workspaceFolders: [WorkspaceFolder]? = nil) async throws -> [ConversationMode]? { + return try await withXPCServiceConnected { + service, continuation in + let workspaceFoldersData = workspaceFolders.flatMap { try? JSONEncoder().encode($0) } + service.getModes(workspaceFolders: workspaceFoldersData) { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let modes = try JSONDecoder().decode([ConversationMode].self, from: data) + continuation.resume(modes) + } catch { + continuation.reject(error) + } + } + } + } + + @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) + } + } + } + } + + @XPCServiceActor + public func updateCopilotModels() async throws -> [CopilotModel]? { + return try await withXPCServiceConnected { + service, continuation in + service.updateCopilotModels { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let models = try JSONDecoder().decode([CopilotModel].self, from: data) + continuation.resume(models) + } catch { + continuation.reject(error) + } + } + } + } + + // MARK: BYOK + @XPCServiceActor + public func saveBYOKApiKey(_ params: BYOKSaveApiKeyParams) async throws -> BYOKSaveApiKeyResponse? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.saveBYOKApiKey(params) { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(BYOKSaveApiKeyResponse.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func listBYOKApiKey(_ params: BYOKListApiKeysParams) async throws -> BYOKListApiKeysResponse? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.listBYOKApiKeys(params) { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(BYOKListApiKeysResponse.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func deleteBYOKApiKey(_ params: BYOKDeleteApiKeyParams) async throws -> BYOKDeleteApiKeyResponse? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.deleteBYOKApiKey(params) { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(BYOKDeleteApiKeyResponse.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func saveBYOKModel(_ params: BYOKSaveModelParams) async throws -> BYOKSaveModelResponse? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.saveBYOKModel(params) { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(BYOKSaveModelResponse.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func listBYOKModels(_ params: BYOKListModelsParams) async throws -> BYOKListModelsResponse? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.listBYOKModels(params) { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(BYOKListModelsResponse.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } + + @XPCServiceActor + public func deleteBYOKModel(_ params: BYOKDeleteModelParams) async throws -> BYOKDeleteModelResponse? { + return try await withXPCServiceConnected { + service, continuation in + do { + let params = try JSONEncoder().encode(params) + service.deleteBYOKModel(params) { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(BYOKDeleteModelResponse.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } catch { + continuation.reject(error) + } + } + } +} diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift index c59d70b6..4489233f 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -4,57 +4,64 @@ 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( - editorContent: Data, - withReply reply: @escaping (Data?, Error?) -> Void - ) - func promptToCode( - 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 getNESSuggestionAcceptedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getSuggestionRejectedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getNESSuggestionRejectedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getRealtimeSuggestedCode(editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func getPromptToCodeAcceptedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func openChat(withReply reply: @escaping (Error?) -> Void) + func promptToCode(editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func customCommand(id: String, editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void) + + func toggleRealtimeSuggestion(withReply reply: @escaping (Error?) -> Void) + func toggleRealtimeNES(withReply reply: @escaping (Error?) -> Void) + func prefetchRealtimeSuggestions(editorContent: Data, withReply reply: @escaping () -> Void) + + func getXPCServiceVersion(withReply reply: @escaping (String, String) -> Void) + 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, + chatAgentMode: Data?, + customChatModeId: Data?, + workspaceFolders: Data? ) - func customCommand( - id: String, - editorContent: Data, - withReply reply: @escaping (Data?, Error?) -> Void + func listMCPRegistryServers(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func getMCPRegistryServer(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func getMCPRegistryAllowlist(withReply reply: @escaping (Data?, Error?) -> Void) + func getAvailableLanguageModelTools(withReply reply: @escaping (Data?) -> Void) + func refreshClientTools(withReply reply: @escaping (Data?) -> Void) + func updateToolsStatus( + tools: Data, + chatAgentMode: Data?, + customChatModeId: Data?, + workspaceFolders: Data?, + withReply reply: @escaping (Data?) -> Void ) - func toggleRealtimeSuggestion(withReply reply: @escaping (Error?) -> Void) + func getCopilotFeatureFlags(withReply reply: @escaping (Data?) -> Void) + func getCopilotPolicy(withReply reply: @escaping (Data?) -> Void) + func updateCopilotModels(withReply reply: @escaping (Data?, Error?) -> Void) + func getModes(workspaceFolders: Data?, withReply reply: @escaping (Data?, Error?) -> Void) - func prefetchRealtimeSuggestions( - editorContent: Data, - withReply reply: @escaping () -> 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 getXPCServiceVersion(withReply reply: @escaping (String, String) -> Void) - func getXPCServiceAccessibilityPermission(withReply reply: @escaping (ObservedAXStatus) -> 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) @@ -154,4 +161,3 @@ extension ExtensionServiceRequestType { } } } - 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 1245d98f..3bb2f76e 100644 --- a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -2,16 +2,12 @@ import AppKit import Foundation public class AppInstanceInspector: ObservableObject { - let runningApplication: NSRunningApplication + public let runningApplication: NSRunningApplication public let processIdentifier: pid_t 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 } @@ -35,8 +36,12 @@ public class AppInstanceInspector: ObservableObject { public func activate() -> Bool { return runningApplication.activate() } + + public func activate(options: NSApplication.ActivationOptions) -> Bool { + 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..1b4fbdf3 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 { @@ -430,7 +447,7 @@ public extension AXUIElement { if element.identifier == "editor context" { return .skipDescendantsAndSiblings } - if element.isSourceEditor { + if element.isNonNavigatorSourceEditor { return .skipDescendantsAndSiblings } if description == "Code Coverage Ribbon" { @@ -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..a5355be5 100644 --- a/Tool/Sources/XcodeInspector/Helpers.swift +++ b/Tool/Sources/XcodeInspector/Helpers.swift @@ -16,3 +16,26 @@ public extension FileManager { } } +extension AXUIElement { + public 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 953cee35..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 ) } @@ -262,7 +270,8 @@ public extension SourceEditor { var cursorRange = CursorRange(start: .zero, end: .outOfScope) for (i, line) in lines.enumerated() { if countS <= range.lowerBound, - range.lowerBound < countS + line.utf16.count + // when equal, means the cursor is located at the lowerBound + range.lowerBound <= countS + line.utf16.count { cursorRange.start = .init(line: i, character: range.lowerBound - countS) } @@ -293,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+TriggerCommand.swift b/Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift index e6ea06eb..f7779b87 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift @@ -2,20 +2,54 @@ import AppKit import AXExtension import Foundation import Logger +import Status public extension XcodeAppInstanceInspector { func triggerCopilotCommand(name: String, activateXcode: Bool = true) async throws { - let bundleName = Bundle.main - .object(forInfoDictionaryKey: "EXTENSION_BUNDLE_NAME") as! String + let bundleName = Bundle.main.object(forInfoDictionaryKey: "EXTENSION_BUNDLE_NAME") as! String + let status = await getExtensionStatus(bundleName: bundleName) + guard status == .granted else { + let reason: String + switch status { + case .notGranted: + reason = "No bundle found for \(bundleName)." + case .disabled: + reason = "\(bundleName) is found but disabled." + default: + reason = "" + } + throw CantRunCommand(path: "Editor/\(bundleName)/\(name)", reason: reason) + } + try await triggerMenuItem(path: ["Editor", bundleName, name], activateApp: activateXcode) } + + private func getExtensionStatus(bundleName: String) async -> ExtensionPermissionStatus { + let app = AXUIElementCreateApplication(runningApplication.processIdentifier) + + guard let menuBar = app.menuBar, + let editorMenu = menuBar.child(title: "Editor") else { + return .notGranted + } + + if let bundleMenuItem = editorMenu.child(title: bundleName, role: "AXMenuItem") { + var enabled: CFTypeRef? + let error = AXUIElementCopyAttributeValue(bundleMenuItem, kAXEnabledAttribute as CFString, &enabled) + if error == .success, let isEnabled = enabled as? Bool { + return isEnabled ? .granted : .disabled + } + return .disabled + } + + return .notGranted + } } public extension AppInstanceInspector { struct CantRunCommand: Error, LocalizedError { let path: String let reason: String - public var errorDescription: String? { + public var errorDescription: String { "Can't run command \(path): \(reason)" } } diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index daa66c6c..6a8c5d5e 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,31 +285,20 @@ 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) - if let editorElement = focusedElement, editorElement.isSourceEditor { + focusedElement = xcode.getFocusedElement(shouldRecordStatus: true) + + if let editorElement = focusedElement, editorElement.isNonNavigatorSourceEditor { focusedEditor = .init( runningApplication: xcode.runningApplication, element: editorElement ) } else if let element = focusedElement, - let editorElement = element.firstParent(where: \.isSourceEditor) + let editorElement = element.firstParent(where: \.isNonNavigatorSourceEditor) { 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 @@ -379,7 +374,7 @@ public final class XcodeInspector: ObservableObject { guard Date().timeIntervalSince(lastRecoveryFromAccessibilityMalfunctioningTimeStamp) > 5 else { return } - if let editor = focusedEditor, !editor.element.isSourceEditor { + if let editor = focusedEditor, !editor.element.isNonNavigatorSourceEditor { NotificationCenter.default.post( name: .accessibilityAPIMalfunctioning, object: "Source Editor Element Corrupted: \(source)" @@ -405,7 +400,7 @@ public final class XcodeInspector: ObservableObject { """ if UserDefaults.shared.value(for: \.toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted) { - toast.toast(content: message, type: .warning) + toast.toast(content: message, level: .warning) } else { Logger.service.info(message) } @@ -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/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index d2506822..1f767be6 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -111,6 +111,72 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return url } } + + // Fallback: If no child has the workspace path in description, + // try to derive it from the window's document URL + if let documentURL = extractDocumentURL(windowElement: windowElement) { + if let workspaceURL = deriveWorkspaceFromDocumentURL(documentURL) { + return workspaceURL + } + } + + return nil + } + + static func deriveWorkspaceFromDocumentURL(_ documentURL: URL) -> URL? { + // Check if documentURL itself is already a workspace/project/playground + if documentURL.pathExtension == "xcworkspace" || + documentURL.pathExtension == "xcodeproj" || + documentURL.pathExtension == "playground" { + return documentURL + } + + // Try to find .xcodeproj or .xcworkspace in parent directories + var currentURL = documentURL + while currentURL.pathComponents.count > 1 { + currentURL.deleteLastPathComponent() + + // Check if current directory is a playground + if currentURL.pathExtension == "playground" { + return currentURL + } + + // Check if this directory contains .xcodeproj or .xcworkspace + guard let contents = try? FileManager.default.contentsOfDirectory(atPath: currentURL.path) else { + continue + } + + // Check for .playground, .xcworkspace, and .xcodeproj in a single pass + var foundPlaygroundURL: URL? + var foundWorkspaceURL: URL? + var foundProjectURL: URL? + for item in contents { + if foundPlaygroundURL == nil, item.hasSuffix(".playground") { + foundPlaygroundURL = currentURL.appendingPathComponent(item) + } + if foundWorkspaceURL == nil, item.hasSuffix(".xcworkspace") { + foundWorkspaceURL = currentURL.appendingPathComponent(item) + } + if foundProjectURL == nil, item.hasSuffix(".xcodeproj") { + foundProjectURL = currentURL.appendingPathComponent(item) + } + } + if let playgroundURL = foundPlaygroundURL { + return playgroundURL + } + if let workspaceURL = foundWorkspaceURL { + return workspaceURL + } + if let projectURL = foundProjectURL { + return projectURL + } + + // Stop at the user's home directory or root + if currentURL.path == "/" || currentURL.path == NSHomeDirectory() { + break + } + } + return nil } @@ -152,4 +218,3 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return url } } - diff --git a/Tool/Tests/GitHelperTests/GitHunkTests.swift b/Tool/Tests/GitHelperTests/GitHunkTests.swift new file mode 100644 index 00000000..03e79a2f --- /dev/null +++ b/Tool/Tests/GitHelperTests/GitHunkTests.swift @@ -0,0 +1,272 @@ +import XCTest +import GitHelper + +class GitHunkTests: XCTestCase { + + func testParseDiffSingleHunk() { + let diff = """ + @@ -1,3 +1,4 @@ + line1 + +added line + line2 + line3 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1) + XCTAssertEqual(hunk.deletedLines, 3) + XCTAssertEqual(hunk.startAddedLine, 1) + XCTAssertEqual(hunk.addedLines, 4) + XCTAssertEqual(hunk.additions.count, 1) + XCTAssertEqual(hunk.additions[0].start, 2) + XCTAssertEqual(hunk.additions[0].length, 1) + XCTAssertEqual(hunk.diffText, " line1\n+added line\n line2\n line3") + } + + func testParseDiffMultipleHunks() { + let diff = """ + @@ -1,2 +1,3 @@ + line1 + +added line1 + line2 + @@ -10,2 +11,3 @@ + line10 + +added line10 + line11 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 2) + + // First hunk + let hunk1 = hunks[0] + XCTAssertEqual(hunk1.startDeletedLine, 1) + XCTAssertEqual(hunk1.deletedLines, 2) + XCTAssertEqual(hunk1.startAddedLine, 1) + XCTAssertEqual(hunk1.addedLines, 3) + XCTAssertEqual(hunk1.additions.count, 1) + XCTAssertEqual(hunk1.additions[0].start, 2) + XCTAssertEqual(hunk1.additions[0].length, 1) + + // Second hunk + let hunk2 = hunks[1] + XCTAssertEqual(hunk2.startDeletedLine, 10) + XCTAssertEqual(hunk2.deletedLines, 2) + XCTAssertEqual(hunk2.startAddedLine, 11) + XCTAssertEqual(hunk2.addedLines, 3) + XCTAssertEqual(hunk2.additions.count, 1) + XCTAssertEqual(hunk2.additions[0].start, 12) + XCTAssertEqual(hunk2.additions[0].length, 1) + } + + func testParseDiffMultipleAdditions() { + let diff = """ + @@ -1,5 +1,7 @@ + line1 + +added line1 + +added line2 + line2 + line3 + +added line3 + line4 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.additions.count, 2) + + // First addition block + XCTAssertEqual(hunk.additions[0].start, 2) + XCTAssertEqual(hunk.additions[0].length, 2) + + // Second addition block + XCTAssertEqual(hunk.additions[1].start, 6) + XCTAssertEqual(hunk.additions[1].length, 1) + } + + func testParseDiffWithDeletions() { + let diff = """ + @@ -1,4 +1,2 @@ + line1 + -deleted line1 + -deleted line2 + line2 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1) + XCTAssertEqual(hunk.deletedLines, 4) + XCTAssertEqual(hunk.startAddedLine, 1) + XCTAssertEqual(hunk.addedLines, 2) + XCTAssertEqual(hunk.additions.count, 0) // No additions, only deletions + } + + func testParseDiffNewFile() { + let diff = """ + @@ -0,0 +1,3 @@ + +line1 + +line2 + +line3 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1) // Should be adjusted from 0 to 1 + XCTAssertEqual(hunk.deletedLines, 0) + XCTAssertEqual(hunk.startAddedLine, 1) // Should be adjusted from 0 to 1 + XCTAssertEqual(hunk.addedLines, 3) + XCTAssertEqual(hunk.additions.count, 1) + XCTAssertEqual(hunk.additions[0].start, 1) + XCTAssertEqual(hunk.additions[0].length, 3) + } + + func testParseDiffDeletedFile() { + let diff = """ + @@ -1,3 +0,0 @@ + -line1 + -line2 + -line3 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1) + XCTAssertEqual(hunk.deletedLines, 3) + XCTAssertEqual(hunk.startAddedLine, 1) // Should be adjusted from 0 to 1 + XCTAssertEqual(hunk.addedLines, 0) + XCTAssertEqual(hunk.additions.count, 0) + } + + func testParseDiffSingleLineContext() { + let diff = """ + @@ -1 +1,2 @@ + line1 + +added line + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1) + XCTAssertEqual(hunk.deletedLines, 1) // Default when not specified + XCTAssertEqual(hunk.startAddedLine, 1) + XCTAssertEqual(hunk.addedLines, 2) + XCTAssertEqual(hunk.additions.count, 1) + XCTAssertEqual(hunk.additions[0].start, 2) + XCTAssertEqual(hunk.additions[0].length, 1) + } + + func testParseDiffEmptyString() { + let diff = "" + let hunks = GitHunk.parseDiff(diff) + XCTAssertEqual(hunks.count, 0) + } + + func testParseDiffInvalidFormat() { + let diff = """ + invalid diff format + no hunk headers + """ + + let hunks = GitHunk.parseDiff(diff) + XCTAssertEqual(hunks.count, 0) + } + + func testParseDiffTrailingNewline() { + let diff = """ + @@ -1,2 +1,3 @@ + line1 + +added line + line2 + + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.diffText, " line1\n+added line\n line2") + XCTAssertFalse(hunk.diffText.hasSuffix("\n")) + } + + func testParseDiffConsecutiveAdditions() { + let diff = """ + @@ -1,3 +1,6 @@ + line1 + +added1 + +added2 + +added3 + line2 + line3 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.additions.count, 1) + XCTAssertEqual(hunk.additions[0].start, 2) + XCTAssertEqual(hunk.additions[0].length, 3) + } + + func testParseDiffMixedChanges() { + let diff = """ + @@ -1,6 +1,7 @@ + line1 + -deleted line + +added line1 + +added line2 + line2 + line3 + line4 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1) + XCTAssertEqual(hunk.deletedLines, 6) + XCTAssertEqual(hunk.startAddedLine, 1) + XCTAssertEqual(hunk.addedLines, 7) + XCTAssertEqual(hunk.additions.count, 1) + XCTAssertEqual(hunk.additions[0].start, 2) + XCTAssertEqual(hunk.additions[0].length, 2) + } + + func testParseDiffLargeLineNumbers() { + let diff = """ + @@ -1000,5 +1000,6 @@ + line1000 + +added line + line1001 + line1002 + line1003 + line1004 + """ + + let hunks = GitHunk.parseDiff(diff) + + XCTAssertEqual(hunks.count, 1) + let hunk = hunks[0] + XCTAssertEqual(hunk.startDeletedLine, 1000) + XCTAssertEqual(hunk.startAddedLine, 1000) + XCTAssertEqual(hunk.additions.count, 1) + XCTAssertEqual(hunk.additions[0].start, 1001) + XCTAssertEqual(hunk.additions[0].length, 1) + } +} diff --git a/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift b/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift index 8bd81349..d6cdcbff 100644 --- a/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift +++ b/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift @@ -41,6 +41,14 @@ final class FetchSuggestionTests: XCTestCase { ), ]) as! E.Response } + 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( @@ -80,6 +88,15 @@ final class FetchSuggestionTests: XCTestCase { ), ]) as! E.Response } + + 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/GitHubCopilotServiceTests/SystemInfoTests.swift b/Tool/Tests/GitHubCopilotServiceTests/SystemInfoTests.swift deleted file mode 100644 index 442b25a8..00000000 --- a/Tool/Tests/GitHubCopilotServiceTests/SystemInfoTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import CopilotForXcodeKit -import LanguageServerProtocol -import XCTest - -@testable import Workspace -@testable import GitHubCopilotService - -final class SystemInfoTests: XCTestCase { - func test_get_xcode_version() async throws { - guard let version = SystemInfo().xcodeVersion() else { - XCTFail("The Xcode version should not be nil.") - return - } - let versionPattern = "^\\d+(\\.\\d+)*$" - let versionTest = NSPredicate(format: "SELF MATCHES %@", versionPattern) - - 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.") - } -} diff --git a/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift b/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift new file mode 100644 index 00000000..4dae3722 --- /dev/null +++ b/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift @@ -0,0 +1,98 @@ +import XCTest + +@testable import SystemUtils + +final class SystemUtilsTests: XCTestCase { + func test_get_xcode_version() async throws { + guard let version = SystemUtils.xcodeVersion else { + XCTFail("The Xcode version should not be nil.") + return + } + let versionPattern = "^\\d+(\\.\\d+)*$" + let versionTest = NSPredicate(format: "SELF MATCHES %@", versionPattern) + + 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 new file mode 100644 index 00000000..36883d28 --- /dev/null +++ b/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift @@ -0,0 +1,600 @@ +import ConversationServiceProvider +import CoreServices +import Foundation +import LanguageServerProtocol +@testable import Workspace +import XCTest + +// MARK: - Mocks for Testing + +class MockFSEventProvider: FSEventProvider { + var createdStream: FSEventStreamRef? + var didStartStream = false + var didStopStream = false + var didInvalidateStream = false + var didReleaseStream = false + var didSetDispatchQueue = false + var registeredCallback: FSEventStreamCallback? + var registeredContext: UnsafeMutablePointer? + + var simulatedFiles: [String] = [] + + func createEventStream( + paths: CFArray, + latency: CFTimeInterval, + flags: UInt32, + callback: @escaping FSEventStreamCallback, + context: UnsafeMutablePointer + ) -> FSEventStreamRef? { + registeredCallback = callback + registeredContext = context + let stream = unsafeBitCast(1, to: FSEventStreamRef.self) + createdStream = stream + return stream + } + + func startStream(_ stream: FSEventStreamRef) { + didStartStream = true + } + + func stopStream(_ stream: FSEventStreamRef) { + didStopStream = true + } + + func invalidateStream(_ stream: FSEventStreamRef) { + didInvalidateStream = true + } + + func releaseStream(_ stream: FSEventStreamRef) { + didReleaseStream = true + } + + func setDispatchQueue(_ stream: FSEventStreamRef, queue: DispatchQueue) { + didSetDispatchQueue = true + } +} + +class MockWorkspaceFileProvider: WorkspaceFileProvider { + var subprojects: [URL] = [] + var filesInWorkspace: [ConversationFileReference] = [] + var xcProjectPaths: Set = [] + var xcWorkspacePaths: Set = [] + + func getProjects(by workspaceURL: URL) -> [URL] { + return subprojects + } + + func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [ConversationFileReference] { + return filesInWorkspace + } + + func isXCProject(_ url: URL) -> Bool { + return xcProjectPaths.contains(url.path) + } + + 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 + +final class BatchingFileChangeWatcherTests: XCTestCase { + var mockFSEventProvider: MockFSEventProvider! + var publishedEvents: [[FileEvent]] = [] + + override func setUp() { + super.setUp() + mockFSEventProvider = MockFSEventProvider() + publishedEvents = [] + } + + func createWatcher(projectURL: URL = URL(fileURLWithPath: "/test/project")) -> BatchingFileChangeWatcher { + return BatchingFileChangeWatcher( + watchedPaths: [projectURL], + changePublisher: { [weak self] events in + self?.publishedEvents.append(events) + }, + publishInterval: 0.1, + fsEventProvider: mockFSEventProvider + ) + } + + func testInitSetsUpTimerAndFileWatching() { + let _ = createWatcher() + + XCTAssertNotNil(mockFSEventProvider.createdStream) + XCTAssertTrue(mockFSEventProvider.didStartStream) + } + + func testDeinitCleansUpResources() { + var watcher: BatchingFileChangeWatcher? = createWatcher() + weak var weakWatcher = watcher + + watcher = nil + + // Wait for the watcher to be deallocated + let startTime = Date() + let timeout: TimeInterval = 1.0 + + while weakWatcher != nil && Date().timeIntervalSince(startTime) < timeout { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) + } + + XCTAssertTrue(mockFSEventProvider.didStopStream) + XCTAssertTrue(mockFSEventProvider.didInvalidateStream) + XCTAssertTrue(mockFSEventProvider.didReleaseStream) + } + + func testAddingEventsAndPublishing() { + let watcher = createWatcher() + let fileURL = URL(fileURLWithPath: "/test/project/file.swift") + + watcher.onFsEvent(url: fileURL, type: .created, isDirectory: false) + + // No events should be published yet + XCTAssertTrue(publishedEvents.isEmpty) + + XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout") + + // Only verify array contents if we have events + guard !publishedEvents.isEmpty else { return } + + XCTAssertEqual(publishedEvents[0].count, 1) + XCTAssertEqual(publishedEvents[0][0].uri, fileURL.absoluteString) + XCTAssertEqual(publishedEvents[0][0].type, .created) + } + + func testProcessingFSEvents() { + let watcher = createWatcher() + let fileURL = URL(fileURLWithPath: "/test/project/file.swift") + + // 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 } + XCTAssertEqual(publishedEvents[0].count, 1) + XCTAssertEqual(publishedEvents[0][0].type, .created) + + // Test file modification + publishedEvents = [] + watcher.onFsEvent(url: fileURL, type: .changed, isDirectory: false) + + XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout") + + guard !publishedEvents.isEmpty else { return } + XCTAssertEqual(publishedEvents[0].count, 1) + XCTAssertEqual(publishedEvents[0][0].type, .changed) + + // Test file deletion + publishedEvents = [] + 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 { + func waitForPublishedEvents(timeout: TimeInterval = 1.0) -> Bool { + let start = Date() + while publishedEvents.isEmpty && Date().timeIntervalSince(start) < timeout { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) + } + return !publishedEvents.isEmpty + } +} + +// MARK: - Tests for FileChangeWatcherService + +final class FileChangeWatcherServiceTests: XCTestCase { + var mockWorkspaceFileProvider: MockWorkspaceFileProvider! + var publishedEvents: [[FileEvent]] = [] + + override func setUp() { + super.setUp() + mockWorkspaceFileProvider = MockWorkspaceFileProvider() + publishedEvents = [] + } + + func createService(workspaceURL: URL = URL(fileURLWithPath: "/test/workspace")) -> FileChangeWatcherService { + return FileChangeWatcherService( + workspaceURL, + publisher: { [weak self] events in + self?.publishedEvents.append(events) + }, + publishInterval: 0.1, + workspaceFileProvider: mockWorkspaceFileProvider, + watcherFactory: MockFileWatcherFactory(), + directoryChangePublisher: nil + ) + } + + func testStartWatchingCreatesWatchersForProjects() { + let project1 = URL(fileURLWithPath: "/test/workspace/project1") + let project2 = URL(fileURLWithPath: "/test/workspace/project2") + mockWorkspaceFileProvider.subprojects = [project1, project2] + + let service = createService() + service.startWatching() + + 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() + + 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() + + XCTAssertNotNil(service.watcher) + + // Simulate adding a new project + let project2 = URL(fileURLWithPath: "/test/workspace/project2") + mockWorkspaceFileProvider.subprojects = [project1, project2] + + // Set up mock files for the added project + let file1URL = URL(fileURLWithPath: "/test/workspace/project2/file1.swift") + let file1 = ConversationFileReference( + url: file1URL, + relativePath: file1URL.relativePath, + fileName: file1URL.lastPathComponent + ) + let file2URL = URL(fileURLWithPath: "/test/workspace/project2/file2.swift") + let file2 = ConversationFileReference( + url: file2URL, + relativePath: file2URL.relativePath, + fileName: file2URL.lastPathComponent + ) + mockWorkspaceFileProvider.filesInWorkspace = [file1, file2] + + MockFileWatcher.triggerFileDelete(for: workspace.appendingPathComponent("contents.xcworkspacedata")) + + XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout") + + guard !publishedEvents.isEmpty else { return } + + // Verify file events were published + XCTAssertEqual(publishedEvents[0].count, 2) + + // Verify both files were reported as created + XCTAssertEqual(publishedEvents[0][0].type, .created) + XCTAssertEqual(publishedEvents[0][1].type, .created) + } + + func testProjectMonitoringDetectsRemovedProjects() { + let workspace = URL(fileURLWithPath: "/test/workspace") + 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() + + 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 = ConversationFileReference( + url: file1URL, + relativePath: file1URL.relativePath, + fileName: file1URL.lastPathComponent + ) + let file2URL = URL(fileURLWithPath: "/test/workspace/project2/file2.swift") + let file2 = ConversationFileReference( + url: file2URL, + relativePath: file2URL.relativePath, + fileName: file2URL.lastPathComponent + ) + mockWorkspaceFileProvider.filesInWorkspace = [file1, file2] + + // 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 file events were published + XCTAssertEqual(publishedEvents[0].count, 2) + + // Verify both files were reported as deleted + XCTAssertEqual(publishedEvents[0][0].type, .deleted) + XCTAssertEqual(publishedEvents[0][1].type, .deleted) + } +} + +extension FileChangeWatcherServiceTests { + func waitForPublishedEvents(timeout: TimeInterval = 3.0) -> Bool { + let start = Date() + while publishedEvents.isEmpty && Date().timeIntervalSince(start) < timeout { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) + } + return !publishedEvents.isEmpty + } +} diff --git a/Tool/Tests/WorkspaceTests/WorkspaceDirectoryTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceDirectoryTests.swift new file mode 100644 index 00000000..c43916a8 --- /dev/null +++ b/Tool/Tests/WorkspaceTests/WorkspaceDirectoryTests.swift @@ -0,0 +1,241 @@ +import XCTest +import Foundation +@testable import Workspace + +class WorkspaceDirectoryTests: XCTestCase { + + // MARK: - Directory Skip Pattern Tests + + func testShouldSkipDirectory() throws { + // Test skip patterns at different positions in path + XCTAssertTrue(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/some/path/.git")), "Should skip .git at end") + XCTAssertTrue(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/some/.git/path")), "Should skip .git in middle") + XCTAssertTrue(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/.git")), "Should skip .git at root") + XCTAssertTrue(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/some/node_modules/package")), "Should skip node_modules in middle") + XCTAssertTrue(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/project/Preview Content")), "Should skip Preview Content") + XCTAssertTrue(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/project/.swiftpm")), "Should skip .swiftpm") + + XCTAssertFalse(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/some/valid/path")), "Should not skip valid paths") + XCTAssertFalse(WorkspaceDirectory.shouldSkipDirectory(URL(fileURLWithPath: "/some/gitfile.txt")), "Should not skip files containing skip pattern in name") + } + + // MARK: - Directory Validation Tests + + func testIsValidDirectory() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + do { + // Create valid directory + let validDirURL = try createSubdirectory(in: tmpDir, withName: "ValidDirectory") + XCTAssertTrue(WorkspaceDirectory.isValidDirectory(validDirURL), "Valid directory should return true") + + // Create directory with skip pattern name + let gitDirURL = try createSubdirectory(in: tmpDir, withName: ".git") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(gitDirURL), ".git directory should return false") + + let nodeModulesDirURL = try createSubdirectory(in: tmpDir, withName: "node_modules") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(nodeModulesDirURL), "node_modules directory should return false") + + let previewContentDirURL = try createSubdirectory(in: tmpDir, withName: "Preview Content") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(previewContentDirURL), "Preview Content directory should return false") + + let swiftpmDirURL = try createSubdirectory(in: tmpDir, withName: ".swiftpm") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(swiftpmDirURL), ".swiftpm directory should return false") + + // Test file (should return false) + let fileURL = try createFile(in: tmpDir, withName: "file.swift", contents: "// Swift") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(fileURL), "File should return false for isValidDirectory") + + // Test Xcode workspace directory (should return false due to shouldSkipURL) + let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "test.xcworkspace") + _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(xcworkspaceURL), "Xcode workspace should return false") + + // Test Xcode project directory (should return false due to shouldSkipURL) + let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "test.xcodeproj") + _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + XCTAssertFalse(WorkspaceDirectory.isValidDirectory(xcprojectURL), "Xcode project should return false") + + } catch { + throw error + } + } + + // MARK: - Directory Enumeration Tests + + func testGetDirectoriesInActiveWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + do { + let myWorkspaceRoot = try createSubdirectory(in: tmpDir, withName: "myWorkspace") + let xcWorkspaceURL = try createXCWorkspaceFolder(in: myWorkspaceRoot, withName: "myWorkspace.xcworkspace", fileRefs: [ + "container:myProject.xcodeproj", + "group:../myDependency",]) + let _ = try createXCProjectFolder(in: myWorkspaceRoot, withName: "myProject.xcodeproj") + let myDependencyURL = try createSubdirectory(in: tmpDir, withName: "myDependency") + + // Create valid directories + let _ = try createSubdirectory(in: myWorkspaceRoot, withName: "Sources") + let _ = try createSubdirectory(in: myWorkspaceRoot, withName: "Tests") + let _ = try createSubdirectory(in: myDependencyURL, withName: "Library") + + // Create directories that should be skipped + _ = try createSubdirectory(in: myWorkspaceRoot, withName: ".git") + _ = try createSubdirectory(in: myWorkspaceRoot, withName: "node_modules") + _ = try createSubdirectory(in: myWorkspaceRoot, withName: "Preview Content") + _ = try createSubdirectory(in: myDependencyURL, withName: ".swiftpm") + + // Create some files (should be ignored) + _ = try createFile(in: myWorkspaceRoot, withName: "file.swift", contents: "") + _ = try createFile(in: myDependencyURL, withName: "file.swift", contents: "") + + let directories = WorkspaceDirectory.getDirectoriesInActiveWorkspace( + workspaceURL: xcWorkspaceURL, + workspaceRootURL: myWorkspaceRoot + ) + let directoryNames = directories.map { $0.url.lastPathComponent } + + // Should include valid directories but not skipped ones + XCTAssertTrue(directoryNames.contains("Sources"), "Should include Sources directory") + XCTAssertTrue(directoryNames.contains("Tests"), "Should include Tests directory") + XCTAssertTrue(directoryNames.contains("Library"), "Should include Library directory from dependency") + + // Should not include skipped directories + XCTAssertFalse(directoryNames.contains(".git"), "Should not include .git directory") + XCTAssertFalse(directoryNames.contains("node_modules"), "Should not include node_modules directory") + XCTAssertFalse(directoryNames.contains("Preview Content"), "Should not include Preview Content directory") + XCTAssertFalse(directoryNames.contains(".swiftpm"), "Should not include .swiftpm directory") + + // Should not include project metadata directories + XCTAssertFalse(directoryNames.contains("myProject.xcodeproj"), "Should not include Xcode project directory") + + } catch { + throw error + } + } + + func testGetDirectoriesInActiveWorkspaceWithSingleProject() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + do { + let xcprojectURL = try createXCProjectFolder(in: tmpDir, withName: "myProject.xcodeproj") + + // Create valid directories + let sourcesDir = try createSubdirectory(in: tmpDir, withName: "Sources") + let _ = try createSubdirectory(in: tmpDir, withName: "Tests") + + // Create nested directory structure + let _ = try createSubdirectory(in: sourcesDir, withName: "MyModule") + + // Create directories that should be skipped + _ = try createSubdirectory(in: tmpDir, withName: ".git") + _ = try createSubdirectory(in: tmpDir, withName: "Preview Content") + + let directories = WorkspaceDirectory.getDirectoriesInActiveWorkspace( + workspaceURL: xcprojectURL, + workspaceRootURL: tmpDir + ) + let directoryNames = directories.map { $0.url.lastPathComponent } + + // Should include valid directories + XCTAssertTrue(directoryNames.contains("Sources"), "Should include Sources directory") + XCTAssertTrue(directoryNames.contains("Tests"), "Should include Tests directory") + XCTAssertTrue(directoryNames.contains("MyModule"), "Should include nested MyModule directory") + + // Should not include skipped directories + XCTAssertFalse(directoryNames.contains(".git"), "Should not include .git directory") + XCTAssertFalse(directoryNames.contains("Preview Content"), "Should not include Preview Content directory") + + // Should not include project metadata + XCTAssertFalse(directoryNames.contains("myProject.xcodeproj"), "Should not include Xcode project directory") + + } catch { + throw error + } + } + + // MARK: - Test Helper Methods + // Following the DRY principle and Test Utility Pattern + // https://martinfowler.com/bliki/ObjectMother.html + + func deleteDirectoryIfExists(at url: URL) { + if FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.removeItem(at: url) + } catch { + print("Failed to delete directory at \(url.path)") + } + } + } + + func createTemporaryDirectory() throws -> URL { + let temporaryDirectoryURL = FileManager.default.temporaryDirectory + let directoryName = UUID().uuidString + let directoryURL = temporaryDirectoryURL.appendingPathComponent(directoryName) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) +#if DEBUG + print("Create temp directory \(directoryURL.path)") +#endif + return directoryURL + } + + func createSubdirectory(in directory: URL, withName name: String) throws -> URL { + let subdirectoryURL = directory.appendingPathComponent(name) + try FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil) + return subdirectoryURL + } + + func createFile(in directory: URL, withName name: String, contents: String) throws -> URL { + let fileURL = directory.appendingPathComponent(name) + let data = contents.data(using: .utf8) + FileManager.default.createFile(atPath: fileURL.path, contents: data, attributes: nil) + return fileURL + } + + func createXCProjectFolder(in baseDirectory: URL, withName projectName: String) throws -> URL { + let projectURL = try createSubdirectory(in: baseDirectory, withName: projectName) + if projectName.hasSuffix(".xcodeproj") { + _ = try createFile(in: projectURL, withName: "project.pbxproj", contents: "// Project file contents") + } + return projectURL + } + + func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, fileRefs: [String]?) throws -> URL { + let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName) + if let fileRefs { + _ = try createXCworkspacedataFile(directory: xcworkspaceURL, fileRefs: fileRefs) + } + return xcworkspaceURL + } + + func createXCworkspacedataFile(directory: URL, fileRefs: [String]) throws -> URL { + let contents = generateXCWorkspacedataContents(fileRefs: fileRefs) + return try createFile(in: directory, withName: "contents.xcworkspacedata", contents: contents) + } + + func generateXCWorkspacedataContents(fileRefs: [String]) -> String { + var contents = """ + + + """ + for fileRef in fileRefs { + contents += """ + + + """ + } + contents += "" + return contents + } +} diff --git a/Tool/Tests/WorkspaceTests/WorkspaceFileTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceFileTests.swift new file mode 100644 index 00000000..87276a06 --- /dev/null +++ b/Tool/Tests/WorkspaceTests/WorkspaceFileTests.swift @@ -0,0 +1,460 @@ +import XCTest +import Foundation +@testable import Workspace + +class WorkspaceFileTests: XCTestCase { + func testMatchesPatterns() { + let url1 = URL(fileURLWithPath: "/path/to/file.swift") + let url2 = URL(fileURLWithPath: "/path/to/.git") + let patterns = [".git", ".svn"] + + XCTAssertTrue(WorkspaceFile.matchesPatterns(url2, patterns: patterns)) + XCTAssertFalse(WorkspaceFile.matchesPatterns(url1, patterns: patterns)) + } + + func testIsXCWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "myWorkspace.xcworkspace") + XCTAssertFalse(WorkspaceFile.isXCWorkspace(xcworkspaceURL)) + let xcworkspaceDataURL = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "") + XCTAssertTrue(WorkspaceFile.isXCWorkspace(xcworkspaceURL)) + } catch { + throw error + } + } + + func testIsXCProject() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "myProject.xcodeproj") + XCTAssertFalse(WorkspaceFile.isXCProject(xcprojectURL)) + let xcprojectDataURL = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + XCTAssertTrue(WorkspaceFile.isXCProject(xcprojectURL)) + } catch { + throw error + } + } + + func testGetFilesInActiveProject() throws { + let tmpDir = try createTemporaryDirectory() + do { + let xcprojectURL = try createXCProjectFolder(in: tmpDir, withName: "myProject.xcodeproj") + _ = try createFile(in: tmpDir, withName: "file1.swift", contents: "") + _ = try createFile(in: tmpDir, withName: "file2.swift", contents: "") + _ = try createSubdirectory(in: tmpDir, withName: ".git") + let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: xcprojectURL, workspaceRootURL: tmpDir) + let fileNames = files.map { $0.url.lastPathComponent } + XCTAssertEqual(files.count, 2) + XCTAssertTrue(fileNames.contains("file1.swift")) + XCTAssertTrue(fileNames.contains("file2.swift")) + } catch { + deleteDirectoryIfExists(at: tmpDir) + throw error + } + deleteDirectoryIfExists(at: tmpDir) + } + + func testGetFilesInActiveWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let myWorkspaceRoot = try createSubdirectory(in: tmpDir, withName: "myWorkspace") + let xcWorkspaceURL = try createXCWorkspaceFolder(in: myWorkspaceRoot, withName: "myWorkspace.xcworkspace", fileRefs: [ + "container:myProject.xcodeproj", + "group:../notExistedDir/notExistedProject.xcodeproj", + "group:../myDependency",]) + let xcprojectURL = try createXCProjectFolder(in: myWorkspaceRoot, withName: "myProject.xcodeproj") + let myDependencyURL = try createSubdirectory(in: tmpDir, withName: "myDependency") + + // Files under workspace should be included + _ = try createFile(in: myWorkspaceRoot, withName: "file1.swift", contents: "") + // unsupported patterns and file extension should be excluded + _ = try createFile(in: myWorkspaceRoot, withName: "unsupportedFileExtension.xyz", contents: "") + _ = try createSubdirectory(in: myWorkspaceRoot, withName: ".git") + + // Files under project metadata folder should be excluded + _ = try createFile(in: xcprojectURL, withName: "fileUnderProjectMetadata.swift", contents: "") + + // Files under dependency should be included + _ = try createFile(in: myDependencyURL, withName: "depFile1.swift", contents: "") + // Should be excluded + _ = try createSubdirectory(in: myDependencyURL, withName: ".git") + + // Files under unrelated directories should be excluded + _ = try createFile(in: tmpDir, withName: "unrelatedFile1.swift", contents: "") + + let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: xcWorkspaceURL, workspaceRootURL: myWorkspaceRoot) + let fileNames = files.map { $0.url.lastPathComponent } + XCTAssertEqual(files.count, 2) + XCTAssertTrue(fileNames.contains("file1.swift")) + XCTAssertTrue(fileNames.contains("depFile1.swift")) + } catch { + throw error + } + } + + func testGetSubprojectURLsFromXCWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + let workspaceDir = try createSubdirectory(in: tmpDir, withName: "workspace") + + // Create tryapp directory and project + let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp") + _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj") + + // Create Copilot for Xcode project + _ = try createXCProjectFolder(in: workspaceDir, withName: "Copilot for Xcode.xcodeproj") + + // Create Test1 directory + let test1Dir = try createSubdirectory(in: tmpDir, withName: "Test1") + + // Create Test2 directory and project + let test2Dir = try createSubdirectory(in: tmpDir, withName: "Test2") + _ = try createXCProjectFolder(in: test2Dir, withName: "project2.xcodeproj") + + // Create the workspace data file with our references + let xcworkspaceData = """ + + + + + + + + + + + + + + """ + let workspaceURL = try createXCWorkspaceFolder(in: workspaceDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData) + + let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL) + + XCTAssertEqual(subprojectURLs.count, 4) + let resolvedPaths = subprojectURLs.map { $0.path } + let expectedPaths = [ + tryappDir.path, + workspaceDir.path, // For Copilot for Xcode.xcodeproj + test1Dir.path, + test2Dir.path + ] + XCTAssertEqual(resolvedPaths, expectedPaths) + } + + func testGetSubprojectURLsFromEmbeddedXCWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + // Create the workspace data file with a self reference + let xcworkspaceData = """ + + + + + + """ + + // Create the MyApp directory structure + let myAppDir = try createSubdirectory(in: tmpDir, withName: "MyApp") + let xcodeProjectDir = try createXCProjectFolder(in: myAppDir, withName: "MyApp.xcodeproj") + let embeddedWorkspaceDir = try createXCWorkspaceFolder(in: xcodeProjectDir, withName: "MyApp.xcworkspace", xcworkspacedata: xcworkspaceData) + + let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: embeddedWorkspaceDir) + XCTAssertEqual(subprojectURLs.count, 1) + XCTAssertEqual(subprojectURLs[0].lastPathComponent, "MyApp") + XCTAssertEqual(subprojectURLs[0].path, myAppDir.path) + } + + func testGetSubprojectURLsFromXCWorkspaceOrganizedByGroup() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + // Create directories for the projects and groups + let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp") + _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj") + + let webLibraryDir = try createSubdirectory(in: tmpDir, withName: "WebLibrary") + + // Create the group directories + let group1Dir = try createSubdirectory(in: tmpDir, withName: "group1") + let group2Dir = try createSubdirectory(in: group1Dir, withName: "group2") + _ = try createSubdirectory(in: group2Dir, withName: "group3") + _ = try createSubdirectory(in: group1Dir, withName: "group4") + + // Create the MyProjects directory + let myProjectsDir = try createSubdirectory(in: tmpDir, withName: "MyProjects") + + // Create the copilot-xcode directory and project + let copilotXcodeDir = try createSubdirectory(in: myProjectsDir, withName: "copilot-xcode") + _ = try createXCProjectFolder(in: copilotXcodeDir, withName: "Copilot for Xcode.xcodeproj") + + // Create the SwiftLanguageWeather directory and project + let swiftWeatherDir = try createSubdirectory(in: myProjectsDir, withName: "SwiftLanguageWeather") + _ = try createXCProjectFolder(in: swiftWeatherDir, withName: "SwiftWeather.xcodeproj") + + // Create the workspace data file with a complex group structure + let xcworkspaceData = """ + + + + + + + + + + + + + + + + + + + + """ + + // Create a test workspace structure + let workspaceURL = try createXCWorkspaceFolder(in: tmpDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData) + + let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL) + XCTAssertEqual(subprojectURLs.count, 4) + let expectedPaths = [ + tryappDir.path, + webLibraryDir.path, + copilotXcodeDir.path, + swiftWeatherDir.path + ] + for expectedPath in expectedPaths { + XCTAssertTrue(subprojectURLs.contains { $0.path == expectedPath }, "Expected path not found: \(expectedPath)") + } + } + + func deleteDirectoryIfExists(at url: URL) { + if FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.removeItem(at: url) + } catch { + print("Failed to delete directory at \(url.path)") + } + } + } + + func createTemporaryDirectory() throws -> URL { + let temporaryDirectoryURL = FileManager.default.temporaryDirectory + let directoryName = UUID().uuidString + let directoryURL = temporaryDirectoryURL.appendingPathComponent(directoryName) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) + #if DEBUG + print("Create temp directory \(directoryURL.path)") + #endif + return directoryURL + } + + func createSubdirectory(in directory: URL, withName name: String) throws -> URL { + let subdirectoryURL = directory.appendingPathComponent(name) + try FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil) + return subdirectoryURL + } + + func createFile(in directory: URL, withName name: String, contents: String) throws -> URL { + let fileURL = directory.appendingPathComponent(name) + let data = contents.data(using: .utf8) + FileManager.default.createFile(atPath: fileURL.path, contents: data, attributes: nil) + return fileURL + } + + func createXCProjectFolder(in baseDirectory: URL, withName projectName: String) throws -> URL { + let projectURL = try createSubdirectory(in: baseDirectory, withName: projectName) + if projectName.hasSuffix(".xcodeproj") { + _ = try createFile(in: projectURL, withName: "project.pbxproj", contents: "// Project file contents") + } + return projectURL + } + + func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, fileRefs: [String]?) throws -> URL { + let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName) + if let fileRefs { + _ = try createXCworkspacedataFile(directory: xcworkspaceURL, fileRefs: fileRefs) + } + return xcworkspaceURL + } + + func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, xcworkspacedata: String) throws -> URL { + let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName) + _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: xcworkspacedata) + return xcworkspaceURL + } + + func createXCworkspacedataFile(directory: URL, fileRefs: [String]) throws -> URL { + let contents = generateXCWorkspacedataContents(fileRefs: fileRefs) + return try createFile(in: directory, withName: "contents.xcworkspacedata", contents: contents) + } + + func generateXCWorkspacedataContents(fileRefs: [String]) -> String { + var contents = """ + + + """ + for fileRef in fileRefs { + contents += """ + + + """ + } + contents += "" + return contents + } + + func testIsValidFile() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + // Test valid Swift file + let swiftFileURL = try createFile(in: tmpDir, withName: "ValidFile.swift", contents: "// Swift code") + XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL)) + + // Test valid files with different supported extensions + let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript") + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL)) + + let mdFileURL = try createFile(in: tmpDir, withName: "README.md", contents: "# Markdown") + XCTAssertTrue(try WorkspaceFile.isValidFile(mdFileURL)) + + let jsonFileURL = try createFile(in: tmpDir, withName: "config.json", contents: "{}") + XCTAssertTrue(try WorkspaceFile.isValidFile(jsonFileURL)) + + // Test case insensitive extension matching + let swiftUpperURL = try createFile(in: tmpDir, withName: "File.SWIFT", contents: "// Swift") + XCTAssertTrue(try WorkspaceFile.isValidFile(swiftUpperURL)) + + // Test unsupported file extension + let unsupportedFileURL = try createFile(in: tmpDir, withName: "file.xyz", contents: "unsupported") + XCTAssertFalse(try WorkspaceFile.isValidFile(unsupportedFileURL)) + + // Test files matching skip patterns + let gitFileURL = try createFile(in: tmpDir, withName: ".git", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(gitFileURL)) + + let dsStoreURL = try createFile(in: tmpDir, withName: ".DS_Store", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(dsStoreURL)) + + let nodeModulesURL = try createFile(in: tmpDir, withName: "node_modules", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(nodeModulesURL)) + + // Test directory (should return false) + let subdirURL = try createSubdirectory(in: tmpDir, withName: "subdir") + XCTAssertFalse(try WorkspaceFile.isValidFile(subdirURL)) + + // Test Xcode workspace (should return false) + let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "test.xcworkspace") + _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(xcworkspaceURL)) + + // Test Xcode project (should return false) + let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "test.xcodeproj") + _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(xcprojectURL)) + + } catch { + throw error + } + } + + func testIsValidFileWithCustomExclusionFilter() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let swiftFileURL = try createFile(in: tmpDir, withName: "TestFile.swift", contents: "// Swift code") + let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript") + + // Test without custom exclusion filter + XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL)) + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL)) + + // Test with custom exclusion filter that excludes Swift files + let excludeSwiftFilter: (URL) -> Bool = { url in + return url.pathExtension.lowercased() == "swift" + } + + XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeSwiftFilter)) + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeSwiftFilter)) + + // Test with custom exclusion filter that excludes files with "Test" in name + let excludeTestFilter: (URL) -> Bool = { url in + return url.lastPathComponent.contains("Test") + } + + XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeTestFilter)) + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeTestFilter)) + + } catch { + throw error + } + } + + func testIsValidFileWithAllSupportedExtensions() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let supportedExtensions = supportedFileExtensions + + for (index, ext) in supportedExtensions.enumerated() { + let fileName = "testfile\(index).\(ext)" + let fileURL = try createFile(in: tmpDir, withName: fileName, contents: "test content") + XCTAssertTrue(try WorkspaceFile.isValidFile(fileURL), "File with extension .\(ext) should be valid") + } + + } catch { + throw error + } + } +} diff --git a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift new file mode 100644 index 00000000..d6d2e5ec --- /dev/null +++ b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift @@ -0,0 +1,230 @@ +import XCTest +import Foundation +import LanguageServerProtocol +@testable import Workspace + +class WorkspaceTests: XCTestCase { + func testCalculateIncrementalChanges_IdenticalContent() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "Hello World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNil(changes, "Identical content should return nil") + } + + func testCalculateIncrementalChanges_EmptyOldContent() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "" + let newContent = "New content" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range, LSPRange(start: Position(line: 0, character: 0), end: Position(line: 0, character: 0))) + XCTAssertEqual(changes?[0].text, "New content") + } + + func testCalculateIncrementalChanges_EmptyNewContent() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Old content" + let newContent = "" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].text, "") + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 0) + XCTAssertEqual(changes?[0].rangeLength, oldContent.utf16.count) + } + + func testCalculateIncrementalChanges_InsertAtBeginning() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "World" + let newContent = "Hello World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 0) + XCTAssertEqual(changes?[0].text, "Hello ") + } + + func testCalculateIncrementalChanges_InsertAtEnd() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello" + let newContent = "Hello World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 5) + XCTAssertEqual(changes?[0].text, " World") + } + + func testCalculateIncrementalChanges_InsertInMiddle() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "Hello Beautiful World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 6) + XCTAssertEqual(changes?[0].text, "Beautiful ") + } + + func testCalculateIncrementalChanges_DeleteFromBeginning() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 0) + XCTAssertEqual(changes?[0].range?.end.character, 6) + XCTAssertEqual(changes?[0].text, "") + } + + func testCalculateIncrementalChanges_DeleteFromEnd() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "Hello" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 5) + XCTAssertEqual(changes?[0].text, "") + } + + func testCalculateIncrementalChanges_ReplaceMiddle() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "Hello Swift" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 6) + XCTAssertEqual(changes?[0].text, "Swift") + } + + func testCalculateIncrementalChanges_MultilineInsert() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Line 1\nLine 3" + let newContent = "Line 1\nLine 2\nLine 3" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 1) + XCTAssertEqual(changes?[0].range?.start.character, 5) + XCTAssertEqual(changes?[0].text, "2\nLine ") + } + + func testCalculateIncrementalChanges_MultilineDelete() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Line 1\nLine 2\nLine 3" + let newContent = "Line 1\nLine 3" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 1) + XCTAssertEqual(changes?[0].range?.start.character, 5) + XCTAssertEqual(changes?[0].range?.end.line, 2) + XCTAssertEqual(changes?[0].text, "") + } + + func testCalculateIncrementalChanges_MultilineReplace() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Line 1\nOld Line\nLine 3" + let newContent = "Line 1\nNew Line\nLine 3" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 1) + XCTAssertEqual(changes?[0].text, "New") + } + + func testCalculateIncrementalChanges_UTF16Characters() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello 世界" + let newContent = "Hello 🌍 世界" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 6) + XCTAssertEqual(changes?[0].text, "🌍 ") + } + + func testCalculateIncrementalChanges_VeryLargeContent() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = String(repeating: "a", count: 220_000) + let newContent = String(repeating: "b", count: 220_000) + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + // Should fallback to nil for very large contents (> 200_000 characters) + XCTAssertNil(changes, "Very large content should return nil for fallback") + } + + func testCalculateIncrementalChanges_ComplexEdit() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = """ + func hello() { + print("Hello") + } + """ + let newContent = """ + func hello(name: String) { + print("Hello, \\(name)!") + } + """ + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + // Verify that a change was detected + XCTAssertFalse(changes?[0].text.isEmpty ?? true) + } + + func testCalculateIncrementalChanges_NewlineVariations() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Line 1\nLine 2" + let newContent = "Line 1\nLine 2\n" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 1) + XCTAssertEqual(changes?[0].text, "\n") + } +}